MCP 协议设计与实现

第19章 Claude Code 的 MCP 客户端:12 万行的实战

作者 杨艺韬 · 7,249 字

第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 DesktopClaude 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 --> [*]

关键决策:

  1. 解析配置文件,构建 Server 注册表
  2. 对 stdio 类型的 Server,在首次被引用时才 spawn 子进程
  3. 对 HTTP 类型的 Server,在首次请求时才建立连接
  4. 每个 Server 启动后执行 MCP 初始化握手(initializeinitialized
  5. 缓存 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 存储在操作系统的密钥链中:

平台存储位置
macOSKeychain
LinuxSecret Service (libsecret)
WindowsCredential 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,会造成:

  1. 上下文浪费:工具描述可能占用数千 token,挤占真正有用的对话内容
  2. 决策干扰:LLM 面对过多工具时,工具描述、参数 schema 和相似命名会增加选择难度;具体影响必须用项目内评测衡量,不能套用固定百分比
  3. 启动延迟:等待所有 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_searchjira_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 在关闭时:

  1. 向所有 Server 发送关闭通知
  2. 等待正在进行的工具调用完成(最多 5 秒)
  3. 关闭所有传输连接
  4. 终止所有 stdio 子进程(graceful → SIGTERM → SIGKILL)
  5. 清理临时文件和端口占用
  6. 关闭日志文件

这个顺序本身就是一种”洋葱模型”——先温柔通知,再强制关闭,最后物理清理。

19.8 1.6 万行代码的启示:规模工程学(实测纠正)

本节数字大幅修订——之前的版本基于”约 12 万行 MCP 代码”的传闻数字、配 5 类百分比饼图——实测 claude-code-main/src/ 后修正为真实数字。 整个 Claude Code 仓库 src/ 下所有 .ts(不含 test)共 379997 行;其中所有 *mcp* 路径下的 .ts16230 行(含 test)/ 13262 行(不含 test)——MCP 相关代码占整个 Claude Code 4.3%——比”12 万行”小近一个量级。下面给出实测拆分。

实测目录分布(claude-code-main 仓库当前 main 分支)——

路径文件行(不含 test)
src/services/mcp/2212238
src/commands/mcp/3558
src/utils/mcp/2457
src/components/mcp/19
合计(非测试)2813262
加上 tests 后16230

src/services/mcp/ 22 文件按行数排序——前 5 大占 71%——

文件角色
client.ts3348本目录最大(27%)——MCPClient、transport 选择、连接生命周期
auth.ts2465OAuth 全流程——20%
config.ts1578多层配置合并 + 环境变量替换 + schema 验证
useManageMCPConnections.ts1141React/Ink hook:连接管理状态机
utils.ts575通用工具
xaa.ts / xaaIdpLogin.ts511 + 487XAA(内部代号)IdP 登录路径
channelNotification.ts / elicitationHandler.ts / channelPermissions.ts316 / 313 / 240通道层:通知 / Elicitation / 权限
types.ts258TypeScript 类型
其余 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-493logContextMetrics() 会并行预取 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 协议在真实产品中最大规模的落地案例。通过研究它的架构,我们看到了:

  1. Server 发现通过分层配置文件实现,支持全局、项目级、环境变量三个维度
  2. OAuth 认证在 CLI 环境下通过临时本地 HTTP Server + PKCE 实现,兼顾安全与易用
  3. 工具延迟加载通过”摘要 + 按需获取”的两层策略,解决了大规模工具集成的 context 压力
  4. 内置工具与 MCP 工具通过统一的 Tool Manager 融合,对 LLM 透明
  5. 12 万行代码中真正的复杂性在于将协议适配到具体产品形态,而非协议本身

核心心得

协议提供了骨架,产品思维才能填充血肉。

这些经验告诉我们:设计一个好的 MCP 客户端,既要深入理解协议规范,更要理解你的用户在什么场景下、用什么方式与 AI 工具交互。

在下一章,我们将角色互换——从消费 MCP 的 Client 转向生产 MCP 的 Server,从零开始构建一个生产级 MCP Server。