package console // ============================ MCP 工具发现 (mcptools) ============================ // // 后台配 MCP 服务时,「这个服务有哪些工具」只有服务端自己知道。以前运营得去问对接方、 // 再手抄工具名填进 tools 白名单,抄错了要到线上调用失败才发现。这里直接连上去问: // 走标准 MCP 握手(initialize → tools/list),把工具名与说明列出来给人勾。 // // 两种入参(二选一): // {url, type} —— 新建/编辑服务时还没落库,按当前填的地址现探 // {app_name, id, region} —— 已落库的服务(agent 绑定 MCP 时用,只有 svc id); // 基础行没配 url 时自动找一个配了 url 的区域分叉来探 // // 传输类型 type:0=HTTP(Streamable HTTP),1=SSE,与 comm.McpBaseFields / DBMcpServer.Stype 一致。 import ( "context" "strconv" "strings" "time" "yunyan/comm" "yunyan/lego/sys/postgres" "yunyan/pb" "github.com/gin-gonic/gin" mcpclient "github.com/mark3labs/mcp-go/client" mcpproto "github.com/mark3labs/mcp-go/mcp" ) // mcpToolsTimeout 单次探测的总超时:握手 + 拉列表。后台点一下要等的时间,别设太长。 const mcpToolsTimeout = 15 * time.Second // mcpToolItem 下发给后台前端的工具项。 type mcpToolItem struct { Name string `json:"name"` Description string `json:"description"` } // getMcpTools 连上 MCP 服务列出它暴露的全部工具。 func (this *serverComp) getMcpTools(c *gin.Context) { var req struct { Url string `json:"url"` Type string `json:"type"` // "0"=HTTP "1"=SSE AppName string `json:"app_name"` Id string `json:"id"` Region int32 `json:"region"` } _ = c.ShouldBindJSON(&req) url := strings.TrimSpace(req.Url) stype := strings.TrimSpace(req.Type) from := "" // 说明 url 是从哪解析来的,前端提示用 if url == "" { if strings.TrimSpace(req.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "请先填写 MCP 服务地址(url),或指定已保存的服务 id") return } var msg string if url, stype, from, msg = this.resolveMcpEndpoint(req.AppName, req.Id, req.Region); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } } tools, err := fetchMcpTools(url, stype == "1") if err != nil { writeErr(c, pb.ErrorCode_SystemError, "连接 MCP 服务失败: "+err.Error()) return } writeOK(c, gin.H{"tools": tools, "count": len(tools), "url": url, "from": from}) } // resolveMcpEndpoint 解析已落库 MCP 服务的 url/type:先按应用作用域找服务(未命中回退全局), // 再取指定区域的分叉;基础行与该区域都没有 url 时,退而找任意一个配了 url 的区域分叉 // ——MCP 的地址常常只配在区域分叉上(老 mcp 表一行绑一个区域,迁移后就是这形态)。 // 返回 (url, type, 来源说明, 错误说明);错误说明非空即失败。 func (this *serverComp) resolveMcpEndpoint(appName, id string, region int32) (string, string, string, string) { svc := &ThirdSvcConfig{} err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", appName, id) if err != nil && appName != "" { err = postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", id) } if err != nil { return "", "", "", "MCP 服务不存在: " + id } if !comm.CategoriesHasMCP(svc.Categories) { return "", "", "", "该服务不是 MCP 类型: " + id } ovrs := make([]*SvcRegionOverride, 0) _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", svc.AppName, svc.Id) pick := func(r int32) *SvcRegionOverride { for _, o := range ovrs { if o.Region == r { return o } } return nil } // ① 指定区域(含 0=全区域默认)的解析结果 if m := comm.ResolveMcpServer(svc, pick(region), region); m != nil { return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(region), "" } // ② 该区域没 url:找第一个配了 url 的分叉,探它总比直接报「没配」有用 for _, o := range ovrs { if m := comm.ResolveMcpServer(svc, o, o.Region); m != nil { return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(o.Region), "" } } return "", "", "", "MCP 服务「" + svc.Name + "」还没配 url(基础行与各区域分叉都没有)" } // regionSourceLabel 给前端说明这次探的是哪一份配置。 func regionSourceLabel(region int32) string { if region <= 0 { return "全区域默认" } return "区域 " + strconv.Itoa(int(region)) + " 分叉" } // fetchMcpTools 走标准 MCP 协议握手并列出工具。 // 用官方客户端而不是手拼 JSON-RPC:session id、SSE 事件流、initialized 通知这些细节由它兜着, // 否则 SSE 类服务根本拿不到 tools/list 的响应(inspect 那边只做连通性探测就是这个原因)。 func fetchMcpTools(url string, sse bool) ([]mcpToolItem, error) { ctx, cancel := context.WithTimeout(context.Background(), mcpToolsTimeout) defer cancel() var ( cli *mcpclient.Client err error ) if sse { cli, err = mcpclient.NewSSEMCPClient(url) } else { cli, err = mcpclient.NewStreamableHttpClient(url) } if err != nil { return nil, err } defer cli.Close() if err = cli.Start(ctx); err != nil { return nil, err } initReq := mcpproto.InitializeRequest{} initReq.Params.ProtocolVersion = mcpproto.LATEST_PROTOCOL_VERSION initReq.Params.ClientInfo = mcpproto.Implementation{Name: "starpivot-console", Version: "1.0"} if _, err = cli.Initialize(ctx, initReq); err != nil { return nil, err } res, err := cli.ListTools(ctx, mcpproto.ListToolsRequest{}) if err != nil { return nil, err } out := make([]mcpToolItem, 0, len(res.Tools)) for _, t := range res.Tools { out = append(out, mcpToolItem{Name: t.Name, Description: strings.TrimSpace(t.Description)}) } return out, nil }