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。