Claude Code 源码分析第 07 课 · 第 02
第 07 课

MCP 集成

Claude Code 如何发现、连接、代理与鉴权 MCP 服务器:从 stdio 子进程,到 SSE / HTTP / WebSocket,再到进程内桥接。

01 概览

MCP(Model Context Protocol)是 Claude Code 接入外部工具和数据源的标准方式。对 Claude 来说,一个 MCP 服务器最终会被折叠成一组普通工具;对工程实现来说,它却是一整套独立子系统:配置装配、连接建链、OAuth、能力协商、工具代理和失败重连。

所以这节课真正要回答的问题不是“Claude 能不能调 MCP”,而是“Claude Code 如何把一个远程协议端点,变成和内置 Bash / Read / Write 同级的本地工具体验”。

services/mcp/

连接生命周期、配置加载、OAuth 身份验证、传输构建、诱导、重复数据删除。

tools/MCPTool/

包装每个远程 MCP 调用的瘦代理工具。 在运行时用服务器工具列表中的真实姓名、模式和 call() 覆盖。

commands/mcp/

User-facing /mcp 斜杠命令:重新连接、启用/禁用、设置 UI。

components/mcp/

React UI 面板:MCPSettings、MCPListPanel、ElicitationDialog、MCPReconnect、CapabilitySection。

2 运输类型

MCP 在 Claude Code 里本质上是一层传输抽象:stdio 适合本地子进程,SSE 和可流式 HTTP 适合远端服务,WebSocket 面向长连接场景,IDE 变体用于编辑器桥接,而 in-process 则是内部高性能通道。

所以“连上 MCP 服务器”这件事,在不同传输下其实意味着完全不同的生命周期成本:进程管理、鉴权方式、网络超时、重连行为,都会随传输类型而变化。

graph LR A([Claude Code CLI]) --> B{Transport Type} B -->|stdio| C[StdioClientTransport
Subprocess spawn] B -->|sse| D[SSEClientTransport
Persistent EventSource +
OAuth / ClaudeAuthProvider] B -->|http| E[StreamableHTTPClientTransport
JSON + SSE on same POST
OAuth / session ingress] B -->|ws| F[WebSocketTransport
ws module or Bun WS] B -->|sse-ide| G[SSEClientTransport
IDE extension only
no auth] B -->|ws-ide| H[WebSocketTransport
IDE extension with
optional auth token] B -->|sdk| I[SdkControlClientTransport
In-process / control msgs] B -->|in-process| J[InProcessTransport
Linked pair
Chrome / Computer Use] style C fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style D fill:#22201d,stroke:#6e9468,color:#b8b0a4 style E fill:#22201d,stroke:#6e9468,color:#b8b0a4 style F fill:#22201d,stroke:#b8965e,color:#b8b0a4 style G fill:#22201d,stroke:#c47a50,color:#b8b0a4 style H fill:#22201d,stroke:#c47a50,color:#b8b0a4 style I fill:#22201d,stroke:#8e82ad,color:#b8b0a4 style J fill:#22201d,stroke:#8e82ad,color:#b8b0a4
stdio public

生成一个子进程,通过 stdin/stdout 进行通信。 默认类型(省略 type 此处默认)。 Stderr 通过管道传输并限制为 64 MB。

sse public

HTTP 服务器发送的事件。 用途 ClaudeAuthProvider 对于 OAuth。 GET(SSE 流)有意跳过 60 秒的请求超时 — 只有 POST 才能获取。

http public

可流式传输的 HTTP(MCP 2025-03-26 规范)。 做广告 Accept: application/json, text/event-stream 在每个帖子上。 支持 OAuth + 会话入口 JWT。

ws public

WebSocket 与 protocols: ['mcp']。 用途 ws 节点上的模块,本机 Bun WS。 支持代理和 mTLS。

sse-ide internal

IDE 扩展的 SSE 变体(VS Code 等)。 没有 OAuth。 基于锁定文件的身份验证令牌已计划但尚未连接。

ws-ide internal

IDE 扩展的 WebSocket 变体。 接受可选 authToken 发送为 X-Claude-Code-Ide-Authorization header.

sdk internal

到 SDK 进程的控制消息桥。 工具通过 stdout/stdin 调用路由。 从不直接生成进程。

in-process in-proc

由 Chrome MCP 和计算机使用使用。 createLinkedTransportPair() 创建两个 InProcessTransport 实例; 消息通过传递 queueMicrotask 以避免堆栈溢出。

代码:InProcessTransport链接对
// services/mcp/InProcessTransport.ts
class InProcessTransport implements Transport {
  private peer: InProcessTransport | undefined

  async send(message: JSONRPCMessage): Promise<void> {
    // Deliver async to avoid stack depth in sync req/resp cycles
    queueMicrotask(() => { this.peer?.onmessage?.(message) })
  }
}

export function createLinkedTransportPair(): [Transport, Transport] {
  const a = new InProcessTransport()
  const b = new InProcessTransport()
  a._setPeer(b); b._setPeer(a)
  return [a, b]
}

3 配置范围级联

MCP 配置级联最大的价值,在于把‘声明来源很多’这件事变得可裁决。企业托管、命令行临时配置、项目配置、用户配置和插件配置,不再互相打架,而是进入一条明确优先级链。

这条链一旦确定,重复声明、覆盖关系和策略禁用都会变得可预测。换句话说,MCP 配置系统不是在“收集配置”,而是在“解决配置冲突”。

Priority Scope 源文件/位置 Notes
1 最高 enterprise managed-mcp.json (MDM 管理的路径) 如果存在,则阻止所有用户管理的添加/删除。 独家控制。
2 dynamic CLI 标志 --mcp-config <path> 启动时通过; 使用前经过策略过滤。
3 claudeai Claude.ai 连接器 API(远程获取) 通过 URL 签名对手动配置的服务器进行重复数据删除。
4 project .mcp.json (CWD 和父目录,根目录下) 最接近 cwd 的获胜。 还搜索父目录; 孩子优先于父母。
5 local ~/.claude/projects/<hash>/ 每个项目的本地状态,未签入 git。
6 user ~/.claude/settings.json (全局配置) 用户范围的默认值。
7 managed 插件提供的服务器 Namespaced plugin:name:server。 通过签名(URL 或命令数组)对手动服务器进行内容重复数据删除。
注意:企业锁
When managed-mcp.json 存在,调用 addMcpConfig() 立即抛出: “企业 MCP 配置处于活动状态并具有独占控制权”。 类似的工具 claude mcp add 被完全封锁。
代码:作用域级联组装(由getAllMcpConfigs简化而来)
// services/mcp/config.ts — conceptual merge order
const allConfigs = {
  ...enterpriseServers,      // wins if present
  ...dynamicServers,         // --mcp-config flag
  ...claudeAiServers,        // deduplicated by URL sig
  ...projectServers,         // .mcp.json, root-down
  ...localServers,           // ~/.claude/projects/…
  ...userServers,            // ~/.claude/settings.json
  ...pluginServers,          // namespaced, sig-deduplicated
}

// Env vars expanded in all configs before connection
// e.g. command: "npx", args: ["$MY_SERVER_PATH"]

策略允许/拒绝列表

企业政策可以定义 allowedMcpServers and deniedMcpServers 在设置中。 拒绝名单具有绝对优先权。 匹配可以通过:

  • Name — 确切的服务器名称字符串
  • Command — stdio 服务器的完整命令+参数数组
  • 网址模式 — 与 * 远程服务器的通配符

4 连接生命周期

从配置对象到真正可用的 MCP 工具,中间要经过一整条连接状态机:配置展开、批量连接、传输建立、超时竞争、认证处理、能力协商、工具/资源拉取,以及后续的实时变化订阅。

这也是为什么 MCP 集成看起来像“多了几把工具”,源码里却是一大块独立系统。Claude Code 实际上是在运行一个小型连接管理器,而不只是发几次 JSON-RPC 请求。

pending
connected
failed
needs-auth
disabled
1

配置装配

所有范围均已合并并经过策略过滤。 环境变量扩展($VAR / ${VAR})。 缺少变量记录为警告,但不会阻止连接。

2

批量连接

Stdio 服务器以 3 个为一组进行连接(MCP_SERVER_CONNECTION_BATCH_SIZE)。 远程服务器批处理为 20。每次调用 connectToServer() 被记忆为 name + JSON(config).

3

交通建设

基于 serverRef.type,正确的 SDK 传输类被实例化。 此处附有身份验证提供程序、代理和 mTLS 选项。

4

client.connect() 超时

默认 30 秒(MCP_TIMEOUT 环境变量)。 比赛 connectPromise vs timeoutPromise。 超时还会关闭进程内服务器(如果已启动)。

5

授权处理

If UnauthorizedError (401):服务器移至 needs-auth 状态。 一个 McpAuthTool 注入伪工具,以便模型可以触发 OAuth。 15 分钟后,needs-auth 缓存条目将过期。

6

能力谈判

Claude Code 声明 roots: {} and elicitation: {} 能力。 服务器的功能读取通过 getServerCapabilities()。 服务器指令被截断为 2048 个字符。

7

工具/资源/提示获取

并行获取的工具、资源、提示。 标准化工具名称 (mcp__server__tool)。 每个工具都是克隆的 MCPTool 与覆盖 name, description, inputSchema, 和 call().

8

实时通知

订阅 ToolListChanged, ResourceListChanged, PromptListChanged 通知。 任何更改都会触发重新获取和 AppState 更新。 指数退避重新连接(1 秒 → 30 秒上限,最多 5 次尝试)。

代码:与超时竞赛的连接
// services/mcp/client.ts (simplified)
const connectPromise = client.connect(transport)
const timeoutPromise = new Promise<never>((_, reject) => {
  const id = setTimeout(() => {
    transport.close().catch(() => {})
    reject(new Error(`MCP server "${name}" timed out after ${timeout}ms`))
  }, getConnectionTimeoutMs())
  connectPromise.then(() => clearTimeout(id), () => clearTimeout(id))
})

await Promise.race([connectPromise, timeoutPromise])

5 工具代理

MCP 工具代理最关键的意义,是把远程工具重新包装成本地工具语义:拥有规范化名字、描述、输入 schema,以及与内置工具一致的调用界面。

这样一来,Claude 在心智模型上根本不需要知道自己在调用‘远程工具’还是‘本地工具’,因为 Claude Code 已经把这层差异吃掉了。

MCP 服务器
tool.name
tool.description
inputSchema
Normalize
mcp__server__
tool_name
克隆 MCP工具
覆盖名称
描述、方案
call()
AppState
mcp.tools[]
可供
Claude
工具调用
client.callTool()
→ JSON-RPC
→ 运输

名称规范化

// services/mcp/normalization.ts
export function normalizeNameForMCP(name: string): string {
  // Replace any char not in [a-zA-Z0-9_-] with underscore
  let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
  // For claude.ai servers: collapse consecutive underscores,
  // strip leading/trailing (__ delimiter must stay clean)
  if (name.startsWith('claude.ai ')) {
    normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
  }
  return normalized
}

// Full tool name: "mcp__my_server__list_files"
export function buildMcpToolName(server: string, tool: string): string {
  return `mcp__${normalizeNameForMCP(server)}__${normalizeNameForMCP(tool)}`
}
工具描述帽
已观察到 OpenAPI 生成的 MCP 服务器将 15–60 KB 的文档转储到 tool.description。 Claude Code 对工具描述和服务器指令进行硬限制,位于 2048 个字符 保持上下文窗口易于管理。
代码:结果处理 — 图像、二进制 blob、截断
// After client.callTool() returns a CallToolResult...

// 1. Image content items → resized/downsampled, returned as base64
if (content.type === 'image' && IMAGE_MIME_TYPES.has(mimeType)) {
  const buf = maybeResizeAndDownsampleImageBuffer(rawBuf)
  // → ContentBlockParam with base64 data
}

// 2. Binary blobs → persisted to disk, path returned as text
if (!IMAGE_MIME_TYPES.has(mimeType)) {
  await persistBinaryContent(content)
  // → getBinaryBlobSavedMessage(path)
}

// 3. Total result > 100 KB → truncation with instructions
if (mcpContentNeedsTruncation(result)) {
  result = truncateMcpContentIfNeeded(result)
}

6 OAuth 身份验证

OAuth 在这里不是‘用户自己打开网页登录一下’的附属逻辑,而是 MCP 生命周期的一部分。连接失败、进入 needs-auth、注入认证工具、拿到 token、重连服务器,这是一整条标准化链路。

它之所以重要,是因为 Claude Code 要让 MCP 服务器看起来像长期可用的本地能力,而不是每次掉认证都把控制权完全交还给用户手动处理。

sequenceDiagram participant C as Claude Code CLI participant KS as Keychain / SecureStorage participant AS as Auth Server participant MCP as MCP Server C->>C: client.connect(transport) → 401 UnauthorizedError C->>C: set server state: needs-auth C->>C: inject McpAuthTool pseudo-tool Note over C: Model calls mcp__server__authenticate C->>AS: discoverOAuthServerMetadata() AS-->>C: authServerMetadata (PKCE endpoint) C->>AS: /authorize?code_challenge=…&redirect_uri=… C-->>C: open browser / return URL AS-->>C: callback → authorization_code C->>AS: POST /token (code + verifier) AS-->>C: access_token + refresh_token C->>KS: store tokens (keychain on macOS) C->>MCP: reconnectMcpServerImpl() MCP-->>C: connected — real tools swap in
McpAuthTool:模型触发的 OAuth
当服务器进入 needs-auth state,一个名为 mcp__<server>__authenticate 被注入。 该模型可以调用它来启动 OAuth 流程并接收授权 URL 以向用户显示。 一旦回调触发,真实工具会自动替换伪工具(AppState 上基于前缀的替换)。

令牌刷新和 Slack 怪癖

标准 RFC 6749 invalid_grant 错误会触发令牌失效。 但是 Slack 返回 HTTP 200 {"error":"invalid_refresh_token"} — SDK 会将其视为 ZodError。 Claude Code 将这些非标准代码规范化为 invalid_grant 在传递到 SDK 的错误类映射器之前。

代码:Slack 200 错误标准化
// services/mcp/auth.ts
const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([
  'invalid_refresh_token',
  'expired_refresh_token',
  'token_expired',
])

// Wraps fetch: peeks at 2xx POST responses, rewrites error bodies
// matching OAuthErrorResponseSchema (but NOT OAuthTokensSchema)
// to a synthetic 400 response — so SDK error-class mapping applies.

XAA——跨应用程序访问

SSO 流程的企业扩展。 什么时候 xaa: true 在 MCP 服务器配置上设置,系统不会启动浏览器,而是以静默方式将 IdP ID 令牌交换为 MCP 服务器的 OAuth 令牌。 配置一次 settings.xaaIdp,在所有启用 XAA 的服务器之间共享。

7 Elicitation

Elicitation 让 MCP 服务器不只是被动执行工具,还可以主动向用户索取结构化输入。Claude Code 在这里扮演的是中介层:把请求排队、交给 UI 或 hooks,再把结果送回服务器。

这意味着 MCP 协议不再只是“模型请求工具返回结果”,而是能把人也正式纳入交互闭环。

表格模式

服务器发送 JSON Schema; 用户填写表格。 响应是 accept 与内容,或 decline / cancel.

网址模式

服务器发送 URL(例如 OAuth 升级,外部确认)。 两阶段:打开URL→等待 ElicitationComplete 通知。 用户可以忽略或重试。

请求登陆 AppState.elicitation.queue as ElicitationRequestEvent 物体具有 respond() 打回来。 这 ElicitationDialog React 组件轮询该队列。 挂钩(executeElicitationHooks) 可以通过编程方式满足请求而不显示 UI。

代码:引发处理程序注册
// services/mcp/elicitationHandler.ts
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
  // 1. Try hooks first (programmatic response)
  const hookResponse = await runElicitationHooks(serverName, request.params, extra.signal)
  if (hookResponse) return hookResponse

  // 2. Queue for user interaction
  const response = new Promise<ElicitResult>(resolve => {
    setAppState(prev => ({
      ...prev,
      elicitation: {
        queue: [...prev.elicitation.queue, {
          serverName, requestId: extra.requestId,
          params: request.params,
          respond: resolve,
        }],
      },
    }))
  })
  return await response
})

8 服务器重复数据删除

MCP 去重最值得注意的一点,是它看‘底层是不是同一台服务器’,而不是看‘名字是不是一样’。stdio 看命令签名,远程连接看 URL,这样才能正确识别重复定义。

这种做法尤其适合 Claude.ai 连接器、插件默认服务器和手工配置同时存在的情况。名字可能不同,但如果它们本质上连的是同一个端点,就只该保留一个生效版本。

CCR 代理解包
在远程会话中,claude.ai 连接器到达时会带有重写的 URL,以通过 CCR/session-ingress 代理进行路由。 原始供应商 URL 保留在 mcp_url 查询停止。 unwrapCcrProxyUrl() 在签名比较之前提取它,因此直接指向 Slack 的 MCP 服务器的插件仍然可以针对通过 CCR 路由的 claude.ai 连接器正确地进行重复数据删除。

去重规则:

  • 手动胜过插件 — 用户配置的总是胜过插件提供的。
  • 第一个插件获胜 — 如果两个插件提供相同的服务器,则首先加载的插件获胜。
  • 启用手动战胜 claude.ai — 禁用的手动服务器不会抑制其连接器孪生(因此两者都不会运行)。
  • 插件服务器是命名空间的 plugin:name:server 即使在重复数据删除之前也可以避免键冲突。

要点

  • 7 种运输类型 — stdio、sse、http、ws、sse-ide、ws-ide、sdk — 以及供 Chrome/计算机使用的内部进程内对。 公共交通支持OAuth; IDE 变体是免身份验证的或基于令牌的。
  • 7层范围级联 — 企业 > 动态 > claudeai > 项目 > 本地 > 用户 > 托管。 企业存在会锁定所有手动添加/删除操作。
  • 配置文件遍历目录树.mcp.json 从每个父目录读取到文件系统根目录; 子目录覆盖父目录。
  • 所有工具名称均经过规范化mcp__<server>__<tool>,任何非字母数字字符都替换为下划线。 Claude.ai 服务器名称得到额外的折叠/剥离处理以保护 __ delimiter.
  • OAuth 是模型可触发的 - 这 McpAuthTool 伪工具让 Claude 自主启动身份验证流程并将 URL 返回给用户; 真正的工具在回调后自动替换。
  • 工具描述的硬上限为 2048 个字符 — 防止 OpenAPI 生成的服务器发生上下文爆炸。
  • 重复数据删除是基于内容的,而不是基于名称的 — 相同的命令数组或 URL = 相同的服务器,无论它们在不同的配置源中被称为什么。

知识检查

Q1. 当同一台 MCP 服务器同时出现在项目配置和用户配置中时,默认谁优先?
正确! MCP 配置有明确的作用域优先级。项目范围覆盖用户范围,因此更贴近当前仓库的定义会先赢。
Q2. 为什么 stdio MCP 服务器会把 stderr 设成 pipe,而不是直接打到用户终端?
正确! MCP 服务器经常会打印调试日志或错误信息。把 stderr 接管进管道,Claude Code 才能决定如何记录、截断和展示,而不是污染终端界面。
Q3. wrapFetchWithTimeout() 为什么刻意跳过 SSE 的 GET 请求超时?
正确! SSE 的核心就是长时间保持连接。如果给它套普通请求超时,流式 MCP 连接会被错误地当成超时失败。
Q4. Slack 这类服务器把刷新令牌错误放在 HTTP 200 响应体里返回时,Claude Code 会怎么处理?
正确! 这是一层兼容性修正。Claude Code 会把非标准的成功状态错误体重写成标准 OAuth 失败语义,好让上层统一走 token 失效与重新认证流程。
Q5. 企业环境里一旦存在 managed-mcp.json,用户执行 claude mcp add 通常会怎样?
正确! 企业托管文件不是“更高优先级的一层配置”,而是会锁死用户侧增删改权限的独占控制入口。
0/5