Book 第二十章:MCP 为什么是一层动态扩展运行时
第四部分:扩展与协作

第二十章:MCP 为什么是一层动态扩展运行时

把 MCP 看成动态工具工厂,而不是远端 RPC 直通。

1. 为什么这一层必须单独拆

前面拆工具时,我们已经知道 Claude Code 不只靠内建工具工作。

它还有一整个 MCP 扩展面:

  • 远端 server 可以声明 tools
  • 远端 server 可以声明 prompts
  • 远端 server 可以声明 resources
  • 某些 server 还会带 skills、OAuth、URL elicitation、长输出、图片输出这些额外语义

如果只看 src/tools/MCPTool/MCPTool.ts,会觉得它就是个普通壳子。

但真正的关键根本不在这个文件本身,而在:

Claude Code 是怎么把“一个外部协议 server”变成自己运行时里的正式工具、命令和资源入口的。

这一层我建议单独看成:

MCP dynamic tool runtime。

2. 先说结论

我对这一层的总体判断是:

Claude Code 并没有把 MCP 当成“远端插件函数调用”,而是把它做成了一个受 Claude Code 自己运行时治理的动态工具工厂。

更具体一点,它做了五件事:

  1. 先把每个 MCP server 连接成一个带状态的 runtime client
  2. 再把 server 暴露的 tools/listprompts/listresources/list 翻译成 Claude Code 自己的 Tool / Command / Resource
  3. 把 MCP 自带的 metadata 和 annotations 映射回 Claude Code 的权限、defer、collapse、open-world 语义
  4. 对认证失败、session 过期、URL elicitation、长输出、图片输出这些“不像普通函数调用”的情况做额外治理
  5. 最后再把这些动态产物增量塞进 AppState.mcp.*,并和内建工具池统一合并

所以这一层的本质不是:

  • “调一下 MCP SDK”

而是:

  • 把异构外部能力编译进 Claude Code 自己的 runtime 协议。

3. 源码锚点

这次主要看的源码是:

  • src/tools/MCPTool/MCPTool.ts
  • src/tools/MCPTool/UI.tsx
  • src/tools/MCPTool/classifyForCollapse.ts
  • src/tools/McpAuthTool/McpAuthTool.ts
  • src/services/mcp/client.ts

配套还看了:

  • src/services/mcp/mcpStringUtils.ts
  • src/services/mcp/utils.ts
  • src/services/mcp/types.ts
  • src/utils/toolPool.ts
  • src/main.tsx

4. 总体结构图

flowchart TD
    A["MCP config"] --> B["connectToServer()"]
    B --> C{"连接结果"}

    C -- "connected" --> D["fetchToolsForClient()"]
    C -- "connected" --> E["fetchCommandsForClient()"]
    C -- "connected" --> F["fetchResourcesForClient()"]
    C -- "needs-auth" --> G["createMcpAuthTool()"]
    C -- "failed / disabled" --> H["只更新 server 状态"]

    D --> D1["把 tools/list 转成 Claude Tool"]
    D1 --> D2["映射 annotations / _meta / schema / permission"]
    D2 --> D3["真实 call() 走 callMCPToolWithUrlElicitationRetry()"]

    E --> E1["把 prompts/list 转成 Claude Command"]
    F --> F1["把 resources/list 转成 Resource 索引"]
    F1 --> F2["必要时动态注入 ListMcpResources / ReadMcpResource"]

    G --> G1["authenticate pseudo-tool"]
    G1 --> G2["OAuth 完成后重连并替换成真实工具"]

    D3 --> I["AppState.mcp.tools / commands / resources"]
    I --> J["mergeAndFilterTools()"]
    J --> K["最终工具池"]

这张图里最重要的一点是:

MCP server 不是直接暴露给模型,而是先被翻译成 Claude Code 自己那套工具运行时对象。

5. MCPTool:它只是原型,不是真正的 MCP 工具

src/tools/MCPTool/MCPTool.ts 这个文件本身非常容易误导人。

如果只看它,会觉得:

  • 名字叫 mcp
  • 输入 schema 是 passthrough
  • call() 返回空
  • checkPermissions() 只给了个 passthrough

其实这正说明了它的定位:

它不是一个真正可用的工具,而是一个动态工具模板。

5.1 它保留的是“公共壳”,不是业务语义

这个原型里主要保留的是通用行为:

  • isMcp: true
  • 通用 UI 渲染器
  • 通用结果映射
  • 通用 passthrough 权限起点
  • 通用输入 schema 占位

而真正决定一个 MCP 工具是什么的部分,全都在 client.ts 里动态覆写:

  • name
  • description
  • prompt
  • call
  • userFacingName
  • inputJSONSchema
  • checkPermissions
  • 甚至某些 server 的专用 UI override

所以更准确的理解是:

MCPTool 是 Claude Code 的 MCP tool prototype。

6. client.ts 才是真正的 MCP 工厂

整个 MCP 动态扩展层真正的中心,不是 MCPTool.ts,而是 src/services/mcp/client.ts

6.1 它先统一连接各种 transport

connectToServer() 这一段非常重。

它统一处理了多种 server 类型:

  • stdio
  • sse
  • http
  • ws
  • sse-ide
  • ws-ide
  • sdk
  • claudeai-proxy

而且不是“都走一个 fetch”这么简单。

不同 transport 会走完全不同的连接策略:

  • stdio 走子进程 transport
  • sse / http 走 SDK 的远程 transport + OAuth provider
  • ws 走自定义 websocket transport
  • IDE server 有自己的内网特例
  • Chrome / Computer Use 某些 MCP server 甚至直接 in-process 跑,不起外部大进程

这说明 Claude Code 把 MCP 先抽象成:

  • Server connection runtime

然后才抽象成:

  • Tool runtime

6.2 它给每个 server 明确建了状态机

types.ts 能看到,MCP server 不是只有“连上 / 没连上”两种状态。

至少有这些状态:

  • pending
  • connected
  • needs-auth
  • failed
  • disabled

这点很重要。

因为后面的工具暴露策略、ToolSearch 行为、UI 展示、重连逻辑,全部都依赖这个状态机。

特别是在 headless / print 模式里,main.tsx 还会先把 server 推成 pending

  • 这样 ToolSearchTool 才能告诉模型“有些 server 还在连”
  • 而不是让模型误以为工具不存在

也就是说,Claude Code 对 MCP 的建模单位不是“工具”,而是:

有生命周期的 server endpoint。

7. 真正的动态工具生成:fetchToolsForClient()

这一段是整层最核心的代码。

7.1 它不是直接透传 tools/list

fetchToolsForClient() 会先调用:

  • client.request({ method: 'tools/list' }, ListToolsResultSchema)

但拿到结果后,它不是直接把 MCP tool 扔给模型,而是会逐个转成 Claude Code 自己的 Tool

每个工具都会被加工成:

  • ...MCPTool
  • 再覆写 name / schema / permission / call / UI 语义

这一步很关键,因为 Claude Code 要把 MCP 世界里的概念,映射回自己这套工具运行时。

7.2 MCP 元数据会被翻译成 Claude Tool 元数据

这里至少有几类映射非常重要。

第一类是 _meta

  • _meta['anthropic/searchHint'] -> tool.searchHint
  • _meta['anthropic/alwaysLoad'] -> tool.alwaysLoad

这直接决定了:

  • ToolSearch 的关键词匹配质量
  • 某个 MCP 工具是否能绕过 defer,直接首轮曝光

第二类是 annotations

  • readOnlyHint -> isConcurrencySafe() + isReadOnly()
  • destructiveHint -> isDestructive()
  • openWorldHint -> isOpenWorld()
  • title -> userFacingName() 的展示名

这说明 Claude Code 不只是“能调 MCP”,而是在认真吸收 MCP 协议给出的语义标签。

7.3 它还会补 Claude 自己的运行时语义

除了 MCP 协议自带信息,Claude Code 还会加自己的语义层:

  • toAutoClassifierInput() 会把输入编码成 classifier 友好的字符串
  • isSearchOrReadCommand() 会调用 classifyMcpToolForCollapse(),让 transcript/UI 知道哪些 MCP 工具应当折叠成 search/read 类
  • checkPermissions() 会给出 Claude Code 风格的 allow-rule suggestion

这说明 MCP tool 一旦进来,就不再只是“外部工具”。

它已经被 Claude Code 本地化成自己工具协议的一等公民了。

7.4 SDK MCP 还有一层“去前缀覆盖内建”的特例

这里有个很细但非常重要的点。

fetchToolsForClient() 里有 skipPrefix 逻辑:

  • 如果是 sdk 类型 MCP server
  • 且环境变量允许
  • 可以不用 mcp__server__tool 这种前缀名
  • 直接用原始工具名

这意味着 SDK MCP 工具有机会按名字覆盖内建工具。

但 Claude Code 没把权限匹配搞乱,因为它同时保留了:

  • mcpInfo: { serverName, toolName }

后面做 permission check 时,会用 fully-qualified name 来避免和 builtin 同名冲突。

这个设计非常成熟:

  • 模型侧可以拿到更自然的工具名
  • 安全侧仍然用完整限定名做规则匹配

8. MCP tool 调用不是一跳 RPC,而是一条被 Claude Code 包过的调用链

fetchToolsForClient() 里生成的每个 tool,真正 call() 的时候会继续走 Claude Code 自己的包装逻辑。

8.1 先发本地进度,再进真实调用

每次调用前后,Claude Code 都会把 MCP 进度转成自己的 mcp_progress

  • started
  • progress
  • completed
  • failed

这样 UI、headless stream 和 transcript 都不用直接依赖 MCP SDK 的事件格式。

8.2 真正调用走的是 callMCPToolWithUrlElicitationRetry()

这一层非常关键。

它不是直接 client.callTool(),而是先走:

  • ensureConnectedClient()
  • callMCPToolWithUrlElicitationRetry(...)

这个包装器专门处理 MCP 里一个很“非函数式”的情况:

  • tool 调用过程中需要用户打开某个 URL 完成外部动作

如果 MCP server 返回 UrlElicitationRequired,Claude Code 会:

  • 先跑 elicitation hooks,看能不能程序化解决
  • 如果不行,交给 print/SDK mode 的 structured IO,或者 REPL 的队列式对话框
  • 等用户完成后再自动重试 tool call

这说明 Claude Code 不把 MCP tool 看成:

  • 输入一次,输出一次

而是看成:

  • 中途可能进入“用户外部交互”子流程的长事务。

8.3 认证失败和 session 过期也会被翻译成 Claude Code 自己的异常语义

callMCPTool() 里还专门处理两类问题:

  • 401 / UnauthorizedError -> McpAuthError
  • 404 + -32001 / Connection closed -> McpSessionExpiredError

一旦 session 过期,它会:

  • 清掉 server cache
  • 让上层重新建 fresh client

如果只是普通报错,则再包装成 telemetry-safe error,避免只收到无意义的 Error / McpError

这说明 Claude Code 对 MCP 错误不是“捕了就算”,而是在做:

  • 协议错误分类
  • 会话错误恢复
  • 认证错误升级

9. MCP 结果也不会直接原样回灌

processMCPResult() 这块很值得注意。

9.1 它先把结果标准化成 Claude 能消费的三类

transformMCPResult() 会把结果归一成三类:

  • toolResult
  • structuredContent
  • contentArray

如果格式不符合预期,直接报错。

也就是说,Claude Code 不愿意让 MCP server 随便往上喷不受控数据结构。

9.2 大输出会被截断,或者直接落盘

如果结果太大,它不会无脑把整段塞回模型上下文。

而是:

  • 先估 token 大小
  • feature 关闭时做旧式 truncation
  • feature 打开时优先把大输出持久化到文件
  • 再返回“去读这个文件”的指引

但如果内容里包含图片,就不会落成 JSON 文件,而是退回 truncation。

因为作者很清楚:

  • 图片一旦被 JSON 化保存,就丢了原本更适合宿主显示和压缩的表示方式

这又是一个典型的 Claude Code 风格:

不是只保证“调用成功”,还要治理模型上下文污染。

9.3 二进制 blob 也会落盘再回引用

MCP 返回二进制内容时,Claude Code 也不会直接往 transcript 硬塞。

它会:

  • persistBinaryContent(...)
  • 根据 mime type 和大小落到本地
  • 再给模型/用户返回一段“这个内容已经保存到哪里”的文本说明

这意味着 Claude Code 对 MCP 输出的治理原则和 Bash 大输出、本地文件读取是统一的:

  • 尽量让上下文里保留可操作引用,而不是把大块原始数据塞满上下文。

10. McpAuthTool:它不是业务工具,而是 needs-auth 状态下的占位替身

src/tools/McpAuthTool/McpAuthTool.ts 这块非常有代表性。

10.1 它解决的问题不是“怎么认证”,而是“工具缺席时怎么让模型知道发生了什么”

当某个 MCP server 因为 OAuth 没法正常连上时,Claude Code 不会简单把整组工具藏起来。

它会创建一个 pseudo-tool:

  • 名字像 mcp__server__authenticate
  • userFacingName() 明确写成 server - authenticate (MCP)
  • 描述里解释这是一个已安装但未认证的 server

这很重要。

因为它把“server 存在但暂时不可用”显式暴露给了模型。

10.2 调用这个 pseudo-tool 以后,会触发真正的 OAuth 启动流程

createMcpAuthTool()call() 会:

  • http / sse server 启动 performMCPOAuthFlow(...)
  • 设置 skipBrowserOpen: true
  • 拿到授权 URL 后回给模型,请模型转告用户打开

而 OAuth 回调在后台完成后,又会:

  • clearMcpAuthCache()
  • reconnectMcpServerImpl(...)
  • 用 prefix 替换的方式,把旧的 auth pseudo-tool 和旧工具清掉
  • 把新的真实 tools / commands / resources 塞回 AppState

这说明 McpAuthTool 本质上是:

needs-auth 状态下的过渡替身。

10.3 它的替换机制设计得很干净

作者专门让 auth pseudo-tool 和真实工具共享同一个前缀:

  • mcp__<server>__*

这样 OAuth 完成后,不需要做复杂映射,只要按前缀替换:

  • 删旧的
  • 塞新的

就能把整个 server 的工具面平滑切换过去。

这个设计很适合后面移植到 C#。

11. getMcpToolsCommandsAndResources():不是一次性加载,而是按 server 增量推进

这一段把 Claude Code 的工程味道暴露得很明显。

11.1 它不是串行连接一大堆 server

这里没有写“for 循环一个一个连”。

它会按 server 类型分组:

  • 本地 server
  • 远程 server

再分别用不同并发度跑:

  • 本地 server 并发更低,避免大量起子进程抢资源
  • 远程 server 并发更高,因为主要是网络连接

而且作者还专门写了注释解释为什么不用旧的“分批串行 batch”,而改成 pMap

  • 慢 server 不该卡住后面整批 server

这说明 Claude Code 是认真在优化:

  • 多 server 连接调度

11.2 它会跳过最近刚确认 needs-auth 的 server

这一点也很成熟。

client.ts 里有一套 15 分钟 TTL 的 mcp-needs-auth-cache.json

如果一个远端 server 最近刚 401,或者已经探测出“发现流程存在但本地没有 token”,Claude Code 会:

  • 暂时不重复探测这个 server
  • 直接返回 needs-auth 状态 + McpAuthTool

这样就避免了:

  • 每次启动都去打一轮注定失败的 auth 探测
  • print mode 被几十个无效 connector 拖慢

这是典型的 runtime 降噪设计。

11.3 resource tools 不是每个 server 都复制一份

ListMcpResourcesToolReadMcpResourceTool 并不是每连一个支持 resources 的 server 就重复注入一遍。

getMcpToolsCommandsAndResources() 里会用一个 resourceToolsAdded 标记:

  • 第一个支持 resources 的 server 进来时,才把这两个工具加进来
  • 后面别的 server 即使也支持 resources,也不会重复加

这也说明 Claude Code 分得很清楚:

  • server-specific tool 要动态生成
  • resource access gateway 是 runtime 共享入口

12. fetchCommandsForClient()fetchResourcesForClient() 说明 MCP 在 Claude Code 里不只是工具

这也是很容易被忽略的一点。

Claude Code 对 MCP 的接入,不只接:

  • tools

还接:

  • prompts -> Command
  • resources -> ServerResource
  • skills -> MCP skill

这意味着在 Claude Code 的世界里,MCP server 不是“工具提供方”,而是更广义的:

  • runtime capability provider

它既能给工具,也能给 slash command,也能给资源,也能给 skills。

13. Claude Code 对 MCP 还做了哪些额外处理

这一层的“额外处理”其实非常多,我觉得最关键的有九件事。

13.1 它把 transport 差异吃掉了

  • stdio / http / sse / ws / sdk / claudeai-proxy 都统一进同一套 MCPServerConnection

13.2 它把 server 变成显式状态机

  • pending
  • connected
  • needs-auth
  • failed
  • disabled

13.3 它把 MCP 元数据吸收到 Claude Tool 协议里

  • searchHint
  • alwaysLoad
  • readOnlyHint
  • destructiveHint
  • openWorldHint

13.4 它把认证失败变成一个可调用的 pseudo-tool

  • 不是静默消失
  • 不是只弹错误
  • 而是给模型一个正式恢复入口

13.5 它把 URL elicitation 变成正式子流程

  • hook
  • UI / structured IO
  • completion retry

13.6 它把 session 过期当成可恢复问题,不当成普通失败

  • 清 cache
  • 重连
  • retry

13.7 它把大输出和二进制输出治理掉了

  • truncation
  • persist to file
  • binary blob save

13.8 它把 transcript/UI 语义也补齐了

  • classifyMcpToolForCollapse()
  • renderToolUseMessage()
  • renderToolUseProgressMessage()
  • renderToolResultMessage()

13.9 它把工具池排序和去重稳定化了

  • mergeAndFilterTools() 里 built-in 和 MCP 分开排序
  • 名字去重
  • prompt cache 稳定

这说明 MCP 在 Claude Code 里不是外挂,而是深度接进核心运行时的。

14. 如果翻成 C#,我建议怎么拆

这层如果要翻成 C#,我不建议写成一个“万能 McpService”。

更合适的拆法大概是:

14.1 先把连接层和生成层分开

  • IMcpConnectionManager
  • IMcpCapabilityTranslator

其中:

  • connection manager 负责 transport、auth、reconnect、state machine
  • translator 负责把 MCP 的 tools/prompts/resources 翻译成本地 runtime 对象

14.2 把动态工具原型和动态实例分开

  • McpToolPrototype
  • McpToolInstanceFactory

不要把“所有工具共用的 UI / result policy”和“某个 server 的真实 call 逻辑”写死在同一个类里。

14.3 needs-auth 要保留成显式替身工具

建议直接保留一个:

  • McpAuthenticateTool

别只在 UI 上显示“未认证”。

因为 Claude Code 这里证明了一件事:

  • 把恢复动作也做成工具,模型会更容易继续推进流程。

14.4 输出治理要作为 MCP 子系统的一部分

不要把这些逻辑扔到全局“工具调用后处理”里草草解决。

最好单独有:

  • McpResultNormalizer
  • McpLargeOutputPolicy
  • McpBinaryContentStore
  • McpUrlElicitationCoordinator

因为这些问题是 MCP 层高度特有的。

15. 最后收一下

这一层真正说明的是:

Claude Code 接 MCP 的方式,不是“给系统插几个远端函数”,而是“把外部能力重新编译成 Claude Code 自己的运行时对象”。

MCPTool 只是原型壳。

McpAuthTool 是 needs-auth 状态下的恢复替身。

client.ts 才是真正的工厂、连接编排器、错误恢复器和语义翻译器。

这三者合起来,Claude Code 才能把 MCP 变成一套可控的动态扩展层,而不是一堆不可预期的远端 RPC。

如果后面要做 C# 版,我会把这一层放在很前面认真设计。

因为它直接决定你的系统最后是:

  • “能连上 MCP”

还是

  • “能把 MCP 变成自己 runtime 的一部分”。