Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 15 章:MCP 协议实现

Model Context Protocol(MCP)是 Claude Code 可扩展性的核心基础设施。通过 MCP,外部服务可以将自己的工具、资源和提示注入到 Claude 的能力空间中,使其能够操作数据库、调用 API、访问设计工具等几乎任何外部系统。本章将深入 src/services/mcp/client.ts 这个超过 3300 行的协议引擎,解析其连接管理、工具转换和资源系统的完整实现。

15.1 MCP 协议概述

MCP 建立在 JSON-RPC 2.0 之上,通过多种传输层(Transport)承载消息。其核心思想是将 AI 工具调用抽象为标准化的请求-响应协议,使得工具提供者和 AI 客户端可以独立演进。

协议的基本交互模式:

sequenceDiagram
    participant CC as Claude Code (Client)
    participant MCP as MCP Server

    CC->>MCP: initialize (capabilities, protocolVersion)
    MCP-->>CC: initialize response (serverCapabilities)
    CC->>MCP: initialized (通知)

    Note over CC,MCP: 连接已建立

    CC->>MCP: tools/list
    MCP-->>CC: tools (name, description, inputSchema)

    CC->>MCP: resources/list
    MCP-->>CC: resources (uri, name, mimeType)

    CC->>MCP: tools/call (name, arguments)
    MCP-->>CC: result (content[])

    MCP->>CC: elicitation/request (URL/Form)
    CC-->>MCP: elicitation/response

Claude Code 作为 MCP 客户端,使用 @modelcontextprotocol/sdk 官方 SDK 实现协议层。但在 SDK 之上,Claude Code 构建了大量的工程逻辑:连接池管理、认证处理、工具类型转换、输出截断、错误恢复等。

15.2 七种传输层

Claude Code 支持多种 MCP 传输层,覆盖了从本地进程到远程服务的完整场景。

15.2.1 stdio 传输

最基础的传输方式——启动一个子进程,通过标准输入/输出传递 JSON-RPC 消息:

// src/services/mcp/client.ts (connectToServer 函数内)
if (!serverRef.type || serverRef.type === 'stdio') {
  transport = new StdioClientTransport({
    command: serverRef.command,
    args: serverRef.args,
    env: { ...subprocessEnv(), ...expandedEnv },
    cwd: getOriginalCwd(),
  })
}

stdio 是本地 MCP 服务器的首选传输。它的优势是简单、安全(进程隔离)、不需要网络端口。

15.2.2 SSE 传输(Server-Sent Events)

用于远程 HTTP MCP 服务器的旧式传输:

if (serverRef.type === 'sse') {
  const authProvider = new ClaudeAuthProvider(name, serverRef)
  const combinedHeaders = await getMcpServerHeaders(name, serverRef)

  const transportOptions: SSEClientTransportOptions = {
    authProvider,
    fetch: wrapFetchWithTimeout(
      wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider),
    ),
    requestInit: {
      headers: {
        'User-Agent': getMCPUserAgent(),
        ...combinedHeaders,
      },
    },
  }

  // EventSource 连接不能使用超时包装——它是长连接
  transportOptions.eventSourceInit = {
    fetch: async (url, init) => {
      const authHeaders: Record<string, string> = {}
      const tokens = await authProvider.tokens()
      if (tokens) {
        authHeaders.Authorization = `Bearer ${tokens.access_token}`
      }
      return fetch(url, { ...init, ...proxyOptions, headers: {
        'User-Agent': getMCPUserAgent(),
        ...authHeaders, ...init?.headers, ...combinedHeaders,
        Accept: 'text/event-stream',
      }})
    },
  }

  transport = new SSEClientTransport(new URL(serverRef.url), transportOptions)
}

SSE 传输的关键设计:POST 请求使用超时包装(60 秒),但 EventSource 连接(GET)不使用——因为 SSE 流是持久连接,加超时会错误地断开它。

15.2.3 HTTP Streamable 传输

这是 MCP 2025-03-26 规范定义的新一代传输:

// MCP Streamable HTTP 规范要求客户端声明接受 JSON 和 SSE
const MCP_STREAMABLE_HTTP_ACCEPT = 'application/json, text/event-stream'

HTTP Streamable 传输统一了请求-响应和流式通知,是 SSE 传输的演进版本。

15.2.4 WebSocket 传输

用于需要全双工通信的场景,特别是 IDE 集成:

if (serverRef.type === 'ws-ide') {
  const tlsOptions = getWebSocketTLSOptions()
  const wsHeaders = {
    'User-Agent': getMCPUserAgent(),
    ...(serverRef.authToken && {
      'X-Claude-Code-Ide-Authorization': serverRef.authToken,
    }),
  }
  // Bun 和 Node.js ws 模块的差异处理
  let wsClient: WsClientLike
  if (typeof Bun !== 'undefined') {
    // Bun WebSocket
  } else {
    wsClient = await createNodeWsClient(url, { headers: wsHeaders, ...tlsOptions })
  }
  transport = new WebSocketTransport(wsClient)
}

15.2.5 SDK 传输

用于同进程的 MCP 服务器(如 Agent SDK 嵌入场景):

if (serverRef.type === 'sdk') {
  transport = new SdkControlClientTransport(serverRef)
}

15.2.6 IDE 变体(sse-ide, ws-ide)

IDE 传输是 SSE 和 WebSocket 的简化版本,去掉了 OAuth 认证层,因为 IDE 服务器运行在本地,通过锁文件或 token 进行身份验证。

15.2.7 claudeai-proxy 传输

这是 claude.ai 网页版的代理传输,通过 Anthropic 的基础设施中转 MCP 请求:

export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike {
  return async (url, init) => {
    const doRequest = async () => {
      await checkAndRefreshOAuthTokenIfNeeded()
      const currentTokens = getClaudeAIOAuthTokens()
      if (!currentTokens) throw new Error('No claude.ai OAuth token available')
      const headers = new Headers(init?.headers)
      headers.set('Authorization', `Bearer ${currentTokens.accessToken}`)
      const response = await innerFetch(url, { ...init, headers })
      return { response, sentToken: currentTokens.accessToken }
    }

    const { response, sentToken } = await doRequest()
    if (response.status !== 401) return response

    // 401 重试逻辑
    const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false)
    if (!tokenChanged) {
      const now = getClaudeAIOAuthTokens()?.accessToken
      if (!now || now === sentToken) return response
    }
    try {
      return (await doRequest()).response
    } catch {
      return response // 重试失败,返回原始 401
    }
  }
}

401 重试逻辑的设计值得研究:它精确地捕获了发送时使用的 token,避免在并发 401 场景下出现 ABA 问题——另一个连接器可能已经在你检查之前刷新了 token。

15.3 客户端实现

15.3.1 连接管理

连接建立使用了 memoize 模式,确保每个服务器只维护一个连接:

export const connectToServer = memoize(
  async (name: string, serverRef: ScopedMcpServerConfig, serverStats?):
    Promise<MCPServerConnection> => {
    const connectStartTime = Date.now()
    try {
      let transport
      // ... 根据 serverRef.type 创建传输层

      // 创建 MCP SDK Client
      const client = new Client(
        { name: 'claude-code', version: '...' },
        { capabilities: { ... } },
      )

      await client.connect(transport)

      return {
        name, type: 'connected', client,
        capabilities: client.getServerCapabilities(),
        config: serverRef,
      }
    } catch (error) {
      // 认证失败 -> needs-auth
      // 其他失败 -> error
    }
  },
  (name, serverRef) => getServerCacheKey(name, serverRef),
)

memoize 的 key 是服务器名称和完整配置的 JSON 序列化——这确保了同名但不同配置的服务器不会共用连接。

15.3.2 连接批次控制

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)的并发连接限制为 3——因为每个连接都会启动子进程,过多的并发进程会拖慢系统。远程服务器的限制为 20——网络请求的开销更小。

15.3.3 会话过期与重连

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

MCP 规范要求服务器在会话过期时返回 HTTP 404 + JSON-RPC 错误码 -32001。客户端同时检查两个信号来避免误判——普通的 404(错误 URL、服务器宕机)不会触发重连逻辑。

15.4 工具转换

MCP 服务器返回的工具定义需要转换为 Claude Code 内部的 Tool 类型。这个转换过程发生在 fetchToolsForClient 中。

15.4.1 工具名称构建

// 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)}__`
}

MCP 工具名称遵循 mcp__<server>__<tool> 的三段式格式。这个设计确保了:

  1. 不同服务器的同名工具不会冲突
  2. 权限规则可以在服务器级别批量控制
  3. 用户可以通过命名约定理解工具来源

15.4.2 工具属性映射

export const fetchToolsForClient = memoizeWithLRU(
  async (client: MCPServerConnection): Promise<Tool[]> => {
    // ...
    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,

        // MCP annotations → 内部属性
        searchHint: typeof tool._meta?.['anthropic/searchHint'] === 'string'
          ? tool._meta['anthropic/searchHint'].replace(/\s+/g, ' ').trim() || undefined
          : undefined,
        alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true,

        isConcurrencySafe() { return tool.annotations?.readOnlyHint ?? false },
        isReadOnly() { return tool.annotations?.readOnlyHint ?? false },
        isDestructive() { return tool.annotations?.destructiveHint ?? false },
        isOpenWorld() { return tool.annotations?.openWorldHint ?? false },

        inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],

        async checkPermissions() {
          return {
            behavior: 'passthrough' as const,
            message: 'MCPTool requires permission.',
            suggestions: [{
              type: 'addRules' as const,
              rules: [{ toolName: fullyQualifiedName, ruleContent: undefined }],
              behavior: 'allow' as const,
              destination: 'localSettings' as const,
            }],
          }
        },
        // ...
      }
    })
  },
  (client) => client.name,
  MCP_FETCH_CACHE_SIZE,
)

转换的关键映射关系:

flowchart LR
    subgraph "MCP Tool 定义"
        A[tool.name]
        B[tool.description]
        C[tool.inputSchema]
        D[tool.annotations.readOnlyHint]
        E[tool.annotations.destructiveHint]
        F[tool.annotations.openWorldHint]
        G["tool._meta['anthropic/searchHint']"]
        H["tool._meta['anthropic/alwaysLoad']"]
    end
    subgraph "内部 Tool 类型"
        I[name: mcp__server__tool]
        J[description/prompt]
        K[inputJSONSchema]
        L[isReadOnly / isConcurrencySafe]
        M[isDestructive]
        N[isOpenWorld]
        O[searchHint]
        P[alwaysLoad]
    end
    A --> I
    B --> J
    C --> K
    D --> L
    E --> M
    F --> N
    G --> O
    H --> P

MCP 工具的 checkPermissions 默认返回 passthrough——意味着 MCP 工具没有自定义的权限逻辑,完全依赖全局的规则系统。同时它提供了一个 suggestion,建议用户将该工具添加到本地设置的 allow 列表中。

15.4.3 工具描述截断

const MAX_MCP_DESCRIPTION_LENGTH = 2048

async prompt() {
  const desc = tool.description ?? ''
  return desc.length > MAX_MCP_DESCRIPTION_LENGTH
    ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '... [truncated]'
    : desc
}

OpenAPI 生成的 MCP 服务器经常把完整的端点文档(15-60KB)塞进工具描述。2048 字符的截断限制在保留核心意图的同时,防止上下文窗口被淹没。

15.4.4 工具调用与会话恢复

async call(args, context, _canUseTool, parentMessage, onProgress?) {
  const MAX_SESSION_RETRIES = 1
  for (let attempt = 0; ; attempt++) {
    try {
      const connectedClient = await ensureConnectedClient(client)
      const mcpResult = await callMCPToolWithUrlElicitationRetry({
        client: connectedClient, clientConnection: client,
        tool: tool.name, args, meta,
        signal: context.abortController.signal,
        setAppState: context.setAppState,
        onProgress: ...,
        handleElicitation: context.handleElicitation,
      })
      // 处理结果...
    } catch (error) {
      if (attempt < MAX_SESSION_RETRIES && isMcpSessionExpiredError(error)) {
        // 清除缓存,重新连接
        connectToServer.cache.delete(key)
        fetchToolsForClient.cache.delete(name)
        continue
      }
      throw error
    }
  }
}

会话过期时自动重连一次。重连前清除所有相关缓存(连接、工具列表、资源列表),确保下次使用全新的连接状态。

15.5 资源系统

MCP 资源让服务器向 Claude 暴露结构化数据(文件、数据库记录、API 响应等)。

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

    try {
      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,  // 附加服务器名称
      }))
    } catch (error) {
      logMCPError(client.name, `Failed to fetch resources: ${errorMessage(error)}`)
      return []
    }
  },
  (client) => client.name,
  MCP_FETCH_CACHE_SIZE,
)

资源获取使用 LRU 缓存来避免重复请求。每个资源被标注了来源服务器名称,方便后续的权限检查和 UI 展示。

MCP Prompt 同样被转换为 Claude Code 的 Command 系统:

export const fetchCommandsForClient = memoizeWithLRU(
  async (client: MCPServerConnection): Promise<Command[]> => {
    // ...
    return promptsToProcess.map(prompt => ({
      type: 'prompt' as const,
      name: 'mcp__' + normalizeNameForMCP(client.name) + '__' + prompt.name,
      description: prompt.description ?? '',
      isMcp: true,
      source: 'mcp',
      async getPromptForCommand(args) {
        const connectedClient = await ensureConnectedClient(client)
        const result = await connectedClient.client.getPrompt({
          name: prompt.name,
          arguments: zipObject(argNames, argsArray),
        })
        return result.messages.map(message =>
          transformResultContent(message.content, connectedClient.name),
        ).flat()
      },
    }))
  },
)

15.6 进度流与 Elicitation

15.6.1 进度报告

MCP 工具在执行过程中可以报告进度:

if (onProgress && toolUseId) {
  onProgress({
    toolUseID: toolUseId,
    data: {
      type: 'mcp_progress',
      status: 'started',
      serverName: client.name,
      toolName: tool.name,
    },
  })
}

// ... 执行完成后
onProgress({
  toolUseID: toolUseId,
  data: {
    type: 'mcp_progress',
    status: 'completed',
    serverName: client.name,
    toolName: tool.name,
    elapsedTimeMs: Date.now() - startTime,
  },
})

15.6.2 Elicitation 机制

Elicitation 是 MCP 的一种交互模式,允许服务器在工具执行过程中请求用户输入。有两种模式:

// src/services/mcp/elicitationHandler.ts
function getElicitationMode(params: ElicitRequestParams): 'form' | 'url' {
  return params.mode === 'url' ? 'url' : 'form'
}

URL 模式:服务器提供一个 URL(通常是 OAuth 回调页面),Claude Code 打开浏览器让用户完成交互,然后等待完成通知:

export type ElicitationRequestEvent = {
  serverName: string
  requestId: string | number
  params: ElicitRequestParams
  signal: AbortSignal
  respond: (response: ElicitResult) => void
  waitingState?: ElicitationWaitingState
  onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void
  completed?: boolean
}

Form 模式:服务器定义一个表单 Schema,Claude Code 在终端中渲染交互式表单,收集用户输入后返回。

Elicitation 还支持 Hook 拦截,允许自动化环境在不需要人工交互的情况下处理 elicitation 请求:

export function registerElicitationHandler(
  client: Client,
  serverName: string,
  setAppState: (f: (prevState: AppState) => AppState) => void,
): void {
  try {
    client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
      // 首先运行 Hook
      // 如果 Hook 提供了响应,直接返回
      // 否则显示 UI 让用户交互
    })
  } catch { /* 服务器未声明 elicitation 能力 */ }
}

完成通知使用专门的 ElicitationCompleteNotificationSchema 处理,通过 elicitationId 将完成事件关联到等待中的 elicitation 请求。

sequenceDiagram
    participant Claude as Claude Code
    participant Server as MCP Server
    participant Browser as 浏览器
    participant Hook as Elicitation Hook

    Claude->>Server: tools/call
    Server->>Claude: elicitation/request (URL mode)

    alt Hook 处理
        Claude->>Hook: 执行 Hook
        Hook-->>Claude: 返回响应
    else 用户交互
        Claude->>Browser: 打开 URL
        Browser->>Server: 用户完成交互
        Server->>Claude: elicitation/complete 通知
    end

    Claude-->>Server: elicitation/response
    Server-->>Claude: tools/call result

这种设计实现了 MCP 协议的完整交互能力——工具不仅可以被动地接收输入、返回结果,还可以在执行过程中主动请求额外信息,打开浏览器进行身份验证,或展示表单收集配置参数。