Part 6

连接性

agent 的触达范围已超越 localhost。

第15章:MCP——通用工具协议

为什么 MCP 的重要性超越了 Claude Code

本书的其他章节都在探讨 Claude Code 的内部机制,而本章有所不同。Model Context Protocol(模型上下文协议)是一项开放规范,任何 Agent 均可实现,而 Claude Code 的 MCP 子系统是目前最完善的生产级客户端之一。如果你正在构建一个需要调用外部工具的 Agent——无论是何种 Agent、使用何种语言或基于何种模型——本章中的模式都可以直接迁移应用。

其核心主张非常直观:MCP 定义了一套基于 JSON-RPC 2.0 的协议,用于客户端(Agent)与服务端(工具提供方)之间的工具发现和调用。客户端发送 tools/list 来发现服务端提供的工具,然后发送 tools/call 来执行。服务端通过名称、描述以及输入参数的 JSON Schema 来描述每个工具。这就是全部的契约。其余所有工作——传输方式选择、身份认证、配置加载、工具名称规范化——都是将一份简洁的规范转化为能够经受住现实世界考验的工程实现。

Claude Code 的 MCP 实现涵盖四个核心文件:types.tsclient.tsauth.tsInProcessTransport.ts。它们共同支持八种传输类型、七种配置作用域、基于两项 RFC 的 OAuth 发现机制,以及一个工具包装层,该层使得 MCP 工具与内置工具无法区分——即第6章中介绍的同一个 Tool 接口。本章将逐层解析这些内容。


八种传输类型

在任何 MCP 集成中,首要的设计决策是客户端如何与服务端通信。Claude Code 支持八种传输配置:

有三个设计选择值得注意。首先,stdio 是默认方式——当省略 type 时,系统会假定使用本地子进程。这与最早的 MCP 配置向后兼容。其次,fetch 包装器是分层堆叠的:超时包装在最外层,其次是升级检测(step-up detection),最内层是基础 fetch。每个包装器只处理单一关注点。第三,ws-ide 分支存在 Bun/Node 运行时差异——Bun 的 WebSocket 原生支持代理和 TLS 选项,而 Node 则需要 ws 包。

如何选择。 对于本地工具(文件系统、数据库、自定义脚本),使用 stdio——无需网络,无需认证,仅通过管道通信。对于远程服务,http(Streamable HTTP)是当前规范的推荐方式。sse 虽为旧版但部署广泛。sdk、IDE 和 claudeai-proxy 类型则专用于各自的生态系统内部。


配置加载与作用域

MCP 服务器配置从七个作用域加载,并进行合并与去重:

作用域来源信任级别
local工作目录中的 .mcp.json需用户批准
user~/.claude.json 的 mcpServers 字段用户管理
project项目级配置项目共享设置
enterprise企业托管配置组织预批准
managed插件提供的服务器自动发现
claudeaiClaude.ai Web 界面通过 Web 预授权
dynamic运行时注入 (SDK)编程方式添加

去重基于内容而非名称。 两个名称不同但命令或 URL 相同的服务器会被识别为同一服务器。getMcpServerSignature() 函数计算一个规范键:本地服务器为 stdio:["command","arg1"],远程服务器为 url:https://example.com/mcp。如果插件提供的服务器签名与手动配置匹配,则会被抑制。


工具包装:从 MCP 到 Claude Code

当连接成功后,客户端会调用 tools/list。每个工具定义都会被转换为 Claude Code 内部的 Tool 接口——与内置工具使用的接口相同。经过包装后,模型无法区分内置工具和 MCP 工具。

包装过程分为四个阶段:

1. 名称规范化。 normalizeNameForMCP() 将无效字符替换为下划线。全限定名遵循 mcp__{serverName}__{toolName} 格式。

2. 描述截断。 上限为 2,048 个字符。据观察,OpenAPI 生成的服务器会将 15-60KB 的内容塞入 tool.description——对于单个工具而言,这大约相当于每轮对话消耗 15,000 个 token。

3. Schema 透传。 工具的 inputSchema 直接传递给 API。在包装时不进行任何转换或验证。Schema 错误会在调用时暴露,而非注册时。

4. 注解映射。 MCP 注解映射为行为标志:readOnlyHint 标记工具可安全并发执行(如第7章流式执行器所述),destructiveHint 触发额外的权限审查。这些注解来自 MCP 服务器——恶意服务器可能会将破坏性工具标记为只读。这是一个既定的信任边界,但值得理解:用户主动选择了该服务器,而恶意服务器将破坏性工具标记为只读是一种真实的攻击向量。系统接受这种权衡,因为替代方案——完全忽略注解——将阻碍合法服务器改善用户体验。


MCP 服务器的 OAuth

远程 MCP 服务器通常需要身份认证。Claude Code 实现了完整的 OAuth 2.0 + PKCE 流程,包括基于 RFC 的发现机制、跨应用访问(Cross-App Access)以及错误响应体规范化。

发现链

authServerMetadataUrl 这一逃生舱口的存在是因为某些 OAuth 服务器对这两项 RFC 均未实现。

跨应用访问 (XAA)

当 MCP 服务器配置包含 oauth.xaa: true 时,系统会通过身份提供商(Identity Provider)执行联合令牌交换——一次 IdP 登录即可解锁多个 MCP 服务器。

错误响应体规范化

normalizeOAuthErrorBody() 函数用于处理违反规范的 OAuth 服务器。Slack 对错误响应返回 HTTP 200,并将错误信息隐藏在 JSON 响应体中。该函数会检查 2xx POST 响应体,当响应体匹配 OAuthErrorResponseSchema 但不匹配 OAuthTokensSchema 时,将响应重写为 HTTP 400。它还会将 Slack 特有的错误码(invalid_refresh_tokenexpired_refresh_tokentoken_expired)规范化为标准的 invalid_grant


进程内传输

并非每个 MCP 服务器都需要作为独立进程运行。InProcessTransport 类允许在同一进程中运行 MCP 服务器和客户端:

class InProcessTransport implements Transport {
  async send(message: JSONRPCMessage): Promise<void> {
    if (this.closed) throw new Error('Transport is closed')
    queueMicrotask(() => { this.peer?.onmessage?.(message) })
  }
  async close(): Promise<void> {
    if (this.closed) return
    this.closed = true
    this.onclose?.()
    if (this.peer && !this.peer.closed) {
      this.peer.closed = true
      this.peer.onclose?.()
    }
  }
}

整个文件仅有 63 行。有两个设计决策值得关注。首先,send() 通过 queueMicrotask() 传递消息,以防止同步请求/响应循环中出现栈深度问题。其次,close() 会级联关闭对端(peer),防止出现半开状态。Chrome MCP 服务器和 Computer Use MCP 服务器均采用了此模式。


连接管理

连接状态

每个 MCP 服务器连接处于五种状态之一:connected(已连接)、failed(失败)、needs-auth(需要认证,带有 15 分钟 TTL 缓存,防止 30 个服务器各自独立地发现同一个过期令牌)、pending(等待中)或 disabled(已禁用)。

会话过期检测

MCP 的 Streamable HTTP 传输使用会话 ID。当服务器重启时,请求会返回 HTTP 404 及 JSON-RPC 错误码 -32001。isMcpSessionExpiredError() 函数会同时检查这两个信号——注意它使用字符串包含来检测错误消息中的错误码,这种做法务实但较为脆弱:

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')
}

一旦检测到过期,连接缓存将被清除,并重试一次调用。

批量连接

本地服务器以每批 3 个的方式连接(生成过多进程可能会耗尽文件描述符),远程服务器以每批 20 个的方式连接。React Context Provider MCPConnectionManager.tsx 负责管理生命周期,对比当前连接与新配置的差异。


Claude.ai 代理传输

claudeai-proxy 传输展示了一种常见的 Agent 集成模式:通过中介进行连接。Claude.ai 订阅用户通过 Web 界面配置 MCP “连接器(connectors)“,CLI 则通过 Claude.ai 的基础设施进行路由,由后者处理供应商侧的 OAuth。

createClaudeAiProxyFetch() 函数在请求时捕获 sentToken,而不是在收到 401 后重新读取。在多个连接器并发收到 401 的情况下,另一个连接器的重试可能已经刷新了令牌。即使刷新处理器返回 false,该函数也会检查是否存在并发刷新——即另一个连接器赢得了锁文件竞争的 “ELOCKED 争用” 情况。


超时架构

MCP 的超时是分层的,每一层都针对不同的故障模式提供保护:

层级时长防护对象
连接30秒不可达或启动缓慢的服务器
单请求60秒 (每次请求重新创建)过期的超时信号 bug
工具调用~27.8小时合理的长时间操作
认证每个 OAuth 请求 30秒不可达的 OAuth 服务器

单请求超时值得强调。早期实现在连接时创建一个单一的 AbortSignal.timeout(60000)。在空闲 60 秒后,下一个请求会立即中止——因为信号已经过期。修复方案是:wrapFetchWithTimeout() 为每个请求创建一个全新的超时信号。它还规范化了 Accept 头,作为最后一道防线,以应对那些会丢弃该头的运行时和代理。


实践应用:将 MCP 集成到你自己的 Agent 中

从 stdio 开始,后续再增加复杂性。 StdioClientTransport 处理一切事务:启动、管道、终止。一行配置,一个传输类,你就能拥有 MCP 工具。

规范化名称并截断描述。 名称必须匹配 ^[a-zA-Z0-9_-]{1,64}$。添加 mcp__{serverName}__ 前缀以避免冲突。描述上限为 2,048 个字符——否则 OpenAPI 生成的服务器会浪费上下文 token。

延迟处理认证。 在服务器返回 401 之前不要尝试 OAuth。大多数 stdio 服务器不需要认证。

对内置服务器使用进程内传输。 createLinkedTransportPair() 消除了你自己控制的服务器的子进程开销。

尊重工具注解并对输出进行清理。 readOnlyHint 启用并发执行。对响应进行清理,防范可能误导模型的恶意 Unicode(双向覆盖字符、零宽连接符等)。

MCP 协议刻意保持极简——仅两个 JSON-RPC 方法。从这些方法到生产部署之间的一切都是工程工作:八种传输方式、七种配置作用域、两项 OAuth RFC 以及分层超时机制。Claude Code 的实现展示了这种工程在规模化时的样貌。

下一章将探讨当 Agent 超越 localhost 时会发生什么:远程执行协议使 Claude Code 能够在云容器中运行、接受来自 Web 浏览器的指令,并通过注入凭证的代理隧道传输 API 流量。