# Step 48: MCP 工具动态注册 —— 外部工具并入 Registry
一句话导读:step47 能连 MCP server、能手动
/mcp call,但模型还不能自动用。这一步把 MCP 工具包装成本地ToolDef塞进 registry——从此模型调mcp__demo__add和调 Bash/Read 走的是同一条路,它根本不知道这工具其实在另一个进程里。这就是适配器模式的经典应用。
# 一、这一步做了什么(What)
新增 mcpToolDefs(client),把一个 MCP server 的工具列表逐个包装成我们 registry 认的 ToolDef,启动时注册进去。对应真实 Claude Code 把 MCP 工具并入工具表的机制。
实测:注册后 registry 里出现 mcp__demo__add / mcp__demo__echo,toAPI() 里有它们(模型可见),execute({a:2,b:40}) 透明走 MCP 返回 42;启动工具数 11 → 13。
# 二、面试官视角:为什么要"并入 registry"?(Why)
面试题:step47 已经能
client.callTool调 MCP 工具了,为什么模型还是不能自动用?非得包一层塞进 registry 才行?
因为模型"发现"工具的唯一渠道,是 API 请求里的 tools 参数,而这个参数只来自 registry.toAPI()。
模型不会读你的代码、不知道你有个 McpClient。它能调什么,完全取决于你在请求里声明了哪些 tools。step47 里 MCP 工具只存在于 client.tools 数组,从没进过 registry、从没出现在 toAPI() 里——所以对模型而言它们不存在,只能靠人手动 /mcp call。
| 状态 | step47 | step48 |
|---|---|---|
| MCP 工具在哪 | 只在 client.tools | 包装后进 registry |
出现在 toAPI()? | 否 | 是 |
| 模型能看见/自动调? | 否,只能手动 /mcp call | 是,和本地工具一样 |
所以问题的本质是:registry 是模型发现工具的唯一入口。想让模型自动用外部工具,就必须让它"看起来像"registry 里的一个本地工具。这就需要一个适配器:把"远程 MCP 调用"这个异构的东西,包装成 registry 认的统一 ToolDef 接口。
# 三、原理:它是怎么工作的(How)
# 核心:把"外部工具"伪装成"本地工具"
// services/mcp/client.ts
export function mcpToolDefs(client: McpClient): ToolDef[] {
return client.tools.map((t) => ({
name: `mcp__${client.name}__${t.name}`, // ① 命名约定,防撞名
description: (t.description || "") + `(来自 MCP server: ${client.name})`,
inputSchema: normalizeSchema(t.inputSchema), // ② schema 兜底
async execute(input): Promise<ToolResult> { // ③ 适配层:转发给 MCP server
try {
const text = await client.callTool(t.name, input);
return { content: [{ type: "text", text: text || "(无输出)" }] };
} catch (e: any) {
return { content: [{ type: "text", text: "MCP 调用失败:" + (e?.message || e) }], isError: true };
}
},
}));
}
// 有些 MCP 工具 schema 缺 additionalProperties,DeepSeek 代理会挑剔——补上 false
function normalizeSchema(schema: any): Record<string, unknown> {
if (schema && schema.type === "object" && schema.additionalProperties === undefined)
return { ...schema, additionalProperties: false };
return schema ?? { type: "object", additionalProperties: false, properties: {} };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
启动时:连接 MCP → for (const def of mcpToolDefs(c)) registry.register(def)。
# 三个要点
| 点 | 做法 | 原因 |
|---|---|---|
| 命名 | mcp__<server>__<tool> | 不同 server / 本地工具之间防撞名(真实 CC 同款约定) |
| schema 兜底 | normalizeSchema 补 additionalProperties:false | 有些 MCP 工具 schema 缺它,DeepSeek 代理会挑剔 |
| 适配层 | execute 转发 client.callTool | 把"远程调用"包成统一的 ToolDef 接口 |
# 数据流:对引擎和模型完全透明
模型发 tool_use(mcp__demo__add, {a:2,b:40})
↓
引擎查 registry(和查 Bash/Read 一模一样)→ 找到那个包装出的 ToolDef
↓
执行它的 execute(input) → client.callTool("add", {a:2,b:40})
↓ ↓(JSON-RPC over stdio,step47 那套)
↓ MCP server 算出 42,回 { content:[{text:"42"}] }
↓
execute 返回 tool_result "42" → 引擎回传模型 → 模型继续
2
3
4
5
6
7
8
9
从引擎和模型的角度看,这跟调一个本地工具没有任何区别——走的是同一条路。差异全被 execute 里那一行 client.callTool 吸收掉了。
# 题眼:适配器模式(Adapter Pattern)
这一步是设计模式教科书级的应用。上层引擎只认一个接口——ToolDef(有 name / description / inputSchema / execute)。而 MCP 工具的调用方式是异构的(JSON-RPC、跨进程、异步)。mcpToolDefs 就是那个适配器:把"远程 callTool"这套外部接口,转接成引擎认的 ToolDef.execute 内部接口。结果是上层引擎零改动就能用上外部能力——这正是适配器"让不兼容的接口协同工作"的定义。
# 四、深入追问(面试常见 follow-up)
Q:命名为什么是 mcp__<server>__<tool> 这种双下划线三段式,直接用工具原名不行吗?
A:防撞名。你可能同时接多个 MCP server,两个 server 都有个叫 search 的工具;本地也可能已有同名工具。如果直接用原名,registry 里就冲突了。加 mcp__ 前缀 + server 名做命名空间,天然隔离了「本地工具 / server A / server B」三个来源。真实 Claude Code 用的就是完全一样的约定。
Q:normalizeSchema 补 additionalProperties:false 是在解决什么问题?
A:外部 MCP server 的 schema 是别人写的,质量参差——有的没写 additionalProperties。而 DeepSeek 的兼容代理对工具 schema 校验较严,缺这个字段会拒绝请求。normalizeSchema 是一层防御性兜底,把外部不规范的 schema 补齐成后端能接受的形态。这体现一个原则:跨系统边界的数据都要做规范化,不能假设对方给的一定合规。
Q:既然模型看不出本地工具和 MCP 工具的区别,那 MCP 工具挂了会发生什么?
A:execute 里包了 try/catch,client.callTool 抛错(比如 server 崩了、超时)会被捕获,返回 { isError: true } 的 tool_result。对模型来说这就是一次"工具执行失败",它会像对待任何工具报错一样去应对(重试或换路)。把远程故障翻译成本地工具的标准错误形态,也是适配层的职责之一。
Q:这套"注册即可见"的机制,安全上有什么隐患? A:有。任何进 registry 的工具模型都能自动调,而 MCP 工具来自外部进程,可能有副作用(删文件、发请求)。我们这版是全量注册、无差别放行。真实版会加工具级权限/允许列表——哪些 MCP 工具需要用户确认才能跑。这正是"registry 是唯一入口"的另一面:入口统一了,权限管控也要统一挂在这个入口上。
# 五、踩坑 / 设计权衡
- 动态刷新缺失:我们只在启动时注册一次。如果 server 中途重连、工具集变了,registry 不会跟着更新。真实版支持 reconnect 后动态增删工具——但那要处理"注册表变更"的一致性,教学版先跳过。
- schema 信任问题:
normalizeSchema只补了最常见的一个字段,外部 schema 若有更深的不规范仍可能出问题。跨边界的输入永远不能全信。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
mcpToolDefs 包装注册 | MCP 工具被包成内部 Tool 并入工具表 |
mcp__server__tool 命名 | 完全一致的命名约定 |
| execute 转发 callTool(适配器) | 一致 |
normalizeSchema 兜底 | 真实版也做 schema 规范化 |
| 还缺:工具级权限/允许列表、reconnect 后动态刷新、并发上限 | 真实版都有 |
# 七、一句话总结
动态注册 = 用适配器把外部工具伪装成本地工具:因为 registry 是模型发现工具的唯一入口,mcpToolDefs 把每个 MCP 工具包成标准 ToolDef——用 mcp__server__tool 命名防撞名、normalizeSchema 补齐 schema、execute 转发 client.callTool 吸收跨进程差异,于是模型调外部工具和调 Bash 走同一条路,完全透明。
# 下一节预告
工具接通了,但 MCP server 还能暴露另外两类东西。step49 补上 resources(只读上下文数据)和 prompts(预设指令模板)两条协议。