第二十章: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 自己运行时治理的动态工具工厂。
更具体一点,它做了五件事:
- 先把每个 MCP server 连接成一个带状态的 runtime client
- 再把 server 暴露的
tools/list、prompts/list、resources/list翻译成 Claude Code 自己的Tool/Command/Resource - 把 MCP 自带的 metadata 和 annotations 映射回 Claude Code 的权限、defer、collapse、open-world 语义
- 对认证失败、session 过期、URL elicitation、长输出、图片输出这些“不像普通函数调用”的情况做额外治理
- 最后再把这些动态产物增量塞进
AppState.mcp.*,并和内建工具池统一合并
所以这一层的本质不是:
- “调一下 MCP SDK”
而是:
- 把异构外部能力编译进 Claude Code 自己的 runtime 协议。
3. 源码锚点
这次主要看的源码是:
src/tools/MCPTool/MCPTool.tssrc/tools/MCPTool/UI.tsxsrc/tools/MCPTool/classifyForCollapse.tssrc/tools/McpAuthTool/McpAuthTool.tssrc/services/mcp/client.ts
配套还看了:
src/services/mcp/mcpStringUtils.tssrc/services/mcp/utils.tssrc/services/mcp/types.tssrc/utils/toolPool.tssrc/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 里动态覆写:
namedescriptionpromptcalluserFacingNameinputJSONSchemacheckPermissions- 甚至某些 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 类型:
stdiossehttpwssse-idews-idesdkclaudeai-proxy
而且不是“都走一个 fetch”这么简单。
不同 transport 会走完全不同的连接策略:
stdio走子进程 transportsse/http走 SDK 的远程 transport + OAuth providerws走自定义 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 不是只有“连上 / 没连上”两种状态。
至少有这些状态:
pendingconnectedneeds-authfaileddisabled
这点很重要。
因为后面的工具暴露策略、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:
startedprogresscompletedfailed
这样 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() 会把结果归一成三类:
toolResultstructuredContentcontentArray
如果格式不符合预期,直接报错。
也就是说,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/sseserver 启动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 都复制一份
ListMcpResourcesTool 和 ReadMcpResourceTool 并不是每连一个支持 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 变成显式状态机
pendingconnectedneeds-authfaileddisabled
13.3 它把 MCP 元数据吸收到 Claude Tool 协议里
searchHintalwaysLoadreadOnlyHintdestructiveHintopenWorldHint
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 先把连接层和生成层分开
IMcpConnectionManagerIMcpCapabilityTranslator
其中:
- connection manager 负责 transport、auth、reconnect、state machine
- translator 负责把 MCP 的 tools/prompts/resources 翻译成本地 runtime 对象
14.2 把动态工具原型和动态实例分开
McpToolPrototypeMcpToolInstanceFactory
不要把“所有工具共用的 UI / result policy”和“某个 server 的真实 call 逻辑”写死在同一个类里。
14.3 needs-auth 要保留成显式替身工具
建议直接保留一个:
McpAuthenticateTool
别只在 UI 上显示“未认证”。
因为 Claude Code 这里证明了一件事:
- 把恢复动作也做成工具,模型会更容易继续推进流程。
14.4 输出治理要作为 MCP 子系统的一部分
不要把这些逻辑扔到全局“工具调用后处理”里草草解决。
最好单独有:
McpResultNormalizerMcpLargeOutputPolicyMcpBinaryContentStoreMcpUrlElicitationCoordinator
因为这些问题是 MCP 层高度特有的。
15. 最后收一下
这一层真正说明的是:
Claude Code 接 MCP 的方式,不是“给系统插几个远端函数”,而是“把外部能力重新编译成 Claude Code 自己的运行时对象”。
MCPTool 只是原型壳。
McpAuthTool 是 needs-auth 状态下的恢复替身。
client.ts 才是真正的工厂、连接编排器、错误恢复器和语义翻译器。
这三者合起来,Claude Code 才能把 MCP 变成一套可控的动态扩展层,而不是一堆不可预期的远端 RPC。
如果后面要做 C# 版,我会把这一层放在很前面认真设计。
因为它直接决定你的系统最后是:
- “能连上 MCP”
还是
- “能把 MCP 变成自己 runtime 的一部分”。