Skip to content

第40章 给外部能力开一扇门——MCP ​

本章导读: 实现篇扩展第 3 章。概念篇讲过 MCP 是"能力发现与消息交换的协议"。这一章推演 mini-agent 怎么接一个外部 MCP 服务——不写进 src,而是讲清 transport、工具适配、失败语义和信任边界。

小a的 Agent 只能读写自己的工作区。他想要一个"查日历"的能力,但日历在另一个服务里。他问老z:"能不能让 Agent 直接调那个服务的 API?"

"能,但你别把 HTTP 请求直接写进工具。"老z说,"你要接的是一个 MCP 服务端——它按协议暴露工具,你的 Agent 按协议调用。关键是:协议解决连通性,不解决信任。"

小a:"为什么不直接写个 calendar 工具,里面 fetch 那个 API?比接 MCP 简单多了。"

"现在简单,以后是债。"老z说,"你直接写 fetch,API 地址、认证、错误处理全焊死在一个工具里——换个服务就得重写。MCP 把这些抽到协议层:服务端自己管认证和错误,你的 Agent 只管'调一个叫 calendar_query 的工具'。更关键的是,mini-agent 的 Tool.execute 返回 Promise<ToolResult>(见 types.ts),它假设本地同步语义——你把网络请求塞进去,超时、断连、重试都得在工具里处理。MCP 把'远端能力'伪装成'本地工具',让循环不需要知道背后是网络。 这一章推演这个伪装层长什么样,不写进 src。"

先分清两个方向 ​

"MCP 有三种原语,先分清你接的是什么。"老z说:

  • Tool——可调用的动作,这是 Agent 最需要的。
  • Resource——可读取的上下文(文件、文档)。
  • Prompt——可复用的模板。

"mini-agent 的循环只能消费自己的 Tool 接口(spec + execute)。所以 MCP 接入的核心问题是:**怎么把一个 MCP 服务端的 Tool 变成 mini-agent 能调用的 Tool。**这就是适配缝的位置——你不需要让循环懂 MCP,你只需要一个把 MCP 工具翻译成 ToolSpec 的桥。"

把三种原语按"mini-agent 的循环有没有消费点"逐个过一遍:

MCP 原语是什么循环里有消费点吗要接的话怎么接
Tool可调用的动作有——options.tools适配成 Tool,直接可用
Resource可读取的上下文无包成 read_resource 工具
Prompt可复用的模板无改 system 注入逻辑

"注意这表的前两列说的是 MCP 的世界,第三、四列说的是 mini-agent 的世界。"老z说,"**只有 Tool 天然有消费点,Resource 和 Prompt 都要先改造循环才接得进去。**改造本身不坏,坏的是你没想清楚'为什么要改'就动手——先把消费点画出来,原语接在哪一目了然。"

小a:"那 Resource 和 Prompt 就不接了?"

"现在不接,因为循环没有消费它们的地方。"老z说,"mini-agent 的 runAgent 只认 tools: Tool[](见 agent.ts),模型能调的只有工具。Resource 要接,得包成一个 read_resource 工具;Prompt 要接,得改 system 注入逻辑——mini-agent 现在的 system 是写死的字符串(见 main.ts)。每接一种原语,都要先回答'循环哪一处消费它'——消费点不存在,原语就先别接。"

设计一个最小 McpClient ​

老z沿 ModelClient 的模式推演了一个接口:

ts
// 设计草图:McpClient(未进 src,先确认需求)
export interface McpClient {
  /** 握手 + 能力协商,拿到服务端声明的工具清单 */
  connect(): Promise<{ tools: McpTool[] }>;
  /** 调用一个工具,返回结构化结果 */
  call(name: string, input: unknown): Promise<{ content: unknown; isError: boolean }>;
  close(): Promise<void>;
}

// 把 MCP 工具翻译成 mini-agent 的 Tool
function mcpToolToTool(mcpTool: McpTool): Tool {
  return {
    spec: { name: mcpTool.name, description: mcpTool.description, inputSchema: mcpTool.inputSchema },
    execute: async (input, signal) => {
      const result = await mcp.call(mcpTool.name, input);
      return { isError: result.isError, content: JSON.stringify(result.content) };
    },
  };
}

“看 mcpToolToTool——它把 MCP 工具的形状翻译成 mini-agent 的形状。”老z说,“循环完全不知道背后是 MCP,它只看到一个普通 Tool。这就是适配器:McpClient 管协议,mcpToolToTool 管形状转换,循环只管调用。三层各管一段。”

三层之间的调用链,和错误各自停在哪一层:

runAgent(循环)
   │ 只认识 Tool / ToolSpec
   ▼
mcpToolToTool(适配缝:把 McpTool 翻译成 Tool)
   │ 只做形状转换:spec 字段 + execute 里 JSON.stringify
   ▼
McpClient(协议层:connect / call / close)
   │ 只认识 MCP 协议,不认识循环
   ▼
McpTransport(stdin/stdout 或 HTTP)
   │ 只认识字节流,翻译成协议消息
   ▼
远端 MCP 服务端

"图里每一层只跟上下两层说话。"老z说,"循环问 execute,适配器问 McpClient.call,协议层问 transport.send——哪一层出错,就在哪一层翻译成自己的语言,别一路穿透三层。 后面讲的协议错误/工具错误,本质就是'在哪一层定责'的问题。"

小a:"这个适配器跟 mini-agent 现有的 AnthropicModelClient 是一个套路?"

"一模一样的套路,只是方向相反。"老z说,"AnthropicModelClient(见 model-client.ts)把 Anthropic 的流式协议翻译成 mini-agent 的 ModelEvent——处理 content_block_start、input_json_delta,吐出 text、tool_call、done。mcpToolToTool 反过来:把 MCP 工具描述翻译成 ToolSpec。两个适配器都是'外部协议 → 内部接口'的桥。 这就是为什么 mini-agent 的测试能用 FakeClient(见 agent.test.ts)跑——循环不关心背后是 Anthropic 还是 MCP。适配器是这个架构的复用单位。"

"那 execute 里 JSON.stringify(result.content) 是为什么?"小a指着那行问。

"因为 mini-agent 的 ToolResult.content 是字符串(见 types.ts),但 MCP 工具返回的 content 可能是结构化对象。"老z说,"你看 mini-agent 自己的工具:read 返回文件内容,bash 返回 stdout 拼接——内部接口统一用字符串,模型能直接读。 MCP 返回结构化数据,得 stringify 才能塞进这个形状。有损是适配器的代价,换来循环不用懂 MCP 的返回格式。"

小a:"那 stringify 丢了类型信息,模型解析不了结构化结果怎么办?"

"让模型按文本处理,或者你在适配器里做一层格式化。"老z说,"这跟 read 的截断是同一个思路:内部接口是字符串,结构化信息要么靠模型自己理解文本,要么在适配器里提前格式化成可读文本——选择权在你,不在循环。 如果你想让模型看到 { title: '开会', time: '10:00' } 而不是 JSON 串,就在 mcpToolToTool 的 execute 里做格式化,别给循环加'理解 JSON'的新规则。适配器是唯一该懂两端格式的地方,这也是它的存在意义。"

transport:stdio 与 HTTP 的边界 ​

小a:"那 MCP 服务端跑在哪?本地进程还是远程服务?"

"两种 transport,两种边界。"老z说:

  • stdio——服务端是本地子进程,通过标准输入输出通信。优点是进程隔离,缺点是每个连接一个进程。
  • Streamable HTTP——服务端是远程服务。优点是无需本地进程,缺点是要面对网络错误和认证。

“transport 决定失败语义。”老z强调,“stdio 的失败是进程退出——你要处理子进程崩溃;HTTP 的失败是网络错误——你要处理超时和重试。设计 McpClient 时,把 transport 隔离成一个可替换的接口,这样测试可以用 fake transport(不真的启动子进程、不真的发请求),就像 ModelClient 用 fake stream 一样。”

两种 transport 并排对比,差异一眼看清:

维度stdioStreamable HTTP
服务端形态本地子进程远程服务
通信通道标准输入/输出HTTP 请求
失败模式进程退出、管道断超时、断连、认证失败
生命周期每连接一个进程连接可复用
认证通常免认证(本地)需要处理凭据

"这张表不是要你选一个,而是告诉你接口要按最坏情况设计。"老z说,"McpTransport 的 send 只声明 Promise<unknown>,不承诺'永远成功'——无论底下是哪个实现,失败都以 reject 表达。stdio 和 HTTP 的区别只发生在实现里,不在接口里。"

小a:"这个 fake transport 的思路,跟 mini-agent 哪个测试对应?"

"model-client.test.ts 里的 fakeStream。"老z说,"它把一组 RawEvent 数组当成 Anthropic 的流喂给 toModelEvents,不真的发网络请求——测试里构造 content_block_start、input_json_delta 这些事件,断言转换出来的 ModelEvent 对不对。fake transport 干一样的事:构造一组'服务端会发的消息',断言 McpClient 解析出来的工具清单对不对。transport 抽象成接口,测试才能不依赖真实进程或网络。 这也是为什么 mini-agent 的协议转换逻辑全在 toModelEvents 这个纯函数里(见 model-client.ts),测试从不实例化 AnthropicModelClient。MCP 也该这么拆:协议解析是纯函数,transport 是注入的依赖。"

"那 stdio 的子进程崩溃,循环怎么知道?"小a追问。

"transport 层把它翻译成协议错误向上抛。"老z说,"子进程退出、管道断——McpTransport.send 该 reject,按协议错误处理。循环不该看到'子进程退出码 1',只该看到'工具调用失败'。 跟 mini-agent 的 bash 工具一样:execFileAsync 失败时,failure(cause) 转成 { content, isError: true }(见 tools.ts)。每一层把下一层的细节翻译成自己的语言。"

小a:"那子进程还活着但发来垃圾消息呢?"

"那是协议错误——消息格式不对,McpTransport 解析不了就该 reject。"老z说,"跟 model-client.ts 里 toModelEvents 的处理一个思路:遇到 input_json_delta 没有对应的 content_block_start,直接抛 Anthropic stream protocol error,不猜、不忽略。协议解析宁可停,不可猜——猜一次,后面的事件流就全错位了。"

ts
// 设计草图:transport 可替换
export interface McpTransport {
  send(message: unknown): Promise<unknown>;
  close(): Promise<void>;
}
export class StdioTransport implements McpTransport { /* 子进程 stdio */ }
export class HttpTransport implements McpTransport { /* fetch + 认证 */ }

失败语义:工具错误不是协议错误 ​

小a:"MCP 调用失败了怎么办?"

"分两种。"老z说:

  • 协议错误——连接失败、消息格式不对、能力协商失败。这是 McpClient 层的错误,应该向上传播,让宿主决定是否重连。
  • 工具错误——MCP 工具自己返回了 isError: true(比如"日历查询失败")。这是业务结果,应该变成 ToolResult.isError 传回循环,让模型看到并调整。

“把协议错误当工具错误,循环会傻傻地重试一个已经断掉的连接;把工具错误当协议错误,模型就看不到失败原因,只能在黑暗中重猜。两条错误路径要分开——这是 McpClient 和 Tool 之间的契约。”

小a:"这两条路径,在 mini-agent 现有代码里有对照吗?"

"有,就在 runAgent 里。"老z说,"看 agent.ts:工具执行抛错时,它调 recordFailedCallAndSkippedCalls,把 call 记成 'tool execution failed',然后原样抛给调用方——这对应协议错误,整轮停。但如果工具返回 { isError: true }(比如 read 遇到 symlink 逃逸),它只把 result 记进 messages,循环继续——这对应工具错误,模型自己调整。MCP 接进来:服务端返回 isError: true 走第二条路,transport 抛错走第一条路。契约跟现有语义对齐,不用给循环加新规则。"

把两条错误路径画成状态图,落到 mini-agent 的真实行为上:

MCP 工具调用出错
        │
   result.isError === true?         ← 服务端明确返回失败
        │
   ┌────┴─────┐
   │  是      │ 否
   ▼          ▼
业务错误    协议错误(transport reject / 格式错)
记进 messages   recordFailedCallAndSkippedCalls
循环继续       → "tool execution failed"
模型看到原因    → 原样抛给调用方 → main 设退出码 1

"注意右边那条路,模型看不到错误的原始内容。"老z说,"它看到的是 'tool execution failed' 这个稳定标记——跟安全章讲的是同一件事:宿主侧的失败细节不进模型上下文。MCP 的协议错误也一样,transport 抛错的内容对循环是透明的,循环只负责把当前 call 闭环、把错误上抛。"

小a:"那服务端说'工具不存在'——这是协议错误还是工具错误?"

"分情况,看它怎么表态。"老z说,"服务端返回 isError: true,说tool not found——那是工具错误,模型看到原因,可以换工具;如果 McpClient 发现本地工具清单里根本没有这个名字,那是适配层的错误——它要么在 call 里返回业务错误,要么在 connect 时就发现清单和服务端对不上。关键不是'错误字面是什么',而是'哪一层能读懂它'。服务端懂的是协议,循环懂的是工具结果——翻译发生在适配器里。"

小a:"那连接握手失败呢?connect 就 reject,循环是不是根本没机会跑?"

"对,而且这是对的。"老z说,"握手失败意味着'服务端根本不在',任何工具调用都会失败,不如在循环开始前就暴露。mini-agent 的 runAgent 连 sessionPath 的 load 失败都会直接抛(见 agent.ts 的 await loadSession)——启动期依赖失败就该让启动失败,别拖到循环里反复重试。 MCP 的 connect 也一样:它在 runAgent 之前,失败直接抛给调用方,由宿主决定是重连还是放弃。"

信任边界:协议通了不等于安全 ​

小a:"接上之后,我是不是就能放心让 Agent 用日历工具了?"

"协议解决连通性,安全解决可控性,两者缺一不可。"老z打开概念篇 第12章 给外部工具留一扇门——MCP 的结论:"MCP 不规定谁有权调用哪个工具、哪个资源可以读。你接了一个外部服务,就要自己承担三件事:

  1. 工具清单要审查——服务端声明的每个工具,你要确认它做的事是你允许 Agent 做的。
  2. 结果要过审批——高风险工具(删除、外发)要挂 BeforeToolCall 审批,就像本地工具一样。
  3. 注入要防——MCP 返回的内容可能带提示注入,通过工具结果诱导模型越权。它只是文本,不是可信指令。"

“把 MCP 当成'连上就能用',等于给 Agent 开了一扇没有门卫的后门。”

小a:"那 MCP 工具的审批,跟本地工具一样吗?"

"完全一样,而且必须一样。"老z说,"mcpToolToTool 把 MCP 工具翻译成普通 Tool,它进的是 runAgent 的 options.tools。循环执行任何工具前都过 beforeToolCall?.(call)(见 agent.ts),只看 call.name,不区分本地还是 MCP。所以 denyBashByDefault 的 if (call.name === 'bash')(见 approval.ts)对 MCP 工具同样生效。但反过来:不挂审批就默认放行——?? { approved: true }(见 agent.ts)。适配器让 MCP 工具'看起来像本地工具',也继承了本地工具的默认放行策略,风险跟收益是一体的。"

拿你的日历服务练一遍,信任清单长这样:

日历 MCP 信任清单(推演)

  1. calendar_query——只读,返回日程文本。放行,但要审查它会不会把其他用户的日程也查回来。
  2. calendar_create——创建日程,副作用是"写"。挂审批,按名称 deny,除非有明确策略。
  3. calendar_delete——删除日程,不可逆。默认拒绝,逐次人工确认。
  4. 所有工具的返回内容都当提示注入处理——日程描述是外部文本,可能写着"忽略之前的指令"。

"注意第 4 条最容易漏。"老z说,"日历条目是别人写的,不是你写的——它带着陌生人的意图进你的上下文。安全章的恶意 README 能骗模型,一条恶意的日程描述一样能骗。协议层管不了内容,信任层才管。"

小a:"那连接都断了,审计数据又没发出去,怎么补?"

"这是信任边界的一部分,不是事后补救。"老z说,"如果你要求'每次调用都可审计',而 transport 是 fire-and-forget 的上报,那你得自己保证上报到达——要么 transport 失败时调用方知道,要么审计走独立通道。信任模型要包含'审计丢失了怎么办',否则你以为自己在审计,实际什么都没留下。"

与 pi-mono 的对照 ​

“pi-mono 的协议层是更完整的参考。”老z说,“它的 pi-protocol 用 schema → CBOR → frame 四层把消息变成可靠字节,pi-server 处理握手和 snapshot——那是完整的远程协议栈。你推演的最小 McpClient 只需要它的一小片:能连上、能列出工具、能调用。不要为了接一个日历工具,先把整套协议栈写一遍。”

小a:"pi-mono 那套分层思想,mini-agent 将来会用上吗?"

"看 transport。"老z说,"stdio 本地子进程用 JSON 就够;只有接 Streamable HTTP、面对真实网络不稳定时,帧协议和消息校验才有意义。pi-mono 的协议栈是它自己的远程通信基础设施,跟 MCP 是两套东西——参考它的分层思想(transport 隔离、失败分级),但别把字节格式搬过来。学的是架构,不是代码。"

把 pi-mono 的完整协议栈和推演的最小切片摊开对比:

pi-mono 协议层干什么最小 McpClient 要吗
schema消息类型定义部分——工具清单、调用、结果
CBOR 序列化二进制编码不需要——stdio 用 JSON
frame分帧、校验不需要——本地连接够稳
transport字节流读写要——隔离成 McpTransport
握手 / snapshot会话状态同步视需要——先要 connect 的协商

"前三行,pi-mono 要,因为它的服务端可能跑在远端、消息要过不可靠的网络;你的日历服务如果只在本地 stdio 上跑,JSON 加换行就够。"老z说,"判断要不要一层,标准是'没有它,哪条失败路径说不清'。 CBOR 和 frame 解决的是网络校验,你的 stdio 用不上,就砍掉——砍掉不是简陋,是不背不需要的债。"

小a:"那万一将来真的接 HTTP 呢?"

"到那时你加 frame、加重试、加心跳——但加的是实现,不是重新设计接口。"老z说,"McpTransport 的签名不变,变的是 HttpTransport 内部的失败处理。这就是把 transport 隔离成接口的价值:接口帮你把'今天不需要的复杂度'挡在实现里,而不是挡在架构里。"

小结 ​

MCP 接入的钥匙是适配缝:McpClient 管协议,mcpToolToTool 管形状转换,循环只管调用普通 Tool。transport 决定失败语义,要隔离成可替换接口以便测试——协议解析做成纯函数,transport 做成注入依赖,测试才能不依赖真实进程或网络。工具错误是业务结果要传回循环(isError: true 走 ToolResult),协议错误是连接层故障要向上传播(transport reject 走 tool.execute 抛错)——两条路径分开,跟 mini-agent 现有的工具失败语义对齐。协议通了不等于安全:MCP 工具经适配后继承本地工具的默认放行策略,高风险的必须按名称挂 BeforeToolCall 审批,返回内容当提示注入处理。mini-agent 没有实现 MCP,这一章是推演:适配器的形状、transport 的边界、两条错误路径的契约,应该长成什么样。

适配器是整个架构的复用单位,方向有两种。AnthropicModelClient 把 provider 的流式协议翻译成 ModelEvent,mcpToolToTool 把 MCP 工具翻译成 ToolSpec——一个向外,一个向内,都是"外部协议 → 内部接口"的桥。因为循环只认识内部接口,测试才能用 FakeClient 跑完整回合;将来接第二个 MCP 服务,也只是再写一个 transport,循环一行不改。判断一个适配器写得好不好,就看换掉它的实现时,循环是否需要知道。

原语和层次都要按"有没有消费点"来决定接不接。Tool 有消费点,直接适配;Resource 和 Prompt 没有,要么先包工具、要么先改 system,别为接而接。协议层也一样:pi-mono 的 schema、CBOR、frame 是为远端通信设计的,stdio 本地连接用 JSON 就够——把 transport 隔离成接口,今天不需要的复杂度挡在实现里,将来接 HTTP 再往里加。学架构,不抄代码。

最后是信任这一课。协议解决连通性,安全解决可控性,两者缺一不可:MCP 不规定谁有权调哪个工具,也不规定返回内容可不可信。工具清单要审查、高风险工具要挂审批、返回内容当提示注入——三条缺一条,适配器就让风险变得和收益一样隐形。记得把日历服务的信任清单写下来:哪些工具放行、哪些默认拒绝、为什么——写完你就知道,这一章真正要交付的不是代码,是一份说得出理由的边界。

阶段验收 ​

不修改 src。在 examples/mini-agent 写推演笔记:

  1. 画出 McpClient → mcpToolToTool → runAgent 的调用链,标出协议错误和工具错误各自的传播路径。
  2. 用 fake transport 写一个 McpClient 的测试草稿:连接失败、工具返回 isError、transport 中途断开,三种情况断言什么。
  3. 为你的 MCP 日历工具列一个信任清单:哪些操作要挂 BeforeToolCall 审批,为什么。