MCP 协议设计与实现
第19章 Claude Code 的 MCP 客户端:12 万行的实战
第19章 Claude Code 的 MCP 客户端:12 万行的实战
规范是理想,实现是现实。真正的 MCP 知识藏在真实产品的每一个边界处理里。
本章要点
- 理解 Claude Code 作为 MCP 客户端的整体架构及其与 Claude Desktop 的本质区别
- 掌握 Server 发现机制:从 claude_desktop_config.json 到动态注册
- 深入 OAuth 授权流程在 CLI 环境下的特殊处理
- 理解工具延迟加载(Deferred Loading)如何解决大规模 MCP 集成的性能问题
- 认识 MCP 工具如何与 Claude Code 内置工具在统一接口下共存
- 学习 12 万行代码背后的规模工程学 —— 哪 25% 是测试、哪 25% 是错误处理
19.1 为什么要研究 Claude Code
在前面的章节中,我们从协议规范和 SDK 源码的角度理解了 MCP 的方方面面。但规范是抽象的,SDK 提供的是积木,真正的挑战在于用这些积木搭建一个面向百万用户的产品。
Claude Code 是 Anthropic 官方的命令行 AI 编程工具。截至 2026 年初,其代码库中与 MCP 相关的代码量超过 12 万行——这不仅是目前最大规模的 MCP 客户端实现,也是 MCP 协议本身的”试验场”:许多协议特性是在 Claude Code 的实践中被发现需要、被提出、最终被纳入规范的。
规范与实现的鸿沟
研究 Claude Code 的 MCP 实现,不是为了模仿它的每一行代码,而是为了理解一个成熟的 MCP 客户端需要解决哪些协议规范没有明说的工程问题。
| 维度 | 协议规范说了 | 协议规范没说 |
|---|---|---|
| 消息格式 | ✅ 完整 | - |
| 方法定义 | ✅ 完整 | - |
| 错误码 | ✅ 完整 | - |
| Server 如何发现 | 部分 | 配置文件格式、层级合并、环境变量 |
| OAuth 流程细节 | 部分 | CLI 环境下的回调、Token 存储 |
| 性能优化 | ❌ | 延迟加载、连接池、超时策略 |
| 错误恢复 | ❌ | 重连、健康检查、降级 |
| UI 集成 | ❌ | 终端渲染、Elicitation 交互 |
协议规范定义的是”契约”,而实现决定的是”体验”。Claude Code 12 万行代码,大部分在填补这些”规范没说的”空白。
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 Transport]
end
subgraph "MCP Servers"
S1[本地 Server<br/>stdio]
S2[远程 Server<br/>HTTP]
S3[内置 Server<br/>filesystem 等]
end
CLI --> AG
REPL --> AG
AG --> TM
TM --> SM
SM --> SD
SM --> OA
SM --> DL
SM --> ST
SM --> HT
ST --> S1
HT --> S2
ST --> S3
style AG fill:#3b82f6,color:#fff,stroke:none
style TM fill:#8b5cf6,color:#fff,stroke:none
style SM fill:#ec4899,color:#fff,stroke:none
style SD fill:#f59e0b,color:#fff,stroke:none
style OA fill:#10b981,color:#fff,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 |
| 启动速度 | 用户可等 2-3 秒 | 必须 < 500ms 感知 |
| 并发 Server | 预先全部连接 | 按需连接 |
| 生命周期 | 长驻进程 | 可能短生命周期 |
| 资源占用 | 用户可接受几百 MB | 对资源极敏感 |
这些差异贯穿了 Claude Code 的每一个架构决策。理解这些差异,才能理解 12 万行代码为什么是那个形状。
19.3 Server 发现与配置
19.3.1 三层配置体系
Claude Code 使用与 Claude Desktop 兼容的配置格式,但引入了更灵活的层级系统:
~/.claude/claude_desktop_config.json # 全局配置(所有项目共享)
./.claude/claude_desktop_config.json # 项目级配置
./.mcp.json # MCP 专用配置(简化格式)
典型配置
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_..."
}
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"remote-db": {
"url": "https://db-mcp.example.com/mcp",
"headers": {
"Authorization": "Bearer ${MCP_DB_TOKEN}"
}
}
}
}
合并策略
配置合并策略是深度合并,项目级覆盖全局:
graph TD
Global[~/.claude/config.json<br/>github:v1<br/>filesystem:v1]
Project[./.claude/config.json<br/>github:v2<br/>local-db:new]
Final[最终配置<br/>github:v2 ← 覆盖<br/>filesystem:v1 ← 继承<br/>local-db:new ← 新增]
Global --> Merge{合并器}
Project --> Merge
Merge --> Final
style Merge fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
同名 Server 在项目级配置中出现时完全替换全局配置中的同名条目——不会做字段级合并(因为 Server 定义应该是原子的)。
19.3.2 环境变量替换
注意上面 ${MCP_DB_TOKEN} 的用法——Claude Code 支持在配置文件中引用环境变量。这是一个实用的安全措施:密钥不应硬编码在配置文件中,尤其是项目级配置可能被提交到 Git 仓库。
支持的变量语法:
${VAR} # 必需变量,缺失时报错
${VAR:-default} # 可选变量,缺失时用默认值
${VAR:?msg} # 必需变量,缺失时报告自定义错误消息
19.3.3 Server 启动与健康检查
Server Manager 在启动时并不立即初始化所有 Server。它采用按需启动策略:
stateDiagram-v2
[*] --> NotStarted: 解析配置
NotStarted --> Starting: 首次引用
Starting --> Initializing: 子进程 spawn 成功
Starting --> StartFailed: spawn 失败
Initializing --> Ready: initialize 握手成功
Initializing --> InitFailed: 握手失败
Ready --> Running: 首次工具调用
Running --> Disconnected: 连接中断
Disconnected --> Reconnecting: 自动重连
Reconnecting --> Ready: 重连成功
Reconnecting --> Dead: 连续失败
StartFailed --> [*]
InitFailed --> [*]
Dead --> [*]
关键决策:
- 解析配置文件,构建 Server 注册表
- 对 stdio 类型的 Server,在首次被引用时才 spawn 子进程
- 对 HTTP 类型的 Server,在首次请求时才建立连接
- 每个 Server 启动后执行 MCP 初始化握手(
initialize→initialized) - 缓存 Server 的能力声明,用于后续的功能路由
如果一个 Server 在启动或初始化过程中失败,Server Manager 会记录错误但不影响其他 Server——这是优雅降级的核心体现。
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的随机端口(避免端口碰撞和外部访问) - 使用短超时(通常 5 分钟)——用户没完成授权就自动关闭
- Server 只处理一个请求,处理完立即关闭
- 回调 URL 包含 state 参数,校验匹配
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 存储在操作系统的密钥链中:
| 平台 | 存储位置 |
|---|---|
| macOS | Keychain |
| Linux | Secret Service (libsecret) |
| Windows | Credential Manager |
绝不写在配置文件或环境变量里——这是生产级安全的基本要求。
19.4.2 Token 生命周期管理
OAuth Handler 维护一个 token 缓存,键是 Server URL + 用户标识。每次向远程 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 实现了一个精巧的分层加载机制:
第一层:工具名称 + 简要描述
对话开始时,只向 LLM 提供所有可用工具的名称和一行描述。这类似于一个”目录”,让 LLM 知道有哪些能力可用,但不占用太多 context。
Available deferred tools:
- filesystem_read: Read files from the filesystem
- filesystem_write: Write files to the filesystem
- github_create_issue: Create a new GitHub issue
- github_list_repos: List user's GitHub repositories
- db_query: Execute a SQL query
- ... (75 more)
第二层:完整工具定义
当 LLM 决定使用某个工具时,Claude Code 才获取该工具的完整 JSON Schema,包括详细的参数描述、示例、约束条件等。
这种模式在用户体验中表现为:用户在系统提示中看到类似”以下延迟工具可通过 ToolSearch 获取:Read, Edit, Grep…”的提示,当 Agent 判断需要使用某个工具时,它先调用 ToolSearch 获取完整定义,然后才发起实际的工具调用。
实现代码
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: `${server.name}_${tool.name}`,
description: (tool.description ?? '').slice(0, 80),
};
this.summaries.set(summary.name, summary);
results.push(summary);
}
}
return results;
}
/** LLM 需要时才获取完整定义 */
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;
}
/** ToolSearch 工具:按语义搜索可用工具 */
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 序列化影响 | 可把完整 schema 获取推迟到需要时 |
| 每轮平均 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[github_create_issue MCP]
M2[github_list_repos MCP]
M3[db_query MCP]
M4[slack_send_message MCP]
end
LLM[Claude LLM] -->|统一的工具列表| B1
LLM --> M1
style B1 fill:#3b82f6,color:#fff,stroke:none
style B2 fill:#3b82f6,color:#fff,stroke:none
style B3 fill:#3b82f6,color:#fff,stroke:none
style B4 fill:#3b82f6,color:#fff,stroke:none
style M1 fill:#10b981,color:#fff,stroke:none
style M2 fill:#10b981,color:#fff,stroke:none
style M3 fill:#10b981,color:#fff,stroke:none
style M4 fill:#10b981,color:#fff,stroke:none
当 LLM 发起工具调用时,Tool Manager 根据工具名称路由到正确的执行器——内置工具直接在进程内执行,MCP 工具则通过对应的 Server 连接转发 tools/call 请求。对 LLM 来说,两者完全透明。
19.5.4 命名空间与冲突处理
MCP 工具的命名采用 serverName_toolName 的命名空间格式(_ 而非 :,因为部分 LLM tokenizer 对冒号的处理不稳定)。如果两个 Server 都暴露了名为 search 的工具,它们会被注册为 github_search 和 jira_search,避免冲突。
但如果 MCP 工具名与内置工具名冲突,内置工具优先。这确保了核心功能不会被第三方 Server 意外覆盖——又一个”安全优先”的设计选择。
19.6 Elicitation 与 Sampling 的 CLI 适配
19.6.1 终端中的表单渲染
当 MCP Server 发起 Form Mode Elicitation 时,Claude Code 需要在终端中渲染表单。这比 GUI 应用困难得多——终端没有原生的表单控件。
Claude Code 的做法是将 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)来构建这些表单。这让 TypeScript 的声明式 UI 开发模式可以用在终端中:
<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 对 Sampling 请求的处理尤为独特——它不需要”借用”外部 LLM,因为它本身就运行着 Claude。
当 MCP Server 发送 sampling/createMessage 请求时,Claude Code 直接将请求注入到当前的 LLM 对话流中,利用自己的 Claude 模型来响应。
sequenceDiagram
participant User
participant CC as Claude Code
participant Claude as Claude LLM
participant Server as MCP Server
User->>CC: 请求帮助
CC->>Claude: 用户请求 + 工具列表
Claude-->>CC: 调用 MCP 工具 X
CC->>Server: tools/call X
Server-->>CC: sampling/createMessage<br/>请求 LLM 辅助
Note over CC: 这是"自己给自己"发送的 LLM 请求
CC->>User: 审批 Sampling 请求(可选)
User-->>CC: 批准
CC->>Claude: 转发 Sampling 请求
Claude-->>CC: 返回生成结果
CC->>Server: 返回 Sampling 结果
Server-->>CC: tools/call X 的最终结果
CC->>Claude: 工具结果
Claude-->>User: 最终响应
这带来了一个有趣的递归:Server 通过 Client 借用 LLM 的能力,而这个 LLM 正是驱动 Client 的同一个模型。实际上,这使得 MCP Server 可以利用 Claude 的推理能力来增强自己的工具执行,同时保持了协议层面的解耦。
19.7 连接管理与容错
19.7.1 Server 生命周期状态机
每个 Server 连接都有明确的状态,已在 19.3.3 展示。Server Manager 为每个状态定义了明确的行为:
- 启动失败:记录错误,标记为不可用,不重试(避免反复 spawn 失败进程)
- 初始化失败:可能是版本不兼容,记录 Server 报告的版本信息,帮助用户诊断
- 断开:对 stdio Server,检测子进程是否退出;对 HTTP Server,检测连接是否中断。自动尝试重连
- 错误:工具调用返回错误不影响连接状态,但连续多次错误可能触发健康检查
19.7.2 超时策略
不同操作的超时时间经过精心调优:
| 操作 | 超时时间 | 理由 |
|---|---|---|
| Server 启动 | 30 秒 | npm 安装可能较慢 |
| 初始化握手 | 10 秒 | 协议交互应快速完成 |
| 工具列表 | 5 秒 | 元数据查询不应耗时 |
| 工具调用 | 可配置,默认 120 秒 | 复杂操作可能耗时较长 |
| Elicitation 响应 | 300 秒 | 等待用户输入 |
| Sampling 请求 | 60 秒 | 取决于 LLM 响应速度 |
| OAuth 授权 | 300 秒 | 用户需要时间完成浏览器操作 |
19.7.3 指数退避重连
对于断开的 HTTP 连接,Claude Code 采用指数退避策略:
const RETRY_DELAYS = [1000, 2000, 5000, 10000, 30000]; // ms
const MAX_RETRIES = 5;
async function reconnectWithBackoff(server: RemoteServer) {
for (let i = 0; i < MAX_RETRIES; i++) {
try {
await server.connect();
return;
} catch (error) {
if (i === MAX_RETRIES - 1) throw error;
const delay = RETRY_DELAYS[i] + Math.random() * 1000; // 加抖动
await sleep(delay);
}
}
}
关键细节:
- 抖动(jitter)避免所有客户端同时重连导致”惊群”
- 上限 30 秒——再长用户已经放弃了
- 5 次失败后放弃——避免无限重试
19.7.4 资源管理
Claude Code 作为 CLI 工具,用户期望它在退出时干净地释放所有资源。Server Manager 在关闭时:
- 向所有 Server 发送关闭通知
- 等待正在进行的工具调用完成(最多 5 秒)
- 关闭所有传输连接
- 终止所有 stdio 子进程(graceful → SIGTERM → SIGKILL)
- 清理临时文件和端口占用
- 关闭日志文件
这个顺序本身就是一种”洋葱模型”——先温柔通知,再强制关闭,最后物理清理。
19.8 1.6 万行代码的启示:规模工程学(实测纠正)
本节数字大幅修订——之前的版本基于”约 12 万行 MCP 代码”的传闻数字、配 5 类百分比饼图——实测
claude-code-main/src/后修正为真实数字。 整个 Claude Code 仓库src/下所有.ts(不含 test)共 379997 行;其中所有*mcp*路径下的.ts共 16230 行(含 test)/ 13262 行(不含 test)——MCP 相关代码占整个 Claude Code 4.3%——比”12 万行”小近一个量级。下面给出实测拆分。
实测目录分布(claude-code-main 仓库当前 main 分支)——
| 路径 | 文件 | 行(不含 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 大占 71%——
| 文件 | 行 | 角色 |
|---|---|---|
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(内部代号)IdP 登录路径 |
channelNotification.ts / elicitationHandler.ts / channelPermissions.ts | 316 / 313 / 240 | 通道层:通知 / Elicitation / 权限 |
types.ts | 258 | TypeScript 类型 |
| 其余 9 文件 | 约 800 | 各种小工具(envExpansion / oauthPort / mcpStringUtils / claudeai / etc) |
核心洞察
这个比例揭示了一个重要事实:
MCP 客户端的核心复杂性不在协议本身,而在于将协议适配到具体的产品形态中。
- CLI 有 CLI 的挑战(OAuth 无浏览器、终端 UI 渲染、启动速度)
- 桌面应用有桌面应用的挑战(多窗口、系统集成、自动更新)
- Web 应用有 Web 应用的挑战(跨域、WebSocket 替代、Token 存储)
MCP 协议的抽象层级恰到好处——它定义了”做什么”,但把”怎么做”的自由留给了实现者。
规模效应(修订)
13K 行 MCP 实现的真实结构揭示了一个行业规律——
| 实现层 | 实测代码量 | 倍率 |
|---|---|---|
| 协议 SDK 客户端核心 | mcp-typescript-sdk client/auth.ts 1745 行 + streamableHttp.ts 761 行 + core/shared/protocol.ts 1081 行 ≈ 3600 行 | 1x |
| Claude Code 生产级 MCP 集成 | 13262 行(非测试)/ 16230 行(含测试) | 3.7x ~ 4.5x |
| 整个 Claude Code 仓库 | 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,用户可能只用其中一两个。配置中声明 + 使用时连接 + 结果缓存。
建议 2:配置分层,安全优先
支持全局和项目级配置,密钥通过环境变量或系统密钥链管理。绝不在配置文件硬编码 Token。
建议 3:统一工具接口
MCP 工具和内置工具对 LLM 应该透明无差异,命名空间解决冲突。LLM 不应该知道哪些工具是 MCP、哪些是内置。
建议 4:优雅降级
单个 Server 的故障不应影响整个系统,错误信息应帮助用户定位问题。
建议 5:资源意识
- CLI 工具:启动速度和内存占用敏感——按需加载
- Web 应用:并发连接数敏感——连接复用
- 移动端:电量消耗敏感——避免频繁唤醒
- 服务端:多租户隔离敏感——per-tenant session
了解你的运行环境,才能做出正确的工程取舍。
建议 6:投资 OAuth 基础设施
CLI 的 OAuth 实现比想象中复杂——临时 HTTP Server、PKCE、Token 存储、刷新逻辑。这是一次性投入但收益长远。
建议 7:测试驱动
12 万行中有 25% 是测试。每个错误路径、每个超时、每个竞态都需要测试。不要把”MCP Client 能用”当终点——生产就绪还有 10 倍工作。
19.10 反模式清单
反模式一:启动时连接所有 Server
问题:首次启动慢、资源浪费、单点故障。
对策:按需连接(19.3.3)。
反模式二:工具定义一次性全加载
问题:Context 爆炸、LLM 选择准确率下降。
对策:延迟加载(19.5)。
反模式三:Token 硬编码
问题:泄漏到 git、团队成员共享、难以轮转。
对策:环境变量引用 + 系统密钥链。
反模式四:连接断开不重连
问题:网络抖动就需要用户重启应用。
对策:指数退避重连 + 抖动 + 次数上限(19.7.3)。
反模式五:没有超时保护
问题:一个卡住的 Server 冻结整个 Agent。
对策:每个操作都设合理超时(19.7.2)。
19.10.1 Claude Code 里 MCP 工具进入模型上下文的真实路径
Claude Code 的 MCP 客户端价值不只在“能连 server”,而在它如何把远程工具转成模型可消费的 tool schema。claude-code-main/src/query.ts:689-692 在发起查询时把 appState.mcp.tools 传给 API 层,并同时记录是否还有 pending MCP server;这说明 MCP 工具不是临时字符串拼接进 prompt,而是和内置工具一起进入统一的工具池。随后 claude-code-main/src/services/api/claude.ts:1231-1245 对过滤后的工具调用 toolToAPISchema(),并在工具搜索启用时给需要延迟加载的工具加 deferLoading。
toolToAPISchema() 是关键转换点。claude-code-main/src/utils/api.ts:119-178 先用工具名和 inputJSONSchema 构造 cache key,再输出 name、description、input_schema;同文件 api.ts:211-226 只在本次请求需要时叠加 defer_loading,避免污染缓存的基础 schema;api.ts:232-238 还把实验字段统一放在一个出口处理,避免代理网关因为不认识额外字段而拒绝请求。这个设计解释了 Claude Code 为什么需要把 MCP 工具和内置工具统一建模:只有统一入口,才能统一做 schema 缓存、严格模式、细粒度工具流、延迟加载和代理兼容。
上下文体积也被显式观测。claude-code-main/src/utils/api.ts:479-493 的 logContextMetrics() 会并行预取 MCP 资源、普通工具、用户上下文和系统上下文;api.ts:514-560 再统计 MCP 工具数量、MCP server 数量、MCP 工具 schema 的粗略 token 数以及非 MCP 工具 token 数。这里不需要臆造“工具多会降低多少准确率”:源码已经给出更可靠的工程动作——把工具数量、server 数量和 schema token 纳入可观测指标,让团队用自己的会话数据判断是否需要延迟加载、拆分 server 或缩短 schema。
延迟加载的 UI 与搜索路径也能从源码对上。claude-code-main/src/tools/ToolSearchTool/ToolSearchTool.ts:107-125 的搜索结果包含 matches、query、deferred tool 总数和 pending MCP server;claude-code-main/src/components/ContextVisualization.tsx:265-266 会把 MCP tools 区分成 Loaded 与 Available,并在存在 deferred 工具时显示 loaded on-demand;claude-code-main/src/commands.ts:541-557 还把 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 发现通过分层配置文件实现,支持全局、项目级、环境变量三个维度
- OAuth 认证在 CLI 环境下通过临时本地 HTTP Server + PKCE 实现,兼顾安全与易用
- 工具延迟加载通过”摘要 + 按需获取”的两层策略,解决了大规模工具集成的 context 压力
- 内置工具与 MCP 工具通过统一的 Tool Manager 融合,对 LLM 透明
- 12 万行代码中真正的复杂性在于将协议适配到具体产品形态,而非协议本身
核心心得
协议提供了骨架,产品思维才能填充血肉。
这些经验告诉我们:设计一个好的 MCP 客户端,既要深入理解协议规范,更要理解你的用户在什么场景下、用什么方式与 AI 工具交互。
在下一章,我们将角色互换——从消费 MCP 的 Client 转向生产 MCP 的 Server,从零开始构建一个生产级 MCP Server。