12. MCP 集成
本章目标
让 Agent 动态加载外部工具——连接数据库、Slack、GitHub 等服务,声明一个服务器地址即可,不改源码。
graph TB Config["settings.json / .mcp.json"] --> Manager[McpManager] Manager -->|spawn + stdio| S1[MCP Server A] Manager -->|spawn + stdio| S2[MCP Server B] S1 -->|JSON-RPC| Tools1["mcp__A__tool1<br/>mcp__A__tool2"] S2 -->|JSON-RPC| Tools2["mcp__B__tool3"] Tools1 --> Agent[Agent Loop] Tools2 --> Agent Agent -->|tool_use: mcp__A__tool1| Manager Manager -->|路由到 Server A| S1 style Manager fill:#7c5cfc,color:#fff style Agent fill:#e8e0ff
核心思路:spawn 子进程 → JSON-RPC 握手 → 发现工具 → 前缀注册 → 透明路由。对 Agent Loop 来说,MCP 工具和内置工具没有区别——都是名字 + schema + 执行函数。
Claude Code 怎么做的
MCP(Model Context Protocol)是 Anthropic 发布的开放协议,用于连接 AI 助手与外部工具。Claude Code 的 MCP 实现有以下要点:
配置发现:从 settings.json(用户级、项目级)和 .mcp.json(项目根目录)三处读取服务器配置,优先级后读覆盖先读。企业级还支持 MDM 策略下发。
传输协议:支持 stdio(子进程通信)和 SSE(HTTP 长连接)两种传输方式。stdio 是主流,SSE 用于远程服务。
工具命名:所有 MCP 工具以 mcp__serverName__toolName 格式注册,三段式命名同时解决了命名冲突和路由问题——从名字就能知道该转发到哪个服务器。
连接生命周期:spawn 进程 → initialize 握手(交换版本和能力)→ notifications/initialized 确认 → tools/list 发现工具 → 就绪。初始化和工具发现各有 15 秒超时。
动态刷新:Claude Code 支持运行时重新发现工具(服务器可以通知客户端工具列表已变更),我们简化为一次性发现。
SDK 依赖:Claude Code 使用 @anthropic-ai/sdk 内置的 MCP 客户端,封装了 JSON-RPC 细节。我们直接实现原始 JSON-RPC,不依赖任何 MCP SDK。
配置格式
用户只需在配置文件中声明 MCP 服务器,Agent 启动时自动连接:
// ~/.claude/settings.json(用户级)或 .claude/settings.json(项目级)
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {}
},
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxx"
}
}
}
}也可以使用项目根目录的 .mcp.json,格式相同。三处配置的服务器合并后一起连接,同名服务器后读覆盖先读。
我们的实现
用 ~266 行 的 mcp.ts 实现完整的 MCP 客户端,无任何 SDK 依赖。
| Claude Code | 我们的实现 | 简化原因 |
|---|---|---|
@anthropic-ai/sdk MCP 客户端 | 原始 JSON-RPC(~100 行) | 无 SDK 依赖,读者能看到协议细节 |
| stdio + SSE 两种传输 | 仅 stdio | stdio 覆盖 95% 场景 |
| 动态工具刷新 | 一次性发现 | 教程场景不需要热更新 |
| 企业策略 + 3 种配置源 | settings.json + .mcp.json | 去掉企业级配置 |
| 重试 + 降级 | 静默跳过失败服务器 | 简化错误处理 |
关键代码
1. MCP 连接 — McpConnection 类
每个 MCP 服务器对应一个 McpConnection 实例,负责子进程管理和 JSON-RPC 通信。
class McpConnection {
private process: ChildProcess | null = null;
private nextId = 1;
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
private rl: Interface | null = null;
constructor(private serverName: string, private config: McpServerConfig) {}三个关键状态:process 是子进程句柄,pending 是请求-响应关联表(id → Promise),rl 是 readline 实例用于按行解析 JSON-RPC。
连接与消息解析
async connect(): Promise<void> {
const env = { ...process.env, ...(this.config.env || {}) };
this.process = spawn(this.config.command, this.config.args || [], {
stdio: ["pipe", "pipe", "pipe"],
env,
});
// 按行解析 stdout 中的 JSON-RPC 消息
this.rl = createInterface({ input: this.process.stdout! });
this.rl.on("line", (line: string) => {
try {
const msg = JSON.parse(line);
if (msg.id !== undefined && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
if (msg.error) {
reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
} else {
resolve(msg.result);
}
}
} catch {
// 忽略非 JSON 行(服务器日志等)
}
});
}stdio 模式的核心:子进程的 stdin/stdout 作为双向通信通道,每行一个 JSON-RPC 消息。pending Map 用自增 id 关联请求和响应——发送时存入 Promise,收到响应时 resolve 或 reject。
请求与通知
JSON-RPC 有两种消息:请求(有 id,期望响应)和通知(无 id,发后不管)。
/** 发送请求,等待响应 */
private sendRequest(method: string, params: any = {}): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.process?.stdin?.writable) {
return reject(new Error(`MCP server '${this.serverName}' is not connected`));
}
const id = this.nextId++;
this.pending.set(id, { resolve, reject });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
this.process.stdin.write(msg);
});
}
/** 发送通知,不等响应 */
private sendNotification(method: string, params: any = {}): void {
if (!this.process?.stdin?.writable) return;
const msg = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
this.process.stdin.write(msg);
}区别只在有无 id 字段。有 id 的消息写入 pending 等待配对;无 id 的直接写入 stdin 就结束。
握手、发现、调用
/** MCP 初始化握手 */
async initialize(): Promise<void> {
await this.sendRequest("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "mini-claude", version: "1.0.0" },
});
// 握手成功后发通知确认
this.sendNotification("notifications/initialized");
}
/** 发现服务器提供的工具 */
async listTools(): Promise<McpToolInfo[]> {
const result = await this.sendRequest("tools/list");
if (!result?.tools || !Array.isArray(result.tools)) return [];
return result.tools.map((t: any) => ({
name: t.name,
description: t.description || "",
inputSchema: t.inputSchema,
serverName: this.serverName,
}));
}
/** 调用工具,返回文本结果 */
async callTool(name: string, args: any): Promise<string> {
const result = await this.sendRequest("tools/call", { name, arguments: args });
if (result?.content && Array.isArray(result.content)) {
return result.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n");
}
return JSON.stringify(result);
}三步标准流程:initialize(版本协商)→ listTools(工具发现)→ callTool(执行调用)。MCP 协议要求 initialize 之后必须发 notifications/initialized 通知,告诉服务器客户端准备就绪。
callTool 的返回值处理值得注意:MCP 返回 { content: [{ type: "text", text: "..." }] } 格式,我们只提取 text 类型的内容拼接返回——图片等其他类型暂不处理。
2. MCP 管理器 — McpManager 类
管理所有 MCP 连接的生命周期,对外提供统一接口。
配置加载
export class McpManager {
private connections = new Map<string, McpConnection>();
private tools: McpToolInfo[] = [];
private connected = false;
private loadConfigs(): Record<string, McpServerConfig> {
const merged: Record<string, McpServerConfig> = {};
// 1. 用户级:~/.claude/settings.json
const globalPath = join(homedir(), ".claude", "settings.json");
this.mergeConfigFile(globalPath, merged);
// 2. 项目级:.claude/settings.json
const projectPath = join(process.cwd(), ".claude", "settings.json");
this.mergeConfigFile(projectPath, merged);
// 3. MCP 专用:.mcp.json
const mcpJsonPath = join(process.cwd(), ".mcp.json");
this.mergeConfigFile(mcpJsonPath, merged);
return merged;
}
private mergeConfigFile(filePath: string, target: Record<string, McpServerConfig>): void {
if (!existsSync(filePath)) return;
try {
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
const servers = raw.mcpServers || raw; // .mcp.json 可能直接是服务器映射
for (const [name, config] of Object.entries(servers)) {
if (this.isValidConfig(config)) {
target[name] = config as McpServerConfig;
}
}
} catch {
// 静默跳过格式错误的配置文件
}
}三处配置依次读取、合并,同名服务器后读覆盖先读。raw.mcpServers || raw 这行兼容两种格式:settings.json 的 mcpServers 嵌套结构和 .mcp.json 的扁平结构。
连接与发现
async loadAndConnect(): Promise<void> {
if (this.connected) return; // 幂等:多次调用只连一次
this.connected = true;
const configs = this.loadConfigs();
if (Object.keys(configs).length === 0) return;
const TIMEOUT_MS = 15_000;
for (const [name, config] of Object.entries(configs)) {
const conn = new McpConnection(name, config);
try {
await conn.connect();
// 握手和工具发现都有 15 秒超时
await Promise.race([
conn.initialize(),
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), TIMEOUT_MS)),
]);
const serverTools = await Promise.race([
conn.listTools(),
new Promise<McpToolInfo[]>((_, rej) => setTimeout(() => rej(new Error("timeout")), TIMEOUT_MS)),
]);
this.connections.set(name, conn);
this.tools.push(...serverTools);
console.error(`[mcp] Connected to '${name}' — ${serverTools.length} tools`);
} catch (err: any) {
console.error(`[mcp] Failed to connect to '${name}': ${err.message}`);
conn.close(); // 失败的连接立即清理,不影响其他服务器
}
}
}Promise.race 配合 setTimeout 实现超时。为什么是 15 秒?MCP 服务器常用 npx 启动,首次运行需要下载包,但也不应该无限等待。每个服务器独立连接,一个失败不影响其他。
工具定义转换
getToolDefinitions(): Array<{ name: string; description: string; input_schema: any }> {
return this.tools.map((t) => ({
name: `mcp__${t.serverName}__${t.name}`,
description: t.description || `MCP tool ${t.name} from ${t.serverName}`,
input_schema: t.inputSchema || { type: "object", properties: {} },
}));
}关键操作:把 MCP 原始工具名转换成三段式前缀名。filesystem 服务器的 read_file 工具变成 mcp__filesystem__read_file。返回的格式直接符合 Anthropic API 的 tool 定义规范,可以直接拼接到工具列表里。
路由与调用
isMcpTool(name: string): boolean {
return name.startsWith("mcp__");
}
async callTool(prefixedName: string, args: any): Promise<string> {
// mcp__serverName__toolName → serverName, toolName
const parts = prefixedName.split("__");
if (parts.length < 3) throw new Error(`Invalid MCP tool name: ${prefixedName}`);
const serverName = parts[1];
const toolName = parts.slice(2).join("__"); // 工具名可能包含 __
const conn = this.connections.get(serverName);
if (!conn) throw new Error(`MCP server '${serverName}' not connected`);
return conn.callTool(toolName, args);
}路由逻辑非常简洁:从前缀名中拆出服务器名和工具名,找到对应连接,转发调用。parts.slice(2).join("__") 处理工具名本身可能包含 __ 的情况(虽然罕见,但协议不禁止)。
3. Agent 集成
MCP 对 Agent Loop 的侵入极小——只有两处改动。
首次 chat 时懒加载
// agent.ts — chat() 方法开头
if (!this.mcpInitialized && !this.isSubAgent) {
this.mcpInitialized = true;
try {
await this.mcpManager.loadAndConnect();
const mcpDefs = this.mcpManager.getToolDefinitions();
if (mcpDefs.length > 0) {
this.tools = [...this.tools, ...mcpDefs as ToolDef[]];
}
} catch (err: any) {
console.error(`[mcp] Init failed: ${err.message}`);
}
}三个设计决策:
- 懒加载(首次 chat 时,而非构造函数里):用户可能只是想问个快问题,不需要付 MCP 连接的启动成本
- 只在主 Agent 加载:子 Agent 继承主 Agent 的工具列表,不需要重复连接
- 失败不崩溃:MCP 连接失败只输出日志,Agent 继续用内置工具工作
工具调用路由
// agent.ts — executeToolCall() 方法
private async executeToolCall(name: string, input: Record<string, any>): Promise<string> {
if (name === "enter_plan_mode" || name === "exit_plan_mode") return await this.executePlanModeTool(name);
if (name === "agent") return this.executeAgentTool(input);
if (name === "skill") return this.executeSkillTool(input);
// MCP 工具:前缀匹配,转发到 McpManager
if (this.mcpManager.isMcpTool(name)) return this.mcpManager.callTool(name, input);
return executeTool(name, input, this.readFileState);
}一行 if 判断,一行转发调用。MCP 工具对 Agent Loop 来说完全透明——模型看到的是 mcp__filesystem__read_file,发出 tool_use 调用,得到文本结果,跟内置工具没有任何区别。
关键设计决策
为什么用 JSON-RPC over stdio 而不是 HTTP?
stdio 的优势是零配置:不需要端口管理、不需要发现服务、进程生命周期自动绑定到父进程。子进程退出时所有 pending 请求自动 reject,不存在连接泄漏。HTTP 方案需要处理端口冲突、进程发现、心跳检测,复杂度高一个数量级。
为什么用三段式前缀名(mcp__server__tool)?
一个名字同时解决两个问题:避免冲突(不同服务器可能有同名工具)和嵌入路由信息(从名字直接提取服务器名,无需额外映射表)。Claude Code 用完全相同的命名方案。
为什么 15 秒超时?
MCP 服务器常用 npx 启动,首次运行需要下载 npm 包,通常需要 3-8 秒。15 秒足够覆盖大多数情况,但不至于让用户等太久。超时后静默跳过该服务器,Agent 继续用其他可用工具工作。
为什么懒连接(首次 chat 时而非启动时)?
用户可能启动 Agent 只是想问一句”这个函数是什么意思”,根本用不到 MCP 工具。懒连接让这种场景零开销。代价是第一次需要 MCP 工具时会有几秒延迟,但只发生一次。
为什么不用 MCP SDK?
@anthropic-ai/sdk 提供了 MCP 客户端封装,但直接用原始 JSON-RPC 有两个好处:零依赖(不增加包体积)和教学价值(读者能看到协议的完整细节,理解 MCP 到底在做什么)。整个 JSON-RPC 通信只有 ~60 行代码,足够简单。
简化对比
| 维度 | Claude Code | mini-claude |
|---|---|---|
| MCP SDK | @anthropic-ai/sdk 内置客户端 | 原始 JSON-RPC(无 SDK 依赖) |
| 服务器协议 | stdio + SSE | 仅 stdio |
| 工具发现 | 动态刷新(服务器可通知变更) | 一次性发现 |
| 配置来源 | settings.json + .mcp.json + 企业策略 | settings.json + .mcp.json |
| 错误处理 | 重试 + 降级 | 静默跳过失败服务器 |
| 连接时机 | 首次 chat 时懒加载 | 首次 chat 时懒加载 |
| 子 Agent 支持 | 独立 MCP 连接 | 主 Agent 专属,子 Agent 不连接 |
下一章:完整的架构对比——从 ~3400 行到 50 万行,差距在哪里,以及下一步可以做什么。