s18: MCP Tools — External Tools, Standard Protocol
s01 → ... → s16 → s17 → s18 → s19 → s20 → s21
"External tools, standard protocol" — Discover, assemble, invoke. Agent doesn't need to know who wrote them.
Harness layer: Plugins — External capabilities via a standard protocol.
The Problem
From s01 through s17, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.
Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.
You need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.
The Solution
MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:
| Concept | Purpose |
|---|---|
| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |
| MCP Server | The external service — implements tools/list + tools/call |
| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |
| mcp__server__tool naming | Prevents tool name collisions across different servers |
Carries forward s17's worktree isolation, autonomous claiming, and team protocols. This chapter adds the connect_mcp tool, which connects to a service, discovers its tools, and adds them to the tool pool.
The chapter registers in-process server handlers so the full discovery and invocation flow runs offline. Each handler exposes the two operations the client needs: tools/list and tools/call.
How It Works
MCPClient: Discovery + Invocation
class MCPClient:
def __init__(self, name: str):
self.name = name
self.tools: list[dict] = []
self._handlers: dict[str, callable] = {}
def register(self, tool_defs, handlers):
"""Simulates tools/list discovery."""
self.tools = tool_defs
self._handlers = handlers
def call_tool(self, tool_name: str, args: dict) -> str:
"""Simulates tools/call."""
handler = self._handlers.get(tool_name)
if not handler:
return f"MCP error: unknown tool '{tool_name}'"
return handler(**args)
The registered Python functions provide the server-side tool implementations used by tools/call.
connect_mcp: Connect + Discover
def connect_mcp(name: str) -> str:
if name in mcp_clients:
return f"MCP server '{name}' already connected"
factory = MOCK_SERVERS.get(name)
if not factory:
return f"Unknown server '{name}'. Available: ..."
mcp_client = factory()
mcp_clients[name] = mcp_client
return f"Connected to '{name}'. Discovered: ..."
After connecting, the server's tools are immediately available.
normalize_mcp_name: Name Normalization
_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')
def normalize_mcp_name(name: str) -> str:
return _DISALLOWED_CHARS.sub('_', name)
All non-[a-zA-Z0-9_-] characters are replaced with _. Prevents special characters in server or tool names from causing naming conflicts or injection issues.
assemble_tool_pool: Assemble Tool Pool
def assemble_tool_pool() -> tuple[list[dict], dict]:
tools = list(BUILTIN_TOOLS)
handlers = dict(BUILTIN_HANDLERS)
for server_name, mcp_client in mcp_clients.items():
safe_server = normalize_mcp_name(server_name)
for tool_def in mcp_client.tools:
safe_tool = normalize_mcp_name(tool_def["name"])
prefixed = f"mcp__{safe_server}__{safe_tool}"
tools.append(...)
handlers[prefixed] = (
lambda *, c=mcp_client, t=tool_def["name"], **kw:
c.call_tool(t, kw))
return tools, handlers
The prefix mcp__{server}__{tool} prevents tool name collisions across different servers. Names are normalized through normalize_mcp_name.
MCP tool descriptions include (readOnly) or (destructive) labels, making the distinction visible in the tool metadata.
No Cache: Tool Pool Changes, Prompt Changes Too
s10-s17's agent_loop used prompt caching to avoid re-serialization. s18 removes the cache:
def agent_loop(messages, context):
tools, handlers = assemble_tool_pool() # Rebuild every time
system = assemble_system_prompt(context) # Regenerate every time
...
if any(b.name == "connect_mcp" ...):
tools, handlers = assemble_tool_pool() # Rebuild after connection
system = assemble_system_prompt(context)
After connect_mcp, the tool pool gains entries such as mcp__docs__search. Reusing the old serialized tool list would hide those entries from the model, so the loop rebuilds the pool and system prompt after every connection.
MCP Tools: Lead Only
connect_mcp belongs to the Lead, and assemble_tool_pool serves the Lead's agent loop. Teammates keep their task, file, message, and plan tools; the Lead invokes external services and dispatches the resulting work.
Changes from s17
| Component | Before (s17) | After (s18) |
|---|---|---|
| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |
| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp__ prefixed tools |
| Name safety | None | normalize_mcp_name normalization |
| New type | — | MCPClient class (simulates tools/list + tools/call) |
| Namespace | — | mcp__server__tool prevents collisions |
| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |
| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |
| Lead tools | Worktree and team tools | + connect_mcp and dynamically discovered MCP tools |
| Teammate tools | Task, file, message, and plan tools | Unchanged |
| Extension method | Write code to add tools | Standard protocol, implement servers in any language |
Try It Out
cd learn-claude-code
python s18_mcp_plugin/code.py
Try these prompts:
Search the docs for the worktree cleanup policy.Deploy the current project and report the result.What documentation and deployment actions can you perform?
What to observe: After connecting to an MCP server, do tool names have mcp__docs__ or mcp__deploy__ prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?
What's Next
The Agent can now connect external tools through a standard protocol. The first 18 chapters introduced these mechanisms one at a time so each boundary stayed visible.
Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.
s19 Comprehensive Agent → Combine the mechanisms from s01-s18 into one complete harness. Many mechanisms, one loop.