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 两种传输仅 stdiostdio 覆盖 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.jsonmcpServers 嵌套结构和 .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}`);
  }
}

三个设计决策:

  1. 懒加载(首次 chat 时,而非构造函数里):用户可能只是想问个快问题,不需要付 MCP 连接的启动成本
  2. 只在主 Agent 加载:子 Agent 继承主 Agent 的工具列表,不需要重复连接
  3. 失败不崩溃: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 Codemini-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 万行,差距在哪里,以及下一步可以做什么。