Learn Claude Code
s19

MCP & Plugin

Multi-Agent Platform

External Capability Bus|453 LOC|6 tools

External capabilities join the same routing, permission, and result-append path as native tools.

s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > s10 > s11 > s12 > s13 > s14 > s15 > s16 > s17 > s18 > [ s19 ]

What You'll Learn

  • How MCP (Model Context Protocol -- a standard way for the agent to talk to external capability servers) lets your agent gain new tools without changing its core code
  • How tool name normalization with a mcp__{server}__{tool} prefix keeps external tools from colliding with native ones
  • How a unified router dispatches tool calls to local handlers or remote servers through the same path
  • How plugin manifests let external capability servers be discovered and launched automatically

Up to this point, every tool your agent uses -- bash, read, write, edit, tasks, worktrees -- lives inside your Python harness. You wrote each one by hand. That works well for a teaching codebase, but a real agent needs to talk to databases, browsers, cloud services, and tools that do not exist yet. Hard-coding every possible capability is not sustainable. This chapter shows how external programs can join your agent through the same tool-routing plane you already built.

The Problem

Your agent is powerful, but its capabilities are frozen at build time. If you want it to query a Postgres database, you write a new Python handler. If you want it to control a browser, you write another handler. Every new capability means changing the core harness, re-testing the tool router, and redeploying. Meanwhile, other teams are building specialized servers that already know how to talk to these systems. You need a standard protocol so those external servers can expose their tools to your agent, and your agent can call them as naturally as it calls its own native tools -- without rewriting the core loop every time.

The Solution

MCP gives your agent a standard way to connect to external capability servers over stdio. The agent starts a server process, asks what tools it provides, normalizes their names with a prefix, and routes calls to that server -- all through the same tool pipeline that handles native tools.

LLM
  |
  | asks to call a tool
  v
Agent tool router
  |
  +-- native tool  -> local Python handler
  |
  +-- MCP tool     -> external MCP server
                        |
                        v
                    return result

Read Together

  • If you want to understand how MCP fits into the broader capability surface beyond just tools (resources, prompts, plugin discovery), s19a-mcp-capability-layers.md covers the full platform boundary.
  • If you want to confirm that external capabilities still return through the same execution surface as native tools, pair this chapter with s02b-tool-execution-runtime.md.
  • If query control and external capability routing are drifting apart in your mental model, s00a-query-control-plane.md ties them together.

How It Works

There are three essential pieces. Once you understand them, MCP stops being mysterious.

Step 1. Build an MCPClient that manages the connection to one external server. It starts the server process over stdio, sends a handshake, and caches the list of available tools.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Step 2. Normalize external tool names with a prefix so they never collide with native tools. The convention is simple: mcp__{server}__{tool}.

mcp__postgres__query
mcp__browser__open_tab

This prefix serves double duty: it prevents name collisions, and it tells the router exactly which server should handle the call.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Step 3. Build one unified router. The router does not care whether a tool is native or external beyond the dispatch decision. If the name starts with mcp__, route to the MCP server; otherwise, call the local handler. This keeps the agent loop untouched -- it just sees a flat list of tools.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Step 4. Add plugin discovery. If MCP answers "how does the agent talk to an external capability server," plugins answer "how are those servers discovered and configured?" A minimal plugin is a manifest file that tells the harness which servers to launch:

{
  "name": "my-db-tools",
  "version": "1.0.0",
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"]
    }
  }
}

This lives in .claude-plugin/plugin.json. The PluginLoader scans for these manifests, extracts the server configs, and hands them to the MCPToolRouter for connection.

Step 5. Enforce the safety boundary. This is the most important rule of the entire chapter: external tools must still pass through the same permission gate as native tools. If MCP tools bypass permission checks, you have created a security backdoor at the edge of your system.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

How It Plugs Into The Full Harness

MCP gets confusing when it is treated like a separate universe. The cleaner model is:

startup
  ->
plugin loader finds manifests
  ->
server configs are extracted
  ->
MCP clients connect and list tools
  ->
external tools are normalized into the same tool pool

runtime
  ->
LLM emits tool_use
  ->
shared permission gate
  ->
native route or MCP route
  ->
result normalization
  ->
tool_result returns to the same loop

Different entry point, same control plane and execution plane.

Plugin vs Server vs Tool

LayerWhat it isWhat it is for
plugin manifesta config declarationtells the harness which servers to discover and launch
MCP serveran external process / connectionexposes a set of capabilities
MCP toolone callable capability from that serverthe concrete thing the model invokes

Shortest memory aid:

  • plugin = discovery
  • server = connection
  • tool = invocation

Key Data Structures

Server config

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Normalized external tool definition

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Client registry

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

What Changed From s18

ComponentBefore (s18)After (s19)
Tool sourcesAll native (local Python)Native + external MCP servers
Tool namingFlat names (bash, read_file)Prefixed for externals (mcp__postgres__query)
RoutingSingle handler mapUnified router: native dispatch + MCP dispatch
Capability growthEdit harness code for each toolAdd a plugin manifest or connect a server
Permission scopeNative tools onlyNative + external tools through same gate

Try It

npm run s19
  1. Ask the agent to run pwd
  2. Ask it to run ls -la
  3. Ask it to summarize the current workspace in one sentence
  4. Ask it to create notes/hello.ts and print the file content

What You've Mastered

At this point, you can:

  • Connect to external capability servers using the MCP stdio protocol
  • Normalize external tool names with a mcp__{server}__{tool} prefix to prevent collisions
  • Route tool calls through a unified dispatcher that handles both native and MCP tools
  • Discover and launch MCP servers automatically through plugin manifests
  • Enforce the same permission checks on external tools as on native ones

The Full Picture

You have now walked through the complete design backbone of a production coding agent, from s01 to s19.

You started with a bare agent loop that calls an LLM and appends tool results. You added tool use, then a persistent task list, then subagents, skill loading, and context compaction. You built a permission system, a hook system, and a memory system. You constructed the system prompt pipeline, added error recovery, and gave agents a full task board with background execution and cron scheduling. You organized agents into teams with coordination protocols, made them autonomous, gave each task its own isolated worktree, and finally opened the door to external capabilities through MCP.

Each chapter added exactly one idea to the system. None of them required you to throw away what came before. The agent you have now is not a toy -- it is a working model of the same architectural decisions that shape real production agents.

If you want to test your understanding, try rebuilding the complete system from scratch. Start with the agent loop. Add tools. Add tasks. Keep going until you reach MCP. If you can do that without looking back at the chapters, you understand the design. And if you get stuck somewhere in the middle, the chapter that covers that idea will be waiting for you.

Key Takeaway

External capabilities should enter the same tool pipeline as native ones -- same naming, same routing, same permissions -- so the agent loop never needs to know the difference.