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 envelopeWhy 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"},
...
)These three lines form the master switch for MCP:
enable_mcpis False → nothing happens below,_mcp_clientsstays empty, Registry only has builtin tools- True → calls
host._register_mcp_tools(), connects to external servers, discovers tools, registers Adapters - Regardless,
ContextBuilderis created next; when MCP is on,_mcp_tools_promptis 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 existingtool_registry- The returned
clientsare stored inself._mcp_clients;CodeAgent.close()callsclose_sync()on each to prevent child process leaks tools_metais formatted into natural language;ContextBuilderappends 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_toolsCore 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:
- Environment variable
MCP_SERVERS(a JSON blob) mcp_servers.json/.mcp.json/mcp.jsonin 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:
- Namespacing: The public name includes the server prefix to avoid collisions when two servers both have a
searchtool - Sanitization: Function Calling names only allow
[a-zA-Z0-9_-]; colons become underscores - Schema projection: The remote
inputSchemais converted to a localToolParameterlist, soget_openai_tools()automatically includes these tools — the model sees them the same way it seesRead
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
structuredContentintodata.structured - Non-text content becomes
[binary content ...]/[resource uri]summaries isError=truegoes 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:
tools=schema: The Adapter is in the Registry, soget_openai_tools()naturally includes it- Tool Contracts text:
format_mcp_tools_prompt()generates- name: desc+params: ...;ContextBuilderappends## 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
- Optional dependency: Zero MCP in core install; both the flag and the extra must be active before the SDK loads
- Adapter instead of a parallel pipeline: External tools reuse Registry / schema / orchestration without
if mcpscattered through the loop - Graceful degradation on discovery failure: One server down doesn't affect other tools or the main loop
- Registration and authorization are separate: The registry can grow; execution remains fail-closed
- 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.pyCheck 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