问题引入
Claude Code 能调用数据库、访问 API、操作 Figma——这些能力不是硬编码的,而是通过 MCP 动态加载的。
当你在 .mcp.json 中配置一个 Slack Server,Claude Code 会自动连接到它,发现它提供的所有工具(搜索消息、发送消息、列出频道),然后把这些工具注册为 Claude 可调用的 Tool,就好像它们是内置工具一样。当你说"帮我在 #general 频道发一条消息",Claude 选择调用 mcp__slack__send_message,将参数序列化为 JSON,通过 stdio 管道发送给 Slack Server 进程,等待响应,再把结果呈现给你。
这套机制不仅仅是简单的 RPC 调用。它涉及:
- 多种传输协议(stdio、SSE、HTTP Streamable、WebSocket、SDK 内嵌)的统一抽象
- 连接池与 memoize 缓存,避免重复握手
- 动态工具发现:Server 声明能力后,Claude Code 自动将 MCP Tool 转换为内部
Tool 接口
- JSON Schema 直传:MCP 工具不走 Zod 解析,而是将
inputSchema 以 JSON Schema 形式直接传递给 API
- Resource 和 Prompt 两种辅助原语的集成
- OAuth 认证、URL Elicitation、Session 过期重连等边缘情况的处理
- Agent 子进程间的 MCP Server 共享与隔离
本文将从协议概览开始,逐层深入 Claude Code 的 MCP 集成实现。
MCP 协议概述
Model Context Protocol(MCP)是 Anthropic 提出的开放协议,旨在标准化 AI 模型与外部工具/数据源的交互方式。它的核心设计理念是:模型不需要知道工具的实现细节,只需要知道工具的名字、描述和输入 Schema。
Claude Code 进程
MCP Client
(@modelcontextprotocol/sdk)
MCP 协议定义了五个核心概念:
| 概念 | 角色 | 说明 |
|---|
| Client | 消费者 | Claude Code 进程内的 MCP 客户端,负责连接 Server、发现工具、发起调用 |
| Server | 提供者 | 独立进程或远程服务,暴露 tools/resources/prompts |
| Tool | 可执行操作 | Server 提供的函数,有名字、描述、JSON Schema 定义的输入参数 |
| Resource | 上下文数据 | Server 提供的只读数据(文件内容、API 响应等),可注入对话上下文 |
| Prompt | 预设模板 | Server 提供的 prompt 模板,可被用户作为斜杠命令调用 |
传输层多样性
Claude Code 支持的传输类型远超基本规范。从 types.ts 的 Schema 定义可以看到完整的类型列表:
1export const TransportSchema = lazySchema(() =>
2 z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
3)
每种传输类型对应不同的使用场景:
| 传输类型 | 场景 | 特点 |
|---|
stdio | 本地 CLI 工具 | 启动子进程,通过 stdin/stdout 通信 |
sse | 远程 HTTP 服务 | Server-Sent Events,支持 OAuth |
http | Streamable HTTP | MCP 2025-03-26 规范的新传输 |
ws | WebSocket | 双向实时通信 |
sse-ide | IDE 扩展 | VS Code/JetBrains 内部使用 |
sdk | 进程内 Server | Agent SDK 场景,无需子进程 |
其中 sdk 类型使用了一个精巧的 InProcessTransport:
1class InProcessTransport implements Transport {
2 private peer: InProcessTransport | undefined
3 private closed = false
4
5 onclose?: () => void
6 onerror?: (error: Error) => void
7 onmessage?: (message: JSONRPCMessage) => void
8
9 /** @internal */
10 _setPeer(peer: InProcessTransport): void {
11 this.peer = peer
12 }
13
14 async start(): Promise<void> {}
15
16 async send(message: JSONRPCMessage): Promise<void> {
17 if (this.closed) {
18 throw new Error('Transport is closed')
19 }
20 // 异步投递,避免同步请求/响应导致栈深度问题
21 queueMicrotask(() => {
22 this.peer?.onmessage?.(message)
23 })
24 }
25
26 async close(): Promise<void> {
27 if (this.closed) {
28 return
29 }
30 this.closed = true
31 this.onclose?.()
32 if (this.peer && !this.peer.closed) {
33 this.peer.closed = true
34 this.peer.onclose?.()
35 }
36 }
37}
createLinkedTransportPair() 创建一对互联的 Transport,一端给 Client,一端给 Server。send() 内部使用 queueMicrotask 异步投递消息,避免同步 RPC 调用导致的栈溢出——这在高频工具调用场景下至关重要。
Server 配置与发现
配置层级
MCP Server 的配置来源有多个层级,每个层级有不同的作用域:
每个配置来源的 ConfigScope 类型定义如下:
1export const ConfigScopeSchema = lazySchema(() =>
2 z.enum([
3 'local',
4 'user',
5 'project',
6 'dynamic',
7 'enterprise',
8 'claudeai',
9 'managed',
10 ]),
11)
project 作用域来自项目根目录的 .mcp.json 文件——这是最常用的配置方式。由于项目配置可能包含恶意 Server,Claude Code 引入了审批机制:
1export function getProjectMcpServerStatus(
2 serverName: string,
3): 'approved' | 'rejected' | 'pending' {
4 const settings = getSettings_DEPRECATED()
5 const normalizedName = normalizeNameForMCP(serverName)
6
7 if (
8 settings?.disabledMcpjsonServers?.some(
9 name => normalizeNameForMCP(name) === normalizedName,
10 )
11 ) {
12 return 'rejected'
13 }
14
15 if (
16 settings?.enabledMcpjsonServers?.some(
17 name => normalizeNameForMCP(name) === normalizedName,
18 ) ||
19 settings?.enableAllProjectMcpServers
20 ) {
21 return 'approved'
22 }
23
24 // 非交互模式且启用了 projectSettings 时自动审批
25 if (
26 getIsNonInteractiveSession() &&
27 isSettingSourceEnabled('projectSettings')
28 ) {
29 return 'approved'
30 }
31
32 return 'pending'
33}
注意安全边界:--dangerously-skip-permissions 模式下的自动审批只检查 hasSkipDangerousModePermissionPrompt()——该函数刻意排除了 projectSettings,防止恶意仓库通过项目配置自行批准 bypass 模式。
名称规范化
MCP 协议要求工具名必须匹配 ^[a-zA-Z0-9_-]{1,64}$。由于 Server 名称可能包含空格、点号等特殊字符(尤其是 claude.ai 的 Server),需要规范化处理:
1export function normalizeNameForMCP(name: string): string {
2 let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
3 if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) {
4 // claude.ai Server 额外压缩连续下划线,避免与 __ 分隔符冲突
5 normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
6 }
7 return normalized
8}
工具的完全限定名格式为 mcp__<serverName>__<toolName>:
1export function buildMcpToolName(serverName: string, toolName: string): string {
2 return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
3}
这种命名约定有一个已知限制:如果 Server 名称本身包含 __,解析会出错。代码注释中明确记录了这一点——在实践中这种情况极为罕见。
Server 生命周期管理
连接流程
connectToServer 是整个 MCP 集成的入口函数。它被 memoize 包装,以 name + JSON(config) 作为缓存键,确保相同配置的 Server 不会重复连接:
1export const connectToServer = memoize(
2 async (
3 name: string,
4 serverRef: ScopedMcpServerConfig,
5 serverStats?: {
6 totalServers: number
7 stdioCount: number
8 sseCount: number
9 httpCount: number
10 sseIdeCount: number
11 wsIdeCount: number
12 },
13 ): Promise<MCPServerConnection> => {
14 // ...连接逻辑
15 },
16 getServerCacheKey,
17)
连接结果是一个联合类型,精确表达了五种可能状态:
1export type MCPServerConnection =
2 | ConnectedMCPServer // 连接成功,持有 Client 实例
3 | FailedMCPServer // 连接失败,保留错误信息
4 | NeedsAuthMCPServer // 需要 OAuth 认证
5 | PendingMCPServer // 等待连接(含重连尝试计数)
6 | DisabledMCPServer // 被用户/策略禁用
ConnectedMCPServer 持有连接的核心状态:
1export type ConnectedMCPServer = {
2 client: Client // MCP SDK Client 实例
3 name: string
4 type: 'connected'
5 capabilities: ServerCapabilities // Server 声明的能力
6 serverInfo?: {
7 name: string
8 version: string
9 }
10 instructions?: string // Server 提供的指令(注入 system prompt)
11 config: ScopedMcpServerConfig
12 cleanup: () => Promise<void> // 清理函数
13}
批量连接策略
Claude Code 不会一次性连接所有 Server。它区分本地 Server(stdio/sdk)和远程 Server,分别使用不同的并发限制:
1export function getMcpServerConnectionBatchSize(): number {
2 return parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 3
3}
4
5function getRemoteMcpServerConnectionBatchSize(): number {
6 return (
7 parseInt(process.env.MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE || '', 10) ||
8 20
9 )
10}
本地 Server 默认并发 3(进程启动开销大),远程 Server 默认并发 20(仅网络连接)。在 getMcpToolsCommandsAndResources 中,两组 Server 并行处理:
1await Promise.all([
2 processBatched(
3 localServers,
4 getMcpServerConnectionBatchSize(),
5 processServer,
6 ),
7 processBatched(
8 remoteServers,
9 getRemoteMcpServerConnectionBatchSize(),
10 processServer,
11 ),
12])
清理与重连
每个 stdio Server 连接时都会注册清理函数到全局清理注册表,确保进程退出时所有子进程都被正确终止:
1// 所有传输类型都注册清理——即使网络传输也可能需要清理
2const cleanupUnregister = registerCleanup(cleanup)
3
4// 创建包含注销的包装清理函数
5const wrappedCleanup = async () => {
6 cleanupUnregister?.()
7 await cleanup()
8}
清理过程对 stdio Server 尤其重要——它会先发送 SIGTERM,等待进程退出,如果超时则升级到 SIGKILL:
1logMCPDebug(
2 name,
3 'SIGTERM failed, sending SIGKILL to MCP server process',
4)
5try {
6 process.kill(childPid, 'SIGKILL')
7} catch (killError) {
8 logMCPDebug(
9 name,
10 `Error sending SIGKILL: ${killError}`,
11 )
12}
缓存失效时通过 clearServerCache 清除所有关联缓存:
1export async function clearServerCache(
2 name: string,
3 serverRef: ScopedMcpServerConfig,
4): Promise<void> {
5 const key = getServerCacheKey(name, serverRef)
6
7 try {
8 const wrappedClient = await connectToServer(name, serverRef)
9 if (wrappedClient.type === 'connected') {
10 await wrappedClient.cleanup()
11 }
12 } catch {
13 // 忽略——Server 可能连接失败
14 }
15
16 // 清除连接缓存和所有 fetch 缓存,确保重连获取最新数据
17 connectToServer.cache.delete(key)
18 fetchToolsForClient.cache.delete(name)
19 fetchResourcesForClient.cache.delete(name)
20 fetchCommandsForClient.cache.delete(name)
21}
注意这里清除了四个独立的缓存——连接缓存和三个数据获取缓存。如果只清除连接缓存而保留 fetch 缓存,重连后会使用过时的工具列表。
Session 过期重连
对于 HTTP Streamable 传输,MCP 规范定义了 Session 过期机制(HTTP 404 + JSON-RPC error code -32001)。Claude Code 有专门的检测逻辑:
1export function isMcpSessionExpiredError(error: Error): boolean {
2 const httpStatus =
3 'code' in error ? (error as Error & { code?: number }).code : undefined
4 if (httpStatus !== 404) {
5 return false
6 }
7 // 检查 JSON-RPC 错误码以区分通用 404
8 return (
9 error.message.includes('"code":-32001') ||
10 error.message.includes('"code": -32001')
11 )
12}
工具调用时如果遇到 Session 过期,会自动重试一次:
1const MAX_SESSION_RETRIES = 1
2for (let attempt = 0; ; attempt++) {
3 try {
4 const connectedClient = await ensureConnectedClient(client)
5 const mcpResult = await callMCPToolWithUrlElicitationRetry({
6 client: connectedClient,
7 // ...
8 })
9 return { data: mcpResult.content, /* ... */ }
10 } catch (error) {
11 if (
12 error instanceof McpSessionExpiredError &&
13 attempt < MAX_SESSION_RETRIES
14 ) {
15 logMCPDebug(
16 client.name,
17 `Retrying tool '${tool.name}' after session recovery`,
18 )
19 continue
20 }
21 // ...错误处理
22 }
23}
动态工具生成
这是 MCP 集成最核心的部分:如何将 MCP Server 声明的工具转换为 Claude Code 内部的 Tool 接口。
MCPTool 模板
MCPTool.ts 定义了一个"模板"对象,包含所有 MCP 工具共享的基础行为:
1export const MCPTool = buildTool({
2 isMcp: true,
3 // 在 mcpClient.ts 中被真实的 MCP 工具名 + 参数覆盖
4 isOpenWorld() {
5 return false
6 },
7 name: 'mcp', // 被覆盖
8 maxResultSizeChars: 100_000,
9 async description() {
10 return DESCRIPTION // 被覆盖
11 },
12 async prompt() {
13 return PROMPT // 被覆盖
14 },
15 get inputSchema(): InputSchema {
16 return inputSchema() // 通用的 z.object({}).passthrough()
17 },
18 async call() {
19 return { data: '' } // 被覆盖
20 },
21 async checkPermissions(): Promise<PermissionResult> {
22 return {
23 behavior: 'passthrough',
24 message: 'MCPTool requires permission.',
25 }
26 },
27 userFacingName: () => 'mcp', // 被覆盖
28 // ...渲染函数
29} satisfies ToolDef<InputSchema, Output>)
注意这里的设计模式:MCPTool 使用 z.object({}).passthrough() 作为 inputSchema——这是一个"接受任意对象"的 Zod Schema,因为 MCP 工具的实际 Schema 通过 inputJSONSchema 传递。
fetchToolsForClient:从 Server 到 Tool
fetchToolsForClient 是工具发现的核心函数。它调用 MCP 协议的 tools/list 方法,然后将每个 MCP Tool 映射为内部 Tool 对象:
1export const fetchToolsForClient = memoizeWithLRU(
2 async (client: MCPServerConnection): Promise<Tool[]> => {
3 if (client.type !== 'connected') return []
4
5 const result = (await client.client.request(
6 { method: 'tools/list' },
7 ListToolsResultSchema,
8 )) as ListToolsResult
9
10 // 清理 Unicode 控制字符
11 const toolsToProcess = recursivelySanitizeUnicode(result.tools)
12
13 // SDK 模式下是否跳过 mcp__ 前缀
14 const skipPrefix =
15 client.config.type === 'sdk' &&
16 isEnvTruthy(process.env.CLAUDE_AGENT_SDK_MCP_NO_PREFIX)
17
18 return toolsToProcess.map((tool): Tool => {
19 const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
20 return {
21 ...MCPTool, // 展开模板
22 name: skipPrefix ? tool.name : fullyQualifiedName,
23 mcpInfo: { serverName: client.name, toolName: tool.name },
24 isMcp: true,
25 // 从 _meta 读取搜索提示
26 searchHint:
27 typeof tool._meta?.['anthropic/searchHint'] === 'string'
28 ? tool._meta['anthropic/searchHint']
29 .replace(/\s+/g, ' ').trim() || undefined
30 : undefined,
31 alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true,
32 // 使用 MCP 工具的原始描述
33 async description() {
34 return tool.description ?? ''
35 },
36 // 截断过长的描述(2048 字符上限)
37 async prompt() {
38 const desc = tool.description ?? ''
39 return desc.length > MAX_MCP_DESCRIPTION_LENGTH
40 ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '... [truncated]'
41 : desc
42 },
43 // 从 annotations 推导行为特征
44 isConcurrencySafe() {
45 return tool.annotations?.readOnlyHint ?? false
46 },
47 isReadOnly() {
48 return tool.annotations?.readOnlyHint ?? false
49 },
50 isDestructive() {
51 return tool.annotations?.destructiveHint ?? false
52 },
53 isOpenWorld() {
54 return tool.annotations?.openWorldHint ?? false
55 },
56 // 直接传递 JSON Schema,不转 Zod
57 inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],
58 // ...call 实现, checkPermissions 等
59 }
60 }).filter(isIncludedMcpTool)
61 },
62 { maxSize: MCP_FETCH_CACHE_SIZE, getCacheKey: client => client.name },
63)
这段代码揭示了几个关键设计决策:
1. 对象展开覆盖模式:{ ...MCPTool, ...overrides } 用模板对象作为基础,逐字段覆盖。这比继承更灵活,也更符合 TypeScript 的结构类型系统。
2. MCP Annotations 映射:MCP 2025-03-26 规范引入了 Tool Annotations(readOnlyHint、destructiveHint、openWorldHint),Claude Code 将它们直接映射为内部 Tool 接口的对应方法。
3. 描述长度限制:MAX_MCP_DESCRIPTION_LENGTH = 2048。部分 OpenAPI 自动生成的 MCP Server 会产出 15-60KB 的工具描述,不加限制会浪费大量 token。
4. IDE 工具过滤:isIncludedMcpTool 函数过滤掉非白名单的 IDE 工具,只允许 executeCode 和 getDiagnostics。
工具调用链路
当模型决定调用一个 MCP 工具时,调用链如下:
sequenceDiagram
participant M as Claude API
participant Q as Query Engine
participant T as Tool System
participant MC as MCP Client
participant S as MCP Server
M->>Q: tool_use: mcp__slack__send_message
Q->>T: 查找 Tool 实例
T->>T: call(args, context)
T->>MC: ensureConnectedClient()
MC-->>T: ConnectedMCPServer
T->>MC: callMCPToolWithUrlElicitationRetry()
MC->>S: tools/call {name, arguments}
S-->>MC: CallToolResult
MC->>MC: processMCPResult()
MC->>MC: transformResultContent()
MC-->>T: {data: string, mcpMeta?}
T-->>Q: ToolResult
Q-->>M: tool_result content
Note over MC,S: 超时:默认 ~27.8 小时<br/>Session 过期自动重试
call 方法中的重试逻辑分为两层:
- Session 过期重试:最多 1 次,清除连接缓存后重新获取 Client
- URL Elicitation 重试:最多 3 次,处理 MCP -32042 错误码(Server 要求用户打开 URL 进行授权)
JSON Schema vs Zod:双轨参数验证
这是 Claude Code 工具系统中一个有趣的设计分歧。内置工具使用 Zod Schema,MCP 工具使用 JSON Schema——两套体系并行运行。
内置工具的 Zod 路径
内置工具(如 Read、Write、Bash)定义 inputSchema 为 Zod Schema:
1// 典型的内置工具 inputSchema
2const inputSchema = z.object({
3 file_path: z.string().describe('Absolute path to the file'),
4 offset: z.number().optional().describe('Line offset'),
5 limit: z.number().optional().describe('Number of lines'),
6})
Zod Schema 在发送给 API 前被自动转换为 JSON Schema。
MCP 工具的 JSON Schema 直传
MCP 工具则绕过了 Zod 层。Tool 接口专门定义了 inputJSONSchema 字段:
1export type ToolInputJSONSchema = {
2 [x: string]: unknown
3 type: 'object'
4 properties?: {
5 [x: string]: unknown
6 }
7}
1// MCP 工具可以直接以 JSON Schema 格式指定输入 Schema,
2// 而不是从 Zod Schema 转换
3readonly inputJSONSchema?: ToolInputJSONSchema
在 fetchToolsForClient 中,MCP 工具的 inputSchema(来自 Server 的 JSON Schema)被直接赋值给 inputJSONSchema:
1inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],
为什么不将 JSON Schema 转换为 Zod?原因有三:
- 性能:JSON Schema 到 Zod 的运行时转换有开销,且 MCP Server 可能提供复杂的嵌套 Schema
- 保真度:JSON Schema 的某些特性(如
patternProperties、additionalProperties、oneOf 组合)在 Zod 中没有直接对等物
- 不必要:Claude API 本身接受 JSON Schema,无需中间转换
这就是为什么 MCPTool 的 inputSchema 是一个宽松的 z.object({}).passthrough()——它在运行时不做实际验证,真正的 Schema 通过 inputJSONSchema 直传给 API。
Resource 与 Prompt 集成
Resource:上下文数据注入
MCP Resource 允许 Server 暴露只读数据。Claude Code 为此提供了两个内置工具:
ListMcpResourcesTool:列出所有 MCP Server 提供的 Resource
ReadMcpResourceTool:读取特定 Resource 的内容
Resource 的类型定义继承自 MCP SDK 并扩展了 Server 归属信息:
1export type ServerResource = Resource & { server: string }
在 getMcpToolsCommandsAndResources 中,Resource 工具只在有 Server 声明了 resources capability 时才添加,且全局只添加一次:
1const resourceTools: Tool[] = []
2if (supportsResources && !resourceToolsAdded) {
3 resourceToolsAdded = true
4 resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool)
5}
这个 resourceToolsAdded 标志确保即使有 10 个 Server 都声明了 Resource capability,List 和 Read 工具也只注册一次——它们能访问所有 Server 的 Resource。
Resource 获取也使用 LRU 缓存和并发处理。prefetchAllMcpResources 在启动时预取所有 Server 的工具、命令和 Resource,避免首次对话时的延迟。
Prompt:预设命令模板
MCP Prompt 被转换为 Claude Code 的 Command(斜杠命令)。当 Server 声明了 prompts capability 时,fetchCommandsForClient 调用 prompts/list 获取列表:
1// Prompt 命令的命名遵循 mcp__<server>__<prompt> 格式
2// 与 Tool 命名一致,使用双下划线分隔
Prompt 和 Skill 的区分很微妙:MCP Prompt 设置 isMcp: true,而 MCP Skill(从 skill:// Resource 发现的)设置 loadedFrom: 'mcp'。这种区分影响 /mcp 菜单的能力显示:
1export function filterMcpPromptsByServer(
2 commands: Command[],
3 serverName: string,
4): Command[] {
5 return commands.filter(
6 c =>
7 commandBelongsToServer(c, serverName) &&
8 !(c.type === 'prompt' && c.loadedFrom === 'mcp'),
9 )
10}
Agent 间的 MCP Server 共享与隔离
Claude Code 的多 Agent 架构(主线程 + 子 Agent)引入了 MCP Server 的共享问题。
共享机制
当主线程连接到 MCP Server 后,子 Agent 通过 ToolUseContext.options.mcpClients 继承父进程的连接:
1mcpClients: MCPServerConnection[]
子 Agent 不需要重新连接 MCP Server——它们共享父进程已建立的连接。这是因为 connectToServer 的 memoize 缓存在进程内全局共享。
隔离机制
但是,Server 配置的变更不会自动传播。excludeStalePluginClients 负责检测过期的连接:
1export function excludeStalePluginClients(
2 mcp: {
3 clients: MCPServerConnection[]
4 tools: Tool[]
5 commands: Command[]
6 resources: Record<string, ServerResource[]>
7 },
8 configs: Record<string, ScopedMcpServerConfig>,
9): {
10 clients: MCPServerConnection[]
11 tools: Tool[]
12 commands: Command[]
13 resources: Record<string, ServerResource[]>
14 stale: MCPServerConnection[]
15} {
16 const stale = mcp.clients.filter(c => {
17 const fresh = configs[c.name]
18 if (!fresh) return c.config.scope === 'dynamic'
19 return hashMcpConfig(c.config) !== hashMcpConfig(fresh)
20 })
21 // ...移除过期的 tools/commands/resources
22}
过期检测使用配置的 SHA-256 哈希比较,排除 scope 字段(因为 scope 是元数据,不影响连接参数)。
变更通知
MCP 协议支持 Server 端推送变更通知。useManageMCPConnections 监听三种通知:
1// 来自 useManageMCPConnections.ts 的通知订阅
2ToolListChangedNotificationSchema // 工具列表变更
3ResourceListChangedNotificationSchema // 资源列表变更
4PromptListChangedNotificationSchema // Prompt 列表变更
收到通知后,Claude Code 清除对应的 fetch 缓存并重新获取数据。这使得 Server 可以在运行时动态添加/移除工具——例如,一个数据库 Server 可能在用户切换数据库连接后更新可用的查询工具。
重连与指数退避
断线重连使用指数退避策略:
1const MAX_RECONNECT_ATTEMPTS = 5
2const INITIAL_BACKOFF_MS = 1000
3const MAX_BACKOFF_MS = 30000
每次重连尝试间隔翻倍,直到 30 秒上限,最多尝试 5 次。
OAuth 与 Elicitation
认证缓存
对于需要 OAuth 的远程 Server(SSE、HTTP),Claude Code 维护一个认证缓存以避免重复探测:
1const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 // 15 分钟
2
3type McpAuthCacheData = Record<string, { timestamp: number }>
4
5// 使用 memoize 确保并发的 isMcpAuthCached() 共享同一次文件读取
6let authCachePromise: Promise<McpAuthCacheData> | null = null
7
8function getMcpAuthCache(): Promise<McpAuthCacheData> {
9 if (!authCachePromise) {
10 authCachePromise = readFile(getMcpAuthCachePath(), 'utf-8')
11 .then(data => jsonParse(data) as McpAuthCacheData)
12 .catch(() => ({}))
13 }
14 return authCachePromise
15}
缓存写入通过 promise chain 串行化,防止并发 read-modify-write 竞态:
1let writeChain = Promise.resolve()
2
3function setMcpAuthCacheEntry(serverId: string): void {
4 writeChain = writeChain
5 .then(async () => {
6 const cache = await getMcpAuthCache()
7 cache[serverId] = { timestamp: Date.now() }
8 // ...写入文件
9 // 写入后使读缓存失效
10 authCachePromise = null
11 })
12 .catch(() => {
13 // 尽力而为
14 })
15}
URL Elicitation
MCP 规范的 -32042 错误码表示 Server 需要用户打开 URL 完成授权。Claude Code 对此有完整的处理流程:
1const MAX_URL_ELICITATION_RETRIES = 3
2for (let attempt = 0; ; attempt++) {
3 try {
4 return await callToolFn({
5 client: connectedClient,
6 tool, args, meta, signal, onProgress,
7 })
8 } catch (error) {
9 if (
10 !(error instanceof McpError) ||
11 error.code !== ErrorCode.UrlElicitationRequired
12 ) {
13 throw error
14 }
15 // ...处理 Elicitation
16 }
17}
Elicitation 的处理分三层:
- Hook 优先:
runElicitationHooks 允许自定义逻辑自动处理
- SDK/Print 模式:通过
handleElicitation 回调委托给结构化 IO
- REPL 模式:通过 AppState 队列展示 UI 对话框
大结果处理
MCP 工具可能返回大量数据。processMCPResult 实现了分层处理策略:
1export async function processMCPResult(
2 result: unknown,
3 tool: string,
4 name: string,
5): Promise<MCPToolResult> {
6 const { content, type, schema } = await transformMCPResult(result, tool, name)
7
8 // IDE 工具不发给模型,跳过大小检查
9 if (name === 'ide') {
10 return content
11 }
12
13 // 检查是否需要截断
14 if (!(await mcpContentNeedsTruncation(content))) {
15 return content
16 }
17
18 // 包含图片时回退到截断(保持图片压缩和可查看性)
19 if (contentContainsImages(content)) {
20 return await truncateMcpContentIfNeeded(content)
21 }
22
23 // 大文本结果:持久化到文件,返回路径和读取指令
24 const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}`
25 const persistResult = await persistToolResult(contentStr, persistId)
26 // ...返回读取指令
27}
处理策略的优先级:
- 小结果:直接返回
- 大图片结果:截断(保持可查看性)
- 大文本结果:持久化到文件,返回 Read 指令让模型按需读取
- 持久化失败:回退到截断
inferCompactSchema 函数为结构化内容生成紧凑的 Schema 描述,帮助模型理解输出格式:
1export function inferCompactSchema(value: unknown, depth = 2): string {
2 // 推断值的简要结构描述
3 // 例如:{name: string, items: [{id: number, ...}]}
4}
Claude.ai 代理连接
Claude Code 可以访问在 claude.ai 上配置的 MCP Server,使用特殊的 claudeai-proxy 传输类型。代理连接需要处理 OAuth token 的刷新:
1export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike {
2 return async (url, init) => {
3 const doRequest = async () => {
4 await checkAndRefreshOAuthTokenIfNeeded()
5 const currentTokens = getClaudeAIOAuthTokens()
6 if (!currentTokens) {
7 throw new Error('No claude.ai OAuth token available')
8 }
9 const headers = new Headers(init?.headers)
10 headers.set('Authorization', `Bearer ${currentTokens.accessToken}`)
11 const response = await innerFetch(url, { ...init, headers })
12 // 返回发送时使用的 token,而非当前 token
13 return { response, sentToken: currentTokens.accessToken }
14 }
15
16 const { response, sentToken } = await doRequest()
17 if (response.status !== 401) {
18 return response
19 }
20 // 401 时尝试刷新 token 并重试一次
21 const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false)
22 if (!tokenChanged) {
23 return response
24 }
25 try {
26 return (await doRequest()).response
27 } catch {
28 return response
29 }
30 }
31}
注意 sentToken 的使用——代码特意记录了发送请求时使用的 token,而不是在 401 响应后重新读取当前 token。这是因为并发的 connector 可能在此期间已经通过 handleOAuth401Error 刷新了 token,重新读取会得到新 token,传给 handleOAuth401Error 后会判断"token 未变"从而跳过刷新。
超时与请求包装
工具调用超时
MCP 工具调用的默认超时约为 27.8 小时(实际上是"无限"):
1const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000
这个看似离谱的值是故意的——某些 MCP 工具(如数据库迁移、大规模分析)确实需要很长时间。用户可通过 MCP_TOOL_TIMEOUT 环境变量自定义。
请求级超时包装
与工具超时不同,每个 HTTP 请求有 60 秒超时。wrapFetchWithTimeout 为每个请求创建独立的 AbortController:
1export function wrapFetchWithTimeout(baseFetch: FetchLike): FetchLike {
2 return async (url: string | URL, init?: RequestInit) => {
3 const method = (init?.method ?? 'GET').toUpperCase()
4
5 // GET 请求跳过超时——MCP 的 GET 是长连接 SSE 流
6 if (method === 'GET') {
7 return baseFetch(url, init)
8 }
9
10 // 使用 setTimeout 而非 AbortSignal.timeout()
11 // 因为 Bun 中 AbortSignal.timeout 的内部计时器
12 // 要到 GC 时才释放,每个请求占 ~2.4KB 原生内存
13 const controller = new AbortController()
14 const timer = setTimeout(
15 c => c.abort(new DOMException('The operation timed out.', 'TimeoutError')),
16 MCP_REQUEST_TIMEOUT_MS,
17 controller,
18 )
19 timer.unref?.()
20
21 // 关联父信号
22 const parentSignal = init?.signal
23 const abort = () => controller.abort(parentSignal?.reason)
24 parentSignal?.addEventListener('abort', abort)
25 // ...
26 }
27}
代码注释揭示了一个有趣的 Bun 运行时细节:AbortSignal.timeout() 创建的内部计时器只在 GC 时释放,导致每个请求泄漏约 2.4KB 原生内存。因此改用 setTimeout + clearTimeout 手动管理生命周期。
序列化状态与 CLI 集成
MCP 状态可以序列化为 JSON,用于 /mcp 命令和 CLI 状态导出:
1export interface SerializedTool {
2 name: string
3 description: string
4 inputJSONSchema?: {
5 [x: string]: unknown
6 type: 'object'
7 properties?: { [x: string]: unknown }
8 }
9 isMcp?: boolean
10 originalToolName?: string // 原始未规范化的工具名
11}
12
13export interface MCPCliState {
14 clients: SerializedClient[]
15 configs: Record<string, ScopedMcpServerConfig>
16 tools: SerializedTool[]
17 resources: Record<string, ServerResource[]>
18 normalizedNames?: Record<string, string> // 规范化名到原始名的映射
19}
normalizedNames 映射解决了一个实际问题:用户在权限规则中使用原始名称,但系统内部使用规范化名称。这个映射使得权限检查能正确关联。
可迁移模式与最佳实践
配置组织建议
基于 Claude Code 的配置层级设计,推荐以下组织方式:
| 场景 | 推荐配置位置 | 原因 |
|---|
| 团队共享的项目工具 | .mcp.json(project scope) | 随代码版本控制,新成员自动获取 |
| 个人偏好的通用工具 | ~/.claude.json(user scope) | 跨项目可用 |
| CI/CD 环境 | --mcp-server 参数(dynamic scope) | 临时注入,不修改配置文件 |
| 企业合规工具 | managed 配置(enterprise scope) | 组织统一管理,用户不可修改 |
工具设计原则
从 Claude Code 的 MCP 集成代码中,可以提炼出几条 MCP Server 工具设计原则:
- 使用 annotations:声明
readOnlyHint 让工具可并发执行;声明 destructiveHint 触发额外确认
- 控制描述长度:超过 2048 字符会被截断,把核心用法放在前面
- 支持分页:大结果会被截断或持久化到文件,提供分页参数让模型按需获取
- 使用 searchHint:通过
_meta['anthropic/searchHint'] 帮助 ToolSearch 找到被延迟加载的工具
- 使用 alwaysLoad:对于模型必须在第一轮就看到的关键工具,设置
_meta['anthropic/alwaysLoad'] 跳过 ToolSearch
安全边界
MCP 集成的安全模型值得特别关注:
- Project Server 审批:
.mcp.json 中的 Server 需要用户显式批准(除非在非交互模式且启用了 projectSettings)
- 权限隔离:MCP 工具使用
passthrough 权限模式,意味着每次调用都会检查全局权限规则
- 名称空间隔离:
mcp__ 前缀确保 MCP 工具不会与内置工具冲突(SDK 模式下可选择关闭)
- 描述截断:防止恶意 Server 通过超长描述注入提示
- Unicode 清理:
recursivelySanitizeUnicode 清除可能干扰模型行为的控制字符
架构总结
Claude Code 的 MCP 集成是一个精心设计的可扩展架构。它将"连接管理"与"工具适配"清晰分离:client.ts 负责建立和维护与 MCP Server 的连接,MCPTool.ts 提供工具模板,fetchToolsForClient 将两者桥接——从 Server 的原始能力声明到 Claude 可调用的 Tool 对象,中间经过名称规范化、描述截断、Schema 直传、权限配置等一系列处理。
整体设计的几个核心理念:
- 惰性发现:只在需要时连接 Server、获取工具列表,用 memoize 缓存避免重复操作
- 优雅降级:连接失败不阻塞整个系统,只是该 Server 的工具不可用;需要认证的 Server 会注册一个
McpAuthTool 引导用户完成认证
- 双轨 Schema:内置工具走 Zod 获得类型安全和验证,MCP 工具走 JSON Schema 直传保持灵活和高效
- 防御性设计:从 Unicode 清理到描述截断,从配置哈希比较到认证缓存串行化,处处体现对边缘情况的考量
对于想构建类似扩展系统的项目,MCP 集成提供了一个可参考的模式:使用模板对象 + 对象展开实现动态工具注册,用 memoize 缓存管理连接生命周期,用联合类型精确表达连接状态,用配置层级实现灵活的作用域管理。