s19: MCP Tools — External Tools, Standard Protocol

s01 → ... → s17 → s18 → s19s20

"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 s18, 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 Architecture)

MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:

ConceptPurpose
MCPClientThe agent-side client — connects to servers, discovers tools, invokes tools
MCP ServerThe external service — implements tools/list + tools/call
assemble_tool_poolAssembles built-in tools and MCP tools into one tool pool
mcp__server__tool namingPrevents tool name collisions across different servers

Carries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds: the connect_mcp tool — connect to external services, discover tools, add them to the tool pool.

The tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management.


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 tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC.

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) annotations — the tutorial uses text annotations, while real CC uses structured tool annotations for the permission system.

No Cache: Tool Pool Changes, Prompt Changes Too

s10-s18's agent_loop used prompt caching to avoid re-serialization. s19 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)

Reason: after connect_mcp, the tool pool changes — new tools like mcp__docs__search are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time.

MCP Tools: Lead Only

In the tutorial, connect_mcp is a Lead tool, and assemble_tool_pool only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task).

This is a teaching simplification. In real CC, MCP tools are available to both the main agent and sub-agents — sub-agents inherit the parent's MCP configuration.


Changes from s18

ComponentBefore (s18)After (s19)
Tool sourceAll hand-written built-inHand-written + MCP external tools with dynamic discovery
Tool poolFixed BUILTIN_TOOLSassemble_tool_pool dynamically assembles mcp__ prefixed tools
Name safetyNonenormalize_mcp_name normalization
New typeMCPClient class (simulates tools/list + tools/call)
Namespacemcp__server__tool prevents collisions
Tool descriptionsNo annotations(readOnly)/(destructive) annotations
Prompt cacheYes (since s10)Removed — tool pool is dynamic, cache goes stale
Lead tools17 (s18)18 (+connect_mcp)
Teammate tools8 (s18)8 (unchanged, MCP tools are Lead-only)
Extension methodWrite code to add toolsStandard protocol, implement servers in any language

Try It Out

cd learn-claude-code
python s19_mcp_plugin/code.py

Try these prompts:

  1. Connect to the docs MCP server and search for something
  2. Connect to the deploy server and trigger a deployment
  3. Connect both servers — what tools are now available?

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. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos.

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.

s20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.

Deep Dive into CC Source

The following is based on analysis of CC source: services/mcp/client.ts, auth.ts, config.ts, channelNotification.ts.

1. Six Transport Types

The tutorial only shows a stdio mock. CC supports 6 transport types (types.ts:23-25):

TransportCommunication method
stdioSubprocess stdin/stdout (cross-platform default)
sseHTTP Server-Sent Events
httpStreamable HTTP (POST/SSE bidirectional)
wsWebSocket
sse-ideIDE-embedded SSE transport
sdkIn-process SDK transport

On connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20.

2. Tool Pool Merging Algorithm

assembleToolPool() (tools.ts:345-364):

// Dedup with priority: built-in tools win on name collision (sorted first)
return uniqBy(
  [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],
  'name',
)

Built-in and MCP tools are sorted separately, not together. The reason is CC's claude_code_system_cache_policy places a global cache breakpoint after the last built-in tool at a specific position — mixing the sort would break this design.

3. Naming Convention: mcp__server__tool

buildMcpToolName() (mcpStringUtils.ts:50-52):

mcp__<normalizedServerName>__<normalizedToolName>

All non-[a-zA-Z0-9_-] characters are replaced with _ (normalization.ts:17-23). The tutorial's normalize_mcp_name uses the same rule.

4. Permission Checks

CC has a separate permission system for MCP tools. checkPermissions() applies different logic for MCP tools than for built-in tools — MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and CC decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations (readOnly) / (destructive) in descriptions, without permission enforcement.

5. Configuration Sources and Priority

MCP server configuration comes from multiple sources. CC's priority from lowest to highest:

claude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json

claude.ai connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (config.ts:1267-1289). When enterprise managed-mcp.json exists, all other configurations are excluded.

The tutorial passes server names directly to the MOCK_SERVERS dict, without config merging.

6. Channel Notifications: Servers Push Messages Back

The tutorial only covers agent → MCP Server unidirectional calls. CC also supports reverse notifications (channelNotification.ts):

  1. Server declares capabilities.experimental['claude/channel']
  2. Server sends messages to agent via MCP notification notifications/claude/channel
  3. Messages are wrapped in <channel source="serverName">...</channel> XML tags
  4. Agent is woken up by SleepTool (within 1 second)

Servers can also request permissions: notifications/claude/channel/permission_request → Agent replies notifications/claude/channel/permission. Users confirm/deny via a 5-letter short ID.

7. OAuth Authentication Flow

CC's MCP authentication (auth.ts) supports a full OAuth 2.0 + PKCE flow:

  • OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728)
  • Local callback server receives authorization code
  • Tokens persisted via getSecureStorage() (macOS Keychain / Linux encrypted file / Windows Credential Manager)
  • Auto-refresh 5 minutes before expiry
  • Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups

8. Connection Lifecycle Error Handling

CC has fine-grained error classification and retry for MCP connections (client.ts:1266-1402):

  • Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect
  • Tool call 401: Token expired → throw McpAuthError → trigger re-authentication
  • Tool call timeout: Promise.race timeout (configurable, default ~28 hours)
  • Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order

The Tutorial's Simplifications

  • 6 transport types → 1 (mock stdio): Manageable concept count
  • Channel reverse notifications → omitted: Tutorial agent is always the initiator
  • OAuth flow → omitted: Tutorial assumes servers need no auth
  • Multi-layer config priority → omitted: Tutorial passes server name directly
  • Complex error classification → omitted: Tutorial uses try/except as fallback
  • MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure