MCP 接入 v08

MCP 接入 v08

v05(ch18)我们给产品装了权限层:每个工具声明风险档位,破坏性操作要过审批门。但有一个问题被一路搁置:工具从哪来?

回顾 ch08 的工具系统,所有工具都长在注册表里:get_timeget_weatheradd 是我们自己写的;真要接外部系统,比如查 GitHub 的 issue、读数据库、操作浏览器,就得为每一个系统写一套适配器,把它的 API 翻译成工具 spec + 执行函数。接 N 个系统写 N 套适配器,每套都要处理鉴权、错误、参数映射。工具越多,注册表越像一堆「各家方言」的堆积。

本章给产品加 v08:MCP 接入,用 Model Context Protocol(模型上下文协议) 把「工具如何暴露、如何发现、如何调用」标准化:

本章目标

读完本章并做完配套练习后,你应该能够:

概念与动机:一条协议,统一所有工具

先看一个「错误答案」:为每个系统写适配器

假设我们要让 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 应用以统一方式接入外部工具和数据源(官方发布博客)。它把角色分成两半:

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

我们借鉴的是「统一协议 + 生命周期 + 工具发现」这个机制;帧读写、状态机、注册表桥接的实现都是我们自己的。

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 }回应请求;resulterror 二选一

三句话记牢:

  1. id 是配对键。客户端发出多个请求(握手、列工具、调工具),响应可以乱序到达id 是唯一能把响应和「在途请求」重新配对的钥匙。每个 JSON-RPC client 的心跳都是同一个东西:一个 pending Map,key 是 id,value 是等响应的 promise;
  2. notification 永远没有响应。MCP 的 notifications/initialized 是典型例子:客户端说「握手完成」,服务端绝不能回(回了就是一条没人等、没人认领的孤儿响应);
  3. 错误是响应的一种。失败时响应带 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 的协议版):

所以 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()(一次)
initializedinitialize 成功响应到达只能 notifyInitialized()
ready已发 notifications/initializedtools/listtools/callclose()
closedclose() 或进程退出无(一切调用拒绝)

时序上,完整握手是四个步骤,这也是每个 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/listtools/call);initialize 请求只发一次,失败的握手可以重试,但一个在途的握手不允许再来一个。

工具并入注册表:远程工具 = 普通工具

MCP client 发现工具之后,最关键的一步是把远程工具并入本地注册表(ch08 的工具系统)。registerMcpTools 做的事,本质上是一条桥:

  1. client.listTools() 拿到 [{ name, description, inputSchema }]
  2. 每个工具 register 进本地注册表:inputSchema(JSON Schema)转成 ch08 的 spec.parameters 数组(schemaToParams),name/description 照搬;
  3. executor 是一个薄薄的转发器:args → client.callTool(name, args) → mcpResultToText(结果)

并入之后,远程工具在注册表里get_time 没有任何区别

还有两个工程细节:命名空间前缀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 必须带 resulterror 之一。这些函数不进 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 并记下 serverInfonotifyInitialized 发通知、把状态推到 readytools/listtools/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 恰好也暴露了一个 addregister 抛「already registered」,整条桥接失败。集成时给远程工具加命名空间前缀mcp__<server>__<tool>),并让注册表的「拒绝重名」保持为显式错误而不是静默覆盖。

小结

下一章(ch22)做多智能体 v09:subagent 隔离委派、orchestrator-worker 与 handoff,本章的 MCP client 将成为子 Agent 获取外部能力的标准通道。再下一章(ch23)收尾 v10:配置、部署与全书回顾。先把本章练习做完:亲手实现 JSON-RPC 编解码、生命周期状态机、工具并入注册表这三层。

延伸阅读

完成阅读,去做练习 →