DEV Community

Cover image for Code Agent Dissection (07): How Are External Tools Integrated? A Deep Dive into MCP
WonderLab
WonderLab

Posted on

Code Agent Dissection (07): How Are External Tools Integrated? A Deep Dive into MCP

The Kind of Capability Skills Can't Handle

The Skills from the previous article are local Markdown instructions: no Python, no new processes — the model reads the content on demand and follows along. They work well for procedural knowledge like 'team code review guidelines.'

External capabilities have a different shape. Web search, documentation libraries, browsers, internal company APIs — they run in another process, with their own parameters and lifecycle. Writing a Tool subclass for each service works, but every new service means touching the code again.

The engineering meaning of MCP (Model Context Protocol) is concrete: let external processes expose tools following a standard protocol, have the agent discover them at startup, disguise them as already-registered local Tools, and from that point they flow through the same pipeline introduced in article 05 — schema, orchestration, permissions, and observation writes.


The Conclusion Up Front

The MCP startup entry point isn't in extensions/mcp/, it's during agent context assembly:

CodeAgent._initialize_runtime_components()
        │
        ▼
build_runtime_context()              ← runtime/factory.py
        │
        ├─ _register_builtin_tools()   builtin tools enter Registry first
        │
        ├─ if host.enable_mcp:         ← False by default; skipped if not enabled
        │       host._register_mcp_tools()
        │           │
        │           ▼
        │   register_mcp_servers()     ← extensions/mcp/bootstrap.py
        │           │
        │           ├─ load_mcp_servers()   reads mcp.json / MCP_SERVERS
        │           ├─ MCPClient (stdio / http)
        │           ├─ list_tools() discovers remote tools
        │           └─ MCPToolAdapter → ToolRegistry
        │
        └─ ContextBuilder(...)         ← writes _mcp_tools_prompt into Tool Contracts
                │
                ▼
        Subsequent ReAct steps work the same as Read/Bash:
        tools= schema / Orchestrator → Executor → adapter.run()
                │
                ▼
        protocol.py normalizes MCP content into a unified envelope
Enter fullscreen mode Exit fullscreen mode

Why start from factory? Article 01 explained that build_runtime_context() is where the context engine is assembled at startup. Both Skills and MCP are wired here, in a fixed order: builtin tools first, then MCP, then ContextBuilder. MCP tools must be registered before ContextBuilder is created, so tool_prompt_allowlist=frozenset(host.tool_registry.list_tools()) can include external tools, and mcp_tools_prompt can be passed in accordingly.

The boundary between Skills and MCP:

Skills MCP
Carrier SKILL.md External process / HTTP service
What enters Registry One local Skill tool One Adapter per remote tool
Enabled by default Yes (if file exists) No; requires --enable-mcp
Dependencies None Optional extra: mcp SDK

Step 1: Starting from if host.enable_mcp in factory

To understand MCP, start by opening build_runtime_context() in runtime/factory.py. After handling Skills and builtin tools, comes the MCP branch:

# runtime/factory.py — build_runtime_context()
host._register_builtin_tools()   # ① builtin tools enter Registry first

host._mcp_clients = []
host._mcp_tools_prompt = ""
if host.enable_mcp:              # ② False by default; entire block skipped if not set
    host._register_mcp_tools()

host.context_builder = ContextBuilder(
    tool_registry=host.tool_registry,
    mcp_tools_prompt=host._mcp_tools_prompt,   # ③ MCP documentation enters Tool Contracts
    tool_prompt_allowlist=frozenset(host.tool_registry.list_tools()) | {"Task"},
    ...
)
Enter fullscreen mode Exit fullscreen mode

These three lines form the master switch for MCP:

  1. enable_mcp is False → nothing happens below, _mcp_clients stays empty, Registry only has builtin tools
  2. True → calls host._register_mcp_tools(), connects to external servers, discovers tools, registers Adapters
  3. Regardless, ContextBuilder is created next; when MCP is on, _mcp_tools_prompt is already populated and appended to the system's Tool Contracts

Where does enable_mcp come from? Default Config.enable_mcp = False; CLI --enable-mcp or env var ENABLE_MCP=true enables it. This isn't laziness — stdio spawns child processes, and the SDK (mcp, anyio) shouldn't be bundled into the core install.


Step 2: _register_mcp_tools() Delegates to Bootstrap

Factory only decides and calls; the actual logic lives in host._register_mcp_tools():

# runtime/host.py — _register_mcp_tools() (simplified)
clients, tools_meta = register_mcp_servers(self.tool_registry, self.project_root)
self._mcp_clients = clients
self._mcp_tools_prompt = format_mcp_tools_prompt(tools_meta)
Enter fullscreen mode Exit fullscreen mode
  • register_mcp_servers() connects to servers, discovers tools, and inserts Adapters into the existing tool_registry
  • The returned clients are stored in self._mcp_clients; CodeAgent.close() calls close_sync() on each to prevent child process leaks
  • tools_meta is formatted into natural language; ContextBuilder appends it as ## MCP Tools

If the MCP extra isn't installed but the flag is enabled, this raises MCPExtraRequiredError with a suggestion to pip install 'mycodeagent[mcp]'. The SDK is lazily imported in bootstrap — only when _register_mcp_tools() is actually called:

# extensions/mcp/bootstrap.py — SDK only loaded when really registering
def _load_mcp_runtime():
    try:
        from extensions.mcp.adapter import register_mcp_tools
        from extensions.mcp.client import MCPClient, MCPClientConfig
    except ImportError as exc:
        raise MCPExtraRequiredError(...) from exc
    return MCPClient, MCPClientConfig, register_mcp_tools
Enter fullscreen mode Exit fullscreen mode

Core installation verified: with enable_mcp off, factory never reaches here, and the main loop runs fine.


Step 3: Where Configuration Comes From

load_mcp_servers() reads in priority order:

  1. Environment variable MCP_SERVERS (a JSON blob)
  2. mcp_servers.json / .mcp.json / mcp.json in the project root

Compatible with Claude's common mcpServers wrapper:

{
  "mcpServers": {
    "docs": { "command": "uvx", "args": ["mcp-server-fetch"] },
    "search": { "url": "https://example.com/mcp" }
  }
}
Enter fullscreen mode Exit fullscreen mode

No url means stdio: spawn a process using command + args. With url (or transport=http), use HTTP. uvx/uv will additionally pin the cache directory to .uv_cache under the project, avoiding contamination of the user's global environment.

MCP_CONNECT_MODE defaults to startup: connect and call list_tools at startup. Set to disabled to have config present but not connect.


Step 4: Discovering Tools and Inserting into Registry

register_mcp_servers() creates an MCPClient per server, then calls list_tools_sync(). Each remote tool becomes an MCPToolAdapter:

# extensions/mcp/adapter.py — discovery + naming (simplified)
raw_public_name = f"{namespace}:{remote_name}"   # docs:search
public_name = sanitize_tool_name(raw_public_name)  # docs_search
public_name = ensure_unique(public_name)           # _2 suffix if conflicts with builtin
adapter = MCPToolAdapter(client, public_name, remote_name, description, schema)
tool_registry.register_tool(adapter)
Enter fullscreen mode Exit fullscreen mode

Three details:

  1. Namespacing: The public name includes the server prefix to avoid collisions when two servers both have a search tool
  2. Sanitization: Function Calling names only allow [a-zA-Z0-9_-]; colons become underscores
  3. Schema projection: The remote inputSchema is converted to a local ToolParameter list, so get_openai_tools() automatically includes these tools — the model sees them the same way it sees Read

The Adapter's run() doesn't read local files; it calls mcp_client.call_tool_sync(remote_name, parameters). To the Orchestrator/Executor, this is just another Tool.

Under the hood of list_tools: session.list_tools() issues a JSON-RPC request:

{"method": "tools/list", "params": {}}
Enter fullscreen mode Exit fullscreen mode

The server returns all exposed tools, each with name, description, and inputSchema. register_mcp_tools iterates this list and turns each tool into an MCPToolAdapter in the Registry.

Under the hood of call_tool: When the model triggers a tool call, the Adapter's run() sends:

{"method": "tools/call", "params": {"name": "search", "arguments": {"query": "..."}}}
Enter fullscreen mode Exit fullscreen mode

The server executes and returns MCP content blocks; protocol.py projects them into the project's unified envelope (described in Step 6).


Step 5: Async SDK, Synchronous Tool Pipeline

The official MCP SDK is fully async, but Tool.run() is synchronous. This is the core tension: the main loop is synchronous code and tool execution can't suddenly become await.

MCPClient maintains its own private event loop and uses _run_sync to block coroutines into synchronous calls:

# extensions/mcp/client.py
def _run_sync(self, coro):
    try:
        asyncio.get_running_loop()
        # Already in another loop, can't run_until_complete — would deadlock
        raise RuntimeError("cannot run inside an active event loop")
    except RuntimeError:
        pass  # Not in a loop, safe to proceed

    if self._loop is None or self._loop.is_closed():
        self._loop = asyncio.new_event_loop()
    return self._loop.run_until_complete(coro)

# Expose synchronous versions for Tool.run() to call
def list_tools_sync(self):   return self._run_sync(self.list_tools())
def call_tool_sync(self, name, arguments): return self._run_sync(self.call_tool(name, arguments))
Enter fullscreen mode Exit fullscreen mode

Connection establishment: The two transport types connect differently, but once connected they're identical to upper layers:

stdio (local subprocess):
    StdioServerParameters(command="uvx", args=[...])
        → spawns subprocess, establishes stdin/stdout pipes
        → ClientSession(read, write)
        → session.initialize()   ← MCP handshake, negotiates protocol version

http (remote service):
    streamablehttp_client(url)
        → establishes HTTP connection
        → ClientSession(read, write)
        → session.initialize()
Enter fullscreen mode Exit fullscreen mode

Connections are lazy: the first list_tools or call_tool call actually triggers connect(). When the session is closed by the peer (ClosedResourceError), it close()s first then reconnects once. CodeAgent.close() calls close_sync() on all _mcp_clients to prevent stdio subprocess leaks.


Step 6: Normalizing MCP Results into a Unified Envelope

Remote returns use MCP's content blocks (text / resource / binary), not this project's {status, data, text, ...}. protocol.py does the projection:

  • Extracts and concatenates text blocks into text
  • Puts structuredContent into data.structured
  • Non-text content becomes [binary content ...] / [resource uri] summaries
  • isError=true goes into an error envelope

Errors are categorized by cause to help the model (and circuit breakers) distinguish 'wrong parameters' from 'remote is down':

Situation error.code
Schema validation failure MCP_PARAM_ERROR
Parsing/wrapping failure MCP_PARSE_ERROR
Timeout / connection failure MCP_TIMEOUT / MCP_NETWORK_ERROR
Remote execution failure MCP_EXECUTION_ERROR

This uses the same top-level fields as the protocol from article 05. The model doesn't need to know whether a tool is local Python or an MCP process.


Step 7: How the Model 'Sees' Them

Two channels, consistent with articles 04 and 05:

  1. tools= schema: The Adapter is in the Registry, so get_openai_tools() naturally includes it
  2. Tool Contracts text: format_mcp_tools_prompt() generates - name: desc + params: ...; ContextBuilder appends ## MCP Tools

A single server registration failure only logs a warning and skips that server — it doesn't crash the whole agent startup. This is the correct posture for external processes: the other side might not be set up at any time.


One MVP Limitation Worth Noting

The permission classifier's allowlist is hardcoded:

# tools/permissions.py — RiskClassifier.classify()
READ_ONLY_TOOLS = {"Read", "Grep", "Glob"}   # → ALLOW
WRITE_TOOLS     = {"Edit"}                   # → ALLOW (with path)
"TodoWrite"                                  # → ALLOW
"Bash"                                       # → checked against black/gray/white list
"Skill"                                      # → ALLOW
# All other names → DENY (fail-closed)
Enter fullscreen mode Exit fullscreen mode

MCP tool names are dynamic (docs_search, server_name:tool_name) and not in this allowlist, so they hit the final clause:

return PermissionDecision(
    action=PermissionAction.DENY,
    risk=RiskLevel.UNKNOWN,
    reason=f"unknown tool '{tool_name}' fails closed",
)
Enter fullscreen mode Exit fullscreen mode

Executor receives DENY and short-circuits with a PERMISSION_DENIED error — the tool never actually executes.

This is a known limitation of the current implementation, not the final design. The permission system hasn't been extended to dynamic tool names yet. To make MCP tools truly usable, you'd need to add an 'already-registered MCP tools → ALLOW' branch to the classifier, or switch to a configurable allowlist.

One important design principle worth preserving here: registered in Registry ≠ allowed to execute. The Registry controls 'what the model can see in schemas'; the permission gate controls 'what can actually land and execute' — the two are separate. Even as the Registry grows, execution rights can be independently tightened. Once MCP tools join the allowlist, the orchestration side remains conservative: ToolOrchestrator only treats Read/Grep/Glob as concurrency-safe; MCP tools run in serial batches.


Design Highlights

  1. Optional dependency: Zero MCP in core install; both the flag and the extra must be active before the SDK loads
  2. Adapter instead of a parallel pipeline: External tools reuse Registry / schema / orchestration without if mcp scattered through the loop
  3. Graceful degradation on discovery failure: One server down doesn't affect other tools or the main loop
  4. Registration and authorization are separate: The registry can grow; execution remains fail-closed
  5. Result projection: MCP content blocks don't leak as 'another kind of JSON' into the model context

Summary

Mechanism Role
factory.py if host.enable_mcp Startup entry: MCP runs only after builtin tools are registered
_register_mcp_tools() Delegates to bootstrap, saves clients / tools_prompt
--enable-mcp / ENABLE_MCP Disabled by default to avoid spawning unnecessary child processes
mcp.json / MCP_SERVERS Declares stdio or HTTP servers
MCPClient Lazy connection, sync wrapper, reconnect on disconnect
MCPToolAdapter Remote tool → local Tool
protocol.py MCP result → unified tool envelope
Permission fail-closed Unknown MCP tool names are denied execution by default

Next article: sub-agents — when a task is too complex, how the Task tool delegates work to another lightweight loop.


About the Source Code

All analysis in this series is based on the open-source project MyCodeAgent.

The source code includes inline comments aligned with the series' explanation order at key locations — you can follow along while reading, or clone it, run it, modify it, and extend it to build your own agent.

git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # fill in your LLM API key
uv sync
uv run python main.py
Enter fullscreen mode Exit fullscreen mode

Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage

Top comments (0)