Introducing the Problem
The extension methods covered in the previous two articles were all "do it yourself": write your own tool class, write your own Skill file.
But there are many ready-made tool services in the world: Tavily can search the web, Context7 can look up the latest documentation, GitHub CLI can manage repositories... if the agent could directly call these services, there'd be no need to reinvent the wheel.
MCP (Model Context Protocol) is the protocol standard that solves this problem. It defines a set of specifications: tool service providers expose tools in this format, agent frameworks call them in this format, and both sides agree on the interface without needing to write a dedicated adapter for each service.
The Conclusion First
Connecting an MCP tool service requires only two steps:
| Step | What to Do |
|---|---|
1. Configure mcp_servers.json
|
Tell the framework where the MCP service is and how to start it |
| 2. Enable MCP |
--enable-mcp startup parameter, or ENABLE_MCP=true environment variable |
The framework will automatically connect to services, discover the tool list, and register each remote tool as a local tool — after that, the call chain is identical to built-in tools.
I. MCP's Basic Model
First understand how MCP works, then look at the code.
MCP defines the concept of a "tool server": an independent process (or remote HTTP service) that has several tools registered, each with its own name, description, and parameter schema.
The communication flow between the agent (client) and the tool server has only three steps:
1. Handshake: connect to the server
↓
2. Discovery: list_tools() → get the tool list (name + description + inputSchema)
↓
3. Invocation: call_tool(name, params) → get the result
That's it — three steps, nothing more complex. The protocol's value is in "standardization" — any MCP server supports these three steps, and any MCP client can communicate with any MCP server.
MyCodeAgent implements the client side of MCP, encapsulating these three operations with MCPClient.
II. The Configuration File: mcp_servers.json
To configure an MCP server, simply create (or modify) mcp_servers.json in the project root:
{
"mcpServers": {
"tavily": {
"command": "uvx",
"args": ["mcp-server-tavily"],
"env": {
"TAVILY_API_KEY": "tvly-xxxxx"
}
},
"context7": {
"command": "uvx",
"args": ["--from", "context7-mcp", "context7-mcp"],
"env": {
"CTX7_API_KEY": "your-key"
}
}
}
}
Two key fields:
-
command: the command to start the tool server (uvxis a Python package runner that runs packages directly without installation) -
args: arguments passed to the command -
env: environment variables needed by the tool server (API keys, etc.)
There's also a configuration option for remote HTTP servers:
{
"mcpServers": {
"remote-tool": {
"transport": "http",
"url": "https://your-mcp-server.com/v1"
}
}
}
extensions/mcp/config.py is responsible for reading this file and is compatible with Claude desktop app's {"mcpServers": {...}} wrapper format, letting configuration files be reused across different agents.
III. Connection and Discovery: What register_mcp_servers Does
When the agent starts (with --enable-mcp), register_mcp_servers() is called:
# extensions/mcp/bootstrap.py
def register_mcp_servers(tool_registry, project_root):
# 1. Read mcp_servers.json
servers = load_mcp_servers(project_root)
# 2. Create an MCPClient for each server (not yet connected)
for server_name, spec in servers.items():
config = _build_client_config(project_root, spec, MCPClientConfig)
client = MCPClient(config)
# 3. Connect to server, discover tool list, register to ToolRegistry
tools_meta = register_mcp_tools(tool_registry, client, namespace=server_name)
Note the namespace=server_name parameter. It solves a practical problem: two different MCP servers may both have a tool called search. With namespacing, they become tavily:search and context7:search respectively (colons are cleaned to underscores), avoiding conflicts.
IV. MCPToolAdapter: Making Remote Tools Look Like Local Tools
After register_mcp_tools() calls list_tools_sync(), it creates an MCPToolAdapter for each remote tool:
# extensions/mcp/adapter.py
class MCPToolAdapter(Tool):
"""Disguises MCP remote tools as local Tools."""
def get_parameters(self) -> list[ToolParameter]:
# Generate parameter definitions from the MCP tool's inputSchema
schema = self._schema # JSON Schema obtained from list_tools
properties = schema.get("properties", {})
required = set(schema.get("required", []))
return [
ToolParameter(name=name, type=spec.get("type", "any"), ...)
for name, spec in properties.items()
]
def run(self, parameters: dict) -> ToolResult:
# 1. Validate parameters
invalid = self._validate_params(parameters)
if invalid:
return to_protocol_invalid_param(invalid, ...)
# 2. Call the remote tool
result = self._mcp_client.call_tool_sync(self._remote_name, parameters)
# 3. Convert result to standard envelope
return to_protocol_result(result, ...)
This Adapter does three things:
-
Parameter interface alignment: translates the MCP tool's JSON Schema into a
ToolParameterlist, soget_openai_tools()can generate the correct Function Calling schema -
Execution forwarding: calls
call_tool_sync()to send the request to the remote end -
Result normalization: converts the MCP format response into
ToolResult, so subsequent pipeline steps don't need to care whether this tool is local or remote
After registration, tavily_search and Read and Bash are indistinguishable from the framework's perspective — they're all in the ToolRegistry, they all appear in the list returned by get_openai_tools(), and they all execute through the same Executor pipeline.
V. The Complete Chain: From Configuration to Model Invocation
Putting the above steps together, the complete chain from configuring an MCP tool to the model invoking it:
Write mcp_servers.json, fill in server configuration
│
▼
Agent starts (--enable-mcp)
│
▼
bootstrap.py calls register_mcp_servers()
│
├── Read mcp_servers.json
├── Create MCPClient for each server
└── Connect to server, call list_tools_sync(), get tool list
│
▼
Create MCPToolAdapter for each tool
MCPToolAdapter registered to ToolRegistry
│
▼
tool_registry.get_openai_tools()
returns tool list including tavily_search
│
▼
Model sees tool list, decides to call tavily_search
│
▼
ToolRegistry.execute_tool("tavily_search", params)
→ ToolExecutor runs four gates (permission/lock/circuit-breaker/run)
→ MCPToolAdapter.run()
→ MCPClient.call_tool_sync("search", params)
→ result converted to ToolResult → returned to model
In the entire chain, MCP's special nature is only in the MCPToolAdapter.run() layer — every other part (permission checks, circuit breaker, byte budget, writing results to history) completely reuses the built-in tool code.
VI. The Difference Between Two Transport Types
_build_client_config() decides which transport to use based on the configuration:
stdio mode (local subprocess):
{
"command": "uvx",
"args": ["mcp-server-tavily"]
}
The framework forks a subprocess to run the tool server, communicating through stdin/stdout pipes. A new process is created at startup; the subprocess and agent process share the same lifecycle.
http mode (remote HTTP service):
{
"transport": "http",
"url": "https://your-mcp-server.com/v1"
}
The framework communicates with the remote service via HTTP, no subprocess needed. Suitable for MCP tools hosted on servers (like paid SaaS APIs).
Both modes are completely transparent to upper-level code — MCPToolAdapter.run() doesn't care whether the underlying layer is a subprocess or HTTP; it just calls call_tool_sync().
VII. A Single Server Failure Doesn't Affect the Overall Startup
There's an important error isolation mechanism in register_mcp_servers():
for server_name, spec in servers.items():
try:
tools_meta = register_mcp_tools(tool_registry, client, namespace=server_name)
registered_tools.extend(tools_meta)
except Exception as exc:
logger.warning("MCP tool registration failed for %s: %s", server_name, exc)
continue # single failure, continue to next
If the Tavily server fails to start (for example, the key wasn't filled in), it won't affect Context7's registration, and won't cause the entire agent startup to fail. It only leaves a warning in the log.
This design follows the same thinking as CompositeRuntimeEventSink (covered in Article 15): failures in observability infrastructure or external services must not stop the agent's main path.
Design Highlights
1. The adapter pattern hides "remote"
MCPToolAdapter makes remote tools indistinguishable from local tools within the framework. Permission checks, circuit breakers, byte budgets — all the protections that built-in tools enjoy, MCP tools automatically get without needing to rewrite that logic for MCP.
2. Namespacing prevents naming conflicts
Two MCP servers both have a search tool? With namespacing they become tavily_search and context7_search, the model can clearly distinguish them and won't call the wrong one.
3. Configuration format is compatible with Claude desktop app
The {"mcpServers": {...}} format is the standard format for the Claude desktop app. MyCodeAgent is compatible with this format, meaning users can directly reuse MCP configuration files already set up for the Claude desktop app without any re-translation.
Summary
| Design Choice | Approach | Engineering Value |
|---|---|---|
| Connection method | JSON config file + --enable-mcp | Zero-code connection, just change config |
| Tool registration | MCPToolAdapter implements Tool interface | Remote and local tools go through the same pipeline |
| Namespace | namespace:remote_name prefix | Multiple servers don't conflict |
| Fault isolation | Single server failure → continue | Doesn't affect other servers or agent startup |
| Transport | stdio (local process) / http (remote service) | Covers both local and cloud deployment scenarios |
This completes Part 6 "Extending from Scratch." Looking back at these four articles:
- 20: Adding new tools — Tool base class, protocol envelope, sandbox check, registration and testing
- 21: Connecting new LLMs — table-driven provider routing, one profile row and you're done
- 22: Writing Skills — Markdown defines expert behavior, hot reload, $ARGUMENTS argument injection
- 23: Connecting MCP — config file + adapter, one-click access to the external tool ecosystem
These four extension dimensions together cover nearly every possible "make the agent do new things" need you can think of:
- Need the agent to execute a new specific action → Tool
- Need the agent to use a new LLM → Provider
- Need the agent to learn a new way of handling things → Skill
- Need the agent to call an existing external service → MCP
About the Source Code for This Series
All analysis in this series is based on the open source project MyCodeAgent.
The source code already has companion comments added at key locations in the order covered by this series — you can read alongside the code, or clone it directly to run, modify, 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
Visit PrimeSkills — a carefully curated AI Agent and skills marketplace where every piece of content is validated through real enterprise-grade workflows. No hype, only what actually works.
For more practical knowledge and interesting products, visit my personal homepage
Top comments (0)