跳到主要内容

MCP 工具:如何把外部工具合并进工具池

🔴 深度

Claude Code 的工具生态不局限于内置工具。通过 MCP(Model Context Protocol),任何人都可以编写外部工具服务器,让 Claude 在会话中动态使用这些扩展工具。这篇文章深入解析 Claude Code 如何连接 MCP 服务器、处理工具 schema 转换,以及将外部工具合并进运行时工具池的全过程。

MCP 简介

MCP(Model Context Protocol)是 Anthropic 开源的一个协议标准,定义了 LLM 宿主应用(Host)与外部工具服务器(Server)之间的通信格式。

核心设计理念:把"工具实现"从"模型宿主"中解耦。工具服务器独立运行,通过标准协议向模型暴露工具能力,模型宿主(Claude Code)负责协议握手、工具调用转发、结果回传。

传输层:stdio vs SSE vs HTTP

Claude Code 支持多种 MCP 传输方式,在 source/src/services/mcp/types.ts 中定义:

// source/src/services/mcp/types.ts,第 23 行
export const TransportSchema = lazySchema(() =>
z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
)

stdio 传输(最常用的本地工具):

// source/src/services/mcp/types.ts,第 28 行
export const McpStdioServerConfigSchema = lazySchema(() =>
z.object({
type: z.literal('stdio').optional(),
command: z.string().min(1, 'Command cannot be empty'),
args: z.array(z.string()).default([]),
env: z.record(z.string(), z.string()).optional(),
}),
)

使用配置示例:

{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}

stdio 模式启动一个子进程,通过标准输入/输出流进行 JSON-RPC 通信。这是最简单的集成方式——开发者只需要写一个接受 stdin、输出 stdout 的程序。

SSE 传输(远程 HTTP 服务器):

// McpSSEServerConfigSchema
z.object({
type: z.literal('sse'),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
oauth: McpOAuthConfigSchema().optional(), // 支持 OAuth 认证
})

SSE(Server-Sent Events)模式连接远程服务器,支持 OAuth 认证流程。适合需要云端服务支持的工具(如数据库访问、第三方 API 集成)。

HTTP Streamable 传输(新一代协议):

// McpHTTPServerConfigSchema
z.object({
type: z.literal('http'),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
})

这是 MCP 协议的最新传输层,基于 HTTP 流式响应,比 SSE 更灵活。

连接建立:connectToMCPServer()

连接过程在 source/src/services/mcp/client.ts 中实现。以 stdio 为例:

// source/src/services/mcp/client.ts(简化)
async function connectStdioServer(
name: string,
config: McpStdioServerConfig,
): Promise<MCPServerConnection> {
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
// 继承当前进程环境,加入 MCP 服务器专用变量
...subprocessEnv(config.env),
// 注入 Claude Code 的会话 ID,让服务器可以识别调用来源
CLAUDE_CODE_SESSION_ID: getSessionId(),
},
})

const client = new Client(
{ name: 'claude-code', version: CLAUDE_CODE_VERSION },
{ capabilities: { roots: {}, sampling: {} } },
)

await client.connect(transport)

// 获取服务器能力声明
const capabilities = client.getServerCapabilities()

// 获取工具列表
const toolsResult: ListToolsResult = await client.listTools()

return {
client,
serverName: name,
config,
tools: toolsResult.tools,
status: 'connected',
}
}

连接成功后,Claude Code 持有一个 MCPServerConnection 对象,包含 MCP 客户端实例和服务器声明的工具列表。

Schema 转换:MCP 格式 → Claude Tool 格式

这是 MCP 集成最核心的步骤。MCP 服务器返回的工具描述格式和 Claude Code 的 Tool 接口不同,需要通过包装对象进行转换。

MCPTool 包装类(source/src/tools/MCPTool/MCPTool.ts)承担这个职责:

// 从 MCP 服务器获取的工具描述格式(标准 MCP 格式)
interface MCPToolDefinition {
name: string
description?: string
inputSchema: {
type: 'object'
properties?: Record<string, unknown>
required?: string[]
}
}

// 转换为 Claude Code 的 Tool 接口
function createMCPToolWrapper(
serverName: string,
mcpTool: MCPToolDefinition,
client: Client,
): Tool {
const toolName = buildMcpToolName(serverName, mcpTool.name)

return {
name: toolName,
// MCP 工具使用 JSON Schema 直接定义 input,而非 Zod schema
inputJSONSchema: mcpTool.inputSchema,
// 需要提供一个 Zod schema 用于运行时类型操作
inputSchema: z.object({}).passthrough(),

isConcurrencySafe() {
// MCP 工具默认不并发(保守策略)
return false
},
isReadOnly() {
// MCP 工具的只读性由服务器元数据声明
return mcpTool.annotations?.readOnlyHint === true
},
isMcp: true, // 标记为 MCP 工具,用于调试和过滤
mcpInfo: { serverName, toolName: mcpTool.name },

async call(args, context, canUseTool, parentMessage, onProgress) {
// 通过 MCP 协议调用服务器端工具
const result = await client.callTool(
{ name: mcpTool.name, arguments: args },
CallToolResultSchema,
{ signal: context.abortController.signal },
)
return { data: result }
},

// 权限检查委托给通用的 MCP 权限逻辑
async checkPermissions(input, context) {
return checkMcpToolPermission(toolName, context)
},
// ...
}
}

工具名称格式化是通过 buildMcpToolName() 完成的:

// source/src/services/mcp/mcpStringUtils.ts
export function buildMcpToolName(serverName: string, toolName: string): string {
return `mcp__${normalizeNameForMCP(serverName)}__${normalizeNameForMCP(toolName)}`
}

normalizeNameForMCP() 将服务器名和工具名中不符合 API 要求的字符(空格、点等)替换为下划线:

// source/src/services/mcp/normalization.ts
export function normalizeNameForMCP(name: string): string {
let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
// claude.ai 服务器名特殊处理:合并连续下划线、去除首尾下划线
if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) {
normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
}
return normalized
}

assembleToolPool() 中的 MCP 合并

回到 tools.ts 中的核心函数,MCP 工具的合并在这里发生:

// source/src/tools.ts,第 345 行
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools, // 来自 appState.mcp.tools,已经是包装好的 Tool 对象
): Tools {
const builtInTools = getTools(permissionContext)

// 对 MCP 工具也应用 deny 规则(支持整个服务器前缀屏蔽)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

// 关键:排序保证提示缓存稳定
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
// 内置工具在前,MCP 工具在后(保证缓存断点位置稳定)
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name', // 按名称去重,内置工具优先
)
}

filterToolsByDenyRules() 能识别 MCP 服务器前缀规则:

export function filterToolsByDenyRules<T extends {
name: string
mcpInfo?: { serverName: string; toolName: string }
}>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

用户可以配置:

  • deny: mcp__untrusted_server — 屏蔽整个 MCP 服务器
  • deny: mcp__myserver__dangerous_tool — 只屏蔽特定工具

运行时动态工具注册

MCP 服务器可以在会话进行中连接,工具池需要动态更新。这通过 refreshTools 回调实现:

// source/src/Tool.ts(ToolUseContext 中的字段)
options: {
// ...
/** 可选回调:在查询中途 MCP 服务器连接后获取最新工具列表 */
refreshTools?: () => Tools
}

在 query 循环中,每次准备发送 API 请求前,如果有新的 MCP 连接建立,就通过 refreshTools() 更新工具列表。这样 Claude 在同一次对话中就能使用后来连接的 MCP 服务器的工具,无需重启会话。

MCP 工具调用的完整流程

Claude 决定调用 mcp__filesystem__read_file

toolOrchestration.ts:找到对应的 MCPTool 包装对象

MCPTool.call() 调用

通过 MCP 协议:client.callTool({ name: "read_file", arguments: { path: "/tmp/x" } })

MCP 服务器处理:执行实际的文件读取

MCP 响应:{ content: [{ type: "text", text: "file content..." }] }

MCPTool 将响应转换为 ToolResult<MCPToolResult>

Claude 收到 tool_result,继续会话

输出的大小限制与截断

MCP 工具的输出可能非常大(比如数据库查询返回大量数据)。Claude Code 有专门的截断逻辑:

// source/src/utils/mcpValidation.ts(简化)
const MCP_CONTENT_BUDGET_CHARS = 100_000 // 100KB

export function truncateMcpContentIfNeeded(
result: MCPToolResult,
): MCPToolResult {
const size = getContentSizeEstimate(result.content)
if (size <= MCP_CONTENT_BUDGET_CHARS) return result

// 超过限制:截断文本内容,但保留图片块
return {
...result,
content: truncateTextBlocks(result.content, MCP_CONTENT_BUDGET_CHARS),
isError: false,
}
}

如果单个 MCP 工具调用结果超过 100KB,文本内容会被截断,同时给 Claude 一个提示说明结果被截断了。图片内容(base64)会单独处理,支持压缩降采样。

工具描述长度限制

MCP 服务器生成的工具描述有时极其冗长(一些基于 OpenAPI 的服务器会把整个 API 文档塞进 description):

// source/src/services/mcp/client.ts,第 218 行
/**
* Cap on MCP tool descriptions and server instructions sent to the model.
* OpenAPI-generated MCP servers have been observed dumping 15-60KB of
* endpoint docs into tool.description; this caps the p95 tail without
* losing the intent.
*/
const MAX_MCP_DESCRIPTION_LENGTH = 2048

超过 2048 字符的工具描述会被截断,避免工具列表本身就耗尽大量上下文 token。

📄source/src/services/mcp/client.tsL1-350查看源码 →
📄source/src/services/mcp/types.tsL1-200查看源码 →