最近 MCP 大火,其实 tRPC 也可以提供泛 HTTP 接入的能力。内网其实已经对 mcp-go 进行了封装并支持,但是相关代码还没有同步到开源版上。

不过实际上,在 tRPC 框架也是可以接入各种泛 HTTP 能力的。本文就以 mcp-go 和 tRPC 结合作为引子,也介绍一下在 Cursor 等 AI 生产力工具中如何开发和使用 MCP 能力吧。

系列文章


MCP 应用场景简介

LLM的MCP(Model Context Protocol,模型上下文协议)是由 Anthropic 公司主导开发的一种开放协议,旨在为大型语言模型(LLM)与外部数据源、工具和服务提供标准化交互接口,解决传统开发中因接口碎片化导致的功能扩展难题。其核心设计类似“AI领域的USB-C标准”,通过统一协议打破数据孤岛,使LLM能够安全、高效地调用外部资源。

上面官话看起来其实云里雾里的,我们简单地说:MCP 就是提供了一个大家都遵循的协议格式, 这样你可以在你的大模型应用中调用 MCP 服务, 从而为大模型提供更多更强的能力。落地到这两年特别火的 AI 开发工具 Copilot, Cursor 等,我们在与其对话的时候,其实我们也可以理解为它们的工作流程也是一个定制化的 MCP 流程。现在,我们可以自己给这些大模型工具提供我们自定义的 MCP 能力了。

其实赋予大模型获取现实世界信息的能力,甚至是修改现实世界信息的能力,相信绝大多数使用 LLM 的开发者们都会想到。MCP 就是这样的一个能力,它并没有什么高深的技术含量,只是由于各种机缘巧合,成为了行业内使用最为广泛的协议罢了。


mcp-go 框架简介

目前最流行的 Go MCP 框架就是 mark3labs/mcp-go,从去年 11 月第一个 commit 到现在不到半年就拥有了 3k+ 的 Star,足见其受欢迎程度。

在 mcp-go 的 README 页中,给出的第一个例子非常简单。从功能上,它包含了以下几个部分:

声明 MCP 服务能力和参数

    // Add tool
    tool := mcp.NewTool("hello_world",
        mcp.WithDescription("Say hello to someone"),
        mcp.WithString("name",
            mcp.Required(),
            mcp.Description("Name of the person to greet"),
        ),
    )

这段代码声明了一个名为 hello_world 的 MCP 工具。其实名字不重要,更重要的是它剩余的参数:

  • 功能描述: mcp.WithDescription("Say hello to someone") 包含的是对这个工具的完整描述, 这是一份交给大模型阅读理解工具功能的文档,因此开发者 务必 在此处将工具的功能完整的描述清楚,最好将工具的出参作详细说明。只有有了足够的资料,大模型才能够在它的问答链路中,正确地识别是否应该调用该 MCP 工具
  • 入参描述: mcp.WithString("name", ... 这里是对入参及其格式的描述。本例中,入参 name 是一个 string 类型参数。MCP 采用古老(但不一定标准)的 jsonrpc 协议进行交互,因此只要是符合 json 定义的数据类型参数,都是合法可用的。

实现 MCP 逻辑

在 mcp-go 框架下,声明了 MCP 工具之后,只需要再实现一个函数用来对接 mcp-go 就行了,当 MCP 请求到来之后,自然会调用你的函数。在 mcp-go 的最简示例中,就只简单地返回了一个 hello message:

func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    name, ok := request.Params.Arguments["name"].(string)
    if !ok {
        return nil, errors.New("name must be a string")
    }

    return mcp.NewToolResultText(fmt.Sprintf("Hello, %s!", name)), nil
}

这个例子已经是非常清晰地说明了获取入参、返回出参的过程。当实现了这个 handler 之后,只需再加一行就可以接入到 mcp-go 框架内了:

    s.AddTool(tool, helloHandler)

使用 HTTP 接入 MCP

mcp-go 接入 HTTP 的方式

在 mcp-go 的最简例子中,是使用 server.ServeStdio(s) 启动 MCP 服务的。正如函数名描述的那样,这是使用 stdio 来承接数据的输入和输出,这适合于个人、纯本地环境。不过如果希望将自己的 MCP 服务开放使用的话,就必须得通过 HTTP 来提供 MCP 服务了。在 mcp-go 框架中,如果我们要将 mcp 通过 http 进行服务,其实只需要 server.Start() 函数就行。

我们可以看 server.Start() 方法,可以看到其实它也只是简单实现了一下 http.Handler 接口,然后使用原生的 net/http 包启动 HTTP 服务。

这就简单了,其实 tRPC 也是支持这种模式的。

接入方法

在原生的 net/http 中,http.Handler 接口要求实现一个方法 ServeHTTP(http.ResponseWriter, *http.Request)。而在 tRPC-Go 中,则是按照 path 注册 handler 的模式,每一个 handler 的类型与 http.Handler 其实差不多,只是多了一个 error 返回而已,大不了就返回 nil 嘛。

具体的实现方式,读者可以看我的实现代码的 serveHTTP 函数:

// serveHTTP 启动HTTP服务
func serveHTTP(svc *trpcserver.Server, mcpSvr *server.MCPServer) {
    // 创建SSE服务器
    // SSE端点会自动变为 /mcp/sse
    // 消息端点会自动变为 /mcp/message
    sseServer := server.NewSSEServer(mcpSvr, server.WithBasePath("/mcp"))

    wrappedHTTP := &wrappedHTTP{Handler: sseServer}
    thttp.HandleFunc("/mcp/sse", wrappedHTTP.ServeHTTP)
    thttp.HandleFunc("/mcp/message", wrappedHTTP.ServeHTTP)

    thttp.RegisterNoProtocolService(svc.Service("trpc.amc.demo.mcp"))
    if err := svc.Serve(); err != nil {
        log.Errorf("TRPC server error: '%v'", err)
    }
}

MCP path

这段函数首先需要注意的部分, 在函数中的注释已经说明了。简单地说,通过 HTTP 暴露 MCP 服务的话,是通过 sse 和 message 两个接口来实现的,其中前者负责给 MCP client 下发临时凭证,后者则负责主要的数据交互。sse 和 message 两个接口我们都可以人工指定,默认就是 base path + /sse 和 base path + /message 的模式。

HTTP Wrapping

第二个需要注意的点,就是我定义了一个 wrappedHTTP 实例,用来包装一层 mcp-go 的 HTTP 函数。这主要是为了适配标准 net/http 的 handler 和 tRPG 的 handler 格式(加一个 error 返回)。此外,读者也可以具体看这个类型的 实现,除了加 error 这一点之外,还拦截了一下 http 收包和回包的过程,便于我们观察 MCP 的交互过程。

tRPC Service

第三点则是 thttp.RegisterNoProtocolService(svc.Service("trpc.amc.demo.mcp"))。在 net/http 中,启动 HTTP 服务的时候,监听在哪一个网卡、什么端口,是需要在代码中传入的。而 tRPC 框架则将这些参数转移到了 trpc_go.yaml 配置文件中。读者可以看看 示例配置

因此,这一句主要就是将 HTTP 服务与 yaml 配置文件中的具体项目绑定起来。


启动 MCP 服务

读者可以把我的仓库 clone 下来,然后到 app/mcp 目录下执行 go run . 命令,就可以在 localhost 的 8080 端口下启动一个 MCP HTTP 服务——如果要修改启动参数,可以在代码中的 trpc_go.yaml 文件中修改。

Cursor 配置

因为我目前用的 IDE 是 Cursor,因此我就以 Cursor 为例子说明吧。对于 Mac 用户,打开 Cursor 之后,在菜单栏中的 Cursor 项下拉,找到 Cursor Settings:

Cursor Settings

在 Settings tab,找到 MCP:

MCP

选择 "+ Add new global MCP server",填入以下内容:

{
  "mcpServers": {
    "demo_mcp": {
      "url": "http://localhost:8080/mcp/sse"
    }
  }
}

这个 /mcp/sse 的路径,就对应了前文我提到的 sse 接口。

如果你的服务还没启动,你关掉配置页之后可能会发现出现这样的错误:

mcp error

如果你按照我前文所说的 go run . 启动了的话,或者是启动之后,点一下右上角的刷新按钮,那么就会看到朴实无华的绿灯

mcp success

测试

绿灯亮起后,我们在 Cursor 中验证一下 MCP 工具是否生效:

Time

这里我把思考过程和 MCP 调用过程都展开来了。可以看到,Cursor 在思考中推断出可以用 MCP 工具来获取当前的真实时间,然后在根据它自己的知识,推测出印度时间与北京时间的差异,最后经过 MCP 返回的数据,计算出印度时间。我们都知道,大模型的时间是滞后的,这里给出了正确的时间,也就说明了 MCP 的有效性。


观察 MCP 交互

前文我提到了,我使用 wrappedHTTP 拦截了输入和输出请求。读者可以通过服务的标准输出查看 Cursor 和服务的交互过程。

建立连接

首先,Cursor 启动后,首先通过 /mcp/sse 接口与 server 建立连接并获取实际交互的接口以及 token。就本例子来说,从日志中我们可以观测到,Cursor 向 /mcp/sse 发起了一个 GET 请求,然后 mcp-go 返回了以下数据:

event: endpoint
data: /mcp/message?sessionId=d4f3737e-d4ae-48b1-a1c9-7661baff8814

MCP 功能探测

如果我们点击 Cursor 的刷新按钮,Cursor 根据上一轮的响应,发起 /mcp/message?sessionId=d4f3737e-d4ae-48b1-a1c9-7661baff8814 请求,这次是一个 POST 请求,请求正文格式化之后为:

{
    "method": "initialize",
    "params": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "tools": true,
            "prompts": false,
            "resources": true,
            "logging": false,
            "roots": {
                "listChanged": false
            }
        },
        "clientInfo": {
            "name": "cursor-vscode",
            "version": "1.0.0"
        }
    },
    "jsonrpc": "2.0",
    "id": 0
}

mcp-go 按照我们的配置,返回:

{
    "jsonrpc": "2.0",
    "id": 0,
    "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "tools": {}
        },
        "serverInfo": {
            "name": "ip-mcp",
            "version": "1.0.0"
        }
    }
}

这是对整个 server 的初始化,不重要。接着 Cursor 再次发起一个 POST 请求:

{"method":"notifications/initialized","jsonrpc":"2.0"}

MCP 响应:

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "tools": [
            {
                "description": "\n根据行政区划代码获取行政区划名称, 返回 JSON 格式, 包含以下字段:\n\n- province: 规范化的省级行政区名称\n- city: 规范化的市级行政区名称\n- county: 规范化的区县级行政区名称\n- province_code: 省级行政区代码, 如广东省为 44\n- city_code: 市级行政区代码, 如广州市为 01\n- county_code: 区县级行政区代码, 如越秀区为 04\n",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "city": {"description": "市级行政区名称","type": "string"},
                        "county": {"description": "县级行政区名称","type": "string"},
                        "province": {"description": "省级行政区名称","type": "string"}
                    },
                    "required": ["province"]
                },
                "name": "admin_division_query"
            },
            {
                "description": "\n返回当前的时间,以 JSON 格式输出,包含以下字段:\n\n- utc: 格式为 \"YYYY-MM-DD HH:MM:SS\" 的当前 UTC 时间\n- beijing: 格式为 \"YYYY-MM-DD HH:MM:SS\" 的当前北京时间\n- timestamp_sec: 当前时间戳,单位为秒\n",
                "inputSchema": {
                    "type": "object",
                    "properties": {}
                },
                "name": "datetime_query"
            }
        ]
    }
}

这就是我们在代码中定义的两个工具了

MCP 逻辑交互

这次我们不用前面的 datetime 工具了,我们来问一个带参数的。同样,我展开了思考和 MCP 交互过程:

行政区划

这里大模型调用了两次,两次请求大同小异,我们就看第一个请求。Cursor 发起了一个请求 /mcp/message?sessionId=bac39787-91e9-4b4a-8503-090af903a662,可以看到 session ID 变化了,这是在某次 /mcp/sse 刷新的,这不重要。

这次请求依旧是 POST,请求正文为:

{
    "method": "tools/call",
    "params": {
        "name": "admin_division_query",
        "arguments": {
            "province": "云南省",
            "city": "西双版纳"
        }
    },
    "jsonrpc": "2.0",
    "id": 4
}

参数、function call 名称,都很清晰。MCP 的响应为

{
    "jsonrpc": "2.0",
    "id": 4,
    "result": {
        "content": [
            {
                "type": "text",
                "text": "{\"province\":\"云南省\",\"province_code\":\"53\",\"city\":\"西双版纳傣族自治州\",\"city_code\":\"28\"}"
            }
        ]
    }
}

我是使用 text 格式返回的,然后我把字段序列化之后存在这个 text 之后。当然我们也可以看到,Cursor 的大模型拿到这个 text 之后,成功把其中的信息解析出来了。这也可见 LLM 的泛用性之强。


总结和应用

好了,这篇文章我们从MCP的基本概念聊到了如何在tRPC-Go中实现MCP服务。从最简单的mcp-go框架示例出发,我们看到了如何在tRPC-Go框架下通过HTTP接口来提供MCP服务能力。通过几个实际例子,我们观察到了MCP服务与Cursor这样的大模型工具之间建立连接、探测能力和交互的全过程。

如果你想开发自己的MCP服务,希望这篇文章能给你一些启发,也顺便展示了一下 tRPC 实现普通泛 HTTP 服务的能力。

其实我自己还实现了通过 IP 地址获取本机地理位置的功能,然后再实现了一个获取天气信息的 API(基于高德 API),实验后我们可以发现,LLM 也能够根据所有的 MCP 工具的能力,将不同阶段的参数串起来,最终实现我想要的要求,看 Cursor 的思考和调用过程其实还是蛮有趣的:

weather


参考资料


本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

原作者: amc,原文发布于腾讯云开发者社区,也是本人的博客。欢迎转载,但请注明出处。

原文标题:《腾讯 tRPC-Go 教学——(8)通过泛 HTTP 能力实现和观测 MCP 服务》

发布日期:2025-04-18

原文链接:https://cloud.tencent.com/developer/article/2514815

CC BY-NC-SA 4.0 DEED.png


amc
927 声望229 粉丝

微电子学毕业,硬件开发转行软件工程师,混迹嵌入式和云计算多年