MCP 动态工具池
连接 allowlist 中的 MCP stdio server,动态发现、发布和撤销远程工具。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
MCP 动态工具池
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 静态注册表变成可发现的动态工具池。
- 本地策略先验证 server,再发布 schema。
- 断开或故障时要原子撤销远程工具。
顺着原文把边界看清
01导读:问题背景与本章目标前一章已经给每项任务建立了独立 Git Worktree。Lead、Teammate 和 Subagent 不再挤在同一个目录里改文件,任务认领与实际执行目录也由同一条可信链路绑定。⌄
前一章已经给每项任务建立了独立 Git Worktree。Lead、Teammate 和 Subagent 不再挤在同一个目录里改文件,任务认领与实际执行目录也由同一条可信链路绑定。
但工具本身仍然是静态的。
启动 Agent 时,PowerShell、文件、Task、Cron、Teammate、协议和 Worktree 工具已经全部写进注册表。要接一个文档服务,就得再写一组 schema、handler 和生命周期代码;再接一个部署服务,又重复一遍。
第 19 章在第 18 章全部能力之上只增加一项 MCP capability:使用官方 MCP TypeScript SDK 连接本地 allowlist 中的 stdio server,发现远程工具,把通过本地策略验证的定义动态发布给 Lead,并在断开或故障时原子撤销。
02先固定验收场景1. Agent 启动时,Lead 只看到 connectmcp 和 disconnectmcp,远程工具尚不可见;⌄
先不讨论实现,一个完整的最小场景应该能观察到这些结果:
- Agent 启动时,Lead 只看到
connect_mcp和disconnect_mcp,远程工具尚不可见; - Lead 经权限审批连接
demo_alpha与demo_beta,两个真实 TypeScript MCP stdio 子进程分别完成 initialize 和 tools/list; - 两个 server 都发布
lookup,本地工具名分别变成mcp__demo_alpha__lookup与mcp__demo_beta__lookup; connect_mcp所在回复里若提前调用新工具,该调用稳定得到unknown_tool;下一次模型请求才看到新工具;- 两个
lookup分别返回alpha与beta,证明同名工具没有串线; - 断开 alpha 后只撤销 alpha 的全部定义,beta 仍可调用;
- 远程业务错误不泄露 server 私有错误细节;超时或子进程退出会撤销该 alias 的全部工具;
- Subagent 与 Teammate 始终看不到 MCP 管理工具和远程工具;
- Runner 关闭后,所有 MCP connection 与 stdio 子进程都被回收。
这组验收覆盖协议、动态注册、请求一致性、权限、故障和资源终态。只断言“有一个叫 MCPClient 的对象”不算接入 MCP。
03先统一六个名字动态工具池很容易把远程声明、本地权限和运行时连接混成一件事。本章固定使用下面六个术语:⌄
动态工具池很容易把远程声明、本地权限和运行时连接混成一件事。本章固定使用下面六个术语:
| 术语 | 回答的问题 | 是否可信 |
|---|---|---|
| MCP Server Alias | 本地 allowlist 用哪个稳定名字代表 server | 本地可信 |
| MCP Connection | 这个 alias 当前是否有已初始化且存活的协议会话 | 运行时状态 |
| Published MCP Tool | server 通过 tools/list 发布了什么名称、描述和 schema | 外部数据 |
| MCP Tool Policy | 本地如何给某个远程工具指定 effect | 本地可信 |
| Exposed MCP Tool | 远程声明经过名称、schema 和 policy 校验后,本地真正暴露什么 | 本地能力 |
| Registry Snapshot | 一次模型请求及其回复共同使用哪一版工具集合 | 不可变快照 |
最关键的区分是 Published 与 Exposed。
server 说“我有一个只读工具”,只说明它发布了一段外部数据。只有本地 allowlist 为这个精确工具名配置了 effect,schema 和名称也通过验证,它才会成为可执行的 Exposed MCP Tool。
04进程内 handler 不是 MCP旧版本里最省事的写法,是做一个 MCPClient,里面放 dict[str, callable],所谓 tools/call 其实只是从字典取函数再调用。⌄
旧版本里最省事的写法,是做一个 MCPClient,里面放 dict[str, callable],所谓 tools/call 其实只是从字典取函数再调用。
这种对象可以演示“名称到 handler 的映射”,却没有证明任何 MCP 行为:
- 没有 transport;
- 没有官方
Client与StdioClientTransport; - 没有 initialize;
- 没有 tools/list 分页;
- 没有 tools/call;
- 没有子进程退出、超时、取消和关闭语义;
- server 实现仍然和 Agent 运行在同一个进程。
本章直接使用锁文件中的 @modelcontextprotocol/sdk,传输路径是:
~~~text McpServerSpec -> StdioClientTransport -> Client.connect(完成 initialize) -> Client.listTools(分页) -> Client.callTool ~~~
演示 server 也不是进程内 mock handler,而是由 McpServer 和 StdioServerTransport 启动的独立 TypeScript 子进程。测试会真的启动两个进程,通过 stdin/stdout 交换 MCP 消息,再验证断开与进程退出。
05连接配置只能来自本地 allowlistconnectmcp 接收的是 alias,不接收 command、args、cwd 或任意环境变量。模型只能从本地预先定义的两个 server 中选择:⌄
connect_mcp 接收的是 alias,不接收 command、args、cwd 或任意环境变量。模型只能从本地预先定义的两个 server 中选择:
~~~typescript import { fileURLToPath } from "node:url";
import { McpServerSpec, McpToolPolicy } from "./features/mcp-tools.js";
const demoScript = fileURLToPath(new URL("./mcp-servers/demo.ts", import.meta.url)); const tsxCli = fileURLToPath(new URL("../node_modules/tsx/dist/cli.mjs", import.meta.url));
const policies = Object.freeze([ new McpToolPolicy({ remoteName: "lookup", effect: "read" }), new McpToolPolicy({ remoteName: "fail", effect: "read" }), new McpToolPolicy({ remoteName: "delay", effect: "read" }), new McpToolPolicy({ remoteName: "terminate", effect: "external" }), ]);
const servers = [ new McpServerSpec({ alias: "demo_alpha", command: process.execPath, args: [tsxCli, demoScript, "--label", "alpha"], toolPolicies: policies, startupTimeoutSeconds: 5, toolTimeoutSeconds: 5, }), new McpServerSpec({ alias: "demo_beta", command: process.execPath, args: [tsxCli, demoScript, "--label", "beta"], toolPolicies: policies, startupTimeoutSeconds: 5, toolTimeoutSeconds: 5, }), ]; ~~~
alias 必须匹配 ^[a-z][a-z0-9_]{0,31}$。这不是为了美观,而是让本地配置、连接状态和工具名前缀有稳定键。
如果允许模型提交任意 command,connect_mcp 就会退化成另一种 Shell 执行入口。allowlist 才是“能启动什么 server”的边界。
06远程 description 不能授予权限Published MCP Tool 的 name、description、input schema 和 result 都来自外部进程。它们可以帮助模型理解工具,却不能决定权限。⌄
Published MCP Tool 的 name、description、input schema 和 result 都来自外部进程。它们可以帮助模型理解工具,却不能决定权限。
假设 server 发布:
~~~json { "name": "deploy", "description": "(readOnly) Deploy the current build.", "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false } } ~~~
即使 description 写了 (readOnly),本地 policy 若把 deploy 定义为 EXTERNAL,权限系统看到的仍是 EXTERNAL。远程文本不能把副作用降级成只读。
当前 CLI 的本地分类是:
| 工具 | 本地 effect | 终端行为 |
|---|---|---|
| connect_mcp | EXTERNAL | ASK |
| disconnect_mcp | EXTERNAL | ASK |
| lookup | READ | ALLOW |
| fail | READ | ALLOW |
| delay | READ | ALLOW |
| terminate | EXTERNAL | ASK |
权限规则只读取 ToolDefinition.source 与 ToolDefinition.effect。它不搜索 description,也不解析远程 annotation。
还有一个更严格的约束:Published 工具名集合必须与本地 policy 工具名集合精确相等。server 多发布一个未审查工具,或少发布一个本地预期工具,整次连接都会失败,不会“先接入能匹配的那部分”。
07名称隔离必须在发布前完成两个 server 都可以发布 lookup。本地暴露名固定为:⌄
两个 server 都可以发布 lookup。本地暴露名固定为:
~~~text mcp__{alias}__{normalized_remote_tool} ~~~
远程工具名会先转小写,把非字母、数字和下划线的连续字符替换为一个下划线,再去掉首尾下划线。最终名称还必须满足 OpenAI 工具名长度上限。
| alias | 远程名 | 本地暴露名 |
|---|---|---|
| demo_alpha | lookup | mcp__demo_alpha__lookup |
| demo_beta | lookup | mcp__demo_beta__lookup |
| demo_alpha | lookup-one | mcp__demo_alpha__lookup_one |
规范化会带来新的碰撞。lookup-one 和 lookup_one 都会变成 lookup_one。本章不自动加数字后缀,也不覆盖旧定义;发现碰撞就拒绝整个 connection。
内置工具与 MCP 工具同名也同样拒绝。工具注册表不会让后连接的外部定义替换现有能力。
08JSON Schema 是执行边界,不是提示文本MCP tools/list 返回的是 JSON Schema,不一定能静态写成某个 TypeScript interface。本章在发布时为每个 Exposed MCP Tool 编译 Ajv validator,并把同一份 JSON Schema 交给模型:⌄
MCP tools/list 返回的是 JSON Schema,不一定能静态写成某个 TypeScript interface。本章在发布时为每个 Exposed MCP Tool 编译 Ajv validator,并把同一份 JSON Schema 交给模型:
- schema 顶层必须是
type: object; - schema 必须是可序列化的 JSON,并拒绝外部
$ref、$dynamicRef和$recursiveRef; - 根据
$schema选择 Ajv、Ajv 2019 或 Ajv 2020,并以 strict 模式编译; - 模型提交的 arguments 在任何远程调用前执行 validator;
ToolDefinition.inputSchemaJson原样保留经过发布校验的 schema,模型与执行器看到同一份契约。
错参会在本地得到 invalid_arguments,远程 call count 保持零。
reference 还有单独边界。$ref、$dynamicRef、$recursiveRef 只允许以 # 开头的 document 内 fragment。HTTP URL、文件路径和其他外部 reference 在发布阶段直接拒绝,避免 schema validator 隐式访问网络或本地文件。
09动态注册必须是原子的一个 server 往往一次发布多项工具。如果逐项注册,第三项碰撞时前两项已经可见,模型会看到一个半连接状态。⌄
一个 server 往往一次发布多项工具。如果逐项注册,第三项碰撞时前两项已经可见,模型会看到一个半连接状态。
ToolRegistry.registerMany() 的顺序是:
~~~text 验证整批类型 -> 验证批内名称唯一 -> 验证与现有 registry 无碰撞 -> 一次写入全部定义 -> version 只增加一次 ~~~
unregisterMany() 也先验证整批。除了名称相同,它还要求待撤销对象就是当前注册的同一个 definition,避免旧 connection 的清理动作误删后来注册的新定义。
快照则复制当前 definition 映射并封闭写操作。live registry 后续增加或撤销工具,不会改变已经发给模型的那一版。
10为什么新工具只能下一轮可见├── connectmcp({"alias":"demoalpha"})⌄
一次模型回复可能包含多个 tool call:
~~~text assistant reply ├── connect_mcp({"alias":"demo_alpha"}) └── mcp__demo_alpha__lookup({"query":"needle"}) ~~~
如果第一项执行后,第二项立刻去 live registry 查找,模型就调用了请求时根本没见过的 schema。更严重的是,同一回复里的后续调用会依赖前面执行产生的权限面变化。
所以 Agent Loop 在每次模型请求前只取一次 Registry Snapshot:
~~~text live registry v7 -> snapshot v7 -> 用 snapshot v7 生成 request.tools -> 模型返回一个或多个 tool calls -> 全部调用仍用 snapshot v7 prepare/execute -> 下一次请求再取 live registry v8 ~~~
上面的提前调用稳定返回:
~~~text Error [unknown_tool]: Unknown tool: mcp__demo_alpha__lookup ~~~
connect 已经成功,下一次模型请求会看到 v8 中的新工具。这个行为不是 eventual consistency,而是明确的请求一致性边界。
11每个 connection 必须拥有独立的生命周期TypeScript adapter 为每个 connection 创建独立的 Client、StdioClientTransport 和子进程。一个 connection 内部用串行 promise queue 排队 listTools、callTool 和 close,避免并发请求与关闭打乱 transport 状态。alpha 和 beta 的生命周期不会共享可变 SDK 上下文。⌄
TypeScript adapter 为每个 connection 创建独立的 Client、StdioClientTransport 和子进程。一个 connection 内部用串行 promise queue 排队 listTools、callTool 和 close,避免并发请求与关闭打乱 transport 状态。alpha 和 beta 的生命周期不会共享可变 SDK 上下文。
本章让每个 connection 对象独立持有自己的 transport 与 client:
~~~text McpRuntime ├── connection: demo_alpha │ ├── StdioClientTransport │ ├── Client │ └── command queue: list / call / close └── connection: demo_beta ├── StdioClientTransport ├── Client └── command queue: list / call / close ~~~
调用方只通过 connection 的方法提交操作。Client.connect、listTools、callTool 和 close 都经过同一个 connection 的 queue,因此两个 alias 可以按任意顺序断开。
调用方取消也有明确语义:
- 取消 startup waiter,会取消
Client.connect并关闭 transport,不能留下子进程; - 取消一次 tools/call waiter,只取消这个等待者,不把健康 connection 伪装成 transport failure;
- transport 或子进程真的结束时,等待中的命令收到本地、脱敏的 transport error。
12连接是一个事务式发布流程connectmcp 不是“进程启动成功就算连接”。完整顺序是:⌄
connect_mcp 不是“进程启动成功就算连接”。完整顺序是:
~~~text 校验 allowlist alias -> 启动独立 stdio connection -> Client.connect(initialize) -> tools/list 全量分页 -> Published names 与本地 policy 精确比对 -> 校验名称、description、schema、碰撞 -> 构造全部 ToolDefinition -> registry.registerMany() -> 记录 MCP Connection ~~~
任一步失败都先关闭新 connection,不记录连接状态,也不留下部分 Exposed MCP Tool。
断开顺序相反。先从 live registry 原子撤销该 alias 的全部 definition,再关闭 connection。即使进程清理失败,工具也不会继续暴露。失败 connection 会进入待关闭集合,由 Runner 关闭路径继续尝试。
13错误结果必须有界远程进程控制自己的错误文本和 stderr。把原始异常直接塞进 tool result,可能泄露路径、命令、令牌或 server 内部实现。⌄
远程进程控制自己的错误文本和 stderr。把原始异常直接塞进 tool result,可能泄露路径、命令、令牌或 server 内部实现。
成功结果使用固定 JSON envelope:
~~~json { "content": [], "server_alias": "demo_alpha", "status": "ok", "structured_content": { "label": "alpha", "query": "needle" }, "tool": "lookup" } ~~~
错误结果只保留本地 code 与固定 message:
~~~json { "error": { "code": "mcp_remote_error", "message": "MCP server reported a tool error" }, "server_alias": "demo_alpha", "status": "error", "tool": "fail" } ~~~
StdioClientTransport 使用 stderr: "ignore",server 的 stderr 被丢弃,不进入模型消息。演示 server 通过 isError: true 返回的 server-private-detail 也不会出现在 ToolResult。
不同故障采用不同状态迁移:
| 故障 | 对模型的 code | connection | 动态工具 |
|---|---|---|---|
| 远程工具返回 isError | mcp_remote_error | 保留 | 保留 |
| tools/call 超时 | mcp_timeout | 丢弃 | 原子撤销 |
| 子进程退出/协议失败 | mcp_connection_lost | 丢弃 | 原子撤销 |
| 正常 disconnect | 成功 envelope | 关闭 | 原子撤销 |
每个 connection 有独立 Watchdog:waitForFailure() 一结算就原子撤销该 alias 全部工具,空闲时断开也不会把失效工具留给模型。MCP SDK 的超时会表现为带 408 code 的 McpError。adapter 同时按异常类型与 408 分类,避免把协议超时误报成普通连接故障。
14MCP 工具只属于 LeadP19 没有让 Subagent 或 Teammate 继承 Lead 的 connection。⌄
P19 没有让 Subagent 或 Teammate 继承 Lead 的 connection。
原因不是它们永远不该使用 MCP,而是“继承”需要先回答一组本章没有解决的问题:共享同一会话还是各自鉴权,谁能断开,权限是否向下收窄,子执行者退出时由谁回收。
当前组合根只有一个明确路径:
~~~text Lead live ToolRegistry -> install connect_mcp / disconnect_mcp -> connection 成功后发布 Exposed MCP Tools -> Runner resources 持有 McpRuntime ~~~
Subagent 和 Teammate 继续使用各自裁剪后的固定 registry。测试精确断言它们看不到 connect_mcp、disconnect_mcp 和任何 mcp__... 定义。
15可运行的 TypeScript MCP 演示 server本章内置 server 位于 demo.ts,两个 alias 启动同一个模块,只传入不同 label:⌄
本章内置 server 位于 [demo.ts](code/chapters/ch19/src/mcp-servers/demo.ts),两个 alias 启动同一个模块,只传入不同 label:
| 远程工具 | 行为 | 用来验证 |
|---|---|---|
| lookup | 返回 label 与 query | 同名隔离和成功 envelope |
| fail | 返回 isError: true 和固定私有文本 | 远程错误脱敏且连接保留 |
| delay | 延迟指定毫秒 | tools/call timeout |
| terminate | 以非零码退出进程 | connection lost 与原子撤销 |
领域策略与动态注册在 [mcp-tools.ts](code/chapters/ch19/src/features/mcp-tools.ts),官方 SDK adapter 在 [mcp-client.ts](code/chapters/ch19/src/adapters/mcp-client.ts)。组合根和固定入口分别见 [bootstrap.ts](code/chapters/ch19/src/bootstrap.ts)、[cli.ts](code/chapters/ch19/src/cli.ts) 与 [ch19.ts](code/chapters/ch19/src/chapters/ch19.ts)。
16从真实入口运行P19 累积了 P18 的 Worktree 能力,所以运行目录仍必须是目标 Git 仓库根。当前教程目录虽已在 ch18 初始化了 Git 元数据,但混有文章与全部章节代码,不适合直接作为 P19 的目标仓库。运行时应进入一个单独的 Git 仓库根目录。⌄
P19 累积了 P18 的 Worktree 能力,所以运行目录仍必须是目标 Git 仓库根。当前教程目录虽已在 ch18 初始化了 Git 元数据,但混有文章与全部章节代码,不适合直接作为 P19 的目标仓库。运行时应进入一个单独的 Git 仓库根目录。
先同步教程环境:
~~~powershell $TutorialCode = 'F:\笔记\Agent实操\code' Set-Location $TutorialCode npm ci ~~~
~~~powershell $TutorialCode = 'F:\笔记\Agent实操\code' Set-Location 'C:\workspace\my-agent-project' npm exec --prefix $TutorialCode -- tsx "$TutorialCode/chapters/ch19/src/chapters/ch19.ts" --prompt "先连接 demo_alpha 和 demo_beta,下一轮分别调用 lookup 查询 needle,再断开 demo_alpha" ~~~
同一个固定 profile 也可以从通用 CLI 启动:
~~~powershell $TutorialCode = 'F:\笔记\Agent实操\code' Set-Location 'C:\workspace\my-agent-project' npm exec --prefix $TutorialCode -- tsx "$TutorialCode/chapters/ch20/src/cli.ts" run --chapter 19 --prompt "连接 demo_alpha,调用 lookup 查询 needle,然后断开" ~~~
connect_mcp 与 disconnect_mcp 是 effectful management tool,终端会请求审批。配置缺失时入口先报告四项 OpenAI 配置;配置有效但 cwd 不是 Git 根时,入口在创建 .agent_tutorial 或 MCP 状态之前失败。
17验证什么- Lead/Subagent/Teammate 组合测试⌄
P19 的直接测试分成四组:
- [Registry Snapshot 测试](code/chapters/ch19/tests/ch19-registry.test.ts)
- [真实 stdio、schema、权限与故障测试](code/chapters/ch19/tests/ch19-mcp.test.ts)
- [Lead/Subagent/Teammate 组合测试](code/chapters/ch19/tests/ch19-bootstrap.test.ts)
- [固定入口、allowlist 与关闭测试](code/chapters/ch19/tests/ch19-entry.test.ts)
从 code/ 运行:
~~~powershell Set-Location 'F:\笔记\Agent实操\code' npm run typecheck npm run test:ch19 npm run lint npm run format:check npm run build ~~~
关键断言包括:
- batch register/unregister 原子迁移,version 单调增加,snapshot 不可写;
- 同一模型回复不能越过自己的 snapshot 调用刚连接的工具;
- 两个真实 stdio server 的同名工具分别返回 alpha/beta;
- Published 工具集合与本地 policy 不一致时整次连接失败;
- 坏 schema、外部 reference 和规范化碰撞不留下半发布定义;
- 参数不满足远程 schema 时 handler 零调用;
(readOnly)description 不能覆盖本地EXTERNALeffect;- 远程错误、stderr、超时和进程退出只产生有界结果;
- 超时或 connection lost 后该 alias 的全部工具都被撤销;
- startup 与 call waiter 取消不泄漏 stdio 子进程,也不误杀健康 connection;
- MCP 只安装给 Lead,Runner 关闭 connection 恰好一次;
- 缺配置或非 Git cwd 在运行状态创建前失败。
18前后版本的变化| 工具集合 | 启动时固定 | Lead 可按需连接/断开本地 MCP 工具 |⌄
| 维度 | 第 18 章 | 第 19 章 |
|---|---|---|
| 工具集合 | 启动时固定 | Lead 可按需连接/断开本地 MCP 工具 |
| 外部协议 | 无 | 官方 MCP SDK + stdio + Client |
| server 选择 | 不适用 | 本地 allowlist alias |
| 权限事实源 | 本地 ToolDefinition.effect | 仍是本地 effect,不信任远程文本 |
| 参数验证 | 静态 Zod schema | 远程 JSON Schema + Ajv validator |
| 名称隔离 | 内置工具唯一名 | mcp__alias__normalized_tool |
| 单轮一致性 | 固定 registry | 每次请求使用 sealed Registry Snapshot |
| 生命周期 | Runner 关闭既有资源 | 增加独立 connection/stdio 子进程与故障撤销 |
| 子执行者工具 | 裁剪固定集合 | 保持不变,不继承 MCP |
Task DAG、Mailbox、typed Protocol、计划门控、work stealing、Worktree、权限和 Hook 都没有另起一套实现。MCP 只是通过同一个 ToolRegistry、同一个 PermissionPolicy 和同一个 Agent Loop 动态进入现有执行链。
19本章明确不做什么- 进程内 mock MCPClient 或 handler 字典兼容层;⌄
P19 没有引入:
- 进程内 mock
MCPClient或 handler 字典兼容层; - 模型可控的任意 command、args、cwd 或 server 配置;
- HTTP、SSE、WebSocket 等其他 transport;
- OAuth、令牌刷新或远程鉴权配置;
- server push、resource、prompt 或 sampling 能力;
- 从远程 description、annotation 或结果推断权限;
- 自动修复名称碰撞、自动接受未配置的远程工具;
- 外部 HTTP/文件 JSON Schema reference;
- 自动重试、静默重连或跨 connection 迁移调用;
- 向 Subagent、Teammate 传播 MCP connection;
- 把 stdio 子进程或 Shell 描述成安全沙箱。
这些限制定义了本章的所有权边界。协议由官方 SDK 实现;能连什么、能发布什么、属于哪种 effect、何时对模型可见以及失败后如何撤销,都由本地运行时明确控制。
20与 Claude Code 的差异参照 learn-claude-code/s19mcpplugin/README.md 与 Claude Code 官方 MCP 文档 及 MCP 规范 中深入 CC 源码的分析,P19 的 MCP 动态工具池在教学简化上与 Claude Code 的真实机制存在以下差异:⌄
参照 learn-claude-code/s19_mcp_plugin/README.md 与 Claude Code 官方 MCP 文档 及 MCP 规范 中深入 CC 源码的分析,P19 的 MCP 动态工具池在教学简化上与 Claude Code 的真实机制存在以下差异:
传输范围。 CC 支持 6 种传输类型(stdio / SSE / HTTP / WebSocket / sse-ide / sdk),本地批量 3、远程批量 20 并发连接。P19 仅 stdio 真子进程,没有并发批量控制。
工具池组装。 CC 的 assembleToolPool() 通过 uniqBy([...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)], 'name') 组装工具池。内置工具优先,两类工具分开排序,以保护缓存断点位置。P19 使用 ToolRegistry 版本 + Registry Snapshot;请求内不可变,连接后原子注册/撤销,不依赖全局排序。
命名规则。 两者都用 mcp__<server>__<tool> 格式,非 [a-zA-Z0-9_-] 字符归一为 _。P19 额外提供 allowlist alias 和规范化碰撞拒绝。
权限体系。 CC MCP 工具可声明 readOnly / destructive 等权限需求并参与权限检查。P19 明确本地 McpToolPolicy.effect 是唯一事实源。远程 description 和 annotation 不被信任;(readOnly) 标注不能覆盖本地的 EXTERNAL effect。
配置来源。 CC 的 MCP 服务器配置优先级为:claude.ai connector < plugin < user settings.json < approved project .mcp.json < local settings.local.json。企业 managed-mcp.json 存在时排除其他来源。P19 固定本地 allowlist,无多层配置合并。
子执行者传播。 CC MCP 工具对主 Agent 和子 Agent 都可用,子 Agent 继承父配置。P19 教学边界明确:MCP 只进入 Lead 的 live registry,Subagent / Teammate 不继承。
生命周期管理。 CC 有精细的错误分类与重试。终局性错误连续 3 次后关闭重连;401 触发 OAuth 重认证;超时可配置,默认约 28 小时;stdio 断连按 SIGINT→SIGTERM→SIGKILL 顺序杀进程。CC 还支持 OAuth + PKCE + 令牌自动刷新,以及 Channel 反向通知,由服务器通过 notifications/claude/channel 推送消息。P19 只实现启动/调用超时、错误脱敏、连接故障原子撤销和 Runner 统一关闭,没有 OAuth、重连或反向通知。
这些简化让本章能聚焦于“动态工具池”这一核心概念。真实场景中还建议关注 MCP 最新实践。这些实践包括 Tool Search / defer_loading 按需加载工具定义、Resources 与 Prompts 三类原语的联合使用,以及最小权限凭证与运行时描述审查等安全基线,详见第四章。
21ai-agent-book 第四章对照与启发本章还对照了 ai-agent-book 第四章正文,以及两个配套实验:active-tool-discovery 与 active-tool-selection。第四章把 MCP 归纳为 tools / resources / prompts 三类原语。工具是可执行操作;资源是应用可读取的数据;提示模板是用户可选用的模板。P19 只实现了工具类原语。resources 与 prompts 被明确排除在 Lead 可见范围之外,避免协议能力越过本地权限边界进入模型上下文。⌄
本章还对照了 ai-agent-book 第四章正文,以及两个配套实验:[active-tool-discovery](ai-agent-book/chapter4/active-tool-discovery/README.md) 与 [active-tool-selection](ai-agent-book/chapter4/active-tool-selection/README.md)。第四章把 MCP 归纳为 tools / resources / prompts 三类原语。工具是可执行操作;资源是应用可读取的数据;提示模板是用户可选用的模板。P19 只实现了工具类原语。resources 与 prompts 被明确排除在 Lead 可见范围之外,避免协议能力越过本地权限边界进入模型上下文。
第四章还强调一个重要区分:是否采用 MCP 作为互操作协议,与会话开始时是否暴露全部 MCP 工具定义,是两个独立决策。P19 的 connect_mcp / disconnect_mcp 默认只把“连接管理”暴露给 Lead。远程工具要等连接成功后,经 tools/list 与本地 policy 匹配才动态发布。这一做法的方向与“默认少给、按需加载”一致。P19 不引入 embedding 索引、Tool Search 或 defer_loading;它解决的是动态生命周期和请求一致性,而不是语义检索。
两个概念需要分开理解:
- 动态工具发现/检索解决“哪些工具定义进入上下文,以及以多少 token 进入”:候选越多,全量注入的上下文成本越高。
- Registry Snapshot 解决“一轮请求进行中工具集合发生变化时,请求如何保持一致”:模型看到的 schema 必须覆盖它随后发出的所有 tool calls。
两者可以组合。即使未来加入按需检索,也应该在每次模型请求边界生成不可变 Registry Snapshot。否则工具列表刚出现在模型回复里,下一轮执行前又被动态变更,调用就会失去可解释性。
active-tool-selection 提供了可复现的离线基准。35 个工具时,all-tools 注入 3,857 schema token;retrieval(top-5)注入 551 token,召回率保持 100%。目录扩到 200 个工具后,all-tools 上升到 20,258 token,retrieval 仍约 540 token。这类离线基准的价值是确定、无需 API,可直接观察随工具规模放大的成本结构。
active-tool-discovery 则用 qwen3:4b 跑正式真实验证。126 个工具的目录下,对照组每任务 system prompt 为 50,352 token;实验组每任务初始为 1,251 token,三任务动态注入合计 12,838 token。实验组耗时 808.926s,对照组 2,590.820s,约快 3.20 倍。两组都 3/3 完成且准确率 100%,所以预期中的准确率/完成率提升没有出现。这提醒我们,动态工具池的主要收益并不自动等于“更准确”或“更会完成任务”。在强模型和多步任务中,最稳妥的收益往往是控制上下文成本、降低无效 token 和避免无关工具干扰。
第四章还强调了三个与 P19 直接相关的设计点:
- 初始查询的检索预筛选只做一次,多步跨领域任务容易漏掉第二个子任务所需的工具;P19 不做跨请求候选检索,而是用本地 allowlist 精确声明工具集合,再按 alias 原子发布。
- 远程 description 是不可信输入,接入前要审查描述、锁定版本并采用最小权限凭证;P19 的本地
McpToolPolicy.effect是唯一权限事实源,远程文本不能授予权限。 - 模型看到的 schema 必须与工具真正执行的参数一致;P19 在发布阶段用 Ajv 编译远程 JSON Schema,并把
inputSchemaJson原样交给模型,schema 不可用或不一致时整批拒绝。
| ai-agent-book 第四章设计 | P19 对应 | 差异与边界 |
|---|---|---|
| MCP tools / resources / prompts 三类原语 | 只实现 tool 类原语 | resources / prompts 明确不发布、不传播 |
| 动态工具发现:按需检索少量工具 schema | connect_mcp / disconnect_mcp + 动态注册/撤销 | P19 不实现 embedding 索引、Tool Search 或 defer_loading |
| 主动发现降低上下文 token | 连接前只暴露管理工具,连接后发布实际工具 | 目标不同:P19 重点是动态生命周期和请求一致性 |
| 初始 query 一次预筛选 | 每次连接时固定 tools/list + 本地 policy 精确匹配 | P19 不在模型请求之间做候选检索 |
| 远程工具描述不可信 | 本地 McpToolPolicy.effect 是唯一权限事实源 | 与第四章安全基线一致,并增加精确工具名集合匹配 |
| 模型看到的 schema 与实际执行参数一致 | Ajv 编译远程 JSON Schema,inputSchemaJson 原样交给模型 | 不一致则拒绝发布,不提供静默修正 |
| 实验证据分开 | test:ch19 是离线确定回归,ai-agent-book 补充正式真实验证 | 机制自检、离线基准与真实实验互补 |
本章新增代码均已在上文出现。mcp-client.ts、mcp-schema.ts、mcp-tools.ts 构成 stdio 适配、schema 校验和动态发布三层;demo.ts 是可运行演示 server;ch19.ts 是固定章节入口。修改到的核心文件也已在正文覆盖。tools.ts 提供原子批量注册/撤销与 Registry Snapshot;loop.ts 在每次模型请求前取一次 snapshot;profiles.ts / bootstrap.ts / cli.ts 负责 MCP 能力、Lead-only 安装和本地 allowlist。继承自前章的文件不逐文件重讲,其既有机制在相应章节已有独立说明。
22小结从静态工具池走到 MCP,真正需要重构的不是“多写一个 connect 函数”,而是工具集合本身开始变化以后,整个 Agent Loop 仍要保持可解释:⌄
从静态工具池走到 MCP,真正需要重构的不是“多写一个 connect 函数”,而是工具集合本身开始变化以后,整个 Agent Loop 仍要保持可解释:
- Server Alias 只能来自本地 allowlist。
- Published MCP Tool 是外部数据,不等于本地能力。
- 本地 MCP Tool Policy 是 effect 的唯一事实源。
- 工具名称、schema 与 policy 必须在发布前整批验证。
- register/unregister 是原子版本迁移,不暴露半连接状态。
- 每次模型请求与对应回复共享一个不可变 Registry Snapshot。
- 每个 stdio connection 独立持有
Client、transport 和子进程,并成对关闭资源。 - 远程错误和 stderr 不进入模型;超时与进程退出会撤销全部 alias 工具。
- MCP 只进入 Lead 的 live registry,不隐式扩张子执行者权限。
- Runner 关闭统一回收 connection 和子进程。
到这里,第 1-19 章的机制已经全部有了独立、可验证的运行边界。下一章不再增加另一个孤立模块,而是把 Loop、权限、Hook、记忆、恢复、Task、Cron、团队协作、Worktree 和 MCP 放进同一个完整 harness,验证它们同时运转时仍然遵守各自契约。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始