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

A deep dive into MyCodeAgent's MCP integration: optional dependencies, config discovery, stdio/HTTP transport, Adapter disguising remote tools as local Tools, and result normalization into a unified protocol. Understand how external capabilities enter the same tool pipeline, and why 'registered in the registry' and 'allowed to execute' are two separate concerns.

·11 min read·AI Engineering

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

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:

SkillsMCP
CarrierSKILL.mdExternal process / HTTP service
What enters RegistryOne local Skill toolOne Adapter per remote tool
Enabled by defaultYes (if file exists)No; requires --enable-mcp
DependenciesNoneOptional 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"},
    ...
)

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)
  • 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

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" }
  }
}

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)

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": {}}

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": "..."}}}

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

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()

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':

Situationerror.code
Schema validation failureMCP_PARAM_ERROR
Parsing/wrapping failureMCP_PARSE_ERROR
Timeout / connection failureMCP_TIMEOUT / MCP_NETWORK_ERROR
Remote execution failureMCP_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)

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

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

MechanismRole
factory.py if host.enable_mcpStartup entry: MCP runs only after builtin tools are registered
_register_mcp_tools()Delegates to bootstrap, saves clients / tools_prompt
--enable-mcp / ENABLE_MCPDisabled by default to avoid spawning unnecessary child processes
mcp.json / MCP_SERVERSDeclares stdio or HTTP servers
MCPClientLazy connection, sync wrapper, reconnect on disconnect
MCPToolAdapterRemote tool → local Tool
protocol.pyMCP result → unified tool envelope
Permission fail-closedUnknown 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

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