Claude Code设计与实现-第11章 MCP 协议集成

《Claude Code 设计与实现》完整目录

第11章 MCP 协议集成

在前面的章节中,我们详细剖析了 Claude Code 内建的工具体系------从 BashTool 到 FileWriteTool,从权限模型到沙箱隔离。这些内建工具覆盖了绝大多数编程场景,但软件世界的边界远不止于此。当用户需要与 Slack 交互、查询 GitHub Issues、调用私有 API,或者连接任何一个第三方服务时,仅凭内建工具是不够的。

这正是 Model Context Protocol(MCP)协议要解决的核心问题。MCP 定义了一套标准化的协议,让 AI 助手能够通过统一的接口发现、连接和调用任意第三方工具。Claude Code 对 MCP 的集成不是一个简单的适配层,而是一个涵盖连接管理、工具发现、权限控制、认证流程和生命周期管理的完整子系统。

本章将深入 Claude Code 的 MCP 集成实现,从协议架构出发,逐层解析服务器生命周期、工具发现与注册、工具调用流程、资源管理以及 OAuth 认证体系。通过源码分析,我们将看到 Claude Code 如何将一个开放协议转化为安全、可靠、可扩展的工具集成平台。

:::tip 本章要点

  • MCP 协议定位:理解 Model Context Protocol 如何解决第三方工具集成标准化问题,以及它与内建工具系统的关系
  • 多传输层架构:stdio、SSE、HTTP Streamable、WebSocket 四种传输层的适用场景与实现差异
  • 服务器生命周期:从配置加载、连接建立、心跳检测到错误恢复的完整生命周期管理
  • 工具发现与注册:MCP 工具如何通过 MCPTool 包装器无缝接入 Claude Code 的工具体系
  • 工具调用流程:tools/call 的请求-响应链路,包括超时控制、会话恢复和大结果处理
  • 资源系统:resources/list 与 resources/read 如何将外部数据嵌入对话上下文
  • OAuth 认证体系:ClaudeAuthProvider 如何实现完整的 OAuth 2.0 PKCE 流程,包括令牌刷新和跨应用认证 :::

11.1 什么是 MCP,为什么它重要

11.1.1 Model Context Protocol 的定位

Model Context Protocol(MCP)是 Anthropic 提出的开放协议标准,目标是为 AI 助手与外部工具和数据源之间建立统一的通信接口。在 MCP 出现之前,每个 AI 产品都需要为每个第三方服务编写专门的集成代码------这意味着 N 个 AI 产品连接 M 个服务需要 NM 种集成实现。MCP 将这个 NM 问题简化为 N+M:每个 AI 产品实现一次 MCP 客户端,每个服务实现一次 MCP 服务器,两者即可自由组合。

从协议层面看,MCP 基于 JSON-RPC 2.0 构建,定义了三类核心能力:

  • 工具(Tools):服务器暴露的可调用函数,类似于 REST API 的端点,但带有自描述的 JSON Schema 参数定义
  • 资源(Resources):服务器提供的可读取数据,类似于文件系统中的文件,支持文本和二进制内容
  • 提示(Prompts):服务器预定义的交互模板,可以携带参数化的上下文信息

11.1.2 MCP 在 Claude Code 中解决的核心问题

对 Claude Code 而言,MCP 解决了三个关键问题:

第一,工具集成的标准化。 在没有 MCP 的情况下,每增加一个外部工具都需要修改 Claude Code 的源码------定义 Tool 类型、实现 call 方法、编写权限检查逻辑。有了 MCP,第三方开发者只需编写一个 MCP 服务器,Claude Code 就能自动发现和使用其中的工具。

第二,工具生态的去中心化。 MCP 服务器可以由任何人开发和部署,运行在用户的本地机器上(stdio 传输)或远程服务器上(HTTP/SSE 传输)。用户通过配置文件声明要连接的服务器,Claude Code 负责连接管理。这种架构让工具生态可以独立于 Claude Code 本身演进。

第三,安全边界的明确化。 MCP 工具经过 Claude Code 的权限系统审查,用户对每个 MCP 工具的调用都需要明确授权。同时,MCP 的认证协议(OAuth 2.0)确保了与远程服务的安全通信。

11.2 MCP 架构总览

11.2.1 目录结构

Claude Code 的 MCP 集成代码集中在 src/services/mcp/ 目录下,包含约 25 个文件,总计超过 40 万字符的源码。这个规模反映了 MCP 集成的复杂度------它不仅仅是一个协议适配器,更是一个完整的分布式系统客户端:

bash 复制代码
src/services/mcp/
  types.ts                    # 核心类型定义:配置 Schema、连接状态、资源类型
  client.ts                   # MCP 客户端核心:连接、工具发现、工具调用
  config.ts                   # 配置管理:多作用域配置加载与合并
  auth.ts                     # OAuth 认证:ClaudeAuthProvider 实现
  MCPConnectionManager.tsx    # React 上下文:连接管理的组件化封装
  useManageMCPConnections.ts  # React Hook:连接生命周期管理
  normalization.ts            # 名称规范化:确保服务器名符合 API 约束
  mcpStringUtils.ts           # 字符串工具:MCP 工具名的构建与解析
  envExpansion.ts             # 环境变量展开:配置中的 ${VAR} 语法支持
  headersHelper.ts            # 动态请求头:通过外部命令获取认证头
  channelNotification.ts      # 通道通知:MCP 服务器的消息推送机制
  channelPermissions.ts       # 通道权限:推送消息的权限控制
  channelAllowlist.ts         # 通道白名单:允许推送的服务器列表
  elicitationHandler.ts       # 引出处理:MCP 服务器向用户请求输入
  utils.ts                    # 工具函数:过滤、匹配、状态管理
  oauthPort.ts                # OAuth 端口:本地回调服务器端口管理
  xaa.ts                      # 跨应用认证:XAA 令牌交换
  xaaIdpLogin.ts              # XAA IdP 登录:身份提供商交互
  claudeai.ts                 # Claude.ai 集成:云端 MCP 连接器
  InProcessTransport.ts       # 进程内传输:用于内嵌 MCP 服务器
  SdkControlTransport.ts      # SDK 控制传输:SDK 模式的专用传输层
  officialRegistry.ts         # 官方注册表:MCP 服务器注册中心
  vscodeSdkMcp.ts             # VS Code 集成:IDE 内的 MCP 支持

11.2.2 核心文件职责

types.ts 定义了 MCP 子系统的所有核心类型。其中最关键的是传输类型枚举和服务器配置 Schema:

typescript 复制代码
// 源码文件:src/services/mcp/types.ts

export const TransportSchema = lazySchema(() =>
  z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
)

export type MCPServerConnection =
  | ConnectedMCPServer    // 已连接:持有 Client 实例
  | FailedMCPServer       // 连接失败:记录错误信息
  | NeedsAuthMCPServer    // 需要认证:等待 OAuth 流程
  | PendingMCPServer      // 连接中:含重连尝试次数
  | DisabledMCPServer     // 已禁用:用户主动关闭

这个五状态联合类型是理解 MCP 生命周期的钥匙。一个 MCP 服务器在其生命周期内会在这五种状态之间转换,每种状态对应不同的 UI 展示和行为逻辑。

client.ts 是整个 MCP 子系统中最大的文件(约 3300 行),承担了连接建立、工具发现、工具调用和结果处理的核心逻辑。它的复杂度来自于对多种传输层的统一抽象,以及对各种边界情况的细致处理。

config.ts 实现了多层级配置合并,支持从本地(.mcp.json)、用户级、项目级、企业级、动态注入和 Claude.ai 云端等多个来源加载 MCP 服务器配置,并按优先级合并。

auth.ts 实现了完整的 OAuth 2.0 PKCE 认证流程,包括动态客户端注册、令牌存储、令牌刷新和跨应用认证(XAA)。

11.2.3 架构分层

Claude Code 的 MCP 集成采用清晰的分层架构,从配置加载到工具调用形成完整链路。下图展示了各层之间的依赖关系:

flowchart TB subgraph Config["配置层"] Enterprise["企业级配置\n(最高优先级)"] User["用户级配置\n~/.claude/settings"] Project["项目级配置\n.claude/settings"] Local["本地配置\n.mcp.json"] end subgraph Connection["连接层"] ConnMgr["connectToServer()"] ConnMgr --> Stdio["stdio 传输\n本地子进程"] ConnMgr --> SSE["SSE 传输\nHTTP 长连接"] ConnMgr --> Streamable["HTTP Streamable\n会话式"] ConnMgr --> WS["WebSocket\n双向通信"] end subgraph Discovery["发现层"] ToolList["tools/list\n工具发现"] ResourceList["resources/list\n资源发现"] PromptList["prompts/list\n提示发现"] end subgraph Integration["集成层"] MCPTool["MCPTool 包装器"] MCPResource["资源工具注入"] MCPPrompt["提示 -> Slash 命令"] end subgraph ToolSystem["Claude Code 工具系统"] Registry["工具注册表"] Permission["权限系统"] end Config -->|"getClaudeCodeMcpConfigs()"| Connection Connection -->|"JSON-RPC 2.0"| Discovery Discovery --> Integration Integration --> ToolSystem
lua 复制代码
+-----------------------------------------------------------------+
|                      Claude Code 主进程                          |
|  +-----------------------------------------------------------+  |
|  |   MCPConnectionManager (React Context)                     |  |
|  |   提供 reconnect / toggle 操作给 UI 组件                     |
|  +-----------------------------------------------------------+  |
|  |   useManageMCPConnections (React Hook)                     |  |
|  |   生命周期管理: 初始化 / 重连 / 状态同步 / 批量更新           |
|  +-----------------------------------------------------------+  |
|  |   client.ts (核心客户端层)                                   |  |
|  |   connectToServer / fetchToolsForClient / callMCPTool       |  |
|  +-----------------------------------------------------------+  |
|  |   config.ts (配置层)          |   auth.ts (认证层)           |  |
|  |   多作用域配置加载与合并       |   OAuth PKCE + XAA           |  |
|  +-----------------------------------------------------------+  |
|  |   传输层适配                                                 |  |
|  |   StdioTransport | SSETransport | HTTPTransport | WSTransport|  |
|  +-----------------------------------------------------------+  |
+-----------------------------------------------------------------+
         |              |              |              |
    [stdio 子进程]  [SSE 服务器]  [HTTP 服务器]  [WS 服务器]

这个分层架构有一个重要的设计特点:传输层对上层完全透明。无论底层是 stdio 子进程还是远程 HTTP 服务器,上层的工具发现和调用逻辑都使用完全相同的 MCP Client 接口。这种抽象让 Claude Code 能够在不修改业务逻辑的情况下支持新的传输协议。

11.3 服务器生命周期管理

11.3.1 配置加载:多源合并的优先级策略

MCP 服务器配置可以来自多个来源,Claude Code 通过 getClaudeCodeMcpConfigs() 函数实现了一套精细的配置合并策略:

typescript 复制代码
// 源码文件:src/services/mcp/config.ts

export async function getClaudeCodeMcpConfigs(
  dynamicServers: Record<string, ScopedMcpServerConfig> = {},
  extraDedupTargets: Promise<Record<string, ScopedMcpServerConfig>>
    = Promise.resolve({}),
): Promise<{
  servers: Record<string, ScopedMcpServerConfig>
  errors: PluginError[]
}> {
  const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise')

  // 如果存在企业级配置,则独占控制所有 MCP 服务器
  if (doesEnterpriseMcpConfigExist()) {
    const filtered: Record<string, ScopedMcpServerConfig> = {}
    for (const [name, serverConfig] of Object.entries(enterpriseServers)) {
      if (!isMcpServerAllowedByPolicy(name, serverConfig)) continue
      filtered[name] = serverConfig
    }
    return { servers: filtered, errors: [] }
  }

  // 按优先级加载各层级配置
  const { servers: userServers } = getMcpConfigsByScope('user')
  const { servers: projectServers } = getMcpConfigsByScope('project')
  const { servers: localServers } = getMcpConfigsByScope('local')

  // 合并顺序:plugin < user < project < local
  const configs = Object.assign(
    {}, dedupedPluginServers, userServers,
    approvedProjectServers, localServers,
  )
  // ...
}

配置来源的优先级从低到高为:插件 < 用户级 < 项目级 < 本地 。这意味着本地的 .mcp.json 文件拥有最高优先级,可以覆盖用户级和项目级的同名配置。企业级配置是一个特例------当它存在时,会完全替代其他所有配置源,这是为了满足企业安全管控需求。

每个配置项都带有一个 scope 标记,记录它来自哪个层级。这个元数据在后续的权限检查和 UI 展示中发挥作用------例如,项目级配置的 MCP 服务器需要用户显式批准后才能使用。

配置合并还包含一个精巧的去重机制。当一个插件提供的 MCP 服务器与用户手动配置的服务器实际上指向同一个底层进程或 URL 时,系统会通过签名比较来检测并消除重复:

typescript 复制代码
// 源码文件:src/services/mcp/config.ts

export function getMcpServerSignature(config: McpServerConfig): string | null {
  const cmd = getServerCommandArray(config)
  if (cmd) {
    return `stdio:${jsonStringify(cmd)}`
  }
  const url = getServerUrl(config)
  if (url) {
    return `url:${unwrapCcrProxyUrl(url)}`
  }
  return null
}

对于 stdio 类型,签名是命令行数组的序列化;对于远程类型,签名是 URL(如果经过代理包装,会先解包还原出原始 URL)。签名相同的服务器只保留优先级较高的那个。

11.3.2 连接建立:多传输层的统一抽象

连接建立的核心是 connectToServer 函数,它根据配置中的 type 字段选择相应的传输层实现:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export const connectToServer = memoize(
  async (
    name: string,
    serverRef: ScopedMcpServerConfig,
  ): Promise<MCPServerConnection> => {
    let transport

    if (serverRef.type === 'sse') {
      const authProvider = new ClaudeAuthProvider(name, serverRef)
      const combinedHeaders = await getMcpServerHeaders(name, serverRef)
      transport = new SSEClientTransport(
        new URL(serverRef.url),
        { authProvider, fetch: wrapFetchWithTimeout(...), ... },
      )
    } else if (serverRef.type === 'http') {
      // HTTP Streamable 传输
      transport = new StreamableHTTPClientTransport(...)
    } else if (serverRef.type === 'ws') {
      // WebSocket 传输
      transport = new WebSocketTransport(wsClient)
    } else if (serverRef.type === 'stdio' || !serverRef.type) {
      // stdio 传输(默认)
      transport = new StdioClientTransport({
        command: finalCommand,
        args: finalArgs,
        env: { ...subprocessEnv(), ...serverRef.env },
        stderr: 'pipe',
      })
    }

    const client = new Client(
      { name: 'claude-code', version: MACRO.VERSION ?? 'unknown' },
      { capabilities: { roots: {}, elicitation: {} } },
    )

    // 带超时的连接尝试
    await Promise.race([
      client.connect(transport),
      timeoutPromise,
    ])
    // ...
  },
  getServerCacheKey,
)

这里有几个关键的设计决策值得注意:

连接缓存(Memoization)。 connectToServer 使用 lodash 的 memoize 进行缓存,缓存键由服务器名称和配置的序列化结果组合而成。这意味着对同一个服务器的重复连接请求会直接返回缓存的连接实例,避免了重复的进程创建或网络握手。

超时控制。 每次连接都有超时保护。通过 Promise.race 将连接操作与超时 Promise 竞争,如果在指定时间内(默认约 30 秒)未能建立连接,则抛出超时错误。

stderr 管道。 stdio 传输的 stderr 配置为 'pipe',这意味着 MCP 服务器进程的错误输出不会直接打印到用户终端,而是被捕获到内存缓冲区中供调试使用。缓冲区大小被限制在 64MB 以防止内存溢出。

能力声明。 Client 在初始化时声明了两个能力:roots(允许服务器查询工作目录)和 elicitation(允许服务器向用户请求输入)。注释中特别说明了 elicitation 声明使用空对象而非详细结构------因为某些 MCP 服务器实现(如 Spring AI)在遇到未知属性时会抛出错误。

11.3.3 stdio 传输层详解

stdio 传输是最常用的传输类型,也是默认类型。它通过启动一个子进程来运行 MCP 服务器,使用标准输入/输出(stdin/stdout)进行 JSON-RPC 消息交换:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

transport = new StdioClientTransport({
  command: finalCommand,
  args: finalArgs,
  env: {
    ...subprocessEnv(),    // 继承清理后的环境变量
    ...serverRef.env,      // 覆盖用户指定的环境变量
  } as Record<string, string>,
  stderr: 'pipe',
})

环境变量的处理体现了安全意识:subprocessEnv() 提供了一个经过清理的基础环境,服务器配置中的 env 字段允许用户覆盖特定变量。配置文件中的环境变量支持 ${VAR}${VAR:-default} 两种展开语法,由 envExpansion.ts 中的 expandEnvVarsInString 函数实现:

typescript 复制代码
// 源码文件:src/services/mcp/envExpansion.ts

export function expandEnvVarsInString(value: string): {
  expanded: string
  missingVars: string[]
} {
  const missingVars: string[] = []
  const expanded = value.replace(/\$\{([^}]+)\}/g, (match, varContent) => {
    const [varName, defaultValue] = varContent.split(':-', 2)
    const envValue = process.env[varName]
    if (envValue !== undefined) return envValue
    if (defaultValue !== undefined) return defaultValue
    missingVars.push(varName)
    return match
  })
  return { expanded, missingVars }
}

这个设计既支持灵活的变量注入,又能追踪缺失的变量以便报告错误。

11.3.4 错误恢复与自动重连

当一个已连接的 MCP 服务器断开时,Claude Code 会根据传输类型采取不同的恢复策略。这个逻辑在 useManageMCPConnections 中实现:

typescript 复制代码
// 源码文件:src/services/mcp/useManageMCPConnections.ts

const MAX_RECONNECT_ATTEMPTS = 5
const INITIAL_BACKOFF_MS = 1000
const MAX_BACKOFF_MS = 30000

client.client.onclose = () => {
  // stdio 和 sdk 类型不支持自动重连
  if (configType !== 'stdio' && configType !== 'sdk') {
    const reconnectWithBackoff = async () => {
      for (let attempt = 1; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
        if (isMcpServerDisabled(client.name)) return

        updateServer({
          ...client, type: 'pending',
          reconnectAttempt: attempt,
          maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
        })

        try {
          const result = await reconnectMcpServerImpl(client.name, client.config)
          if (result.client.type === 'connected') {
            onConnectionAttempt(result)
            return
          }
        } catch (error) { /* ... */ }

        // 指数退避
        const backoffMs = Math.min(
          INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
          MAX_BACKOFF_MS,
        )
        await new Promise(resolve => setTimeout(resolve, backoffMs))
      }
    }
    void reconnectWithBackoff()
  } else {
    updateServer({ ...client, type: 'failed' })
  }
}

重连策略的核心思想是指数退避:第一次重试等待 1 秒,第二次 2 秒,第三次 4 秒,以此类推,上限为 30 秒。最多尝试 5 次。在每次重试前,系统会检查服务器是否已被用户禁用------如果是,则立即放弃重连。

值得注意的是,stdio 类型的服务器不进行自动重连。这是因为 stdio 服务器是本地子进程,如果进程崩溃,通常意味着程序本身有问题,盲目重启可能只会重复失败。而远程传输(SSE、HTTP、WebSocket)的断开更可能是网络波动,自动重连是合理的策略。

在连接层面,client.ts 还实现了更精细的错误检测。对于连续出现的终端性连接错误(ECONNRESET、ETIMEDOUT、EPIPE 等),系统会在累计达到阈值后主动关闭传输层,触发重连流程:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

const MAX_ERRORS_BEFORE_RECONNECT = 3
let consecutiveConnectionErrors = 0

client.onerror = (error: Error) => {
  if (isTerminalConnectionError(error.message)) {
    consecutiveConnectionErrors++
    if (consecutiveConnectionErrors >= MAX_ERRORS_BEFORE_RECONNECT) {
      closeTransportAndRejectPending('max consecutive errors')
    }
  }
}

11.3.5 批量连接与并发控制

当 Claude Code 启动时,可能需要同时连接多个 MCP 服务器。getMcpToolsCommandsAndResources 函数负责协调这个过程:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export function getMcpServerConnectionBatchSize(): number {
  return parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 3
}

function getRemoteMcpServerConnectionBatchSize(): number {
  return parseInt(
    process.env.MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE || '', 10
  ) || 20
}

本地服务器(stdio/sdk)和远程服务器使用不同的并发度。本地服务器默认 3 个并发,因为每个连接都需要启动一个子进程,过高的并发可能导致系统资源竞争。远程服务器默认 20 个并发,因为网络连接的资源开销远小于进程创建。

底层的批处理使用 pMap 库实现------这是一种滑动窗口并发模型,每当一个连接完成,下一个连接立即开始,避免了固定批次大小导致的"木桶效应"(一个慢服务器阻塞整个批次)。

11.3.6 状态的批量更新

在连接多个服务器时,每个服务器的状态变化都会触发 AppState 更新。为了避免高频的状态更新导致 UI 频繁重渲染,useManageMCPConnections 实现了一个时间窗口批量更新机制:

typescript 复制代码
// 源码文件:src/services/mcp/useManageMCPConnections.ts

const MCP_BATCH_FLUSH_MS = 16  // 约一帧的时间

const updateServer = useCallback((update: PendingUpdate) => {
  pendingUpdatesRef.current.push(update)
  if (flushTimerRef.current === null) {
    flushTimerRef.current = setTimeout(
      flushPendingUpdates,
      MCP_BATCH_FLUSH_MS,
    )
  }
}, [flushPendingUpdates])

16 毫秒的窗口期与浏览器的一帧渲染周期对齐。在这个窗口内到达的所有服务器状态更新会被合并为一次 setAppState 调用。刷新逻辑中,每个更新按服务器名称匹配并替换现有条目,工具和命令按前缀过滤后合并,确保了状态的一致性。

11.4 工具发现与注册

11.4.1 tools/list 端点调用

当一个 MCP 服务器成功连接后,Claude Code 会通过 fetchToolsForClient 函数发现服务器提供的工具:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export const fetchToolsForClient = memoizeWithLRU(
  async (client: MCPServerConnection): Promise<Tool[]> => {
    if (client.type !== 'connected') return []

    if (!client.capabilities?.tools) {
      return []
    }

    const result = (await client.client.request(
      { method: 'tools/list' },
      ListToolsResultSchema,
    )) as ListToolsResult

    // 清理 Unicode 字符
    const toolsToProcess = recursivelySanitizeUnicode(result.tools)
    // ... 转换为 Tool 格式
  },
  (client: MCPServerConnection) => client.name,
  MCP_FETCH_CACHE_SIZE,
)

这里有两个重要的设计决策:

能力检查前置。 在发送 tools/list 请求之前,函数先检查服务器是否在握手阶段声明了 tools 能力。如果没有,则直接返回空数组,避免了一次无意义的网络请求。

LRU 缓存。 工具列表使用 LRU 缓存,缓存键为服务器名称。这意味着在同一个会话中,工具列表只会从服务器获取一次,除非明确清除缓存(重连时会清除)。缓存大小限制为 20 个条目,防止在连接大量服务器时导致内存膨胀。

11.4.2 MCPTool 包装器:MCP 工具与内建工具的桥梁

发现到的 MCP 工具需要被转化为 Claude Code 的 Tool 类型才能被工具系统使用。这个转换通过 MCPTool 基础对象和运行时属性覆盖来实现:

typescript 复制代码
// 源码文件:src/tools/MCPTool/MCPTool.ts

export const MCPTool = buildTool({
  isMcp: true,
  name: 'mcp',                 // 运行时会被覆盖
  maxResultSizeChars: 100_000, // 100KB 结果大小上限
  async description() { return DESCRIPTION },  // 运行时会被覆盖
  async prompt() { return PROMPT },            // 运行时会被覆盖
  async call() { return { data: '' } },        // 运行时会被覆盖
  get inputSchema(): InputSchema { return inputSchema() },
  async checkPermissions(): Promise<PermissionResult> {
    return { behavior: 'passthrough', message: 'MCPTool requires permission.' }
  },
  // ... 渲染方法
} satisfies ToolDef<InputSchema, Output>)

MCPTool 本身是一个模板对象 ------它定义了 MCP 工具的通用行为(如输入 Schema 使用 passthrough 模式接受任意对象、权限检查默认为 passthrough),但关键属性(name、description、call)在 fetchToolsForClient 中被逐个覆盖。

fetchToolsForClient 中,每个 MCP 工具被转换为一个完整的 Tool 对象:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

return toolsToProcess.map((tool): Tool => {
  const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
  return {
    ...MCPTool,
    name: skipPrefix ? tool.name : fullyQualifiedName,
    mcpInfo: { serverName: client.name, toolName: tool.name },
    isMcp: true,
    async description() { return tool.description ?? '' },
    async prompt() {
      const desc = tool.description ?? ''
      return desc.length > MAX_MCP_DESCRIPTION_LENGTH
        ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '... [truncated]'
        : desc
    },
    isConcurrencySafe() {
      return tool.annotations?.readOnlyHint ?? false
    },
    isReadOnly() {
      return tool.annotations?.readOnlyHint ?? false
    },
    isDestructive() {
      return tool.annotations?.destructiveHint ?? false
    },
    inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],
    async call(args, context, _canUseTool, parentMessage, onProgress) {
      // ... 工具调用逻辑
    },
    userFacingName() {
      const displayName = tool.annotations?.title || tool.name
      return `${client.name} - ${displayName} (MCP)`
    },
  }
})

11.4.3 工具命名规范

MCP 工具在 Claude Code 中的命名遵循 mcp__<serverName>__<toolName> 的格式。这个命名方案由 buildMcpToolName 函数生成:

typescript 复制代码
// 源码文件:src/services/mcp/mcpStringUtils.ts

export function buildMcpToolName(serverName: string, toolName: string): string {
  return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
}

export function getMcpPrefix(serverName: string): string {
  return `mcp__${normalizeNameForMCP(serverName)}__`
}

名称规范化函数确保生成的名称符合 API 的字符约束 ^[a-zA-Z0-9_-]{1,64}$

typescript 复制代码
// 源码文件:src/services/mcp/normalization.ts

export function normalizeNameForMCP(name: string): string {
  let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
  if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) {
    normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
  }
  return normalized
}

双下划线 __ 作为分隔符有一个已知的局限性:如果服务器名称本身包含 __,解析时会产生歧义。源码注释中也承认了这一点------但在实际使用中,服务器名称包含双下划线的情况极为罕见。

11.4.4 工具注解的映射

MCP 协议的工具注解(annotations)被直接映射为 Claude Code 工具系统的语义标记。其中最重要的是 readOnlyHint,它同时控制两个行为:

  • isConcurrencySafe():标记为只读的工具可以与其他工具并行执行,这与第 7 章工具编排中的分区并发机制直接相关。
  • isReadOnly():只读工具在权限系统中可以获得更宽松的自动授权策略。

destructiveHint 映射为 isDestructive(),影响权限提示的严重级别;openWorldHint 映射为 isOpenWorld(),标识工具是否会访问互联网等外部资源。

工具描述有 2048 字符的硬上限。这个限制来源于实际观察------某些基于 OpenAPI 生成的 MCP 服务器会在工具描述中输出 15-60KB 的端点文档。截断策略通过添加 [truncated] 后缀来提醒模型描述已被裁切。

11.5 工具调用

11.5.1 tools/call 的完整链路

以下时序图展示了 MCP 工具调用从模型输出到服务器执行再到结果返回的完整链路,包括连接恢复和错误处理:

sequenceDiagram participant Model as Claude 模型 participant MCPTool as MCPTool 包装器 participant Client as MCP Client participant Transport as 传输层 participant Server as MCP 服务器 Model->>MCPTool: tool_use(mcp__server__tool, args) MCPTool->>Client: ensureConnectedClient() alt 连接已断开 Client->>Transport: 重新建立连接 Transport->>Server: initialize Server-->>Transport: capabilities end Client->>Server: tools/call (JSON-RPC) Note over Client,Server: 双重超时保护:\nSDK timeout + Promise.race alt 会话过期 (HTTP 404) Server-->>Client: -32001 session expired Client->>Client: 清除 memoize 缓存 Client->>Server: 重新连接 + 重试 end Server-->>Client: CallToolResult alt isError = true Client-->>MCPTool: McpToolCallError else 正常结果 Client->>Client: processMCPResult() Note over Client: 大结果截断\n持久化到文件 Client-->>MCPTool: {data, mcpMeta} end MCPTool-->>Model: tool_result

当模型决定调用一个 MCP 工具时,调用链路从 Tool.call() 方法开始,经过多层处理最终到达 MCP 服务器:

scss 复制代码
模型输出 tool_use
  -> Tool.call() [fetchToolsForClient 中定义的覆盖版本]
    -> ensureConnectedClient() [确保连接有效]
    -> callMCPToolWithUrlElicitationRetry() [处理 URL 引出重试]
      -> callMCPTool() [实际调用 MCP 服务器]
        -> client.callTool() [MCP SDK 的协议层调用]
          -> [传输层发送 JSON-RPC 请求]
            -> MCP 服务器执行并返回结果
      -> processMCPResult() [结果处理与截断]
    -> 返回 { data, mcpMeta }

这个链路中的每一层都有独特的职责。让我们从底层的 callMCPTool 函数开始分析:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

async function callMCPTool({
  client: { client, name, config },
  tool, args, meta, signal, onProgress,
}): Promise<{ content: MCPToolResult; _meta?; structuredContent? }> {
  const toolStartTime = Date.now()

  // 每 30 秒记录一次进度日志
  const progressInterval = setInterval(() => {
    logMCPDebug(name, `Tool '${tool}' still running (${elapsed}s elapsed)`)
  }, 30000)

  // 双重超时保护
  const timeoutMs = getMcpToolTimeoutMs()
  const result = await Promise.race([
    client.callTool(
      { name: tool, arguments: args, _meta: meta },
      CallToolResultSchema,
      { signal, timeout: timeoutMs, onprogress: /* ... */ },
    ),
    timeoutPromise,
  ])

  // 检查错误标志
  if ('isError' in result && result.isError) {
    throw new McpToolCallError(errorDetails, 'MCP tool returned error')
  }

  const content = await processMCPResult(result, tool, name)
  return { content, _meta: result._meta, structuredContent: result.structuredContent }
}

几个关键设计点:

双重超时。 callMCPTool 实现了自己的超时机制(通过 Promise.race),并且同时使用了 SDK 内建的 timeout 参数。这种双重保护是必要的------SDK 的超时依赖于底层传输的响应,但如果 SSE 流在中间断裂,SDK 的超时可能永远不会触发。外层的 Promise.race 提供了兜底保障。

默认超时极长。 DEFAULT_MCP_TOOL_TIMEOUT_MS 被设置为约 27.8 小时(100,000,000 毫秒)。这看似不合理,但反映了 MCP 工具的多样性------某些工具(如长时间运行的数据分析)确实可能需要很长时间。用户可以通过 MCP_TOOL_TIMEOUT 环境变量自定义超时。

进度日志。 每 30 秒记录一次工具执行进度,这在调试长时间运行的工具调用时非常有价值。

11.5.2 会话恢复机制

MCP 的 HTTP Streamable 传输基于会话机制工作。当会话过期时(服务器返回 HTTP 404 + JSON-RPC -32001),Claude Code 会清除连接缓存并自动重试:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

const MAX_SESSION_RETRIES = 1
for (let attempt = 0; ; attempt++) {
  try {
    const connectedClient = await ensureConnectedClient(client)
    const mcpResult = await callMCPToolWithUrlElicitationRetry({
      client: connectedClient, /* ... */
    })
    return { data: mcpResult.content }
  } catch (error) {
    if (error instanceof McpSessionExpiredError
        && attempt < MAX_SESSION_RETRIES) {
      continue  // 重试一次
    }
    throw error
  }
}

会话过期检测的逻辑非常精细,需要同时匹配 HTTP 状态码和 JSON-RPC 错误码,以避免将普通的 404 错误误判为会话过期:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export function isMcpSessionExpiredError(error: Error): boolean {
  const httpStatus = 'code' in error ? (error as any).code : undefined
  if (httpStatus !== 404) return false
  return (
    error.message.includes('"code":-32001') ||
    error.message.includes('"code": -32001')
  )
}

11.5.3 大结果处理

MCP 工具可能返回超大结果(如查询数据库返回上万行记录)。Claude Code 通过 processMCPResult 函数实现了智能的大结果处理:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export async function processMCPResult(
  result: unknown, tool: string, name: string,
): Promise<MCPToolResult> {
  const { content, type, schema } = await transformMCPResult(result, tool, name)

  if (!(await mcpContentNeedsTruncation(content))) {
    return content  // 正常大小,直接返回
  }

  // 内容过大,持久化到文件
  const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${Date.now()}`
  const contentStr = typeof content === 'string'
    ? content
    : jsonStringify(content, null, 2)
  const persistResult = await persistToolResult(contentStr, persistId)

  if (isPersistError(persistResult)) {
    // 持久化失败,退回截断策略
    return await truncateMcpContentIfNeeded(content)
  }

  // 返回文件路径和读取指引
  return getLargeOutputInstructions(
    persistResult.filepath,
    persistResult.originalSize,
    getFormatDescription(type, schema),
  )
}

大结果处理遵循三级降级策略:

  1. 正常返回:结果大小在阈值内,直接作为上下文传递给模型
  2. 持久化到文件:超出阈值时,将结果写入临时文件,返回文件路径和读取指引给模型
  3. 截断:如果文件持久化也失败了,则截断内容并添加提示信息

特别地,如果结果中包含图片内容,系统会绕过文件持久化路径,直接使用截断策略------因为将图片序列化为 JSON 既浪费空间又破坏了图片压缩逻辑。

11.5.4 结果内容转换

MCP 工具返回的内容需要被转换为 Claude API 能理解的 ContentBlockParam 格式。transformResultContent 函数处理了四种内容类型:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export async function transformResultContent(
  resultContent: PromptMessage['content'],
  serverName: string,
): Promise<Array<ContentBlockParam>> {
  switch (resultContent.type) {
    case 'text':
      return [{ type: 'text', text: resultContent.text }]

    case 'image': {
      const imageBuffer = Buffer.from(String(resultContent.data), 'base64')
      const resized = await maybeResizeAndDownsampleImageBuffer(
        imageBuffer, imageBuffer.length, ext,
      )
      return [{ type: 'image', source: { data: resized.buffer.toString('base64'), ... } }]
    }

    case 'resource': {
      const resource = resultContent.resource
      if ('text' in resource) {
        return [{ type: 'text', text: `[Resource from ${serverName}] ${resource.text}` }]
      } else if ('blob' in resource) {
        // 图片 blob 进行缩放,其他二进制 blob 持久化到文件
        // ...
      }
    }

    case 'resource_link': {
      return [{ type: 'text', text: `[Resource link: ${name}] ${uri}` }]
    }
  }
}

图片内容会经过 maybeResizeAndDownsampleImageBuffer 进行自动缩放和压缩,确保不超出 API 的尺寸限制。二进制内容(音频、非图片 blob)会被持久化到磁盘文件,上下文中只保留文件路径引用。

11.6 MCP 提示与命令

11.6.1 prompts/list 与命令注册

MCP 协议的第三类能力是"提示"(Prompts),它允许服务器提供预定义的交互模板。在 Claude Code 中,MCP 提示被转换为斜杠命令(Command),用户可以通过 /mcp__server__promptName 的格式调用它们。

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export const fetchCommandsForClient = memoizeWithLRU(
  async (client: MCPServerConnection): Promise<Command[]> => {
    if (client.type !== 'connected') return []
    if (!client.capabilities?.prompts) return []

    const result = (await client.client.request(
      { method: 'prompts/list' },
      ListPromptsResultSchema,
    )) as ListPromptsResult

    const promptsToProcess = recursivelySanitizeUnicode(result.prompts)

    return promptsToProcess.map(prompt => {
      const argNames = Object.values(prompt.arguments ?? {}).map(k => k.name)
      return {
        type: 'prompt' as const,
        name: 'mcp__' + normalizeNameForMCP(client.name) + '__' + prompt.name,
        description: prompt.description ?? '',
        isMcp: true,
        source: 'mcp',
        argNames,
        async getPromptForCommand(args: string) {
          const argsArray = args.split(' ')
          const connectedClient = await ensureConnectedClient(client)
          const result = await connectedClient.client.getPrompt({
            name: prompt.name,
            arguments: zipObject(argNames, argsArray),
          })
          const transformed = await Promise.all(
            result.messages.map(message =>
              transformResultContent(message.content, connectedClient.name),
            ),
          )
          return transformed.flat()
        },
      }
    })
  },
)

提示与工具的关键区别在于执行模型:工具调用是模型主动发起的,而提示是用户主动发起的。当用户输入 /mcp__github__summarize_pr 时,Claude Code 会向 MCP 服务器发送 prompts/get 请求获取模板内容,然后将模板内容注入对话上下文。模型随后基于这些上下文生成回复。

提示的参数通过空格分割的方式传递,由 zipObject 函数将位置参数映射为命名参数。这种简化的参数传递方式适合终端交互场景,但也意味着参数值本身不能包含空格。

11.6.2 动态工具和变更通知

MCP 协议支持服务器在运行时动态变更其工具、资源和提示列表,通过发送相应的变更通知来告知客户端。Claude Code 在 useManageMCPConnections 中注册了这些通知的处理器:

当服务器发送 notifications/tools/list_changed 通知时,Claude Code 会重新获取该服务器的工具列表并更新 AppState。类似地,notifications/resources/list_changednotifications/prompts/list_changed 分别触发资源和提示列表的刷新。这种机制使得 MCP 服务器能够根据其内部状态的变化动态调整暴露给 Claude Code 的能力集合。

11.6.3 引出处理:服务器向用户请求输入

MCP 协议中一个值得关注的特性是"引出"(Elicitation)------允许 MCP 服务器在工具执行过程中向用户请求额外的输入。最典型的场景是 URL 引出:服务器返回一个 URL,需要用户在浏览器中完成某个操作(如 OAuth 授权或支付确认),然后才能继续执行。

Claude Code 通过 callMCPToolWithUrlElicitationRetry 函数实现了 URL 引出的重试机制。当工具调用返回特殊的 -32042 错误码(即 UrlElicitationRequired)时,系统会提取错误数据中的引出请求列表,依次处理每个引出:先尝试通过钩子函数自动处理,如果没有钩子可以处理,则在 REPL 模式下弹出对话框请求用户确认,或者在 SDK 模式下通过结构化输出委托给调用方。用户完成操作后,系统自动重试原始的工具调用,最多重试三次。

这个引出机制的设计充分体现了 MCP 协议对人机交互的支持------工具执行不再是一个简单的请求-响应过程,而是可以在执行中间暂停、等待用户输入、然后继续执行的交互式流程。

11.7 MCP 资源

11.7.1 resources/list 与资源发现

MCP 资源是服务器提供的只读数据,类似于文件系统中的文件。Claude Code 通过 fetchResourcesForClient 函数发现服务器的资源列表:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export const fetchResourcesForClient = memoizeWithLRU(
  async (client: MCPServerConnection): Promise<ServerResource[]> => {
    if (client.type !== 'connected') return []

    if (!client.capabilities?.resources) {
      return []
    }

    const result = await client.client.request(
      { method: 'resources/list' },
      ListResourcesResultSchema,
    )

    if (!result.resources) return []

    return result.resources.map(resource => ({
      ...resource,
      server: client.name,
    }))
  },
  (client: MCPServerConnection) => client.name,
  MCP_FETCH_CACHE_SIZE,
)

每个资源对象被附加上 server 字段,标识它来自哪个 MCP 服务器。类型定义为:

typescript 复制代码
// 源码文件:src/services/mcp/types.ts

export type ServerResource = Resource & { server: string }

11.7.2 资源工具的自动注入

当一个 MCP 服务器声明了 resources 能力时,Claude Code 会自动将两个资源操作工具注入到工具列表中:ListMcpResourcesToolReadMcpResourceTool。这个注入在 reconnectMcpServerImpl 中进行:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

const supportsResources = !!client.capabilities?.resources

const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([
  fetchToolsForClient(client),
  fetchCommandsForClient(client),
  feature('MCP_SKILLS') && supportsResources
    ? fetchMcpSkillsForClient!(client) : Promise.resolve([]),
  supportsResources ? fetchResourcesForClient(client) : Promise.resolve([]),
])

const resourceTools: Tool[] = []
if (supportsResources) {
  const hasResourceTools = [ListMcpResourcesTool, ReadMcpResourceTool].some(
    tool => tools.some(t => toolMatchesName(t, tool.name)),
  )
  if (!hasResourceTools) {
    resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool)
  }
}

return {
  client,
  tools: [...tools, ...resourceTools],
  commands,
  resources: resources.length > 0 ? resources : undefined,
}

这里有一个去重检查:如果 MCP 服务器自身已经提供了同名的资源工具,就不再注入默认的。这种设计允许服务器自定义资源访问的行为。

11.7.3 资源在对话中的使用

资源的读取通过标准的 MCP resources/read 请求完成。读取到的内容被转换为适当的 ContentBlockParam 格式嵌入对话上下文。文本资源直接嵌入,图片资源经过压缩后嵌入,其他二进制资源持久化到文件。

资源在上下文中的呈现带有明确的来源标记,例如 [Resource from slack at slack://messages/general],这帮助模型理解数据的来源和性质。

11.8 认证

MCP 远程服务器的认证采用标准的 OAuth 2.0 PKCE 流程。下图展示了从首次连接到令牌刷新的完整认证生命周期:

stateDiagram-v2 [*] --> CheckCache: 连接远程 MCP 服务器 CheckCache --> Authenticated: 缓存令牌有效 CheckCache --> NeedAuth: 无缓存 / 令牌过期 NeedAuth --> Discovery: 发现 OAuth 端点 Discovery --> PKCE: 生成 PKCE 挑战 PKCE --> BrowserAuth: 打开浏览器\n用户授权 BrowserAuth --> Callback: 回调接收 auth_code Callback --> TokenExchange: 换取令牌 TokenExchange --> Authenticated: 获取 access_token + refresh_token Authenticated --> Active: 调用 MCP 服务器 Active --> RefreshNeeded: 401 Unauthorized RefreshNeeded --> TokenRefresh: 刷新令牌 TokenRefresh --> Active: 新 access_token TokenRefresh --> NeedAuth: refresh_token 也过期 Active --> [*]: 连接关闭

11.8.1 OAuth 2.0 PKCE 流程

远程 MCP 服务器通常需要认证。Claude Code 实现了完整的 OAuth 2.0 PKCE(Proof Key for Code Exchange)流程,核心在 ClaudeAuthProvider 类中:

typescript 复制代码
// 源码文件:src/services/mcp/auth.ts

export class ClaudeAuthProvider implements OAuthClientProvider {
  private serverName: string
  private serverConfig: McpSSEServerConfig | McpHTTPServerConfig
  private _codeVerifier?: string
  private _refreshInProgress?: Promise<OAuthTokens | undefined>

  get clientMetadata(): OAuthClientMetadata {
    return {
      client_name: `Claude Code (${this.serverName})`,
      redirect_uris: [this.redirectUri],
      grant_types: ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      token_endpoint_auth_method: 'none', // 公共客户端
    }
  }

  async clientInformation(): Promise<OAuthClientInformation | undefined> {
    const storage = getSecureStorage()
    const data = storage.read()
    const serverKey = getServerKey(this.serverName, this.serverConfig)

    // 优先使用已存储的客户端信息(来自动态注册)
    const storedInfo = data?.mcpOAuth?.[serverKey]
    if (storedInfo?.clientId) {
      return { client_id: storedInfo.clientId, client_secret: storedInfo.clientSecret }
    }

    // 退回到配置中预设的 clientId
    const configClientId = this.serverConfig.oauth?.clientId
    if (configClientId) {
      return { client_id: configClientId }
    }

    // 没有客户端信息,触发动态客户端注册
    return undefined
  }
  // ...
}

OAuth 流程的完整步骤如下:

  1. 元数据发现:通过 RFC 9728(OAuth Protected Resource Metadata)或 RFC 8414(OAuth Authorization Server Metadata)发现授权服务器的端点
  2. 动态客户端注册 (DCR):如果没有预配置的 clientId,向授权服务器注册一个新的公共客户端
  3. PKCE 授权 :生成 code_verifiercode_challenge,在浏览器中打开授权 URL
  4. 本地回调:启动一个临时 HTTP 服务器监听回调端口,接收授权码
  5. 令牌交换 :用授权码和 code_verifier 交换 access_token 和 refresh_token
  6. 安全存储:将令牌存入操作系统的安全存储(macOS Keychain / Linux Secret Service)

元数据发现实现了复杂的降级逻辑:

typescript 复制代码
// 源码文件:src/services/mcp/auth.ts

async function fetchAuthServerMetadata(
  serverName: string,
  serverUrl: string,
  configuredMetadataUrl: string | undefined,
): Promise<AuthorizationServerMetadata> {
  // 1. 优先使用配置的元数据 URL
  if (configuredMetadataUrl) {
    if (!configuredMetadataUrl.startsWith('https://')) {
      throw new Error('authServerMetadataUrl must use https://')
    }
    const response = await authFetch(configuredMetadataUrl)
    return OAuthMetadataSchema.parse(await response.json())
  }

  // 2. RFC 9728 -> RFC 8414 标准发现
  try {
    const { authorizationServerMetadata } = await discoverOAuthServerInfo(serverUrl)
    if (authorizationServerMetadata) return authorizationServerMetadata
  } catch {
    // 标准发现失败,降级到路径感知探测
  }

  // 3. 路径感知 RFC 8414 降级
  // 覆盖在 .well-known/oauth-authorization-server/{path} 共址部署元数据的遗留服务器
  return await discoverAuthorizationServerMetadata(serverUrl)
}

11.8.2 令牌管理与刷新

令牌的生命周期管理是 ClaudeAuthProvider 最复杂的部分。tokens() 方法在每次 MCP 请求前被 SDK 调用,负责返回当前有效的令牌或触发刷新:

typescript 复制代码
// 源码文件:src/services/mcp/auth.ts

async tokens(): Promise<OAuthTokens | undefined> {
  const storage = getSecureStorage()
  const data = await storage.readAsync()
  const tokenData = data?.mcpOAuth?.[serverKey]

  if (!tokenData) return undefined

  const expiresIn = (tokenData.expiresAt - Date.now()) / 1000

  // 令牌过期且没有 refresh_token -> 返回 undefined 触发重新认证
  if (expiresIn <= 0 && !tokenData.refreshToken) return undefined

  // 令牌即将过期(5分钟内)且有 refresh_token -> 主动刷新
  if (expiresIn <= 300 && tokenData.refreshToken && !needsStepUp) {
    if (!this._refreshInProgress) {
      this._refreshInProgress = this.refreshAuthorization(
        tokenData.refreshToken,
      ).finally(() => { this._refreshInProgress = undefined })
    }
    const refreshed = await this._refreshInProgress
    if (refreshed) return refreshed
  }

  return {
    access_token: tokenData.accessToken,
    refresh_token: needsStepUp ? undefined : tokenData.refreshToken,
    expires_in: expiresIn,
    token_type: 'Bearer',
  }
}

这里有几个精妙的设计:

主动刷新。 令牌在过期前 5 分钟就开始刷新,而不是等到 401 错误后再刷新。这避免了一次失败请求的延迟开销。

并发安全。 _refreshInProgress 字段确保同一时刻最多只有一个刷新操作在进行。如果多个请求同时发现令牌即将过期,它们会共享同一个刷新 Promise。

Step-up 授权支持。 当服务器返回 403 insufficient_scope 时,系统会设置 _pendingStepUpScope 标记,在随后的 tokens() 调用中故意省略 refresh_token。这迫使 SDK 跳过刷新流程(因为 RFC 6749 明确禁止通过刷新提升作用域),转而发起一次新的 PKCE 授权流程以获取更高权限的令牌。

性能优化。 注释中详细解释了为什么不在 tokens() 中调用 clearKeychainCache():因为 SDK 在每个请求的 _commonHeaders 中都会调用 tokens(),频繁的缓存清除会导致每秒 30-40 次的 spawnSync 调用(用于读取 macOS Keychain),在 CPU 分析中占到总 CPU 的 7.2%。

11.8.3 OAuth 错误规范化

实际的 OAuth 服务器实现并不总是严格遵循 RFC。auth.ts 中的 normalizeOAuthErrorBody 函数处理了一个常见的不兼容性------某些服务器(特别是 Slack)在 HTTP 200 响应中返回错误信息:

typescript 复制代码
// 源码文件:src/services/mcp/auth.ts

export async function normalizeOAuthErrorBody(
  response: Response,
): Promise<Response> {
  if (!response.ok) return response

  const text = await response.text()
  let parsed: unknown
  try { parsed = jsonParse(text) } catch { return new Response(text, response) }

  // 如果响应符合 OAuthTokens 格式,说明不是错误
  if (OAuthTokensSchema.safeParse(parsed).success) {
    return new Response(text, response)
  }

  // 如果响应匹配 OAuthErrorResponse 格式,重写为 400 状态码
  const result = OAuthErrorResponseSchema.safeParse(parsed)
  if (!result.success) return new Response(text, response)

  // 规范化非标准错误码
  const normalized = NONSTANDARD_INVALID_GRANT_ALIASES.has(result.data.error)
    ? { error: 'invalid_grant', error_description: '...' }
    : result.data

  return new Response(jsonStringify(normalized), {
    status: 400, statusText: 'Bad Request', headers: response.headers,
  })
}

Slack 使用了 invalid_refresh_tokenexpired_refresh_tokentoken_expired 等非标准错误码,而 RFC 6749 规定应使用 invalid_grant。这个规范化层确保了下游的错误处理逻辑能正确匹配并触发令牌失效流程。

11.8.4 认证缓存与短路

为了避免在每次启动时都尝试连接那些已知需要认证的远程服务器,Claude Code 维护了一个认证缓存:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 // 15分钟

async function isMcpAuthCached(serverId: string): Promise<boolean> {
  const cache = await getMcpAuthCache()
  const entry = cache[serverId]
  if (!entry) return false
  return Date.now() - entry.timestamp < MCP_AUTH_CACHE_TTL_MS
}

当一个服务器返回 401 需要认证时,这个状态被缓存 15 分钟。在此期间,后续的连接尝试会直接跳过,以 needs-auth 状态展示给用户。这个优化在启动时特别重要------打印模式(print mode)需要等待所有 MCP 服务器完成连接尝试,每个失败的 401 连接都意味着额外的网络延迟。

缓存写入使用了串行化的 Promise 链来防止并发的读-改-写竞态:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

let writeChain = Promise.resolve()

function setMcpAuthCacheEntry(serverId: string): void {
  writeChain = writeChain.then(async () => {
    const cache = await getMcpAuthCache()
    cache[serverId] = { timestamp: Date.now() }
    await writeFile(cachePath, jsonStringify(cache))
    authCachePromise = null // 使读缓存失效
  }).catch(() => {})
}

11.8.5 安全存储架构

OAuth 令牌被存储在操作系统的安全存储中,而非明文文件。在 macOS 上使用 Keychain,在 Linux 上使用 Secret Service API。存储的数据结构按 MCP 服务器划分:

typescript 复制代码
// 数据结构概览(推导自 auth.ts)
SecureStorageData = {
  mcpOAuth: {
    [serverKey: string]: {
      serverName: string
      serverUrl: string
      clientId: string
      clientSecret?: string
      accessToken: string
      refreshToken?: string
      expiresAt: number
      scope?: string
    }
  }
  mcpOAuthClientConfig: {
    [serverKey: string]: {
      clientSecret?: string
    }
  }
}

serverKey 由服务器名称和 URL 组合生成,确保不同服务器的令牌互不干扰。客户端信息(来自动态注册的 clientId/clientSecret)与令牌信息分开存储,因为它们有不同的生命周期------客户端注册通常是永久有效的,而令牌需要定期刷新。

11.8.6 跨应用认证(XAA)

Claude Code 还实现了跨应用认证(Cross-App Access, XAA)机制,允许通过身份提供商(IdP)的 id_token 进行无浏览器的静默令牌交换。这个机制的核心逻辑在 tokens() 方法中:

typescript 复制代码
// 源码文件:src/services/mcp/auth.ts(tokens 方法内)

if (
  isXaaEnabled() &&
  this.serverConfig.oauth?.xaa &&
  !tokenData?.refreshToken &&
  (!tokenData?.accessToken || (tokenData.expiresAt - Date.now()) / 1000 <= 300)
) {
  if (!this._refreshInProgress) {
    this._refreshInProgress = this.xaaRefresh().finally(() => {
      this._refreshInProgress = undefined
    })
  }
  const refreshed = await this._refreshInProgress
  if (refreshed) return refreshed
}

XAA 只在以下条件同时满足时触发:XAA 功能已启用、服务器配置了 oauth.xaa、没有可用的 refresh_token、access_token 不存在或即将过期。XAA 的静默交换比标准刷新需要更多的网络请求(4 次 vs 1 次),所以只有在 refresh_token 不可用时才使用它。

11.9 通道通知:从工具调用到消息推送

MCP 协议的标准交互模式是请求-响应式的:客户端调用工具,服务器返回结果。但 Claude Code 还实现了一种更高级的交互模式------通道通知(Channel Notifications),允许 MCP 服务器主动向对话中推送消息。

这个功能的典型使用场景是即时通讯集成。当用户通过 Slack 或 Discord 的 MCP 服务器与他人协作时,对方发送的新消息可以通过通道通知实时推送到 Claude Code 的对话中,而不需要模型主动轮询。

typescript 复制代码
// 源码文件:src/services/mcp/channelNotification.ts

export const ChannelMessageNotificationSchema = lazySchema(() =>
  z.object({
    method: z.literal('notifications/claude/channel'),
    params: z.object({
      content: z.string(),
      meta: z.record(z.string(), z.string()).optional(),
    }),
  }),
)

通道通知的注册需要通过多层安全门控:服务器必须声明相应的能力、用户必须已登录 Claude.ai OAuth 认证、通道必须在允许列表中,并且组织的管理策略必须允许通道功能。只有所有条件都满足时,通知处理器才会被注册到 MCP 客户端上。

收到的通道消息会被包装在 <channel> 标签中并加入消息队列,系统中的 SleepTool 会在一秒内检测到队列中的新消息并唤醒模型。模型看到消息来源后,可以决定使用通道的 MCP 工具回复(如 send_message),或者使用其他工具处理,或者直接回复用户。这种设计将消息推送和消息处理解耦,让模型能够灵活地决定如何回应外部消息。

11.10 设计决策深度分析

11.10.1 为什么选择 memoize 而非连接池

connectToServer 使用 lodash 的 memoize 进行连接缓存,这在语义上等价于一个连接池,但实现更简洁。传统的连接池需要管理池大小、借出/归还语义、空闲超时等复杂状态。而 memoize 的语义是"相同输入返回相同输出"------对于 MCP 连接来说,相同的服务器配置应该返回相同的连接实例,这正是 memoize 的天然语义。

缓存清除通过 clearServerCache 显式进行,它不仅清除连接缓存,还一并清除工具、资源和命令的 LRU 缓存:

typescript 复制代码
// 源码文件:src/services/mcp/client.ts

export async function clearServerCache(name, serverRef): Promise<void> {
  const key = getServerCacheKey(name, serverRef)
  connectToServer.cache.delete(key)
  fetchToolsForClient.cache.delete(name)
  fetchResourcesForClient.cache.delete(name)
  fetchCommandsForClient.cache.delete(name)
}

11.10.2 为什么 stdio 不自动重连

stdio 传输的"连接断开"意味着子进程已经退出。自动重启一个崩溃的进程可能导致数据状态不一致(服务器可能有内存中的状态),而且如果崩溃原因是编程错误,重启只会重复失败。因此,stdio 传输在断开时直接标记为 failed,由用户决定是否手动重连。

远程传输则不同------网络断开通常是暂时性的(WiFi 切换、负载均衡器重路由等),服务器端的状态不受客户端连接中断的影响,自动重连是安全且合理的。

11.10.3 MCPTool 的模板模式

MCPTool 使用了对象展开而非类继承来实现多态。fetchToolsForClient 中的 { ...MCPTool, name: ..., call: ... } 模式创建了一个新对象,继承了 MCPTool 的所有默认行为,同时覆盖了特定的属性。

这种模式与第 6 章分析的 buildTool 模式一致------Claude Code 的工具系统采用"工具即对象"而非"工具即类"的范式。MCP 工具尤其适合这种模式,因为同一个 MCPTool 模板需要为数十个甚至数百个具体的 MCP 工具生成实例,而每个实例之间只有名称、描述和调用逻辑不同。

11.10.4 认证与连接的解耦

认证状态(needs-auth)被设计为与连接状态平级的一种独立状态,而非连接失败的子类型。这个设计选择有两个好处:

  1. UI 层可以为 needs-auth 状态展示专门的操作按钮(如"打开浏览器登录"),而不是通用的"重试"按钮
  2. 认证缓存可以在连接层面进行短路------不需要实际发起网络连接就能知道服务器需要认证

11.11 小结

本章深入分析了 Claude Code 的 MCP 协议集成,这是一个横跨配置管理、网络通信、认证安全和工具系统的复杂子系统。我们看到了以下关键设计:

配置的多层合并与去重。 七种配置来源(local/user/project/enterprise/dynamic/claudeai/managed)按优先级合并,签名去重确保同一个服务器不会被连接两次。企业级配置拥有独占控制权,满足安全管控需求。

传输层的统一抽象。 四种传输协议(stdio/SSE/HTTP Streamable/WebSocket)被 MCP SDK 的 Transport 接口统一抽象,上层的工具发现和调用逻辑对传输类型完全透明。

生命周期的精细管理。 五种连接状态(connected/failed/needs-auth/pending/disabled)清晰地描述了 MCP 服务器的全部生命周期。自动重连使用指数退避策略,批量连接使用滑动窗口并发控制,状态更新使用时间窗口批量合并。

MCPTool 的桥接模式。 通过对象展开和属性覆盖,每个 MCP 工具被无缝地封装为 Claude Code 的 Tool 对象,继承了内建工具的全部能力------权限检查、并发控制、UI 渲染和编排参与。

OAuth 认证的完备性。 从 RFC 9728/8414 元数据发现、PKCE 授权流程、动态客户端注册到令牌的主动刷新和跨应用认证,构成了一套完整的安全认证体系。非标准 OAuth 实现的兼容性处理体现了工程实用主义。

安全贯穿始终。 令牌存储使用操作系统级安全存储,认证缓存防止反复尝试,环境变量展开支持凭据注入但不硬编码,项目级 MCP 配置需要用户显式批准。

MCP 协议集成是 Claude Code 从一个封闭的工具集合走向开放的工具平台的关键。它不仅仅是一个协议适配器------它将第三方工具无缝地融入了 Claude Code 的权限模型、编排引擎和 UI 渲染体系,使得外部工具与内建工具享有完全相同的公民地位。这种设计使得 Claude Code 的工具生态可以独立于产品本身持续演进,而 MCP 协议的标准化确保了这种演进是有序的、安全的和互操作的。

相关推荐
杨艺韬8 小时前
Claude Code设计与实现-第6章 工具类型系统设计
agent
杨艺韬8 小时前
Claude Code设计与实现-第1章 为什么需要理解 Claude Code
agent
杨艺韬8 小时前
Claude Code设计与实现-前言
agent
杨艺韬8 小时前
Claude Code设计与实现-第2章 架构总览
agent
杨艺韬8 小时前
Claude Code设计与实现-第4章 Query 引擎:Agent 的心脏
agent
杨艺韬8 小时前
Claude Code设计与实现-第3章 CLI 启动与性能优化
agent
杨艺韬8 小时前
OpenClaw设计与实现-第12章 定时任务与自动化
agent
杨艺韬8 小时前
OpenClaw设计与实现-第14章 CLI 与交互界面
agent
杨艺韬8 小时前
OpenClaw设计与实现-第13章 安全与权限
agent