# 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: {} };
}
1
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" → 引擎回传模型 → 模型继续
1
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(预设指令模板)两条协议。