# Step 47: MCP 协议入门 —— 连接 stdio server
一句话导读:MCP(Model Context Protocol)让"工具"可以来自另一个进程,而不必写死在我们代码里。这一步手写一个最小 MCP 客户端:spawn server 进程、拿 stdin/stdout 当双向管道、在上面跑换行分隔的 JSON-RPC 2.0,完成握手、列工具、调工具。这是 Claude Code 能接数据库/浏览器/各种 SaaS 的底层机制。
# 一、这一步做了什么(What)
给 agent 接上第一个「外部工具进程」。本步只做「连接 + 握手 + tools/list + 能手动调用」(让模型自动用 = step48)。两个文件:
| 文件 | 作用 |
|---|---|
services/mcp/exampleServer.mjs | 一个最小 MCP server(玩具工具 add / echo),让客户端有东西可连 |
services/mcp/client.ts | 最小 MCP 客户端:spawn + JSON-RPC 收发 + initialize + tools/list + tools/call |
启动时 index.ts 连接 MCP_SERVERS 里配置的 server,打印「已连接 MCP『demo』工具:add、echo」。/mcp call demo add {"a":2,"b":40} 手动调用,实测 add(2,40)=42。
# 二、面试官视角:为什么要 MCP 这么一层协议?(Why)
面试题:工具直接写在 agent 代码里、注册进 registry 不就行了?为什么要搞一套协议、让工具跑在另一个进程里,凭空多出 spawn / JSON-RPC / 握手这些麻烦?
因为把工具写死在代码里,等于要求"每加一个能力都改 agent 的源码、还得用同一种语言"——这不可扩展。
设想 Claude Code 要接入 Postgres、Playwright 浏览器、GitHub、Slack……如果每个都内置,agent 的代码会膨胀成一个什么都塞的巨石,而且这些工具的作者五花八门、用各种语言写。MCP 的解法是把"工具"从"代码内的函数"解耦成"一个协议约定":
| 维度 | 工具写死在代码里 | 工具做成 MCP server |
|---|---|---|
| 语言 | 必须和 agent 同语言 | 任意语言,只要说 MCP |
| 耦合 | 加能力要改 agent 源码 | agent 零改动,配置里加一行 server |
| 隔离 | 工具崩了可能拖垮主进程 | 独立进程,崩了不影响 agent |
| 生态 | 每个 agent 各造轮子 | 一个 server 所有支持 MCP 的 host 都能用 |
一句话:MCP 是工具的"USB 接口"——只要设备(server)和主机(agent)都遵守这个协议,就能即插即用,而不用关心对方内部怎么实现、用什么语言写。
# 三、原理:它是怎么工作的(How)
# 传输层:stdio + 换行分隔的 JSON-RPC 2.0
最朴素的进程间通信:客户端 spawn server 进程,拿它的 stdin 当"发"、stdout 当"收",双向管道上跑一行一条的 JSON-RPC。
1. spawn 客户端启动 server 进程,拿到 stdin/stdout 当双向管道
2. 握手 → initialize(互报协议版本/能力)
← 回 serverInfo + capabilities
→ notifications/initialized(通知,无需回复)
3. 用工具 → tools/list ← 工具清单
→ tools/call ← 执行结果
2
3
4
5
6
JSON-RPC 的三态靠有没有 id 区分:请求带自增 id(等回复)、响应按同一 id 配对、通知没有 id(不等回复)。
# 客户端核心:按 id 配对 + 换行切包
export class McpClient {
private buf = "";
private nextId = 1;
private pending = new Map<number, { resolve; reject }>();
constructor(cfg) {
this.proc = spawn(cfg.command, cfg.args, { stdio: ["pipe", "pipe", "inherit"] });
this.proc.stdout.on("data", (chunk) => this.onData(chunk));
}
// stdout 数据可能粘连/拆分,用 buf 累积、按 \n 切出完整 JSON 行
private onData(chunk: string) {
this.buf += chunk;
let idx: number;
while ((idx = this.buf.indexOf("\n")) >= 0) {
const line = this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 1);
const msg = JSON.parse(line);
if (msg.id != null && this.pending.has(msg.id)) { // ★按 id 找回等待的请求
const p = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
}
}
}
private request(method, params, timeoutMs = 10000): Promise<any> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { this.pending.delete(id); reject(...); }, timeoutMs);
this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, ... });
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
async connect() {
await this.request("initialize", { protocolVersion: "2024-11-05", capabilities: {},
clientInfo: { name: "mini-claude", version: "0.1.0" } });
this.notify("notifications/initialized"); // ← 通知,不等回复
this.tools = (await this.request("tools/list"))?.tools ?? [];
return this.tools;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
server 端对称——一个 readline 逐行读、按 method 分派、send 回一行 JSON:
// exampleServer.mjs(节选)
rl.on("line", (line) => {
const { id, method, params } = JSON.parse(line);
if (method === "initialize")
send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05",
capabilities: { tools: {} }, serverInfo: { name: "demo-mcp", version: "0.1.0" } } });
else if (method === "notifications/initialized") { /* 通知,不回 */ }
else if (method === "tools/list") send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
else if (method === "tools/call") { /* 执行 add/echo,回 content */ }
});
2
3
4
5
6
7
8
9
10
# 数据流
spawn exampleServer.mjs(stdin=发, stdout=收, stderr=inherit 直接透传到终端)
↓
→ initialize(id:1) ← serverInfo + capabilities(id:1) 握手
→ notifications/initialized(无 id,即发即忘)
→ tools/list(id:2) ← { tools:[add, echo] }(id:2)
→ tools/call(id:3, {name:"add", arguments:{a:2,b:40}}) ← { content:[{text:"42"}] }(id:3)
2
3
4
5
6
# 题眼:为什么必须"按 id 配对"和"换行切包"?
- 按 id 配对:stdio 是异步的,多个请求可能同时在飞,响应回来的顺序不保证。靠自增
id+pendingMap,才能把每个响应准确交回给它对应的那个 Promise。这是 JSON-RPC 支持并发的核心。 - 换行切包:TCP/管道是字节流,没有"消息边界"概念——一次
data事件可能收到半条 JSON,也可能收到两条粘在一起。用buf累积、按\n切,才能还原出一条条完整消息。这是所有基于流的协议都要处理的"粘包/拆包"问题。
# 四、深入追问(面试常见 follow-up)
Q:initialize 和 notifications/initialized 都是握手,为什么一个要等回复、一个不用?
A:initialize 是请求——客户端要拿到 server 的协议版本和 capabilities 才能知道对方支持什么,必须等响应。notifications/initialized 是通知——它只是客户端告诉 server「我准备好了,可以开始了」,是个单向信号,server 不需要也不会回复。用"有没有 id"把这两种语义区分开,正是 JSON-RPC 的精髓。
Q:请求为什么要挂 10s 超时?
A:server 是个外部进程,可能卡死、可能吞了请求不回。没有超时,对应的 Promise 就永远 pending,pending Map 里的条目永不清理,调用方永久挂起。超时把"server 不响应"转成一个可捕获的错误,让上层能降级或报错,而不是静默卡住。
Q:capabilities 握手时互报,有什么用?
A:这是能力协商。server 在 capabilities 里声明自己支持 tools/resources/prompts 哪几种(demo 现在只报 tools:{}),客户端据此决定"该不该去 tools/list、该不该拉 resources"。避免向一个不支持某能力的 server 发它处理不了的请求。step49 补 resources/prompts 时,这个协商就派上用场了。
Q:server 的 stderr 为什么设成 inherit?
A:stdio: ["pipe", "pipe", "inherit"]——stdin/stdout 我们要用管道跑协议,但 stderr 直接继承到终端,这样 server 里的 console.error 调试输出能直接打印出来,不会污染 stdout 上的 JSON-RPC 数据流。协议数据和日志走不同通道,是 stdio 传输的一个惯例。
# 五、踩坑 / 设计权衡
- stdout 只能走协议:server 里绝不能往 stdout 打普通日志——任何非 JSON 的行都会让客户端的
JSON.parse失败(我们用 try/catch 跳过,但会丢消息)。所有日志必须走 stderr。这是初学 MCP server 最常见的翻车点。 - 退出要 kill 子进程:
exit时调c.close()→proc.kill(),否则 agent 退了,spawn 出来的 server 进程会变成孤儿进程残留。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
McpClient stdio | services/mcp/ 的 stdio transport(基于官方 MCP SDK) |
| initialize / tools/list / tools/call | 一致(标准 MCP 方法) |
| 手写换行切包 + id 配对 | 真实版由 SDK 内部处理 |
| capabilities 能力协商 | 一致 |
| 还缺:SSE/HTTP transport、resources、prompts、OAuth、错误重连 | 真实版都有 |
# 七、一句话总结
MCP = 工具的 USB 接口:把"工具"从代码内函数解耦成一个跨进程协议,本步用最朴素的 stdio + 换行分隔 JSON-RPC 2.0 实现它——靠自增 id 配对支持并发、靠 buf 按 \n 切包还原消息边界、靠有无 id 区分请求/通知,完成握手→列工具→调工具,让任意语言写的外部进程都能把能力接进 agent。
# 下一节预告
现在模型还不能自动调这些 MCP 工具。step48 把外部工具包装成 ToolDef 并入 Registry,让模型像调 Bash/Read 一样自动使用它们。