# 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  ← 执行结果
1
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;
  }
}
1
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 */ }
});
1
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)
1
2
3
4
5
6

# 题眼:为什么必须"按 id 配对"和"换行切包"?

  • 按 id 配对:stdio 是异步的,多个请求可能同时在飞,响应回来的顺序不保证。靠自增 id + pending Map,才能把每个响应准确交回给它对应的那个 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 一样自动使用它们。