MCP 接入 v08
MCP 接入 v08
v05(ch18)我们给产品装了权限层:每个工具声明风险档位,破坏性操作要过审批门。但有一个问题被一路搁置:工具从哪来?
回顾 ch08 的工具系统,所有工具都长在注册表里:get_time、get_weather、add 是我们自己写的;真要接外部系统,比如查 GitHub 的 issue、读数据库、操作浏览器,就得为每一个系统写一套适配器,把它的 API 翻译成工具 spec + 执行函数。接 N 个系统写 N 套适配器,每套都要处理鉴权、错误、参数映射。工具越多,注册表越像一堆「各家方言」的堆积。
本章给产品加 v08:MCP 接入,用 Model Context Protocol(模型上下文协议) 把「工具如何暴露、如何发现、如何调用」标准化:
- 手写 JSON-RPC 2.0 over stdio,不装 MCP SDK:消息编解码、
Content-Length帧读写、握手生命周期全部自己写; - 实现最小 MCP server(暴露一个
echo工具)与最小 MCP client(initialize→notifications/initialized→tools/list→tools/call); - 通过
registerMcpTools把远程工具并入本地注册表:主循环、schema 生成、ch18 的风险分级对本地/远程工具一视同仁。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出「工具协议各自为政」的问题,以及 MCP 为什么能统一「工具暴露 → 发现 → 调用」的流程;
- 手写 JSON-RPC 2.0 的三种消息形态(request / notification / response)、错误码与 id 关联,解释响应乱序到达时 id 为什么是唯一配对键;
- 说出 MCP stdio transport 的帧格式(
Content-Length头 + JSON 体)与字节计数的意义,以及它与「换行分隔 JSON」的渊源; - 画出 MCP 客户端生命周期状态机(
new → initialized → ready → closed)与握手时序,解释「未握手不能调用工具」的校验; - 实现
registerMcpTools:把tools/list的结果映射为本地注册表项,executor 转发tools/call,并说明命名空间前缀与风险分级的继承; - 说出本章的失败模式:帧边界错位、id 匹配失败、初始化时序错乱、错误处理不当、日志污染 stdout。
概念与动机:一条协议,统一所有工具
先看一个「错误答案」:为每个系统写适配器
假设我们要让 Agent 能查 GitHub、能读数据库、能控制浏览器。最直接的思路:给每个系统写一个「桥接工具」:
// 每个系统一套适配器——这是 ch08 时代的常态
registry.register({
name: 'github_get_issue',
description: 'Fetch an issue from GitHub by repo + number.',
parameters: [
{ name: 'repo', type: 'string', description: 'owner/name' },
{ name: 'number', type: 'number', description: 'Issue number.' },
],
}, async (args) => {
const res = await fetch(`https://api.github.com/repos/${args.repo}/issues/${args.number}`, {
headers: { authorization: `Bearer ${GITHUB_TOKEN}` },
});
return res.text();
});
registry.register({
name: 'db_query',
description: 'Run a SELECT query against the demo database.',
parameters: [{ name: 'sql', type: 'string', description: 'SQL to run.' }],
}, async (args) => {
// 数据库 SDK、连接池、结果格式化……又是一套
});
问题在于:每一个系统都发明了自己的工具协议。GitHub 用 REST,数据库用 SQL 驱动,浏览器用 DevTools 协议,注册表里全是方言。而且这些工具只在你的 Agent 里可用:另一个 Agent(比如 Claude Code、Cursor)想用你写的 GitHub 桥接,得把你的适配器逻辑抄过去重写一遍。
这像极了「每个网站自己发明一套登录协议」的时代,所以业界需要一个统一标准,就像 HTTP 统一了网页,OAuth 统一了授权。
MCP 是什么:把「暴露工具」变成一种协议
MCP(Model Context Protocol)是 Anthropic 于 2024 年 11 月开源的开放协议,目标是让 AI 应用以统一方式接入外部工具和数据源(官方发布博客)。它把角色分成两半:
- MCP server:工具的所有者。声明「我提供这些工具」(
tools/list),并执行它们的调用(tools/call)。GitHub、数据库、浏览器的官方 MCP server 就是这个角色; - MCP client:工具的使用者(我们的 Agent 就是 client)。发现 server 的工具表,把远程工具当作本地工具调用。
flowchart LR
subgraph client["MCP client(我们的 Agent)"]
LOOP["主循环(ch14)"]
REG["本地工具注册表(ch08)"]
MCPC["最小 MCP client(本章手写)"]
PERM["风险分级 + 审批门(ch18)"]
LOOP --> REG
REG --> PERM
PERM --> MCPC
end
subgraph wire["传输:stdio 子进程 + JSON-RPC 2.0 帧"]
FRAME["Content-Length 帧读写"]
end
subgraph server["MCP server(外部系统)"]
S1["GitHub MCP server"]
S2["数据库 MCP server"]
S3["本章手写的最小 server(echo)"]
end
MCPC -->|initialize / tools/list / tools/call| FRAME
FRAME -->|stdin / stdout| S1
FRAME -->|stdin / stdout| S2
FRAME -->|stdin / stdout| S3
协议只定义消息长什么样、按什么顺序发,不管工具具体做什么。于是「接一个新系统」从「写一套适配器」变成「spawn 它的 MCP server,然后发现它的工具」,工具本身对 Agent 是透明的。
参照:业界怎么用 MCP
- Claude Code 把 MCP 作为接入外部工具的一等公民:配置里声明 server(
claude mcp add),客户端自动完成握手并把远程工具并入提示上下文(learn-claude-code 的 MCP 章节); - build-your-own-agent 把「从零手写 MCP server + client」列为 Agent 技术栈的 10 个组件之一(§5),结论是「一个能用的 server 只需要三个方法:
initialize、tools/list、tools/call」(reference/05-mcp-server-client); - MCP 官方协议文档把消息层建立在 JSON-RPC 2.0 之上,并定义了 stdio / Streamable HTTP 两种标准传输(MCP 协议文档 · Basic)。
我们借鉴的是「统一协议 + 生命周期 + 工具发现」这个机制;帧读写、状态机、注册表桥接的实现都是我们自己的。
JSON-RPC 2.0 与 stdio transport
MCP 的每一种消息都是 JSON-RPC 2.0(官方规范)。所以「手写 MCP」的第一步,是手写 JSON-RPC。
三种消息形态
| 形态 | 形状 | 语义 |
|---|---|---|
| request | { jsonrpc: '2.0', id, method, params? } | 请求对方做事,期望响应 |
| notification | { jsonrpc: '2.0', method, params? } | 单向通知,没有 id、没有响应 |
| response | { jsonrpc: '2.0', id, result } / { jsonrpc: '2.0', id, error } | 回应请求;result 与 error 二选一 |
三句话记牢:
- id 是配对键。客户端发出多个请求(握手、列工具、调工具),响应可以乱序到达,
id是唯一能把响应和「在途请求」重新配对的钥匙。每个 JSON-RPC client 的心跳都是同一个东西:一个pendingMap,key 是 id,value 是等响应的 promise; - notification 永远没有响应。MCP 的
notifications/initialized是典型例子:客户端说「握手完成」,服务端绝不能回(回了就是一条没人等、没人认领的孤儿响应); - 错误是响应的一种。失败时响应带
error: { code, message, data? }。错误码是标准整数:-32700解析错误、-32600非法请求、-32601方法不存在、-32602参数非法、-32603内部错误(-32000到-32099留给实现自定义)。
stdio transport:一帧 = Content-Length 头 + JSON 体
MCP 的 stdio transport 把消息从子进程的 stdin/stdout 传出去:client spawn server 进程,server 从 stdin 读消息、往 stdout 写消息,日志走 stderr(MCP 协议文档 · Transports)。问题是:stdout 是一根字节流,消息之间怎么划界?
协议文档的文字版本说过「换行分隔 JSON(NDJSON)」,但现网的参考实现,TypeScript SDK(Claude Code 等大多数 MCP client 背后就是它),实际用的是 LSP 风格的长度前缀帧,这也是本章实现的格式:
Content-Length: <JSON 体的 UTF-8 字节数>\r\n\r\n<JSON body>
为什么长度前缀更稳?因为 JSON 体里可以出现任何字符(换行、中文、emoji),换行分隔法遇到含换行的 JSON 就炸了;Content-Length 告诉读取端「这个帧总共读多少字节」,读到就切,跟内容无关。字节数必须按 UTF-8 字节 数,不是字符数:"你好" 是 6 个字节,按字符串长度 2 算帧就错位了。
最小 MCP server:三个方法就够了
一个「能用的」工具型 MCP server 只需要实现三个方法:
| 方法 | 做什么 | 响应 |
|---|---|---|
initialize | 能力协商:双方报协议版本与能力 | { protocolVersion, capabilities: { tools: {} }, serverInfo } |
tools/list | 列出暴露的工具 | { tools: [{ name, description, inputSchema }] } |
tools/call | 执行一个工具 | { content: [{ type: 'text', text }] } |
再加上接收 notifications/initialized 通知(不回复)。本章的 server 暴露一个 echo 工具(MCP 世界里的 hello-world):参数 { text },原样返回。
这里藏着 MCP 最重要的一条错误处理规则(也是 ch08 error-as-data 的协议版):
- 调用不存在的工具 → 协议错误(JSON-RPC
error,-32602),客户端拿到的是「这个方法不可用」; - 工具存在但运行失败 → 数据(正常
result,但带isError: true),因为模型的职责就是看到失败并恢复:换个参数重试、换工具、或者告诉用户。如果运行失败也回 JSON-RPC error,消息交换直接断了,模型连「刚才发生了什么」都看不到。
所以 tools/call 里 try/catch 的是工具函数,不是整个方法分派。
最小 MCP client:一条生命周期
客户端把「连接一个 server」建模成状态机,先握手、后干活:
stateDiagram-v2
[*] --> new
new --> new: initialize() 发出(等响应)
new --> initialized: 收到 initialize 成功响应(记住 serverInfo)
initialized --> ready: 发出 notifications/initialized 通知
ready --> ready: tools/list / tools/call
ready --> closed: close()(stdin EOF)
new --> new: initialize 失败(可重试)
new --> closed: close()
initialized --> closed: close()
| 状态 | 到达条件 | 合法操作 |
|---|---|---|
new | 创建连接 | 只能 initialize()(一次) |
initialized | initialize 成功响应到达 | 只能 notifyInitialized() |
ready | 已发 notifications/initialized | tools/list、tools/call、close() |
closed | close() 或进程退出 | 无(一切调用拒绝) |
时序上,完整握手是四个步骤,这也是每个 MCP client(Claude Code、Cursor、我们的 demo)与每个 MCP server 之间必然发生的对话:
sequenceDiagram
participant CLIENT as MCP client(我们的 Agent)
participant STREAM as stdio 子进程管道
participant SERVER as MCP server(echo)
CLIENT->>SERVER: initialize { protocolVersion, capabilities, clientInfo }(request, id:1)
SERVER-->>CLIENT: result { protocolVersion, capabilities, serverInfo }(response, id:1)
CLIENT->>SERVER: notifications/initialized(notification,无 id)
Note over CLIENT,SERVER: 握手完成:客户端进入 ready
CLIENT->>SERVER: tools/list(request, id:2)
SERVER-->>CLIENT: result { tools: [echo] }
CLIENT->>SERVER: tools/call { name: "echo", arguments: { text } }(request, id:3)
SERVER-->>CLIENT: result { content: [{ type: "text", text }] }
注意两个「时序纪律」:notifications/initialized 必须在任何工具调用之前发出(server 端以它为「可以干活」的信号,本章的 server 在收到它之前会拒绝 tools/list 与 tools/call);initialize 请求只发一次,失败的握手可以重试,但一个在途的握手不允许再来一个。
工具并入注册表:远程工具 = 普通工具
MCP client 发现工具之后,最关键的一步是把远程工具并入本地注册表(ch08 的工具系统)。registerMcpTools 做的事,本质上是一条桥:
client.listTools()拿到[{ name, description, inputSchema }];- 每个工具
register进本地注册表:inputSchema(JSON Schema)转成 ch08 的spec.parameters数组(schemaToParams),name/description照搬; - executor 是一个薄薄的转发器:
args → client.callTool(name, args) → mcpResultToText(结果)。
并入之后,远程工具在注册表里和 get_time 没有任何区别:
modelTools()把它交给模型(name + description + 生成的 schema);executeTool()分发调用、把失败当数据;- ch18 的风险分级自动生效,
registerMcpTools给远程工具一个risk(默认auto,可传入'suggest'/'approve'),于是「远程 server 里那个删文件的工具」同样要过审批门。
还有两个工程细节:命名空间前缀(options.prefix,如 'mcp__' → mcp__echo)避免远程工具与本地工具重名,真实集成里 Claude Code 用的就是 mcp__<server>__<tool> 的格式;isError 兼容:server 端返回 isError: true 时,executor 仍把内容当字符串回填给模型(失败是数据,模型能读)。
手写实现:把协议装进产品
产品新增三个模块 + 一处演进。分层红线不变:编解码与帧读写是纯函数/传输层,不依赖界面;主循环(loop)一个字没改——它只看到注册表。
一、帧读写(lib/mcp-stdio.mjs)
写帧就是把 Content-Length 头和 JSON 体拼起来,读帧则要处理半截帧:stdout 的数据按任意边界到达,一帧可能被切成几块,几帧也可能挤在一个 chunk 里:
export function encodeFrame(message) {
const body = JSON.stringify(message);
// 字节数,不是字符数——中文会撑破帧
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
}
export class FrameReader {
constructor() { this.buffer = Buffer.alloc(0); }
push(chunk) {
this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]);
const messages = [];
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) break; // 头还没完整,等更多字节
const header = this.buffer.subarray(0, headerEnd).toString('utf8');
const match = /^Content-Length:\s*(\d+)$/im.exec(header);
if (!match) { // 坏帧:跳过垃圾继续找
this.buffer = this.buffer.subarray(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const bodyStart = headerEnd + 4;
if (this.buffer.length < bodyStart + length) break; // 体还没完整,等
const body = this.buffer.subarray(bodyStart, bodyStart + length).toString('utf8');
this.buffer = this.buffer.subarray(bodyStart + length);
try { messages.push(JSON.parse(body)); } catch { /* 坏体丢弃,不致命 */ }
}
return messages;
}
}
要点是累积缓冲 + 循环切帧:一次 push 可能产出 0 到多帧,切完剩下的留到下一次。字节缓冲(不是字符串拼接)保证了多字节字符被从中间切开时不会损坏。
二、编解码纯函数(lib/jsonrpc.mjs)
编解码是纯函数:encode 四个(request / notification / result response / error response),decode 一个(parseMessage,解析 + 校验),分类三个(isRequest / isNotification / isResponse),配对一个(responseTo)。校验规则是 JSON-RPC 的形状约束:jsonrpc 必须是 "2.0"、必须「有 id 或有 method」、id 不能是 null、response 必须带 result 或 error 之一。这些函数不进 I/O,浏览器里也能测,这正是配套练习第一关的内容。
三、server 三方法(lib/mcp-server.mjs)
server 读 stdin 的帧,按 method 分派:initialize 回能力协商结果、tools/list 回工具表、tools/call 执行并回 content 块;未知方法回 -32601。两道门禁:工具调用前必须已握手(收到 notifications/initialized 之前拒绝 tools/list/tools/call,回 -32600);未知工具是协议错误(-32602)、工具运行失败是数据(isError: true)。
四、client 状态机(lib/mcp-stdio.mjs)
client 是「子进程 + pending 表 + 状态机」三件套:pending Map 用 id 记住每个在途请求,initialize 的响应到达时把状态推到 initialized 并记下 serverInfo;notifyInitialized 发通知、把状态推到 ready;tools/list、tools/call 只有 ready 才放行。close() 给 stdin 发 EOF,管道结束本身就是关闭信号,不需要一条「关闭消息」。
五、并入注册表(lib/tools.mjs 的 registerMcpTools)
export async function registerMcpTools(registry, client, options = {}) {
const risk = options.risk ?? 'auto';
const prefix = options.prefix ?? '';
const { tools } = await client.listTools();
for (const tool of tools) {
const name = prefix ? `${prefix}${tool.name}` : tool.name;
registry.register(
{
name,
description: tool.description ?? '',
parameters: schemaToParams(tool.inputSchema),
risk, // 远程工具加入 ch18 的风险分级
},
async (args) => mcpResultToText(await client.callTool(tool.name, args)),
);
}
return tools.length;
}
六、跑起来看一条完整链路
demo 把整条链路拉通:spawn 一个 MCP echo server 子进程(真实 stdio 管道,不是进程内模拟)→ 握手 → 工具并入 → mock 模型请求远程工具 → 调用回填。以下是它打印的关键帧(每个 [mcp:...] 都是真实穿过管道的一帧):
--- 1. MCP 握手(JSON-RPC 2.0 over stdio)---
[mcp:send] {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",...}}
[mcp:receive] {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"mcp-echo-server","version":"1.0.0"}}}
· server says: {"name":"mcp-echo-server","version":"1.0.0"} protocol 2025-06-18
[mcp:send] {"jsonrpc":"2.0","method":"notifications/initialized"}
[mcp:send] {"jsonrpc":"2.0","id":2,"method":"tools/list"}
[mcp:receive] {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"Echo the given text back verbatim.","inputSchema":{...}}]}}
· discovered 1 remote tool(s): echo
--- 2. 工具并入本地注册表(registerMcpTools)---
· merged 1 remote tool(s); registry now has 6 tools total
- get_time / get_weather / add / write_note / delete_file / echo
--- 3. Agent 一轮对话(mock LLM 请求远程工具 echo)---
· act : echo({"text":"hello from MCP"})
[mcp:send] {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello from MCP"}}}
[mcp:receive] {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"hello from MCP"}]}}
· obs : -> hello from MCP
echo 工具返回了:hello from MCP
注意 id: 1 → 2 → 3 的递增:握手、发现、调用各占一个 id,响应带着同一 id 回来。echo 工具返回了:hello from MCP 是第二轮模型读到 tool_result 之后的终答——结果完成了「模型 → 注册表 → JSON-RPC 帧 → stdio → server → 回来」的闭环。
常见坑与失败模式
坑一:帧边界错位。 按字符串长度数 Content-Length("你好".length === 2),或者读帧时只读一个 chunk 就以为是一帧,都会让管道解析错位,一错错一串。正确做法:字节数 + 累积缓冲,读完一个帧再找下一个帧头;Content-Length 一定用 UTF-8 字节数(Buffer.byteLength)。
坑二:id 匹配失败。 用「先发先回」的顺序假设配对,或响应到达时不管 id 直接当作「上一个请求的响应」,并发请求下(握手 + 调用交错)会把 initialize 的响应喂给 tools/call 的等待者。正确做法是 pending 表按 id 索引,未知 id 的响应按协议违规处理(记日志,别崩溃)。
坑三:初始化时序错乱。 没握手就 tools/list、或者 initialize 响应没到就发 notifications/initialized,server 端会拒绝(本章的 server 用 -32600 兜底),但客户端拿到错误才意识到时序错了。正确做法是状态机显式建模:未到 ready 一律拒绝工具调用,初始化只发一次(失败可重试)。
坑四:错误处理不分层。 把「工具运行失败」当成 JSON-RPC error 返回——模型立刻失去恢复能力(它连失败内容都读不到)。牢记 MCP 的分层:不存在的方法/工具 = 协议错误;存在但失败 = isError: true 的数据。前者终结对话,后者是模型的学习素材。
坑五:日志污染 stdout。 server 往 stdout 打一行 listening... 或调试信息,client 的帧解析器会把它当成半个帧,随后所有消息错位。stdio transport 的铁律:stdout 只准出现 JSON-RPC 帧,日志一律走 stderr。
坑六:远程工具名与本地重名。 远程 server 恰好也暴露了一个 add,register 抛「already registered」,整条桥接失败。集成时给远程工具加命名空间前缀(mcp__<server>__<tool>),并让注册表的「拒绝重名」保持为显式错误而不是静默覆盖。
小结
- 动机:每个系统自带一套工具协议 → 注册表全是方言,适配器不可复用。MCP 把「暴露 → 发现 → 调用」标准化,接新系统从「写适配器」变成「spawn 它的 server」;
- JSON-RPC 2.0:request(有 id)/ notification(无 id 无响应)/ response(id + result 或 error 二选一);
-32700到-32603标准错误码;id 是响应与在途请求的唯一配对键; - stdio transport:
Content-Length: <字节数>\r\n\r\n<JSON>帧;字节计数 + 累积缓冲处理半截帧;stdout 只准出帧,日志走 stderr; - 生命周期:
new → initialized → ready → closed;initialize一次、notifications/initialized先于一切工具调用、未ready拒绝工具;握手失败可重试; - 并入注册表:
registerMcpTools把tools/list结果变成普通注册表项(schemaToParams+ 转发 executor),命名空间前缀防重名,risk选项让远程工具继承 ch18 审批门; - 错误分层:未知工具 = 协议错误;工具失败 =
isError: true的数据,模型必须读得到失败才能恢复; - 失败模式六连:帧边界错位、id 匹配失败、初始化时序错乱、错误不分层、日志污染 stdout、远程工具重名。
下一章(ch22)做多智能体 v09:subagent 隔离委派、orchestrator-worker 与 handoff,本章的 MCP client 将成为子 Agent 获取外部能力的标准通道。再下一章(ch23)收尾 v10:配置、部署与全书回顾。先把本章练习做完:亲手实现 JSON-RPC 编解码、生命周期状态机、工具并入注册表这三层。
延伸阅读
- Model Context Protocol 官方文档 · Basic:协议的全貌,消息层、版本协商、能力声明、生命周期;「标准错误码、
result/error二选一、通知无 id」这些规则的权威出处。 - Model Context Protocol 官方文档 · Transports:stdio 与 Streamable HTTP 两种传输的定义;stdout 只写消息、stderr 写日志的铁律。
- JSON-RPC 2.0 规范:request / notification / response 与错误码的原始定义,MCP 消息层的「地基」。
- build-your-own-agent · reference/05-mcp-server-client:从零手写 MCP server + client 的教程(本章机制底稿之一,实现为原创);其主页把 MCP 列为 Agent 技术栈 10 组件之一。
- Anthropic · Introducing the Model Context Protocol:MCP 的发布博客,动机(工具集成各自为政)、架构(client/server)与生态愿景。
- learn-claude-code · MCP 章节:Claude Code 中 MCP server 的配置与用法(
claude mcp add、工具并入上下文),可对照本章手写实现看真实产品的形态。