MCP 入门,工具界的 USB-C

从 N×M 集成问题出发,介绍 MCP 的 host、client、server、tools/resources/prompts、JSON-RPC 与传输方式。

outlier 17 分钟 6514 字 0 0
系列教程Agent从入门到放弃 第 22 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:同一个适配器,写了三遍

去年团队里流行用 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 没有标准的世界

开场的故事,放大到整个行业就是这样:

flowchart LR subgraph A["N 个 Agent"] A1["Agent A"] A2["Agent B"] A3["Agent C"] end subgraph T["M 个工具"] T1["GitHub"] T2["数据库"] T3["工单系统"] T4["文档系统"] end A1 --- T1 & T2 & T3 & T4 A2 --- T1 & T2 & T3 & T4 A3 --- T1 & T2 & T3 & T4

N 个 Agent 产品,M 个工具,每一对都要单独写适配,一共 N×M 套代码。每个 Agent 都要自己实现一遍"怎么连 GitHub",每个工具提供方都要为每个 Agent 写一个插件。

1.2 有了标准之后

MCP(Model Context Protocol,模型上下文协议)是 Anthropic 在 2024 年 11 月开源的一个协议。它的思路就像 USB-C:定义一个统一的接口,工具方按这个接口提供能力,Agent 按这个接口使用能力。

flowchart LR subgraph A["N 个 Agent(host)"] A1["Agent A"] A2["Agent B"] A3["Agent C"] end M(("MCP")) subgraph S["M 个 MCP server"] S1["GitHub server"] S2["数据库 server"] S3["工单 server"] S4["文档 server"] end A1 & A2 & A3 --- M M --- S1 & S2 & S3 & S4 style M fill:#fff3e0,stroke:#e65100

工具方写一个 MCP server,所有支持 MCP 的 Agent 都能用;Agent 实现一次 MCP 客户端,所有 MCP server 都能接。N×M 变成了 N+M。

MCP 发布之后的一两年里,各家主流的 Agent 产品和 IDE 陆续支持了它,社区里也出现了大量现成的 MCP server,它逐渐成了事实上的标准。这也是一个协议能不能流行的关键:它解决的问题足够普遍,而它本身足够简单。 下面你会看到,MCP 的核心部分,简单到两百行代码就能实现一个能用的客户端。

二、三个角色

MCP 里有三个角色,名字容易混,画个图就清楚了:

flowchart LR subgraph H["Host(宿主)
比如 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 或用户决定要看的时候才会读。选哪一种,就是在决定把这个能力交给谁。

flowchart LR T["tools"] -->|"模型决定"| M(("模型")) R["resources"] -->|"应用决定"| H["Host"] P["prompts"] -->|"用户决定"| U["用户"] style T fill:#ffcdd2,stroke:#b71c1c style R fill:#e3f2fd,stroke:#1565c0 style P fill:#e8f5e9,stroke:#2e7d32

在实践中,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 执行,返回结果。结果是一组内容块,最常见的是文本。

sequenceDiagram participant C as Client participant S as Server C->>S: initialize(我是谁、协议版本、能力) S-->>C: 我是谁、协议版本、我提供的能力 C-)S: notifications/initialized(通知,不用回) C->>S: tools/list S-->>C: 工具列表(名字、描述、inputSchema) C->>S: tools/call(工具名 + 参数) S-->>C: 结果(内容块)

就这么简单。整个 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 要处理的事情,比两百行多得多。

sequenceDiagram participant C as Client participant S as Server C->>S: tools/call(一个要跑很久的工具) S-)C: 进度通知:30% S-)C: 进度通知:80% S-->>C: 结果 S-)C: notifications/tools/list_changed C->>S: tools/list(重新拉一遍)

五、两种传输方式

JSON-RPC 消息要通过某种通道传过去。MCP 定义了两种:

5.1 stdio:本地子进程

host 把 server 作为子进程启动,往它的 stdin 写,从它的 stdout 读,一行一条 JSON 消息。

flowchart LR H["Host"] -->|"启动子进程"| S["Server 进程"] H -->|"写 stdin:请求"| S S -->|"写 stdout:响应"| H S -.->|"stderr:日志"| L["日志(不是协议)"] style S fill:#fff3e0,stroke:#e65100

这是最常见的方式。它简单、安全(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 怎么选

stdioStreamable HTTP
server 在哪里跑你的电脑上,作为 host 的子进程任何地方,作为一个独立的服务
谁能用只有启动它的那个 host所有能连上它的 host
鉴权不需要,它继承你的权限需要,通常是 OAuth 或者 token
典型场景读本地文件、操作本地 git、连本地数据库公司内部的共享服务、第三方的 SaaS
最大的风险它在你的机器上跑,拥有你的全部权限网络上的一切风险,加上鉴权出错

stdio 那一行"最大的风险"值得多看一眼:一个 stdio 的 MCP server,就是你亲手在自己电脑上运行的一个程序。 它能读你的文件、能联网、能做你能做的任何事。从网上随便找一个 MCP server 装上,和从网上随便下载一个可执行文件运行,本质上是一回事。第 23 篇会细说。

六、MCP 工具是怎么进入上下文的

现在把 MCP 和前几篇连起来。一个 MCP 工具,从 server 到模型,经历了这些:

sequenceDiagram participant S as MCP Server participant H as Host(Agent) participant M as 模型 H->>S: tools/list S-->>H: add_bookmark 等工具定义 Note over H: 翻译成模型 API 的
工具定义格式 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 在中间做了翻译。

flowchart LR S["MCP Server
tools / resources / prompts"] |"JSON-RPC
stdio 或 HTTP"| H["Host
(翻译)"] H |"工具定义 / tool_use / tool_result"| M(("模型
不知道 MCP 的存在")) style H fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭