MCP 协议设计与实现

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

作者 杨艺韬 · 10,791 字 · 发布于 · 更新于

规范是理想,实现是现实。真正的 MCP 知识藏在真实产品的每一个边界处理里。

本章要点

  • 理解 Claude Code 作为 MCP 客户端的整体架构及其与 Claude Desktop 的本质区别
  • 掌握 Server 发现机制:多作用域配置的合并顺序、签名去重与环境变量展开
  • 深入 OAuth 授权流程在 CLI 环境下的特殊处理
  • 理解工具延迟加载(Deferred Loading)如何解决大规模 MCP 集成的性能问题
  • 认识 MCP 工具如何与 Claude Code 内置工具在统一接口下共存
  • 学习 1.6 万行代码背后的规模工程学 —— 测试占 18%,而生产级集成只比 SDK 大 3.74.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 &lt; user &lt; project &lt; 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

关键决策:

  1. 解析并合并多层配置,构建 Server 注册表
  2. 启动时并发连接全部 Server,本地与远程各有并发上限
  3. 每个 Server 连接后执行 MCP 初始化握手(initialize → notifications/initialized)
  4. 缓存 Server 的能力声明,用于后续的功能路由
  5. 连接本身用 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,会造成:

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

  1. 进程内的 sdk 类型 Server:直接关闭 in-process server 和 client
  2. 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 协议在真实产品中最有代表性的落地案例之一。通过研究它的架构,我们看到了:

  1. Server 发现通过多作用域配置实现——enterprise / local / project / user / plugin,按 plugin < user < project < local 覆盖,enterprise 存在则独占;配置里可以用 ${VAR} / ${VAR:-default} 引用环境变量
  2. OAuth 认证在 CLI 环境下通过临时本地 HTTP Server + PKCE 实现,兼顾安全与易用
  3. 工具延迟加载通过"只给名字 + 用 ToolSearch 按需展开"的两层策略,缓解了大规模工具集成的 context 压力
  4. 内置工具与 MCP 工具通过统一的 Tool Manager 融合,对 LLM 透明
  5. 那 1.6 万行里真正的复杂性在于将协议适配到具体产品形态,而非协议本身

核心心得

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

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

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