MCP 协议集成:如何让 AI 工具连接一切

深入 Claude Code 的 MCP 集成——Server 生命周期管理、动态工具生成、Resource/Prompt 支持、Agent 间 Server 继承

问题引入

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)
Tool 系统
(Tool.ts)
Query Engine
(query.ts)
MCP Server 进程
Slack Server
GitHub Server
Database Server

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 定义可以看到完整的类型列表:

src/services/mcp/types.ts
TypeScript
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
httpStreamable HTTPMCP 2025-03-26 规范的新传输
wsWebSocket双向实时通信
sse-ideIDE 扩展VS Code/JetBrains 内部使用
sdk进程内 ServerAgent SDK 场景,无需子进程

其中 sdk 类型使用了一个精巧的 InProcessTransport

src/services/mcp/InProcessTransport.ts
TypeScript
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 类型定义如下:

src/services/mcp/types.ts
TypeScript
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 引入了审批机制:

src/services/mcp/utils.ts
TypeScript
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),需要规范化处理:

src/services/mcp/normalization.ts
TypeScript
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>

src/services/mcp/mcpStringUtils.ts
TypeScript
1export function buildMcpToolName(serverName: string, toolName: string): string {
2 return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
3}

这种命名约定有一个已知限制:如果 Server 名称本身包含 __,解析会出错。代码注释中明确记录了这一点——在实践中这种情况极为罕见。

Server 生命周期管理

连接流程

connectToServer 是整个 MCP 集成的入口函数。它被 memoize 包装,以 name + JSON(config) 作为缓存键,确保相同配置的 Server 不会重复连接:

src/services/mcp/client.ts
TypeScript
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)

连接结果是一个联合类型,精确表达了五种可能状态:

src/services/mcp/types.ts
TypeScript
1export type MCPServerConnection =
2 | ConnectedMCPServer // 连接成功,持有 Client 实例
3 | FailedMCPServer // 连接失败,保留错误信息
4 | NeedsAuthMCPServer // 需要 OAuth 认证
5 | PendingMCPServer // 等待连接(含重连尝试计数)
6 | DisabledMCPServer // 被用户/策略禁用

ConnectedMCPServer 持有连接的核心状态:

src/services/mcp/types.ts
TypeScript
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,分别使用不同的并发限制:

src/services/mcp/client.ts
TypeScript
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 并行处理:

src/services/mcp/client.ts
TypeScript
1await Promise.all([
2 processBatched(
3 localServers,
4 getMcpServerConnectionBatchSize(),
5 processServer,
6 ),
7 processBatched(
8 remoteServers,
9 getRemoteMcpServerConnectionBatchSize(),
10 processServer,
11 ),
12])

清理与重连

每个 stdio Server 连接时都会注册清理函数到全局清理注册表,确保进程退出时所有子进程都被正确终止:

src/services/mcp/client.ts
TypeScript
1// 所有传输类型都注册清理——即使网络传输也可能需要清理
2const cleanupUnregister = registerCleanup(cleanup)
3
4// 创建包含注销的包装清理函数
5const wrappedCleanup = async () => {
6 cleanupUnregister?.()
7 await cleanup()
8}

清理过程对 stdio Server 尤其重要——它会先发送 SIGTERM,等待进程退出,如果超时则升级到 SIGKILL:

src/services/mcp/client.ts
TypeScript
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 清除所有关联缓存:

src/services/mcp/client.ts
TypeScript
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 有专门的检测逻辑:

src/services/mcp/client.ts
TypeScript
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 过期,会自动重试一次:

src/services/mcp/client.ts
TypeScript
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 工具共享的基础行为:

src/tools/MCPTool/MCPTool.ts
TypeScript
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 对象:

src/services/mcp/client.ts
TypeScript
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(readOnlyHintdestructiveHintopenWorldHint),Claude Code 将它们直接映射为内部 Tool 接口的对应方法。

3. 描述长度限制MAX_MCP_DESCRIPTION_LENGTH = 2048。部分 OpenAPI 自动生成的 MCP Server 会产出 15-60KB 的工具描述,不加限制会浪费大量 token。

4. IDE 工具过滤isIncludedMcpTool 函数过滤掉非白名单的 IDE 工具,只允许 executeCodegetDiagnostics

工具调用链路

当模型决定调用一个 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 方法中的重试逻辑分为两层:

  1. Session 过期重试:最多 1 次,清除连接缓存后重新获取 Client
  2. 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:

TypeScript
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 字段:

src/Tool.ts
TypeScript
1export type ToolInputJSONSchema = {
2 [x: string]: unknown
3 type: 'object'
4 properties?: {
5 [x: string]: unknown
6 }
7}
src/Tool.ts
TypeScript
1// MCP 工具可以直接以 JSON Schema 格式指定输入 Schema,
2// 而不是从 Zod Schema 转换
3readonly inputJSONSchema?: ToolInputJSONSchema

fetchToolsForClient 中,MCP 工具的 inputSchema(来自 Server 的 JSON Schema)被直接赋值给 inputJSONSchema

src/services/mcp/client.ts
TypeScript
1inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],

为什么不将 JSON Schema 转换为 Zod?原因有三:

  1. 性能:JSON Schema 到 Zod 的运行时转换有开销,且 MCP Server 可能提供复杂的嵌套 Schema
  2. 保真度:JSON Schema 的某些特性(如 patternPropertiesadditionalPropertiesoneOf 组合)在 Zod 中没有直接对等物
  3. 不必要: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 归属信息:

src/services/mcp/types.ts
TypeScript
1export type ServerResource = Resource & { server: string }

getMcpToolsCommandsAndResources 中,Resource 工具只在有 Server 声明了 resources capability 时才添加,且全局只添加一次:

src/services/mcp/client.ts
TypeScript
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 获取列表:

TypeScript
1// Prompt 命令的命名遵循 mcp__<server>__<prompt> 格式
2// 与 Tool 命名一致,使用双下划线分隔

Prompt 和 Skill 的区分很微妙:MCP Prompt 设置 isMcp: true,而 MCP Skill(从 skill:// Resource 发现的)设置 loadedFrom: 'mcp'。这种区分影响 /mcp 菜单的能力显示:

src/services/mcp/utils.ts
TypeScript
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 继承父进程的连接:

src/Tool.ts
TypeScript
1mcpClients: MCPServerConnection[]

子 Agent 不需要重新连接 MCP Server——它们共享父进程已建立的连接。这是因为 connectToServer 的 memoize 缓存在进程内全局共享。

隔离机制

但是,Server 配置的变更不会自动传播。excludeStalePluginClients 负责检测过期的连接:

src/services/mcp/utils.ts
TypeScript
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 监听三种通知:

TypeScript
1// 来自 useManageMCPConnections.ts 的通知订阅
2ToolListChangedNotificationSchema // 工具列表变更
3ResourceListChangedNotificationSchema // 资源列表变更
4PromptListChangedNotificationSchema // Prompt 列表变更

收到通知后,Claude Code 清除对应的 fetch 缓存并重新获取数据。这使得 Server 可以在运行时动态添加/移除工具——例如,一个数据库 Server 可能在用户切换数据库连接后更新可用的查询工具。

重连与指数退避

断线重连使用指数退避策略:

src/services/mcp/useManageMCPConnections.ts
TypeScript
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 维护一个认证缓存以避免重复探测:

src/services/mcp/client.ts
TypeScript
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 竞态:

src/services/mcp/client.ts
TypeScript
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 对此有完整的处理流程:

src/services/mcp/client.ts
TypeScript
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 的处理分三层:

  1. Hook 优先runElicitationHooks 允许自定义逻辑自动处理
  2. SDK/Print 模式:通过 handleElicitation 回调委托给结构化 IO
  3. REPL 模式:通过 AppState 队列展示 UI 对话框

大结果处理

MCP 工具可能返回大量数据。processMCPResult 实现了分层处理策略:

src/services/mcp/client.ts
TypeScript
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}

处理策略的优先级:

  1. 小结果:直接返回
  2. 大图片结果:截断(保持可查看性)
  3. 大文本结果:持久化到文件,返回 Read 指令让模型按需读取
  4. 持久化失败:回退到截断

inferCompactSchema 函数为结构化内容生成紧凑的 Schema 描述,帮助模型理解输出格式:

src/services/mcp/client.ts
TypeScript
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 的刷新:

src/services/mcp/client.ts
TypeScript
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 小时(实际上是"无限"):

src/services/mcp/client.ts
TypeScript
1const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000

这个看似离谱的值是故意的——某些 MCP 工具(如数据库迁移、大规模分析)确实需要很长时间。用户可通过 MCP_TOOL_TIMEOUT 环境变量自定义。

请求级超时包装

与工具超时不同,每个 HTTP 请求有 60 秒超时。wrapFetchWithTimeout 为每个请求创建独立的 AbortController:

src/services/mcp/client.ts
TypeScript
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 状态导出:

src/services/mcp/types.ts
TypeScript
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 工具设计原则:

  1. 使用 annotations:声明 readOnlyHint 让工具可并发执行;声明 destructiveHint 触发额外确认
  2. 控制描述长度:超过 2048 字符会被截断,把核心用法放在前面
  3. 支持分页:大结果会被截断或持久化到文件,提供分页参数让模型按需获取
  4. 使用 searchHint:通过 _meta['anthropic/searchHint'] 帮助 ToolSearch 找到被延迟加载的工具
  5. 使用 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 直传、权限配置等一系列处理。

整体设计的几个核心理念:

  1. 惰性发现:只在需要时连接 Server、获取工具列表,用 memoize 缓存避免重复操作
  2. 优雅降级:连接失败不阻塞整个系统,只是该 Server 的工具不可用;需要认证的 Server 会注册一个 McpAuthTool 引导用户完成认证
  3. 双轨 Schema:内置工具走 Zod 获得类型安全和验证,MCP 工具走 JSON Schema 直传保持灵活和高效
  4. 防御性设计:从 Unicode 清理到描述截断,从配置哈希比较到认证缓存串行化,处处体现对边缘情况的考量

对于想构建类似扩展系统的项目,MCP 集成提供了一个可参考的模式:使用模板对象 + 对象展开实现动态工具注册,用 memoize 缓存管理连接生命周期,用联合类型精确表达连接状态,用配置层级实现灵活的作用域管理。