本文深入剖析 Claude Code 的 MCP接入层架构。通过分析统一入口设计、六种传输协议的差异化处理、认证缓存机制以及资源发现流程,揭示其"统一入口,不抹掉差异"的设计哲学。该设计在支持多协议的同时,保留了每种传输方式的关键特性,为长期维护提供了坚实基础。
1. 问题定义
在 AI 辅助编程系统中,MCP(Model Context Protocol) 允许模型访问外部工具和服务。然而,MCP 接入面临三个核心挑战:
- 协议多样性:如何支持 SSE、WebSocket、HTTP、stdio 等多种传输方式?
- 认证复杂性:如何处理 OAuth、session ingress token、代理认证等多套认证机制?
- 长期维护性:如何避免每种协议各写一套独立接入流程导致维护困难?
Claude Code 通过统一入口 + 差异化分支的架构系统性解决了这些问题。它摒弃了传统插件系统中的常见的做法:
- 每种协议各写一套独立接入流程,最后维护成一团
- 强行抽象得太早,把不同传输的关键差异也抹平
2. 架构概览:MCP 接入层全景
| 组件 | 文件位置 | 职责 |
|---|---|---|
| 统一入口 | client.ts:595-607 |
所有协议连接的总闸口 |
| 认证缓存 | client.ts:257-287 |
15分钟 TTL 的认证结果缓存 |
| SSE 传输 | client.ts:619-677 |
长连接特殊处理 |
| WebSocket | client.ts:735-783 |
Header 脱敏与 Proxy 配置 |
| HTTP 传输 | client.ts:784-865 |
OAuth 与 session ingress 协调 |
| Claude.ai Proxy | client.ts:868-904 |
身份代理与 URL 重写 |
| Stdio 传输 | client.ts:905-959 |
子进程管理与 in-process 优化 |
| SDK 外派 | client.ts:866-867 |
明确不归这层处理 |
3. 关键常量:认证缓存 TTL
文件位置 :services/mcp/client.ts:257-287
typescript
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
typescript
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 装饰器,这里已经告诉我们两件事:
- 所有协议连接都要从这一个入口进
- 同一个 server 配置不会随便重复建连接
这就让 connectToServer() 变成了 MCP 世界的总闸口。你要支持再多协议,最终都得在这里过一遍。
Memoize 的工程价值主要为:
| 优势 | 说明 |
|---|---|
| 避免重复连接 | 相同配置的 server 只建立一次连接 |
| 资源节约 | 减少不必要的网络握手和认证流程 |
| 状态一致性 | 确保同一 server 在系统中只有一个活跃连接 |
| 性能优化 | 后续调用直接返回缓存的连接对象 |
5. 第一类传输:SSE ------ 长连接的特殊处理
文件位置 :services/mcp/client.ts:619-677
typescript
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
typescript
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 脱敏再写日志。
脱敏逻辑:
typescript
key.toLowerCase() === 'authorization' ? '[REDACTED]' : value
6.2 Bun vs Node.js 运行时适配
代码位置 :client.ts:767-781
typescript
767: if (typeof Bun !== 'undefined') {
770: wsClient = new globalThis.WebSocket(serverRef.url, {...})
776: } else {
777: wsClient = await createNodeWsClient(serverRef.url, {...})
782: }
设计意图:根据运行时环境选择不同的 WebSocket 客户端实现。
| 运行时 | 客户端 | 特点 |
|---|---|---|
| Bun | globalThis.WebSocket |
内置支持,无需额外依赖 |
| Node.js | createNodeWsClient |
需要 ws 库,支持更多配置项 |
这种适配确保了 Claude Code 可以在不同 JavaScript 运行时中运行。
7. 第三类传输:HTTP ------ OAuth 与 Session Ingress 的协调
文件位置 :services/mcp/client.ts:784-865
typescript
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
typescript
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 生成上换了入口。
差异化处理:
| 维度 | 普通 HTTP | Claude.ai Proxy |
|---|---|---|
| Transport | StreamableHTTPClientTransport |
StreamableHTTPClientTransport(复用) |
| 认证方式 | OAuth / Session Ingress | Claude.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
typescript
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 Server | In-process 运行 | 避免启动 ~325 MB 的重型子进程 |
| Computer Use MCP Server | In-process 运行 | 降低资源开销 |
| 普通 Stdio Server | 子进程运行 | 标准处理方式 |
为什么?注释写得很直白:为了避免起一个很重的子进程。
这说明 Claude Code 接 MCP 时不只是"功能能连上就行",它已经开始按成本给不同 server 做优化分流。换句话说,stdio 不是一个简单协议分支,它还是一个性能分流点。
9.2 Stderr 监控与内存保护
文件位置 :client.ts:963-974
typescript
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
typescript
866: } else if (serverRef.type === 'sdk') {
867: throw new Error('SDK servers should be handled in print.ts')
关键观察点 :这个分支很短,sdk 类型不归这层处理。该在哪处理,就去哪里处理。这也体现了统一是为了收口,不是为了把所有东西硬塞进一层。
11 完整连接流程总结
如果只保留骨架,它其实像这样:
text
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 接入层通过统一入口 + 差异化分支的架构,成功解决了协议多样性、认证复杂性和长期维护性三大挑战。其核心设计哲学是:
- 统一入口 :所有协议从
connectToServer()进入 - 保留差异:每种传输的关键特性不被抹平
- 认证协调:OAuth 与 session ingress 优先级明确
- 性能优化:重型 server in-process 运行
- 边界清晰:不属于本层的明确外派
这套设计不仅适用于 MCP 协议接入,也为其他多协议系统集成提供了参考范式。