You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
161 lines
5.8 KiB
161 lines
5.8 KiB
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
|
|
}
|
|
|