MCP 协议设计与实现
第19章 Claude Code 的 MCP 客户端:1.6 万行的实战
规范是理想,实现是现实。真正的 MCP 知识藏在真实产品的每一个边界处理里。
本章要点
- 理解 Claude Code 作为 MCP 客户端的整体架构及其与 Claude Desktop 的本质区别
- 掌握 Server 发现机制:多作用域配置的合并顺序、签名去重与环境变量展开
- 深入 OAuth 授权流程在 CLI 环境下的特殊处理
- 理解工具延迟加载(Deferred Loading)如何解决大规模 MCP 集成的性能问题
- 认识 MCP 工具如何与 Claude Code 内置工具在统一接口下共存
- 学习 1.6 万行代码背后的规模工程学 —— 测试占 18%,而生产级集成只比 SDK 大 3.7
4.5 倍,不是流传的 50100 倍
19.1 为什么要研究 Claude Code
在前面的章节中,我们从协议规范和 SDK 源码的角度理解了 MCP 的方方面面。但规范是抽象的,SDK 提供的是积木,真正的挑战在于用这些积木搭建一个面向大量用户的产品。
Claude Code 是 Anthropic 官方的命令行 AI 编程工具。本章对照的是它的 2026.3.31 源码快照(来自已下架的 @anthropic-ai/claude-code@2.1.88 随包分发的 source map;名字类核对用次日发布的 2.1.89 的 cli.js,来源与不可复核性见专栏首页和 §19.10.1),其中与 MCP 相关的代码共 16230 行(含测试)/ 13262 行(不含测试),占 src/ 的 4.3%。这是一个真实的、被大量开发者日常使用的 Client,而且出自 MCP 的发起方 Anthropic 之手。
这个数字曾经写错过,而且错了近一个量级。 本章早期版本沿用了社区流传的"约 12 万行 MCP 代码",直到 §19.8 真去数了一遍
src/。本专栏凡是提到这一章规模的地方,一律以 §19.8 的实测为准,那一节给出了逐目录、逐文件的拆分。这段说明留着不删,是因为"12 万行"至今仍在别处流传——读者在别的地方读到时,应该知道它是怎么来的。
规范与实现的鸿沟
研究 Claude Code 的 MCP 实现,不是为了模仿它的每一行代码,而是为了理解一个成熟的 MCP 客户端需要解决哪些协议规范没有明说的工程问题。
| 维度 | 协议规范说了 | 协议规范没说 |
|---|---|---|
| 消息格式 | ✅ 完整 | - |
| 方法定义 | ✅ 完整 | - |
| 错误码 | ✅ 完整 | - |
| Server 如何发现 | 部分 | 配置文件格式、层级合并、环境变量 |
| OAuth 流程细节 | 部分 | CLI 环境下的回调、Token 存储 |
| 性能优化 | ❌ | 延迟加载、连接池、超时策略 |
| 错误恢复 | ❌ | 重连、健康检查、降级 |
| UI 集成 | ❌ | 终端渲染、Elicitation 交互 |
协议规范定义的是"契约",而实现决定的是"体验"。Claude Code 那 1.6 万行 MCP 代码,大部分在填补这些"规范没说的"空白。
19.2 架构全景
Claude Code 的 MCP 客户端架构可以分为五个层次:
graph TB
subgraph "用户交互层"
CLI[CLI 界面]
REPL[交互式 REPL]
end
subgraph "Agent 层"
AG[Agent Core]
TM[Tool Manager<br/>统一内置工具 + MCP 工具]
end
subgraph "MCP 客户端层"
SD[Server Discovery<br/>配置发现]
SM[Server Manager<br/>生命周期管理]
OA[OAuth Handler<br/>认证管理]
DL[Deferred Loader<br/>延迟加载]
end
subgraph "传输层"
ST[Stdio Transport]
HT[Streamable HTTP / SSE / WebSocket Transport]
end
subgraph "MCP Servers"
S1[本地 Server<br/>stdio]
S2[远程 Server<br/>HTTP / SSE]
S3[内置 Server<br/>IDE 集成、Chrome 等]
end
CLI --> AG
REPL --> AG
AG --> TM
TM --> SM
SM --> SD
SM --> OA
SM --> DL
SM --> ST
SM --> HT
ST --> S1
HT --> S2
SM -->|sdk / IDE 专用传输| S3
style AG fill:#2563eb,color:#fff,stroke:none
style TM fill:#7c3aed,color:#fff,stroke:none
style SM fill:#db2777,color:#fff,stroke:none
style SD fill:#f59e0b,color:#333,stroke:none
style OA fill:#10b981,color:#333,stroke:none
style DL fill:#6366f1,color:#fff,stroke:none
Claude Code vs Claude Desktop 的关键差异
与 Claude Desktop 不同,Claude Code 是一个 CLI-first 的应用。这意味着它面临一些独特的挑战:
| 挑战 | Claude Desktop | Claude Code |
|---|---|---|
| UI 容器 | 持久 GUI 窗口 | 终端文本界面 |
| 表单渲染 | HTML/CSS 原生 | 文本交互提示 |
| OAuth 回调 | 应用 URL scheme | 临时本地 HTTP Server |
| 启动速度 | 用户可以接受一次窗口加载 | 每次敲 claude 都要立刻有反应 |
| 并发 Server | 预先全部连接 | 也是全部连接,但并发度按本地/远程分别设上限 |
| 生命周期 | 长驻进程 | 可能短生命周期 |
| 资源占用 | 相对宽松 | 对资源极敏感 |
这些差异贯穿了 Claude Code 的每一个架构决策。理解这些差异,才能理解那 1.6 万行为什么是那个形状。
19.3 Server 发现与配置
19.3.1 多层配置体系
Claude Code 使用与 Claude Desktop 兼容的 mcpServers 配置格式,但把它拆成了几个作用域(scope):
enterprise # 企业策略配置:一旦存在,独占控制所有 MCP Server
user # 用户级:跨项目共享
project # 项目级:随仓库走,典型载体是仓库根目录的 .mcp.json
local # 本机项目级:只对当前机器上的这个项目生效
plugin # 插件带来的 Server
这套作用域名称与合并顺序来自《Claude Code 源码深度解析》专栏第 11 章对
src/services/mcp/config.ts的走查——本章无法独立复核内部实现(见 §19.10.1 的说明), 具体路径与文件名请以该章为准。对外可见的部分有官方文档可对(Claude Code 文档 「Connect Claude Code to tools via MCP」,核对日期 2026-09-24):claude mcp add的--scope只接受 local、user、project,默认 local;local 与 user 都存在~/.claude.json(local 挂在当前项目路径下),project 存在项目根目录的.mcp.json;enterprise 对应管理员 下发的managed-mcp.json,文档称之为对 MCP Server 的「独占控制」。
典型配置
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"remote-db": {
"type": "http",
"url": "https://db-mcp.example.com/mcp",
"headers": {
"Authorization": "Bearer ${MCP_DB_TOKEN}"
}
}
}
}
远程条目要写 "type": "http"(或 "sse"、"ws"):官方文档明确说,没有 type 的条目按 stdio 解析,只写 url 会被当成配置错误跳过。另外,@modelcontextprotocol/server-github 这个 npm 包已被标记弃用(开发迁到了 github/github-mcp-server),这里只用它演示 stdio 条目的形状;它读取的变量名是 GITHUB_PERSONAL_ACCESS_TOKEN(第 12 章同一示例有说明)。
合并策略
合并顺序是 plugin < user < project < local——越靠近"这台机器上的这个项目",优先级越高; 而 enterprise 是特例:只要存在企业配置,它就完全取代其余所有来源,以满足企业管控需求。
graph TD
Ent[enterprise<br/>存在即独占]
Plug[plugin]
User[user]
Proj[project · .mcp.json]
Local[local]
Plug --> Merge{按优先级覆盖<br/>plugin < user < project < local}
User --> Merge
Proj --> Merge
Local --> Merge
Merge --> Final[最终配置]
Ent -.->|存在时短路| Final
style Merge fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
同名 Server 在高优先级作用域中出现时完全替换低优先级的同名条目——不会做字段级合并(因为 Server 定义应该是原子的)。另外还有一层签名去重,针对的是插件(以及 claude.ai 连接器)带来的 Server:把 stdio 的命令行数组、远程的 URL 归一化成一个签名,与手动配置的 Server 签名相同时只保留优先级高的那个。官方文档的说法与此一致:三个作用域之间按名字判重,插件和连接器按端点判重。
19.3.2 环境变量替换
注意上面 ${MCP_DB_TOKEN} 的用法——Claude Code 支持在配置文件中引用环境变量。这是一个实用的安全措施:密钥不应硬编码在配置文件中,尤其是项目级配置可能被提交到 Git 仓库。
支持的变量语法只有两种:
${VAR} # 缺失时保留原文不展开,并记入"缺失变量"列表用于提示
${VAR:-default} # 可选变量,缺失时用默认值
没有 ${VAR:?msg} 这类自定义错误消息语法——展开函数只做"取值 / 取默认值 / 记一笔缺失"三件事。
19.3.3 Server 启动与健康检查
这里要纠正一个流传很广的想当然(本章早期版本也这么写过):Claude Code 并不是"用到某个 Server 才去连它"。 它在启动时就会去连接配置里的所有 Server,只是把"并发度"和"已知会失败的连接"管住了:
- 本地(stdio / sdk)与远程 Server 用不同的并发上限——本地默认 3,远程默认 20,都可用环境变量调整。本地压得低,是因为每个连接都要 spawn 一个子进程;远程压得高,是因为网络连接比进程创建便宜得多。
- 批处理不是"凑够一批再发",而是滑动窗口:一个连接完成,下一个立刻补位,避免一个慢 Server 拖住整批。
- 已知需要认证的远程 Server 会被缓存 15 分钟跳过:某个 Server 返回 401 之后,这段时间内不再尝试连接,直接以
needs-auth状态展示。这在打印模式(print mode)下尤其重要——那种模式要等所有 Server 的连接尝试结束,每个注定失败的 401 都是白等的一次网络往返。 - 连接状态的变化按 16ms 窗口(约一帧)批量刷进 UI 状态,避免几十个 Server 同时变更把终端渲染打爆。
以上是 2026.3.31 快照(2.1.88)的行为,后来的版本有变化:按官方文档(核对日期 2026-09-24),v2.1.221 起,之前用过的远程 HTTP/SSE Server 可以从上一次会话保存的发现缓存里直接载入工具列表,在
/mcp里显示为cached,等 Claude 第一次调用它的工具时才真正连接。
连接状态是一个五态模型:
stateDiagram-v2
[*] --> Pending: 启动时并发发起连接
Pending --> Connected: initialize 握手成功
Pending --> NeedsAuth: 401 —— 缓存 15 分钟内不再重试
Pending --> Failed: spawn 失败 / 握手失败
Connected --> Pending: 远程连接断开,指数退避重连
Connected --> Failed: stdio / sdk 断开,不重连
NeedsAuth --> Pending: 用户完成授权
Disabled: 用户显式禁用
Connected --> Disabled
Failed --> Disabled
关键决策:
- 解析并合并多层配置,构建 Server 注册表
- 启动时并发连接全部 Server,本地与远程各有并发上限
- 每个 Server 连接后执行 MCP 初始化握手(
initialize→notifications/initialized) - 缓存 Server 的能力声明,用于后续的功能路由
- 连接本身用 memoize 按"名称 + 配置序列化"缓存——语义上等价于一个连接池,但不用管池大小和借还
如果一个 Server 在启动或初始化过程中失败,Server Manager 会记录错误但不影响其他 Server——这是优雅降级的核心体现。
那"按需"体现在哪里?体现在工具定义的加载上,而不是连接上——见 §19.5 的延迟加载。连接是急切的,schema 是惰性的,这个分工才是 Claude Code 的真实形状。
19.4 OAuth 在 CLI 中的实现
19.4.1 无浏览器环境的挑战
对于远程 MCP Server,认证通常通过 OAuth 完成。在有 GUI 的桌面应用中,这很自然——弹出浏览器,完成授权,回调到应用。但在 CLI 环境中,这需要一些巧妙的处理。
Claude Code 的 OAuth 流程:
sequenceDiagram
participant U as 用户终端
participant CC as Claude Code
participant LS as 本地 HTTP Server
participant RS as 远程 MCP Server
participant AS as OAuth 授权服务器
CC->>RS: 尝试连接(无凭证)
RS->>CC: 401 + OAuth metadata URL
CC->>AS: 获取 OAuth metadata
AS->>CC: authorization_endpoint, token_endpoint
CC->>CC: 生成 PKCE code_verifier/challenge
CC->>LS: 启动临时 HTTP Server (localhost:随机端口)
CC->>U: "请在浏览器中打开此 URL 完成授权"
Note over U: 用户打开浏览器
U->>AS: 访问授权页面
U->>AS: 授权同意
AS->>LS: 重定向回调,携带 authorization_code
LS->>CC: 接收 code
CC->>AS: 用 code + code_verifier 换取 token
AS->>CC: access_token + refresh_token
CC->>RS: 使用 access_token 连接
RS->>CC: 初始化成功
Note over CC: 关闭临时 HTTP Server
Note over CC: 安全存储 token
关键实现细节
1. 临时 HTTP Server 的安全配置
- 只监听
127.0.0.1(避免外部访问),端口默认随机挑选(避免端口碰撞;非 Windows 平台在 49152–65535 之间探测),也可以用claude mcp add --callback-port或环境变量MCP_OAUTH_CALLBACK_PORT固定,以匹配授权服务器上预先登记的http://localhost:PORT/callback - 超时 5 分钟(300000 毫秒)——用户没完成授权就报
Authentication timeout并关闭 - 拿到回调后即关闭本地 Server;如果浏览器重定向失败,官方文档给的退路是把地址栏里的完整回调 URL 粘回 Claude Code
- 回调 URL 包含 state 参数,校验不匹配即按疑似 CSRF 拒绝
2. PKCE(Proof Key for Code Exchange)
CLI 应用是"公开客户端"(public client),没法安全地持有 client secret。PKCE 是专为这类场景设计的防护:
1. 客户端生成随机 code_verifier (43-128 字符)
2. 计算 code_challenge = SHA256(code_verifier) 并 base64url
3. 授权请求携带 code_challenge
4. 获取 code 后,token 请求携带 code_verifier
5. 服务器验证 SHA256(code_verifier) == code_challenge
即使 code 被中间人截获,没有 code_verifier 也换不到 token
3. Token 存储
Token 不写进 MCP 配置文件,而是交给 Claude Code 的凭据存储:
| 平台 | 存储位置 |
|---|---|
| macOS | Keychain(Keychain 拒绝写入时退回下面的文件) |
| Linux | ~/.claude/.credentials.json,文件权限 0600 |
| Windows | %USERPROFILE%\.claude\.credentials.json |
这张表以官方文档「Authentication」页的凭据存储一节为准(核对日期 2026-09-24);2.1.89 的 cli.js 里也能看到同样的分支:只有 darwin 走 Keychain,其余平台用名为 plaintext 的文件后端。也就是说,Linux 上并没有接入 Secret Service,靠的是文件权限,而不是加密。
19.4.2 Token 生命周期管理
OAuth Handler 按 Server 分别存 token(快照里的键由 Server 名与其配置派生;官方文档的说法是「按端点存储」)。每次向远程 Server 发送请求前,都会检查 token 是否即将过期。下面是这套逻辑的示意,函数名和存储接口是为说明而写的,不是快照里的原代码:
async function ensureValidToken(server: RemoteServer): Promise<string> {
const cached = await tokenStore.get(server.url);
// 提前 5 分钟刷新,避免请求中途过期
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) {
return cached.accessToken;
}
// Token 即将过期,刷新
if (cached?.refreshToken) {
try {
const newTokens = await refreshAccessToken(cached.refreshToken);
await tokenStore.set(server.url, newTokens);
return newTokens.accessToken;
} catch {
// refresh_token 也失效了,重新授权
}
}
// 全新授权流程
return await runFullOAuthFlow(server);
}
这种"提前刷新"策略避免了"请求发出去时 token 还有效,服务器处理时已经过期"的竞态问题。
19.5 工具延迟加载(Deferred Loading)
19.5.1 问题的规模
在重度使用场景里,一个 Claude Code 会话可能同时接入多个 MCP Server;只要每个 Server 暴露若干工具,工具总量就会迅速膨胀到人工难以逐项审查的规模。
如果在每次对话开始时都把所有工具的完整定义(包括 JSON Schema)发送给 LLM,会造成:
- 上下文浪费:工具描述可能占用数千 token,挤占真正有用的对话内容
- 决策干扰:LLM 面对过多工具时,工具描述、参数 schema 和相似命名会增加选择难度;具体影响必须用项目内评测衡量,不能套用固定百分比
- 启动延迟:等待所有 Server 返回工具列表会拖慢首次交互
量化问题
假设一次会话接入多个 MCP Server,每个 Server 暴露一组工具:
- 总工具数会随 server 数量线性增长
- 每个工具都会带来 description 与 input schema
- schema 越细,单个工具越好用;工具越多,整体上下文越重
如果把所有完整 schema 一次性发送给模型:
- 工具上下文会挤占用户任务、代码片段和历史对话空间
- 相似工具会互相干扰,增加选择和参数填充难度
- 首次请求需要等待更多 server 完成工具发现
19.5.2 延迟加载策略
Claude Code 实现了一个精巧的分层加载机制:
第一层:只有工具名
对话开始时,延迟加载的工具只以名字出现在模型的上下文里,不带描述,也不带 schema。这类似于一个"目录",让模型知道有哪些能力可用,但不占用太多 context。2.1.89 里格式化这份清单的函数只返回 tool.name;官方文档的说法是「会话开始时只加载工具名和 server instructions」(「Scale with MCP tool search」一节,核对日期 2026-09-24)。MCP 工具默认全部延迟,标了 alwaysLoad 的除外(当前文档里它是按 Server 配置的字段)。
mcp__filesystem__read_file
mcp__github__create_issue
mcp__github__list_repos
mcp__db__query
...(还有很多)
第二层:完整工具定义
当模型需要某个工具时,先调用 ToolSearch(可以用 select:<工具名> 直接点名,也可以用关键词搜索),拿到完整定义后才发起实际的工具调用。
要注意"完整定义从哪里来":Claude Code 并不在这时回头找 MCP Server 要 schema。连接时 tools/list 已经把完整定义拿到了本地;每次请求 API 时,这些定义照样放进 tools 数组,只是带上 defer_loading 标记,由 API 端暂不把它们放进模型上下文。ToolSearch 的返回是一组 tool_reference 块,API 看到引用后才把对应的完整定义展开给模型(§19.10.1 会从另一个角度讲到 defer_loading)。所以延迟的是模型上下文里的 token,而不是客户端与 Server 之间的网络往返。
示意代码
下面是"客户端自己维护摘要、按需取完整定义"的一种写法,用来说明两层加载的思路,不是 Claude Code 的实现——按上面说的,Claude Code 把第二层交给了 API 的 defer_loading 与 tool_reference。
class DeferredToolLoader {
private summaries: Map<string, ToolSummary> = new Map();
private fullDefinitions: Map<string, Tool> = new Map();
async loadSummaries(): Promise<ToolSummary[]> {
const results: ToolSummary[] = [];
for (const server of this.servers) {
const tools = await server.listTools();
for (const tool of tools) {
const summary: ToolSummary = {
name: `mcp__${server.name}__${tool.name}`,
description: (tool.description ?? '').slice(0, 80),
};
this.summaries.set(summary.name, summary);
results.push(summary);
}
}
return results;
}
async getFullDefinition(toolName: string): Promise<Tool | null> {
if (this.fullDefinitions.has(toolName)) {
return this.fullDefinitions.get(toolName)!;
}
const { server, localName } = this.parseQualifiedName(toolName);
if (!server) return null;
const tools = await server.listTools();
const tool = tools.find(t => t.name === localName);
if (tool) {
this.fullDefinitions.set(toolName, tool);
}
return tool ?? null;
}
async search(query: string, maxResults = 5): Promise<ToolSummary[]> {
const scored = Array.from(this.summaries.values())
.map(s => ({ summary: s, score: this.scoreRelevance(s, query) }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults);
return scored.map(x => x.summary);
}
}
效果对比
| 指标 | 无延迟加载 | 延迟加载 |
|---|---|---|
| 初始 prompt tokens | 随完整 schema 数量线性增长 | 主要由工具名清单和实际展开的工具决定 |
| 工具选择准确率 | 依赖项目评测 | 依赖项目评测 |
| 首 token 延迟 | 受 server 发现与 schema 序列化影响 | 上下文变短;server 发现本身并不因此省掉 |
| 每轮平均 tokens | 容易被未使用工具拖高 | 更接近实际任务需要 |
| 月均成本 | 取决于模型、工具数与会话频率 | 取决于延迟加载命中率与评测策略 |
延迟加载不是优化——它是必需。
19.5.3 与内置工具的统一
Claude Code 有大量内置工具——Read(读文件)、Edit(编辑文件)、Bash(执行命令)、Grep(搜索)等。MCP 工具需要与这些内置工具在同一个接口下暴露给 LLM。
Tool Manager 维护一个统一的工具注册表:
graph LR
subgraph "Tool Manager 统一注册表"
direction TB
B1[Read 内置]
B2[Edit 内置]
B3[Bash 内置]
B4[Grep 内置]
M1[mcp__github__create_issue]
M2[mcp__github__list_repos]
M3[mcp__db__query]
M4[mcp__slack__send_message]
end
LLM[Claude LLM] -->|统一的工具列表| B1
LLM --> M1
style B1 fill:#2563eb,color:#fff,stroke:none
style B2 fill:#2563eb,color:#fff,stroke:none
style B3 fill:#2563eb,color:#fff,stroke:none
style B4 fill:#2563eb,color:#fff,stroke:none
style M1 fill:#10b981,color:#333,stroke:none
style M2 fill:#10b981,color:#333,stroke:none
style M3 fill:#10b981,color:#333,stroke:none
style M4 fill:#10b981,color:#333,stroke:none
当 LLM 发起工具调用时,Tool Manager 根据工具名称路由到正确的执行器——内置工具直接在进程内执行,MCP 工具则通过对应的 Server 连接转发 tools/call 请求。对 LLM 来说,两者完全透明。
19.5.4 命名空间与冲突处理
MCP 工具的命名采用 mcp__<serverName>__<toolName> 的命名空间格式——前缀 mcp__ 标明来源,双下划线做分隔。用 _ 而非 :// 的原因很实际:工具名要满足 API 的字符约束(官方 API 文档当前写作 ^[a-zA-Z0-9_-]{1,128}$,核对日期 2026-09-24),Server 名和工具名里的非法字符会被逐个替换成 _。如果两个 Server 都暴露了名为 search 的工具,它们会被注册为 mcp__github__search 和 mcp__jira__search,避免冲突。
这个方案有一个源码注释里也承认的已知局限:如果 Server 名字本身含 __,反解析时会有歧义——实践中罕见,但不是不存在。
但如果 MCP 工具名与内置工具名冲突,内置工具优先。这确保了核心功能不会被第三方 Server 意外覆盖——又一个"安全优先"的设计选择。
19.6 Elicitation 与 Sampling 的 CLI 适配
19.6.1 终端中的表单渲染
当 MCP Server 发起 Form Mode Elicitation 时,Claude Code 需要在终端中渲染表单。这比 GUI 应用困难得多——终端没有原生的表单控件。
下面这几段是设计方案的示意,不是对实现的转述:Claude Code 的 Elicitation 处理在
src/services/mcp/elicitationHandler.ts,而那棵树读者拿不到(§19.10.1)。可以确认的只有两点:它声明了elicitation能力,并且对 URL 模式做了专门的重试路径(callMCPToolWithUrlElicitationRetry)。官方文档(「Respond to MCP elicitation requests」,核对日期 2026-09-24)对外说明的也只到这一层:Form 模式弹出由 Server 定义字段的对话框,URL 模式打开浏览器、完成后回 CLI 确认。以下终端表单的具体形态请当作"在 CLI 里做表单会长成什么样"的推演。
一个自然的做法是把 Elicitation 请求转化为交互式的终端提示:
[MCP Server: github] 请求你提供信息:
请提供你的 GitHub 用户名
用户名 (必填): _
邮箱 (email 格式): _
[Enter] 提交 [Esc] 取消 [Tab] 下一字段
对于枚举类型,使用箭头键选择的列表:
选择部署环境:
> staging
production
development
对于复杂的嵌套结构,退化到多步向导:
Step 1/3: Project Info
Name: my-app
Description: _
[Continue] [Cancel]
终端 UI 库
Claude Code 的终端界面建立在 Ink(React for terminal)之上——快照里是 src/ink/ 下的一套自定义 Ink 渲染器(见《Claude Code 源码深度解析》专栏第 17 章)。这让 TypeScript 的声明式 UI 开发模式可以用在终端中。下面的 JSX 同样只是示意,FormField、Button 是假想的组件(Ink 本身不提供按钮这类表单控件):
<Box flexDirection="column">
<Text bold>[MCP Server: {serverName}]</Text>
<Text>{elicitation.message}</Text>
{elicitation.fields.map(field => (
<FormField
key={field.name}
field={field}
value={values[field.name]}
onChange={v => setValue(field.name, v)}
error={errors[field.name]}
/>
))}
<Box marginTop={1}>
<Button onClick={submit}>Submit</Button>
<Button onClick={cancel}>Cancel</Button>
</Box>
</Box>
19.6.2 Sampling:Claude Code 恰恰没有声明这个能力
这一小节本章早期版本写反了,值得留下来当反例。原文说的是:"Claude Code 对 Sampling 请求的处理尤为独特——它本身就运行着 Claude,所以会把 sampling/createMessage 直接注入当前对话流"。这个说法很有画面感,逻辑上也讲得通——但它不是实现。
事实是:Claude Code 的 MCP Client 在 initialize 时只声明了 roots 和 elicitation 两个能力,没有 sampling(依据《Claude Code 源码深度解析》专栏第 11 章对能力声明那段的走查)。按第 4 章讲过的协商规则,Server 因此不应该向它发 sampling/createMessage。顺带一提,规范 2026-07-28 版已把 Sampling(连同 Roots、Logging)标为 Deprecated(SEP-2577),本专栏锚定的 2025-11-25 版里它仍是正式特性。
顺带一提,那段能力声明里 elicitation 写成空对象 {} 而不是带 form / url 子字段的详细结构,原因也很现实:某些 Server 实现(如 Spring AI)遇到不认识的属性会直接抛错。协商字段写得越"完整",撞上别人 schema 校验 bug 的概率越高——这是规范之外的兼容性经验。
那"最像自我递归"的设计是什么?是 §19.5 的延迟加载与 ToolSearch:模型为了决定调哪个工具,先调用一个工具去查工具。递归发生在工具选择这一层,而不是在 Sampling 上。
这个错误怎么来的:从"Claude Code 内部跑着 Claude"推出"它当然能满足 Sampling"——一次纯粹的合理性推演。合理不等于为真;能力声明这种事,只能去看它到底声明了什么。
19.7 连接管理与容错
19.7.1 Server 生命周期状态机
每个 Server 连接都有明确的状态,已在 19.3.3 展示。Server Manager 为每个状态定义了明确的行为:
- 启动失败:记录错误,标记为不可用,不重试(避免反复 spawn 失败进程)
- 初始化失败:可能是版本不兼容,记录 Server 报告的版本信息,帮助用户诊断
- 断开:stdio(以及进程内 sdk 类型)不自动重连——本地子进程崩溃通常意味着程序本身有问题,盲目重启只会重复失败,因此直接标记为 failed;只有远程传输(SSE / HTTP)断开才走自动重连,因为那更可能是网络抖动
- 错误:工具调用返回错误不影响连接状态,但连续多次错误可能触发健康检查
19.7.2 超时策略
不同操作需要不同的超时口径,这一点没有争议;但具体秒数必须以实现为准,不能凭"合理"想当然。
本章能核实到的有两档。工具调用:DEFAULT_MCP_TOOL_TIMEOUT_MS 是 100,000,000 毫秒(约 27.8 小时),
可以用环境变量 MCP_TOOL_TIMEOUT 覆盖(依据同样来自《Claude Code 源码深度解析》专栏第 11 章;
2.1.89 的 cli.js 里能看到 MCP_TOOL_TIMEOUT 未设置时取 1e8)。Server 启动:环境变量 MCP_TIMEOUT,
默认 30000 毫秒(cli.js 里同样可见,官方文档的环境变量表也写作 30 秒,核对日期 2026-09-24)。
这个值看着离谱,但它恰恰说明了一个设计判断:MCP 工具的耗时分布没有上界——一次长跑的数据分析、
一次等待人工审批的流程,都可能以小时计;与其由 Client 拍一个必然误伤的默认值,不如放宽默认、把
收紧的权力交给用户。应对"卡住"靠的是用户随时可以取消,再加上每 30 秒一条的进度日志帮助定位,而不是短超时。
后来的版本在这一点上收紧了:按官方文档,v2.1.187 起多了空闲超时 CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
(Server 既无响应也无进度通知超过这个时长就中止调用,网络类 Server 默认 5 分钟;stdio 自 v2.1.203 起也纳入,默认 30 分钟),
HTTP/SSE 类 Server 的每个请求还有一个默认 60 秒的首字节计时器。
至于握手、工具列表、Elicitation、Sampling 各自的超时——本章早期版本给过一张 带具体秒数的表,那些数字没有出处,已删除。设计要点保留如下,秒数请在你自己的实现里定:
- 拉起子进程要给足时间(
npx首次下载包可能很慢),但要有上界 - 握手和元数据查询应该很快,超时可以设得比业务调用短得多
- 等人的操作(Elicitation、OAuth 授权)的超时以"人的耐心"为量纲,与协议无关
- 每一类超时都要能被用户覆盖
19.7.3 指数退避重连
对于断开的 HTTP 连接,Claude Code 采用指数退避策略:
const MAX_RECONNECT_ATTEMPTS = 5;
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30000;
// 每次失败后等待 min(1000 * 2^(attempt-1), 30000) 毫秒:1s → 2s → 4s → 8s → 16s
const backoffMs = Math.min(
INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
MAX_BACKOFF_MS,
);
关键细节:
- 严格的指数退避,没有加抖动(jitter)——这是实现的实际选择,不是笔者推荐的做法:多客户端同时重连时抖动能避免"惊群",值得你在自己的实现里补上
- 上限 30 秒——再长用户已经放弃了(不过只有 5 次尝试时,最长一次等待是 16 秒,这个上限实际碰不到)
- 5 次失败后放弃——避免无限重试
- 每次重试前先检查该 Server 是否已被用户禁用,是则立即放弃
19.7.4 资源管理
Claude Code 作为 CLI 工具,用户期望它在退出时干净地释放所有资源。先说规范:2025-11-25 版的生命周期一节明确没有定义任何关闭消息,关闭靠传输层表达——对 stdio,Client 应当先关闭子进程的输入流,等它退出,不退再发 SIGTERM,仍不退再 SIGKILL(docs/specification/2025-11-25/basic/lifecycle.mdx 的 Shutdown 一节)。所以"向 Server 发一条关闭通知"这种步骤在 MCP 里并不存在。
2.1.89 的清理函数里能看到的做法(cli.js 中的日志字符串可对)是:
- 进程内的 sdk 类型 Server:直接关闭 in-process server 和 client
- stdio 子进程:先发
SIGINT,约 100 毫秒后仍在就发SIGTERM,再过约 400 毫秒仍在就SIGKILL;整个监视窗口约 600 毫秒,到点即停止等待
信号逐级升级——先温柔,再强制——与规范建议的精神一致,只是起手用的是 SIGINT,而且整个过程被压在不到一秒之内,不让退出被某个不听话的 Server 拖住。
19.8 1.6 万行代码的启示:规模工程学(实测纠正)
本节数字大幅修订——之前的版本基于"约 12 万行 MCP 代码"的传闻数字、配 5 类百分比饼图——实测
src/后修正为真实数字。 整个 Claude Code 仓库src/下所有.ts共 379997 行(统计命令find src -name '*.ts' -type f -print0 | xargs -0 wc -l | tail -n 1,不过滤测试文件,与《Harness Engineering》专栏第 1 章 §1.3.1 同口径);其中所有*mcp*路径下的.ts共 16230 行(含 test)/ 13262 行(不含 test)——按分子分母同为"含测试"的口径,MCP 相关代码占整个 Claude Code 的 4.3%(16230 / 379997)——比"12 万行"小近一个量级。下面给出实测拆分。口径提示:算占比时分子分母必须同口径。本节的 379997 是
src/一棵子树、含测试;13262 是 MCP 路径下排除测试的行数,拿它去除 379997 会得到一个两边口径不一致的比值,所以正文里的 4.3% 一律取 16230 / 379997。另外,《Claude Code 源码深度解析》专栏首页写的"51 万行代码"是整仓口径,比 379997 大是因为它还包含src/之外的部分——两个数不矛盾,但不能互换。
实测目录分布(2026.3.31 源码快照,即 @anthropic-ai/claude-code@2.1.88 的 source map 还原出的 TypeScript 树)——
另需说明:《Claude Code 源码深度解析》专栏第 11 章说
src/services/mcp/"包含约 25 个文件",本节数到的是 22 个(非测试);client.ts该章记为"约 3300 行",本节数到 3348。两处差异都在"是否把测试文件计入"的口径上,读者遇到时不必当成矛盾。
| 路径 | 文件 | 行(不含 test) |
|---|---|---|
src/services/mcp/ |
22 | 12238 |
src/commands/mcp/ |
3 | 558 |
src/utils/mcp/ |
2 | 457 |
src/components/mcp/ |
1 | 9 |
| 合计(非测试) | 28 | 13262 |
| 加上 tests 后 | — | 16230 |
src/services/mcp/ 22 文件按行数排序——前 5 大(3348 + 2465 + 1578 + 1141 + 575 = 9107)占 74%——
| 文件 | 行 | 角色 |
|---|---|---|
client.ts |
3348 | 本目录最大(27%)——MCPClient、transport 选择、连接生命周期 |
auth.ts |
2465 | OAuth 全流程——20% |
config.ts |
1578 | 多层配置合并 + 环境变量替换 + schema 验证 |
useManageMCPConnections.ts |
1141 | React/Ink hook:连接管理状态机 |
utils.ts |
575 | 通用工具 |
xaa.ts / xaaIdpLogin.ts |
511 + 487 | XAA(Cross-App Access,企业托管授权:先到企业 IdP 登录拿身份令牌,再换 MCP 的 access token;协议侧见 §15.9.4 对 crossAppAccess.ts 的说明) |
channelNotification.ts / elicitationHandler.ts / channelPermissions.ts |
316 / 313 / 240 | 通道层:通知 / Elicitation / 权限 |
types.ts |
258 | TypeScript 类型 |
| 其余 11 文件 | 共 1006 | 各种小工具(envExpansion / oauthPort / mcpStringUtils / claudeai 等) |
核心洞察
这个比例揭示了一个重要事实:
MCP 客户端的核心复杂性不在协议本身,而在于将协议适配到具体的产品形态中。
- CLI 有 CLI 的挑战(OAuth 无浏览器、终端 UI 渲染、启动速度)
- 桌面应用有桌面应用的挑战(多窗口、系统集成、自动更新)
- Web 应用有 Web 应用的挑战(跨域、WebSocket 替代、Token 存储)
MCP 协议的抽象层级恰到好处——它定义了"做什么",但把"怎么做"的自由留给了实现者。
规模效应(修订)
13K 行 MCP 实现的真实结构给出了一个值得记住的量级(只有这一个样本,不宜当成行业规律)——
| 实现层 | 实测代码量 | 倍率 |
|---|---|---|
| 协议 SDK 客户端核心 | TS SDK(pin b8886e7)packages/client/src/client/auth.ts 1745 行 + packages/client/src/client/streamableHttp.ts 761 行 + packages/core/src/shared/protocol.ts 1081 行 ≈ 3600 行 |
1x |
| Claude Code 生产级 MCP 集成 | 13262 行(非测试)/ 16230 行(含测试) | 3.7x ~ 4.5x |
整个 Claude Code src/(含测试) |
379997 行 | — |
MCP 集成只占整个 Claude Code 4.3%——但贡献的是协议层和产品层之间的全部"胶水"工程量。这条比例和"协议 SDK : 生产客户端 = 1 : 50-100"的旧说法差距巨大——真实是 1 : 3.7~4.5。"我用 SDK 两小时写完了 MCP Client" 和 "Claude Code 13K 行 MCP 代码" 仍然不矛盾——前者是 demo、后者是承载错误处理 / OAuth / 多层配置 / 终端 UI 适配的生产代码——只是生产代码并不需要 50-100 倍 SDK 体量、3-5 倍就够了。
19.9 对其他客户端的借鉴
从 Claude Code 的实践中,我们可以提炼出对所有 MCP 客户端实现者的建议:
建议 1:连接要并发且有上限,工具定义要延迟加载
"启动时别连全部 Server"是个听起来很对但 Claude Code(2.1.89)并没有采纳的建议——用户希望 /mcp 一敲就看到所有 Server 的真实状态,惰性连接换来的是"不点开就不知道谁挂了"。它的实际做法是:全部连、但本地与远程分别限并发、把已知需要认证的连接缓存跳过、状态变更按帧批量刷 UI。
真正该延迟的是工具定义:连接可以急切,几十个工具的完整 JSON Schema 不该在第一轮对话就全塞进上下文(§19.5)。
建议 2:配置分层,安全优先
支持全局和项目级配置,密钥通过环境变量或系统密钥链管理。绝不在配置文件硬编码 Token。
建议 3:统一工具接口
MCP 工具和内置工具对 LLM 应该透明无差异,命名空间解决冲突。LLM 不应该知道哪些工具是 MCP、哪些是内置。
建议 4:优雅降级
单个 Server 的故障不应影响整个系统,错误信息应帮助用户定位问题。
建议 5:资源意识
- CLI 工具:启动速度和内存占用敏感——工具定义按需加载
- Web 应用:并发连接数敏感——连接复用
- 移动端:电量消耗敏感——避免频繁唤醒
- 服务端:多租户隔离敏感——per-tenant session
了解你的运行环境,才能做出正确的工程取舍。
建议 6:投资 OAuth 基础设施
CLI 的 OAuth 实现比想象中复杂——临时 HTTP Server、PKCE、Token 存储、刷新逻辑。这是一次性投入但收益长远。
建议 7:测试驱动
1.6 万行里有 18% 是测试(16230 含测试 − 13262 不含测试 = 2968 行)。每个错误路径、每个超时、每个竞态都需要测试。不要把"MCP Client 能用"当终点。
19.10 反模式清单
反模式一:启动时无节制地连接所有 Server
问题:几十个 stdio Server 同时 spawn 会把机器拖垮;某个 Server 卡在握手上会拖住整批;已知会 401 的远程 Server 每次启动都白等一次网络往返。
对策:连接可以是急切的,但要限并发(本地低、远程高)+ 滑动窗口 + 失败状态缓存(19.3.3)。
反模式二:工具定义一次性全加载
问题:Context 爆炸,工具越多、模型的选择与参数填充越受干扰。
对策:延迟加载(19.5)。
反模式三:Token 硬编码
问题:泄漏到 git、团队成员共享、难以轮转。
对策:环境变量引用 + 系统密钥链。
反模式四:连接断开不重连
问题:网络抖动就需要用户重启应用。
对策:指数退避重连 + 抖动 + 次数上限(19.7.3)。
反模式五:没有超时保护
问题:一个卡住的 Server 冻结整个 Agent。
对策:每个操作都设合理超时(19.7.2)。
19.10.1 Claude Code 里 MCP 工具进入模型上下文的真实路径
这一小节的出处读者拿不到,先说清楚:下面提到的
src/query.ts、src/utils/api.ts、src/services/api/claude.ts、src/tools/ToolSearchTool/、src/components/ContextVisualization.tsx、src/commands.ts都是 Claude Code 自身的内部实现,闭源,读者无法复核。所以这一小节刻意不给行号。给一份读者打不开的源码标上
:689-692, 比不给行号更糟:它带着可复核的外观,却没有可复核的实质——一条论断因为 写了「依据 xxx:689-692」而显得最可信,恰恰是最不可查的那一条。本专栏 其余带行号的引用一律指向前言「源码版本」表里锚定的那几棵树,可以逐条 复核;这一小节不在其列。保留这段论述是因为这条实现路径本身有工程价值——它展示了一个规模化客户端 如何把 MCP 工具并入统一的 tool schema 管线。请把它当作一份来自内部实现 的案例描述。真正可复用的结论写在本小节末尾那段「可复用模板」里,那一段 不依赖任何闭源细节。
Claude Code 的 MCP 客户端价值不只在"能连 server",而在它如何把远程工具转成模型可消费的 tool schema。src/query.ts 在发起查询时把 appState.mcp.tools 传给 API 层,并同时记录是否还有 pending MCP server;这说明 MCP 工具不是临时字符串拼接进 prompt,而是和内置工具一起进入统一的工具池。随后 src/services/api/claude.ts 对过滤后的工具调用 toolToAPISchema(),并在工具搜索启用时给需要延迟加载的工具加 deferLoading。
toolToAPISchema() 是关键转换点。src/utils/api.ts 先用工具名和 inputJSONSchema 构造 cache key,再输出 name、description、input_schema;同文件里只在本次请求需要时叠加 defer_loading,避免污染缓存的基础 schema;另有一处把实验字段统一放在一个出口处理,避免代理网关因为不认识额外字段而拒绝请求。这个设计解释了 Claude Code 为什么需要把 MCP 工具和内置工具统一建模:只有统一入口,才能统一做 schema 缓存、严格模式、细粒度工具流、延迟加载和代理兼容。
上下文体积也被显式观测。src/utils/api.ts 的 logContextMetrics() 会并行预取 MCP 资源、普通工具、用户上下文和系统上下文;同文件再统计 MCP 工具数量、MCP server 数量、MCP 工具 schema 的粗略 token 数以及非 MCP 工具 token 数。这里不需要臆造"工具多会降低多少准确率":源码已经给出更可靠的工程动作——把工具数量、server 数量和 schema token 纳入可观测指标,让团队用自己的会话数据判断是否需要延迟加载、拆分 server 或缩短 schema。
延迟加载的 UI 与搜索路径也能从源码对上。src/tools/ToolSearchTool/ToolSearchTool.ts 的搜索结果包含 matches、query、deferred tool 总数和 pending MCP server;src/components/ContextVisualization.tsx 会把 MCP tools 区分成 Loaded 与 Available,并在存在 deferred 工具时显示 loaded on-demand;src/commands.ts 还把 loadedFrom 为 mcp 的 prompt 型命令筛成 MCP-provided skills。这些细节说明,Claude Code 不是简单隐藏工具,而是给模型、用户界面和技能系统分别保留了"可发现但未加载"的状态。
因此,评价 Claude Code 的 MCP 实现时,重点不应是固定百分比的效果数字,而是四个可验证问题:第一,MCP 工具是否进入统一 tool schema 管线;第二,schema 缓存是否包含 inputJSONSchema,避免同名工具或结构化输出串 schema;第三,defer_loading 是否只作为每次请求叠加字段,避免缓存污染;第四,工具数量与 token 开销是否被记录,能否指导后续调优。把这四点做好,MCP 客户端才从"能调用远程工具"升级为"能在大规模工具生态里稳定工作"。
这条路径也给普通 MCP 客户端一个可复用模板:发现阶段只收集 server、工具名、摘要和 schema hash;选择阶段让模型或调度器基于摘要定位候选;执行前再取完整 schema、做权限检查和参数校验;会话结束时记录工具数、schema token 和失败原因。这样做不依赖 Claude Code 的内部实现,但能复用它在规模化工具管理上暴露出的核心经验:工具生态越大,越不能把"全部加载"当成默认策略。
同时,MCP skills 与 MCP tools 要分开治理。skills 更像可被模型调用的提示能力,tools 则会触发外部动作;二者都来自 MCP,但权限、展示、缓存和审计粒度不同。Claude Code 源码里用 loadedFrom === 'mcp' 区分来源,只是第一层标记;真正的产品设计还要继续区分"能影响模型思考的提示"和"能影响外部世界的动作"。
这正是大规模客户端和小型 demo 的分水岭:demo 只要能调用工具,产品必须能解释工具为什么出现、何时加载、谁批准、失败后如何恢复。
19.11 本章小结
Claude Code 是 MCP 协议在真实产品中最有代表性的落地案例之一。通过研究它的架构,我们看到了:
- Server 发现通过多作用域配置实现——enterprise / local / project / user / plugin,按 plugin < user < project < local 覆盖,enterprise 存在则独占;配置里可以用
${VAR}/${VAR:-default}引用环境变量 - OAuth 认证在 CLI 环境下通过临时本地 HTTP Server + PKCE 实现,兼顾安全与易用
- 工具延迟加载通过"只给名字 + 用 ToolSearch 按需展开"的两层策略,缓解了大规模工具集成的 context 压力
- 内置工具与 MCP 工具通过统一的 Tool Manager 融合,对 LLM 透明
- 那 1.6 万行里真正的复杂性在于将协议适配到具体产品形态,而非协议本身
核心心得
协议提供了骨架,产品思维才能填充血肉。
这些经验告诉我们:设计一个好的 MCP 客户端,既要深入理解协议规范,更要理解你的用户在什么场景下、用什么方式与 AI 工具交互。
在下一章,我们将角色互换——从消费 MCP 的 Client 转向生产 MCP 的 Server,从零开始构建一个生产级 MCP Server。