最近 MCP 大火,其实 tRPC 也可以提供泛 HTTP 接入的能力。内网其实已经对 mcp-go 进行了封装并支持,但是相关代码还没有同步到开源版上。
不过实际上,在 tRPC 框架也是可以接入各种泛 HTTP 能力的。本文就以 mcp-go 和 tRPC 结合作为引子,也介绍一下在 Cursor 等 AI 生产力工具中如何开发和使用 MCP 能力吧。
系列文章
- 腾讯 tRPC-Go 教学——(1)搭建服务
- 腾讯 tRPC-Go 教学——(2)trpc HTTP 能力
- 腾讯 tRPC-Go 教学——(3)微服务间调用
- 腾讯 tRPC-Go 教学——(4)tRPC 组件生态和使用
- 腾讯 tRPC-Go 教学——(5)filter、context 和日志组件
- 腾讯 tRPC-Go 教学——(6)服务发现
- 腾讯 tRPC-Go 教学——(7)服务配置和指标上报
- 腾讯 tRPC-Go 教学——(8)通过泛 HTTP 能力实现和观测 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:
在 Settings tab,找到 MCP:
选择 "+ Add new global MCP server",填入以下内容:
{
"mcpServers": {
"demo_mcp": {
"url": "http://localhost:8080/mcp/sse"
}
}
}
这个 /mcp/sse
的路径,就对应了前文我提到的 sse 接口。
如果你的服务还没启动,你关掉配置页之后可能会发现出现这样的错误:
如果你按照我前文所说的 go run .
启动了的话,或者是启动之后,点一下右上角的刷新按钮,那么就会看到朴实无华的绿灯
测试
绿灯亮起后,我们在 Cursor 中验证一下 MCP 工具是否生效:
这里我把思考过程和 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 的思考和调用过程其实还是蛮有趣的:
参考资料
- 使用Go开发MCP Server, 太简单了! - ThinkInAI 社区
- 如何使用Golang创建MCP Server - 潘子夜个人博客
- 几十行代码轻松打造属于自己的MCP服务器
- 認識 MCP Go 工具
本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
原作者: amc,原文发布于腾讯云开发者社区,也是本人的博客。欢迎转载,但请注明出处。
原文标题:《腾讯 tRPC-Go 教学——(8)通过泛 HTTP 能力实现和观测 MCP 服务》
发布日期:2025-04-18
原文链接:https://cloud.tencent.com/developer/article/2514815。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。