从Claude Code泄露源码看工程架构:第八章 —— MCP 接入层设计

0 阅读11分钟

本文深入剖析 Claude Code 的 MCP接入层架构。通过分析统一入口设计、六种传输协议的差异化处理、认证缓存机制以及资源发现流程,揭示其"统一入口,不抹掉差异"的设计哲学。该设计在支持多协议的同时,保留了每种传输方式的关键特性,为长期维护提供了坚实基础。

1. 问题定义

在 AI 辅助编程系统中,MCP(Model Context Protocol) 允许模型访问外部工具和服务。然而,MCP 接入面临三个核心挑战:

  1. 协议多样性:如何支持 SSE、WebSocket、HTTP、stdio 等多种传输方式?
  2. 认证复杂性:如何处理 OAuth、session ingress token、代理认证等多套认证机制?
  3. 长期维护性:如何避免每种协议各写一套独立接入流程导致维护困难?

Claude Code 通过统一入口 + 差异化分支的架构系统性解决了这些问题。它摒弃了传统插件系统中的常见的做法:

  • 每种协议各写一套独立接入流程,最后维护成一团
  • 强行抽象得太早,把不同传输的关键差异也抹平

2. 架构概览:MCP 接入层全景

graph TD
    A[connectToServer] --> B{serverRef.type 判断}
    
    B -->|sse| C[SSE 传输]
    C --> C1[认证提供者]
    C --> C2[长连接特殊处理]
    C --> C3[超时包装区分]
    
    B -->|ws| D[WebSocket 传输]
    D --> D1[Header 脱敏日志]
    D --> D2[Proxy/TLS 配置]
    D --> D3[Bun vs Node 适配]
    
    B -->|http| E[HTTP 传输]
    E --> E1[OAuth Token 检测]
    E --> E2[认证优先级协调]
    E --> E3[Session Ingress 备选]
    
    B -->|claudeai-proxy| F[代理传输]
    F --> F1[复用 HTTP Transport]
    F --> F2[Claude.ai OAuth]
    F --> F3[URL 重写]
    
    B -->|stdio| G[Stdio 传输]
    G --> G1[默认子进程]
    G --> G2[Chrome MCP in-process]
    G --> G3[Computer Use in-process]
    G --> G4[Stderr 监控]
    
    B -->|sdk| H[SDK 外派]
    H --> H1[明确不归这层处理]
    
    I[认证缓存] --> A
    J[资源发现] --> K[ListMcpResourcesTool]
    J --> L[ReadMcpResourceTool]
    
    style A fill:#e1f5ff
    style C fill:#fff4e1
    style D fill:#fff4e1
    style E fill:#fff4e1
    style F fill:#fff4e1
    style G fill:#fff4e1
    style H fill:#ffe1e1
组件文件位置职责
统一入口client.ts:595-607所有协议连接的总闸口
认证缓存client.ts:257-28715分钟 TTL 的认证结果缓存
SSE 传输client.ts:619-677长连接特殊处理
WebSocketclient.ts:735-783Header 脱敏与 Proxy 配置
HTTP 传输client.ts:784-865OAuth 与 session ingress 协调
Claude.ai Proxyclient.ts:868-904身份代理与 URL 重写
Stdio 传输client.ts:905-959子进程管理与 in-process 优化
SDK 外派client.ts:866-867明确不归这层处理

3. 关键常量:认证缓存 TTL

文件位置services/mcp/client.ts:257-287

257:const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 // 15 min
...
282:  const entry = cache[serverId]
283:  if (!entry) {
284:    return false
285:  }
286:  return Date.now() - entry.timestamp < MCP_AUTH_CACHE_TTL_MS
287:}

认证缓存 TTL 被定成 15 分钟,不算长,也绝不算短。这在以下三个维度可以取得平衡点:

考量维度过短 TTL(如 1 分钟)过长 TTL(如 1 小时)15 分钟 TTL
重新认证频率频繁,用户体验差稀少适中
陈旧风险可控
401 处理成本每次都要完整流程可能错过 token 刷新合理折中

这类参数看着不起眼,实际很能说明接入层的思路:MCP 连接不是"连上就完了",而是一个会持续经历鉴权与重连摩擦的系统。


4. 统一入口:connectToServer() 总闸口

文件位置services/mcp/client.ts:595-607

595:export const connectToServer = memoize(
596:  async (
597:    name: string,
598:    serverRef: ScopedMcpServerConfig,
599:    serverStats?: {
600:      totalServers: number
601:      stdioCount: number
602:      sseCount: number
603:      httpCount: number
604:      sseIdeCount: number
605:      wsIdeCount: number
606:    },
607:  ): Promise<MCPServerConnection> => {

整个入口使用了 memoize 装饰器,这里已经告诉我们两件事:

  1. 所有协议连接都要从这一个入口进
  2. 同一个 server 配置不会随便重复建连接

这就让 connectToServer() 变成了 MCP 世界的总闸口。你要支持再多协议,最终都得在这里过一遍。

Memoize 的工程价值主要为:

优势说明
避免重复连接相同配置的 server 只建立一次连接
资源节约减少不必要的网络握手和认证流程
状态一致性确保同一 server 在系统中只有一个活跃连接
性能优化后续调用直接返回缓存的连接对象

5. 第一类传输:SSE —— 长连接的特殊处理

文件位置services/mcp/client.ts:619-677

619:      if (serverRef.type === 'sse') {
620:        const authProvider = new ClaudeAuthProvider(name, serverRef)
624:        const combinedHeaders = await getMcpServerHeaders(name, serverRef)
627:        const transportOptions: SSEClientTransportOptions = {
628:          authProvider,
632:          fetch: wrapFetchWithTimeout(
633:            wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider),
634:          ),
635:          requestInit: {
636:            headers: {
637:              'User-Agent': getMCPUserAgent(),
638:              ...combinedHeaders,
639:            },
640:          },
641:        }
643:        // IMPORTANT: Always set eventSourceInit with a fetch that does NOT use the timeout wrapper.
648:        transportOptions.eventSourceInit = {
649:          fetch: async (url: string | URL, init?: RequestInit) => {
667:                Accept: 'text/event-stream',
668:              },
669:            })
670:          },
671:        }
673:        transport = new SSEClientTransport(
674:          new URL(serverRef.url),
675:          transportOptions,
676:        )

主要针对SSE主要做了长连接与普通请求的超时(timeout选项)区分。


6. 第二类传输:WebSocket —— 脱敏日志与运行时适配

6.1 Authorization Header 脱敏

文件位置services/mcp/client.ts:735-783

735:      } else if (serverRef.type === 'ws') {
741:        const combinedHeaders = await getMcpServerHeaders(name, serverRef)
743:        const tlsOptions = getWebSocketTLSOptions()
744:        const wsHeaders = {
745:          'User-Agent': getMCPUserAgent(),
746:          ...(sessionIngressToken && {
747:            Authorization: `Bearer ${sessionIngressToken}`,
748:          }),
749:          ...combinedHeaders,
750:        }
752:        const wsHeadersForLogging = mapValues(wsHeaders, (value, key) =>
753:          key.toLowerCase() === 'authorization' ? '[REDACTED]' : value,
754:        )
766:        let wsClient: WsClientLike
767:        if (typeof Bun !== 'undefined') {
770:          wsClient = new globalThis.WebSocket(serverRef.url, {
773:            proxy: getWebSocketProxyUrl(serverRef.url),
774:            tls: tlsOptions || undefined,
775:          } as unknown as string[])
776:        } else {
777:          wsClient = await createNodeWsClient(serverRef.url, {
778:            headers: wsHeaders,
779:            agent: getWebSocketProxyAgent(serverRef.url),
780:            ...(tlsOptions || {}),
781:          })
782:        }
783:        transport = new WebSocketTransport(wsClient)

注意 wsHeadersForLogging 这一步。作者不是简单打印连接参数,而是先把 authorization 脱敏再写日志。

脱敏逻辑

key.toLowerCase() === 'authorization' ? '[REDACTED]' : value

6.2 Bun vs Node.js 运行时适配

代码位置client.ts:767-781

767:        if (typeof Bun !== 'undefined') {
770:          wsClient = new globalThis.WebSocket(serverRef.url, {...})
776:        } else {
777:          wsClient = await createNodeWsClient(serverRef.url, {...})
782:        }

设计意图:根据运行时环境选择不同的 WebSocket 客户端实现。

运行时客户端特点
BunglobalThis.WebSocket内置支持,无需额外依赖
Node.jscreateNodeWsClient需要 ws 库,支持更多配置项

这种适配确保了 Claude Code 可以在不同 JavaScript 运行时中运行。


7. 第三类传输:HTTP —— OAuth 与 Session Ingress 的协调

文件位置services/mcp/client.ts:784-865

784:      } else if (serverRef.type === 'http') {
801:        const authProvider = new ClaudeAuthProvider(name, serverRef)
805:        const combinedHeaders = await getMcpServerHeaders(name, serverRef)
812:        const hasOAuthTokens = !!(await authProvider.tokens())
821:        const transportOptions: StreamableHTTPClientTransportOptions = {
822:          authProvider,
826:          fetch: wrapFetchWithTimeout(
827:            wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider),
828:          ),
829:          requestInit: {
830:            ...proxyOptions,
831:            headers: {
832:              'User-Agent': getMCPUserAgent(),
833:              ...(sessionIngressToken &&
834:                !hasOAuthTokens && {
835:                  Authorization: `Bearer ${sessionIngressToken}`,
836:                }),
837:              ...combinedHeaders,
838:            },
839:          },
840:        }
861:        transport = new StreamableHTTPClientTransport(
862:          new URL(serverRef.url),
863:          transportOptions,
864:        )

HTTP请求使用了StreamableHTTP。另外还处理了认证冲突的问题,保证HTTP请求时认证来源优先级。如果这个 server 自己有 OAuth token,那就别再用 session ingress token 抢着塞 Authorization。只有在没有 OAuth tokens 的情况下,才使用 session ingress token。


8. 第四类传输:Claude.ai Proxy —— 身份代理

文件位置services/mcp/client.ts:868-904

868:      } else if (serverRef.type === 'claudeai-proxy') {
874:        const tokens = getClaudeAIOAuthTokens()
875:        if (!tokens) {
876:          throw new Error('No claude.ai OAuth token found')
877:        }
879:        const oauthConfig = getOauthConfig()
880:        const proxyUrl = `${oauthConfig.MCP_PROXY_URL}${oauthConfig.MCP_PROXY_PATH.replace('{server_id}', serverRef.id)}`
885:        const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch)
888:        const transportOptions: StreamableHTTPClientTransportOptions = {
890:          fetch: wrapFetchWithTimeout(fetchWithAuth),
891:          requestInit: {
892:            ...proxyOptions,
893:            headers: {
894:              'User-Agent': getMCPUserAgent(),
895:              'X-Mcp-Client-Session-Id': getSessionId(),
896:            },
897:          },
898:        }
900:        transport = new StreamableHTTPClientTransport(
901:          new URL(proxyUrl),
902:          transportOptions,
903:        )

针对Claude.ai Proxy并没有重新造一个"代理专用 transport",而是继续复用 StreamableHTTPClientTransport,只是在认证和 URL 生成上换了入口。

差异化处理

维度普通 HTTPClaude.ai Proxy
TransportStreamableHTTPClientTransportStreamableHTTPClientTransport(复用)
认证方式OAuth / Session IngressClaude.ai OAuth Tokens
URL 生成直接使用 serverRef.url拼接 proxy URL + server_id
特殊 Header-X-Mcp-Client-Session-Id

9. 第五类传输:Stdio —— 子进程管理与 In-Process 优化

9.1 重型 Server 的 In-Process 特判

文件位置services/mcp/client.ts:905-959

905:      } else if (
906:        (serverRef.type === 'stdio' || !serverRef.type) &&
907:        isClaudeInChromeMCPServer(name)
908:      ) {
909:        // Run the Chrome MCP server in-process to avoid spawning a ~325 MB subprocess
...
923:        transport = clientTransport
924:        logMCPDebug(name, `In-process Chrome MCP server started`)
925:      } else if (
926:        feature('CHICAGO_MCP') &&
927:        (serverRef.type === 'stdio' || !serverRef.type) &&
928:        isComputerUseMCPServer!(name)
929:      ) {
930:        // Run the Computer Use MCP server in-process
...
942:        transport = clientTransport
943:        logMCPDebug(name, `In-process Computer Use MCP server started`)
944:      } else if (serverRef.type === 'stdio' || !serverRef.type) {
945:        const finalCommand =
946:          process.env.CLAUDE_CODE_SHELL_PREFIX || serverRef.command
947:        const finalArgs = process.env.CLAUDE_CODE_SHELL_PREFIX
948:          ? [[serverRef.command, ...serverRef.args].join(' ')]
949:          : serverRef.args
950:        transport = new StdioClientTransport({
951:          command: finalCommand,
952:          args: finalArgs,
953:          env: {
954:            ...subprocessEnv(),
955:            ...serverRef.env,
956:          },
957:          stderr: 'pipe',
958:        })

性能分流点

stdio 分支理论上最朴素,就是起一个子进程、接标准输入输出。但源码在这里又塞了两个特判:

Server 类型优化策略原因
Chrome MCP ServerIn-process 运行避免启动 ~325 MB 的重型子进程
Computer Use MCP ServerIn-process 运行降低资源开销
普通 Stdio Server子进程运行标准处理方式

为什么?注释写得很直白:为了避免起一个很重的子进程。

这说明 Claude Code 接 MCP 时不只是"功能能连上就行",它已经开始按成本给不同 server 做优化分流。换句话说,stdio 不是一个简单协议分支,它还是一个性能分流点。

9.2 Stderr 监控与内存保护

文件位置client.ts:963-974

963:      // Set up stderr logging for stdio transport before connecting
966:      let stderrHandler: ((data: Buffer) => void) | undefined
967:      let stderrOutput = ''
968:      if (serverRef.type === 'stdio' || !serverRef.type) {
969:        const stdioTransport = transport as StdioClientTransport
970:        if (stdioTransport.stderr) {
971:          stderrHandler = (data: Buffer) => {
972:            // Cap stderr accumulation to prevent unbounded memory growth
973:            if (stderrOutput.length < 64 * 1024 * 1024) {
974:              try {

Claude Code在 stderr 日志累积都加了上限,避免 debug 日志自己把内存吃炸。这种内存保护机制也是一个架构成熟的体现,在实现时既要保证正常路径要通,同时也要保证故障路径也得可控。


10. 第六类传输:SDK —— 明确外派

文件位置services/mcp/client.ts:866-867

866:      } else if (serverRef.type === 'sdk') {
867:        throw new Error('SDK servers should be handled in print.ts')

关键观察点:这个分支很短,sdk 类型不归这层处理。该在哪处理,就去哪里处理。这也体现了统一是为了收口,不是为了把所有东西硬塞进一层。


11 完整连接流程总结

如果只保留骨架,它其实像这样:

connectToServer(name, serverRef)
  → 看 serverRef.type
    → sse:认证 + 长连接专门处理(client.ts:619-677)
      → EventSource 不使用超时包装
    → ws:headers / proxy / tls / 脱敏日志(client.ts:735-783)
      → Authorization header 脱敏
      → Bun vs Node 运行时适配
    → http:OAuth 与 ingress token 协调(client.ts:784-865)
      → hasOAuthTokens 检查决定认证优先级
    → claudeai-proxy:复用 HTTP transport,改 URL 和身份(client.ts:868-904)
      → Claude.ai OAuth tokens
      → X-Mcp-Client-Session-Id header
    → stdio:默认子进程传输(client.ts:905-959)
      → 特判:Chrome MCP in-process
      → 特判:Computer Use MCP in-process
      → Stderr 监控与 64MB 上限
    → sdk:明确外派到别处处理(client.ts:866-867)
  → 建立 transport
  → 继续连接、记录日志、处理失败与清理

你看,统一入口的价值这时候就很明显了:

  • 所有连接语义都从这里收口
  • 每种协议的特殊处理也都写在自己那一枝上
  • 上层不用知道每个 server 该怎么握手
  • 下层也没有被强迫抽象成失真的同构代码

12. 假设实验:修改影响评估

实验一:把 SSE 的 eventSourceInit.fetch 也套上普通 timeout

修改位置client.ts:648-670,移除特殊的 fetch 配置

影响分析

维度影响
长连接稳定性会被周期性误杀
错误表象"网络不稳定"
根本原因接入层自己在定时切断流
排查难度非常折磨人,难以定位

结论:长连接会被周期性误杀。最恶心的是,这类 bug 表面像"网络不稳定",实际上是接入层自己在定时切断流,排查起来非常折磨人。


实验二:HTTP 分支不区分 hasOAuthTokens

修改位置client.ts:833-836,移除 !hasOAuthTokens 条件

影响分析

场景后果
OAuth + Session Ingress 共存Session ingress token 可能覆盖 OAuth token
鉴权语义变得不稳定:有时走对,有时走错
UI 表现很难从 UI 直接看出来
调试难度间歇性认证失败,难以复现

结论:那 session ingress token 和 OAuth token 就可能互相覆盖。连是能发起,但鉴权语义会变得不稳定:有时走对,有时走错,还很难从 UI 直接看出来。


实验三:一律用 stdio 起重型 MCP server,不做 in-process 特判

修改方案:移除 client.ts:907-943 的两个特判分支

影响分析

维度影响
功能正确性未必坏
资源成本肉眼上涨
Chrome MCP Server每次连接启动 ~325 MB 子进程
系统负载多个重型 server 同时运行时内存压力巨大
启动速度变慢

结论:功能未必坏,但资源成本会肉眼上涨。尤其一些体积大的 server,会把"接一个工具"变成"顺手拉起一个很重的子进程"。


13. 设计原则总结

基于以上分析,提炼出以下可复用的设计原则:

原则一:统一入口,保留差异

  • 所有协议从 connectToServer() 进入
  • 每种协议的特殊需求在各自分支处理
  • 不因统一而抹杀关键差异

原则二:认证优先级明确

  • OAuth tokens 优先于 session ingress
  • 认证缓存设置合理 TTL(15分钟)
  • 避免认证头相互覆盖

原则三:长连接特殊对待

  • EventSource 不使用普通超时包装
  • WebSocket 区分 Bun 和 Node 运行时
  • 承认不同传输的本质差异

原则四:性能意识内建

  • 重型 server in-process 优化
  • Stderr 日志 64MB 上限保护
  • Memoize 避免重复连接

原则五:边界清晰

  • SDK 类型明确外派
  • 每层只处理自己的职责
  • 不强行抽象失真

14. 结论

Claude Code 的 MCP 接入层通过统一入口 + 差异化分支的架构,成功解决了协议多样性、认证复杂性和长期维护性三大挑战。其核心设计哲学是:

  1. 统一入口:所有协议从 connectToServer() 进入
  2. 保留差异:每种传输的关键特性不被抹平
  3. 认证协调:OAuth 与 session ingress 优先级明确
  4. 性能优化:重型 server in-process 运行
  5. 边界清晰:不属于本层的明确外派

这套设计不仅适用于 MCP 协议接入,也为其他多协议系统集成提供了参考范式。