开场:同一个适配器,写了三遍
去年团队里流行用 Agent 处理工单。我们的工单系统是公司自研的,所以我给它写了几个工具:查工单、改状态、加评论。写得很用心,第 20 篇讲的那些原则都用上了。
一周后,前端组的同事跑过来:"你那几个工单工具挺好用的,能不能给我们用的那个 Agent 也接一下?"他们用的是另一个产品,工具的定义格式不一样,调用方式也不一样。我又写了一遍。
又过了一周,测试组也来了,他们用的是第三个产品。我看着那三份几乎一模一样、只是格式不同的代码,陷入了沉思:同样的三个工具,为什么要写三遍?
后来我发现,这正是 MCP 要解决的问题。我把工具重写成了一个 MCP server,三个团队的 Agent 都通过 MCP 接入,从此只用维护一份。再后来,工单系统加了一个"关联代码提交"的功能,我只在 server 里加了一个工具,三个团队第二天就都用上了,没有一个人来找我。
这一篇讲 MCP 是什么,以及它在底层到底是怎么工作的。我们会用一个两百来行的 Go 程序,拆开一个 MCP server,看清它和客户端之间的每一句对话。读完本篇,你应该能回答这几个问题:
- MCP 解决的 N×M 问题是什么?
- host、client、server 三个角色分别是谁?
- tools、resources、prompts 三种能力有什么区别?
- MCP 的底层协议长什么样?两种传输方式有什么不同?
- 一个 MCP 工具,最终是怎么出现在模型的上下文里的?
- MCP 和 function calling 是什么关系?
一、N×M 问题
1.1 没有标准的世界
开场的故事,放大到整个行业就是这样:
N 个 Agent 产品,M 个工具,每一对都要单独写适配,一共 N×M 套代码。每个 Agent 都要自己实现一遍"怎么连 GitHub",每个工具提供方都要为每个 Agent 写一个插件。
1.2 有了标准之后
MCP(Model Context Protocol,模型上下文协议)是 Anthropic 在 2024 年 11 月开源的一个协议。它的思路就像 USB-C:定义一个统一的接口,工具方按这个接口提供能力,Agent 按这个接口使用能力。
工具方写一个 MCP server,所有支持 MCP 的 Agent 都能用;Agent 实现一次 MCP 客户端,所有 MCP server 都能接。N×M 变成了 N+M。
MCP 发布之后的一两年里,各家主流的 Agent 产品和 IDE 陆续支持了它,社区里也出现了大量现成的 MCP server,它逐渐成了事实上的标准。这也是一个协议能不能流行的关键:它解决的问题足够普遍,而它本身足够简单。 下面你会看到,MCP 的核心部分,简单到两百行代码就能实现一个能用的客户端。
二、三个角色
MCP 里有三个角色,名字容易混,画个图就清楚了:
比如 Claude Code、Cursor、mini-agent"] L(("模型")) C1["Client 1"] C2["Client 2"] end C1 |"MCP"| S1["Server:书签"] C2 |"MCP"| S2["Server:GitHub"] style H fill:#e3f2fd,stroke:#1565c0 style S1 fill:#fff3e0,stroke:#e65100 style S2 fill:#fff3e0,stroke:#e65100
- Host(宿主):用户直接使用的那个应用,也就是 Agent 本身。Claude Code、Cursor、我们第五部分写的 mini-agent,都是 host。它负责和模型打交道,也负责管理所有的 MCP 连接;
- Client(客户端):host 内部的一个组件,每连接一个 server,就有一个 client,一对一。它负责和那个 server 说 MCP 协议;
- Server(服务端):提供能力的一方。一个 MCP server 通常包装一个外部系统:一个数据库、一个 SaaS 服务、一组本地文件。
注意 server 这个名字有点误导:它不一定是一个跑在远处的"服务器"。最常见的 MCP server,是一个在你电脑上运行的本地程序,由 host 作为子进程启动。
三、三种能力
一个 MCP server 可以向 host 提供三种东西。区别在于谁来决定什么时候用它:
| 能力 | 是什么 | 谁决定用 | 例子 |
|---|---|---|---|
| tools | 可以执行的动作 | 模型:它在循环里决定要不要调用 | 收藏一个网址、创建一个工单、执行一条 SQL |
| resources | 可以读取的数据 | 应用:host 决定什么时候读、读哪些放进上下文 | 一个文件、一张表的结构、所有标签的列表 |
| prompts | 预先写好的提示词模板 | 用户:用户主动选择使用 | "把某个标签下的书签整理成阅读清单" |
第 22 篇我们会用 Go 写一个书签管理的 MCP server,它正好三种能力都有。先看一眼它的定义(mcp-bookmark/server.go):
// ---- tools:模型可以主动调用的动作 ----
mcp.AddTool(s, &mcp.Tool{
Name: "add_bookmark",
Description: "收藏一个网址。同一个网址重复收藏时会返回已有的那条,不会重复添加。",
}, ……)
// 还有 search_bookmarks、tag_bookmark
// ---- resources:应用(而不是模型)决定何时读取的数据 ----
s.AddResource(&mcp.Resource{
URI: "bookmarks://tags",
Name: "tags",
Description: "所有标签及其使用次数",
}, ……)
// ---- prompts:用户主动选用的提示词模板 ----
s.AddPrompt(&mcp.Prompt{
Name: "reading_digest",
Description: "把某个标签下的书签整理成一份阅读清单",
}, ……)3.1 为什么要分"谁来决定"
三种能力的区别,乍看像是分类学,其实背后是一个很实际的问题:控制权在谁手里。
- tools 交给模型决定,意味着它会在你没有明确要求的时候被调用。所以 tools 是最强大、也最需要权限管控的一种:一个能"执行 SQL"的工具,模型可能在你没想到的时候去执行一条 SQL(第 27 篇);
- resources 交给应用决定,意味着模型不能自己去读。比如一个数据库 server 把每张表的结构作为 resource 暴露出来,host 可以在用户提到某张表的时候,自动把它的结构放进上下文;也可以在界面上列出来,让用户勾选。数据的读取是可预期的,不会有"模型自己去翻了一遍全库"的意外;
- prompts 交给用户决定,意味着它是一种"快捷方式"。server 的作者最清楚自己的工具该怎么组合使用,于是把一段写好的提示词模板放进 server 里,用户选中它、填上参数,就得到一段高质量的指令。
同样是"读取标签列表"这件事,做成 tool,模型会在它觉得需要的时候自己调;做成 resource,只有 host 或用户决定要看的时候才会读。选哪一种,就是在决定把这个能力交给谁。
在实践中,tools 是用得最多的,绝大部分 MCP server 主要提供的就是工具。resources 和 prompts 的支持程度,各家 host 不太一样:有的 host 会把 prompts 做成斜杠命令让用户选,有的会让用户手动把某个 resource 附加到对话里。这一篇和后面几篇,我们主要关注 tools。
四、底层:JSON-RPC
4.1 三种消息
MCP 的底层协议是 JSON-RPC 2.0,一个很老、很简单的远程调用协议。它只有三种消息:
- 请求:有
id、method、params,要求对方回复; - 响应:有同一个
id,以及result(成功)或error(失败); - 通知:有
method、params,但没有id,不需要回复。
就这么多。MCP 在这之上定义了一组具体的 method:initialize、tools/list、tools/call、resources/read……
4.2 一次完整的对话
配套代码 examples/ch21-mcp-probe 是一个"探针":它扮演 MCP 客户端,连上一个 server,把每一行 JSON-RPC 往来打印出来。我们拿第 22 篇的书签 server 跑一下,下面是真实的输出(为了好读,我把长的行截短了):
第一步:握手。
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},
"clientInfo":{"name":"agent-from-zero","version":"0.1.0"},"protocolVersion":"2025-06-18"}}
← {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"prompts":{"listChanged":true},
"resources":{"listChanged":true},"tools":{"listChanged":true}},
"instructions":"个人书签管理。收藏前先用 search_bookmarks 看看是否已经收藏过。",
"protocolVersion":"2025-06-18","serverInfo":{"name":"bookmark","version":"0.1.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}→ 是客户端发给 server 的,← 是 server 回给客户端的。
客户端先发一个 initialize 请求,告诉 server:我是谁,我支持哪个版本的协议,我有什么能力。server 回复:我是谁(bookmark 0.1.0),我支持哪个版本,我提供哪些能力(tools、resources、prompts、logging 都有),还附带了一段 instructions,这是 server 给模型的使用说明,host 可以把它放进上下文。
然后客户端发一个 notifications/initialized 通知(注意它没有 id),表示"握手完成,可以开始干活了"。
第二步:列出工具。
→ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add_bookmark",
"description":"收藏一个网址。同一个网址重复收藏时会返回已有的那条,不会重复添加。",
"inputSchema":{"type":"object","properties":{"url":{"type":"string",
"description":"要收藏的网址,必须以 http:// 或 https:// 开头"}, ……}}}, ……]}}server 返回了它的所有工具,每个工具有 name、description、inputSchema。这和第 19 篇的工具定义几乎一模一样:名字、描述、JSON Schema。
第三步:调用工具。
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"arguments":{"query":"go"},"name":"search_bookmarks"}}
← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text",
"text":"找到 1 条:\n#1 Go Blog\n https://go.dev/blog\n 标签:go"}]}}客户端发 tools/call,带上工具名和参数;server 执行,返回结果。结果是一组内容块,最常见的是文本。
就这么简单。整个 MCP 客户端,配套仓库只用了两百来行标准库代码就实现了(internal/mcpclient/client.go),没有任何魔法。
4.3 出错的两种方式
tools/call 失败有两种情况,要分清楚:
- 协议错误:JSON-RPC 的
error字段,比如方法不存在、参数格式不对。这是"调用本身出了问题"; - 工具执行出错:
result里的isError: true,content里是错误信息。比如"这个网址格式不对"。这是"工具正常执行了,但结果是失败的"。
后一种要交给模型,让它看到错误、自己纠正(第 20 篇);前一种通常是 host 或 server 的 bug。
4.4 协议版本和能力协商
再回头看一眼握手里的两个字段:protocolVersion 和 capabilities。
MCP 的版本号是一个日期,比如上面的 2025-06-18。协议还在演进,每隔几个月会有新版本,加一些新功能。握手时,客户端说"我支持这个版本",server 回复它实际要用的版本;双方谈不拢,就不能继续。
capabilities 是双方各自声明"我支持哪些功能"。server 说它支持 tools、resources、prompts、logging,客户端就知道可以调哪些方法;客户端也可以声明自己的能力,server 据此决定要不要用某些需要客户端配合的功能(下一节讲)。配套的探针发的 capabilities 是一个空对象,意思是"我什么额外的能力都不提供",这对一个只调工具的客户端来说就够了。
先协商、后干活,这是很多成熟协议的做法。它让新旧版本的客户端和 server 可以共存:一个老客户端连上一个新 server,双方按两边都支持的那部分来。
4.5 不只是一问一答
上面的例子里,都是客户端问、server 答。但 MCP 的通信是双向的,server 也可以主动发消息:
- 列表变了的通知:server 的工具列表可以在运行中变化,比如用户登录之后多了几个工具。这时 server 发一个
notifications/tools/list_changed,客户端收到后重新tools/list。握手响应里的"listChanged": true,说的就是"我会发这种通知"; - 日志和进度:一个要跑很久的工具,可以一边执行一边发进度通知,host 可以显示一个进度条;
- server 反过来请求客户端:这是一个容易被忽略的方向。协议里定义了几种 server 发给客户端的请求,比如请求客户端"替我调用一次模型"(sampling),或者请求客户端"替我问用户一个问题"(elicitation)。前者让 server 自己不用持有模型的 Key,后者让 server 在需要确认的时候能停下来问人。这些功能要客户端在握手时声明支持才能用,各家 host 的支持程度差别很大(截至写作时,以各自的文档为准)。
配套仓库的最小客户端,对所有通知都直接忽略(internal/mcpclient/client.go 里 readLoop 有一行注释写着"这个最小客户端直接忽略"),server 反过来发的请求,除了用来探活的 ping 会正常回应,其他一律回复"方法不存在"。对于"列工具、调工具"这个最核心的用法,这已经够了。但你要知道,一个完整的 MCP host 要处理的事情,比两百行多得多。
五、两种传输方式
JSON-RPC 消息要通过某种通道传过去。MCP 定义了两种:
5.1 stdio:本地子进程
host 把 server 作为子进程启动,往它的 stdin 写,从它的 stdout 读,一行一条 JSON 消息。
这是最常见的方式。它简单、安全(server 只在本地运行,不暴露任何端口)、不需要鉴权。代价是:server 只能在本地跑,每个 host 都要自己启动一份。
有一个非常重要的细节:stdout 是协议通道。server 往 stdout 打印的任何东西,都会被当成 JSON-RPC 消息。如果你在 server 里随手 fmt.Println("调试一下"),客户端就会收到一行无法解析的垃圾,协议直接乱掉。日志只能写到 stderr。第 22 篇写 server 时会再提醒一次。
5.2 Streamable HTTP:远程服务
server 作为一个 HTTP 服务运行,客户端通过 HTTP 请求和它通信,需要时 server 还可以用流式的方式推送消息。
它适合远程的、多人共享的服务:一个公司内部的工单 MCP server,部署一份,所有人的 Agent 都连它。代价是需要处理鉴权、网络、部署这些"服务器"该有的问题。第 23 篇会讲远程 server 和鉴权。
5.3 怎么选
| stdio | Streamable HTTP | |
|---|---|---|
| server 在哪里跑 | 你的电脑上,作为 host 的子进程 | 任何地方,作为一个独立的服务 |
| 谁能用 | 只有启动它的那个 host | 所有能连上它的 host |
| 鉴权 | 不需要,它继承你的权限 | 需要,通常是 OAuth 或者 token |
| 典型场景 | 读本地文件、操作本地 git、连本地数据库 | 公司内部的共享服务、第三方的 SaaS |
| 最大的风险 | 它在你的机器上跑,拥有你的全部权限 | 网络上的一切风险,加上鉴权出错 |
stdio 那一行"最大的风险"值得多看一眼:一个 stdio 的 MCP server,就是你亲手在自己电脑上运行的一个程序。 它能读你的文件、能联网、能做你能做的任何事。从网上随便找一个 MCP server 装上,和从网上随便下载一个可执行文件运行,本质上是一回事。第 23 篇会细说。
六、MCP 工具是怎么进入上下文的
现在把 MCP 和前几篇连起来。一个 MCP 工具,从 server 到模型,经历了这些:
工具定义格式 H->>M: 请求(带上这些工具定义) M-->>H: tool_use:add_bookmark(url=...) H->>S: tools/call add_bookmark S-->>H: 结果 H->>M: tool_result
关键的一点:模型根本不知道 MCP 的存在。 在模型眼里,add_bookmark 和 read_file 没有任何区别,都只是第 19 篇讲的那种工具定义。是 host 在中间做了翻译:从 server 拿到 MCP 格式的工具定义,翻译成模型 API 的格式放进请求;模型发起调用时,再翻译成 MCP 的 tools/call 发给 server。
探针程序的最后一部分,就是把这个翻译过程打印出来:
// MCP 的工具定义和模型 API 的工具定义几乎一模一样,翻译一下字段名就能塞进请求。
var modelTools []llm.Tool
for _, t := range tools {
modelTools = append(modelTools, llm.Tool{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
}对书签 server 跑一下,输出的最后会告诉你:三个工具,翻译成模型 API 的工具定义之后,一共 2415 字节,每次请求都要带上。这只是一个只有三个工具的小 server。接入一个 MCP server,就等于在每次请求里多带这么一段。 第 23 篇会专门讨论这个代价。
6.1 那段 instructions 去哪里了
握手时 server 返回的那段 instructions("收藏前先用 search_bookmarks 看看是否已经收藏过"),协议的设计意图是让 host 放进上下文,告诉模型这个 server 该怎么用。
但放不放、放在哪里,完全是 host 的决定。配套的客户端把它读出来存在了 Instructions 字段里,mini-agent 却没有用它:接入 MCP 时只注册了工具,没有把这段说明拼进 system prompt。这是一个很好的小练习:在 cmd/mini-agent/main.go 里,把每个 server 的 instructions 用 <mcp_server name="..."> 标签包起来,拼进 system prompt。改完之后,模型在收藏网址之前,会不会更倾向于先搜一下?
不过要注意一件事:instructions 是 server 写的,你没法控制它的内容。 把它原样拼进 system prompt,等于让一个第三方程序往你的 system prompt 里写字。对你自己写的 server 没问题;对来路不明的 server,这正是第 23 篇"工具投毒"的入口之一。
6.2 MCP 会取代 function calling 吗
经常有人问这个问题。答案是:它们不在同一层,谈不上谁取代谁。
- function calling 是模型和 host 之间的协议:模型怎么表达"我想调用工具",host 怎么把结果交回去(第 19 篇);
- MCP 是host 和工具之间的协议:工具怎么告诉 host"我有哪些能力",host 怎么去调用它。
一次 MCP 工具调用,两个协议都要走一遍:模型通过 function calling 告诉 host 它想调用 add_bookmark,host 再通过 MCP 的 tools/call 去请 server 执行。MCP 没有替代 function calling,它是建在 function calling 之上的。 没有模型的工具调用能力,MCP 的工具也没人去调。
七、动手:拆开一个 MCP server
实验 1:官方的演示 server
这一篇的实验都要启动子进程,在线 Playground 演示不了,请在本地或 Codespaces 里运行。不调用模型,不需要 Key。
MCP 官方提供了一个用来演示协议各种功能的 server,用 Node.js 写的:
go run ./examples/ch21-mcp-probe -- npx -y @modelcontextprotocol/server-everything-- 后面是启动 server 的命令。探针会启动它,完成握手,列出所有工具,打印每一行 JSON-RPC。这个演示 server 的工具很多,看看翻译成模型 API 格式之后有多少字节。
顺便调用一个工具:
go run ./examples/ch21-mcp-probe -call echo -args '{"message":"hi"}' -- npx -y @modelcontextprotocol/server-everything实验 2:第 22 篇的书签 server
go -C mcp-bookmark build -o /tmp/mcp-bookmark . # 独立模块,用 -C 进它的目录编译
go run ./examples/ch21-mcp-probe -- /tmp/mcp-bookmark -db /tmp/bm.json
go run ./examples/ch21-mcp-probe -call add_bookmark -args '{"url":"https://go.dev/blog","title":"Go Blog","tags":["go"]}' -- /tmp/mcp-bookmark -db /tmp/bm.json
go run ./examples/ch21-mcp-probe -call search_bookmarks -args '{"query":"go"}' -- /tmp/mcp-bookmark -db /tmp/bm.json(mcp-bookmark 是一个独立的 Go 模块,需要 Go 1.25 以上。)
对照 4.2 节,自己把三个阶段的消息找一遍。加上 -q 可以不打印 JSON-RPC 往来,只看结果。
实验 3:读一读客户端的代码
打开 internal/mcpclient/client.go,两百来行,重点看这几个函数:
Start:用os/exec启动 server 子进程,拿到它的 stdin 和 stdout;readLoop:一个 goroutine 不停地从 stdout 读行,按id把响应分发给等待的请求;call:发一个请求,等它的响应;Initialize、ListTools、CallTool:在call之上包一层。
读完你会发现,MCP 客户端就是"启动一个子进程,和它用 JSON 一问一答"。再对照 4.5 节,看看 readLoop 里是怎么区分"响应""server 的请求"和"通知"这三种消息的。
实验 4:在 Agent 里用起来
把 MCP server 接进一个 Agent,看看它的工具是怎么出现的:
mini-agent:仓库里有一份示例配置 agent/mcp/example.mcp.json,接好书签 server 之后,启动信息里会显示"MCP server 1 个",工具会以
mcp__bookmark__add_bookmark这样的名字注册。需要DEEPSEEK_API_KEY,第 31 篇细讲:go run ./cmd/mini-agent -mcp agent/mcp/example.mcp.json- 你常用的编码 Agent:大纲里这一篇的动手作业是"给 Claude Code 接入一个现成的 MCP server"。截至写作时,Claude Code 可以用
claude mcp add命令添加 server,其他产品一般也有类似的配置方式,具体以各自的文档为准。接好之后,留意它的工具列表里多了哪些工具。
实验 5:给 mini-agent 拼上 instructions
按 6.1 节的思路,把每个 MCP server 的 instructions 拼进 mini-agent 的 system prompt。改完之后用探针确认一下书签 server 返回的那段说明,再启动 mini-agent,让它"帮我收藏一下 https://go.dev/blog"。对比改之前和改之后,它在调用 add_bookmark 之前,会不会先调用一次 search_bookmarks。
八、踩坑记录:MCP 的七个坑
坑 1:server 往 stdout 打日志。
stdio 模式下,stdout 是协议通道。一行调试输出就能让协议乱掉,而且报错信息往往很难看懂("无法解析 JSON")。日志一律写 stderr。
坑 2:忘了发 initialized 通知。
握手是三步,不是两步。只发了 initialize、收到了响应,没发 notifications/initialized,有的 server 会拒绝后续的请求。
坑 3:分不清协议错误和工具错误。
把 isError: true 当成异常抛出去,循环就中断了;把 JSON-RPC 的 error 交给模型,模型看到一段协议层的报错也不知道该怎么办。前者交给模型,后者交给开发者。
坑 4:以为 MCP server 是可信的。
MCP server 的工具描述会原样进入模型的上下文,它的返回结果也会。一个恶意的(或者被攻破的)server,可以在描述里藏指令,诱导模型做你不想做的事。第 23 篇会演示"工具投毒"。
坑 5:一口气接了十几个 server。
每个 server 都会带来一批工具定义,全部塞进每一次请求。接得越多,上下文越挤,模型越容易选错工具。只接真正需要的,第 23 篇会算这笔账。
坑 6:随手装来路不明的 server。
"这个 MCP server 能帮你管理 GitHub,一行命令就能装上",一行命令背后,是一个在你电脑上以你的身份运行的程序。装之前看看它是谁写的、代码在哪里、有多少人在用;能固定版本就固定版本,别每次启动都拉最新的。5.3 节那张表的最后一行,说的就是这个。
坑 7:不同 server 的工具名撞车。
两个 server 都有一个叫 search 的工具,host 如果不做处理,后注册的会覆盖先注册的,或者模型根本分不清该调哪一个。mini-agent 的做法是把工具名改写成 mcp__<server>__<tool>,第 31 篇细讲。
一句话总结
MCP 是 Agent 和工具之间的统一接口,把 N×M 的适配变成了 N+M:host 里的 client 和 server 一对一地说 JSON-RPC,握手、列工具、调工具,传输用本地的 stdio 或远程的 HTTP;对模型来说,MCP 工具和普通工具没有区别,是 host 在中间做了翻译。
tools / resources / prompts"] |"JSON-RPC
stdio 或 HTTP"| H["Host
(翻译)"] H |"工具定义 / tool_use / tool_result"| M(("模型
不知道 MCP 的存在")) style H fill:#fff3e0,stroke:#e65100
评论
还没有评论,来说点什么吧。