12. MCP Integration

Chapter Goals

Enable the Agent to dynamically load external tools — connect to databases, Slack, GitHub, and other services by simply declaring a server address, without modifying source code.

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 -->|Route to Server A| S1

    style Manager fill:#7c5cfc,color:#fff
    style Agent fill:#e8e0ff

Core idea: spawn child process JSON-RPC handshake discover tools register with prefix transparent routing. From the Agent Loop’s perspective, MCP tools and built-in tools are indistinguishable — they’re all name + schema + execution function.

How Claude Code Does It

MCP (Model Context Protocol) is an open protocol released by Anthropic for connecting AI assistants to external tools. Key aspects of Claude Code’s MCP implementation:

Configuration discovery: Reads server configuration from three locations — settings.json (user-level and project-level) and .mcp.json (project root), with later reads overriding earlier ones. Enterprise deployments also support MDM policy distribution.

Transport protocols: Supports two transport methods — stdio (child process communication) and SSE (HTTP long-polling). stdio is the mainstream choice; SSE is used for remote services.

Tool naming: All MCP tools are registered in mcp__serverName__toolName format. This three-segment naming scheme simultaneously solves naming conflicts and routing — you can tell from the name alone which server to forward to.

Connection lifecycle: spawn process initialize handshake (exchange version and capabilities) notifications/initialized confirmation tools/list tool discovery ready. Both initialization and tool discovery have 15-second timeouts.

Dynamic refresh: Claude Code supports runtime tool re-discovery (servers can notify the client that the tool list has changed); we simplify to one-time discovery.

SDK dependency: Claude Code uses the @anthropic-ai/sdk built-in MCP client, which wraps JSON-RPC details. We implement raw JSON-RPC directly, with no MCP SDK dependency.

Configuration Format

Users only need to declare MCP servers in a configuration file, and the Agent connects automatically on startup:

// ~/.claude/settings.json (user-level) or .claude/settings.json (project-level)
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
      "env": {}
    },
    "github": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "ghp_xxx"
      }
    }
  }
}

You can also use .mcp.json in the project root, with the same format. Servers from all three configuration sources are merged and connected together; same-name servers are overridden by later reads.

Our Implementation

A complete MCP client in ~266 lines of mcp.ts, with zero SDK dependencies.

Claude CodeOur ImplementationSimplification Reason
@anthropic-ai/sdk MCP clientRaw JSON-RPC (~100 lines)No SDK dependency; readers can see protocol details
stdio + SSE transportsstdio onlystdio covers 95% of scenarios
Dynamic tool refreshOne-time discoveryTutorial scenarios don’t need hot reloading
Enterprise policy + 3 config sourcessettings.json + .mcp.jsonRemoved enterprise-level config
Retry + fallbackSilently skip failed serversSimplified error handling

Key Code

1. MCP Connection — McpConnection Class

Each MCP server corresponds to one McpConnection instance, responsible for child process management and JSON-RPC communication.

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) {}

Three key states: process is the child process handle, pending is the request-response correlation map (id Promise), and rl is the readline instance for line-by-line JSON-RPC parsing.

Connection and Message Parsing

  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,
    });
 
    // Parse JSON-RPC messages line by line from stdout
    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 {
        // Ignore non-JSON lines (server logs, etc.)
      }
    });
  }

The core of stdio mode: the child process’s stdin/stdout serve as a bidirectional communication channel, with one JSON-RPC message per line. The pending Map uses auto-incrementing ids to correlate requests and responses — a Promise is stored when sending, then resolved or rejected when the response arrives.

Requests and Notifications

JSON-RPC has two message types: requests (have an id, expect a response) and notifications (no id, fire and forget).

  /** Send a request and wait for a response */
  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);
    });
  }
 
  /** Send a notification, don't wait for a response */
  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);
  }

The only difference is the presence or absence of the id field. Messages with an id are stored in pending waiting for a match; messages without an id are simply written to stdin and done.

Handshake, Discovery, and Invocation

  /** MCP initialization handshake */
  async initialize(): Promise<void> {
    await this.sendRequest("initialize", {
      protocolVersion: "2024-11-05",
      capabilities: {},
      clientInfo: { name: "mini-claude", version: "1.0.0" },
    });
    // Send notification to confirm after successful handshake
    this.sendNotification("notifications/initialized");
  }
 
  /** Discover tools provided by the server */
  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,
    }));
  }
 
  /** Call a tool and return the text result */
  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);
  }

Three-step standard flow: initialize (version negotiation) listTools (tool discovery) callTool (execute calls). The MCP protocol requires sending a notifications/initialized notification after initialize to tell the server that the client is ready.

The return value handling in callTool is worth noting: MCP returns { content: [{ type: "text", text: "..." }] } format, and we only extract text-type content and concatenate it — other types like images are not processed for now.

2. MCP Manager — McpManager Class

Manages the lifecycle of all MCP connections, providing a unified interface.

Configuration Loading

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. User-level: ~/.claude/settings.json
    const globalPath = join(homedir(), ".claude", "settings.json");
    this.mergeConfigFile(globalPath, merged);
 
    // 2. Project-level: .claude/settings.json
    const projectPath = join(process.cwd(), ".claude", "settings.json");
    this.mergeConfigFile(projectPath, merged);
 
    // 3. MCP-specific: .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 may be a flat server mapping
      for (const [name, config] of Object.entries(servers)) {
        if (this.isValidConfig(config)) {
          target[name] = config as McpServerConfig;
        }
      }
    } catch {
      // Silently skip malformed config files
    }
  }

Three configuration sources are read and merged sequentially; same-name servers are overridden by later reads. The raw.mcpServers || raw line handles two formats: the nested mcpServers structure in settings.json and the flat structure in .mcp.json.

Connection and Discovery

  async loadAndConnect(): Promise<void> {
    if (this.connected) return;  // Idempotent: multiple calls only connect once
    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();
        // Both handshake and tool discovery have 15-second timeouts
        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();  // Clean up failed connections immediately; don't affect other servers
      }
    }
  }

Promise.race combined with setTimeout implements timeouts. Why 15 seconds? MCP servers often start via npx, which needs to download packages on first run, but we shouldn’t wait forever. Each server connects independently; one failure doesn’t affect others.

Tool Definition Conversion

  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: {} },
    }));
  }

The key operation: converting raw MCP tool names into three-segment prefixed names. The filesystem server’s read_file tool becomes mcp__filesystem__read_file. The returned format directly conforms to the Anthropic API’s tool definition spec and can be concatenated directly onto the tool list.

Routing and Invocation

  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("__");  // Tool name might contain __
    const conn = this.connections.get(serverName);
    if (!conn) throw new Error(`MCP server '${serverName}' not connected`);
    return conn.callTool(toolName, args);
  }

The routing logic is very concise: extract the server name and tool name from the prefixed name, find the corresponding connection, and forward the call. parts.slice(2).join("__") handles the case where the tool name itself might contain __ (rare, but the protocol doesn’t prohibit it).

3. Agent Integration

MCP’s impact on the Agent Loop is minimal — only two changes.

Lazy Loading on First Chat

// agent.ts — beginning of chat() method
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}`);
  }
}

Three design decisions:

  1. Lazy loading (on first chat, not in the constructor): The user might just want to ask a quick question and doesn’t need to pay the MCP connection startup cost
  2. Only load in the main Agent: Sub-agents inherit the main Agent’s tool list and don’t need to reconnect
  3. Failure doesn’t crash: MCP connection failures only produce log output; the Agent continues working with built-in tools

Tool Call Routing

// agent.ts — executeToolCall() method
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 tools: prefix match, forward to McpManager
  if (this.mcpManager.isMcpTool(name)) return this.mcpManager.callTool(name, input);
  return executeTool(name, input, this.readFileState);
}

One if check, one forwarding call. MCP tools are completely transparent to the Agent Loop — the model sees mcp__filesystem__read_file, issues a tool_use call, gets a text result back, with absolutely no difference from built-in tools.

Key Design Decisions

Why JSON-RPC over stdio Instead of HTTP?

The advantage of stdio is zero configuration: no port management needed, no service discovery, and the process lifecycle is automatically tied to the parent process. When the child process exits, all pending requests are automatically rejected — no connection leaks. An HTTP approach would need to handle port conflicts, process discovery, and heartbeat detection — an order of magnitude more complex.

Why Three-Segment Prefixed Names (mcp__server__tool)?

One name solves two problems simultaneously: avoiding conflicts (different servers may have same-named tools) and embedding routing information (the server name is extracted directly from the name, with no need for an additional mapping table). Claude Code uses the exact same naming scheme.

Why a 15-Second Timeout?

MCP servers often start via npx, which needs to download npm packages on first run, typically taking 3-8 seconds. 15 seconds is enough to cover most cases without making users wait too long. After a timeout, the server is silently skipped, and the Agent continues working with other available tools.

Why Lazy Connection (on First Chat Rather Than Startup)?

A user might start the Agent just to ask “what does this function mean?” without needing MCP tools at all. Lazy connection makes this scenario zero-overhead. The trade-off is a few seconds of delay the first time an MCP tool is needed, but this only happens once.

Why Not Use the MCP SDK?

@anthropic-ai/sdk provides an MCP client wrapper, but using raw JSON-RPC directly has two benefits: zero dependencies (no added package size) and educational value (readers can see the complete protocol details and understand what MCP is actually doing). The entire JSON-RPC communication is only ~60 lines of code, simple enough.

Simplification Comparison

DimensionClaude Codemini-claude
MCP SDK@anthropic-ai/sdk built-in clientRaw JSON-RPC (no SDK dependency)
Server protocolstdio + SSEstdio only
Tool discoveryDynamic refresh (server can notify of changes)One-time discovery
Config sourcessettings.json + .mcp.json + enterprise policysettings.json + .mcp.json
Error handlingRetry + fallbackSilently skip failed servers
Connection timingLazy load on first chatLazy load on first chat
Sub-agent supportIndependent MCP connectionsMain Agent only; sub-agents don’t connect

Next chapter: Full architecture comparison — from ~3400 lines to 500,000, where’s the gap, and what to do next.