从零给 AI Agent 接入 MCP 工具生态
本文记录了将 MCP(Model Context Protocol)集成进 CowAgent 的完整过程,包括协议层手写、工具适配、生命周期管理和配置兼容的设计思路与踩坑实录。
一、为什么要做这件事
CowAgent 最初的工具集是静态的:搜索、代码执行、浏览器、文件操作......每新增一个工具,都要写一个 BaseTool 子类、注册进 __init__.py、重新部署。工具数量是硬上限,扩展成本高,社区生态也无从复用。
MCP 是 Anthropic 在 2024 年底推出的开放工具协议。它的核心思路是把工具和 Agent 解耦:工具以独立进程(MCP Server)的形式运行,Agent 通过标准协议动态发现并调用工具,两侧互不感知对方的实现细节。
接入 MCP 之后,CowAgent 可以直接使用社区已有的 MCP Server:高德地图提供 12 个地理位置工具,Chrome DevTools 提供 29 个浏览器调试工具,文件系统、数据库、Git 等一应俱全,且完全无需改动 Agent 代码。
二、先搞清楚 MCP 的通信原理
在动手之前,需要理解 MCP 建立在什么之上。
JSON-RPC 2.0 是 MCP 的传输格式,协议本身极简,只有三种消息类型:
json
// Request(期待响应)
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {...}}
// Response(回应 Request)
{"jsonrpc": "2.0", "id": 1, "result": {...}}
// Notification(单向推送,无需响应,没有 id 字段)
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
握手流程 固定为两步:Client 发 initialize 请求,Server 回应能力声明;Client 再发 notifications/initialized 通知,握手完成。之后才能正常调用 tools/list 和 tools/call。
传输方式有两种:
- stdio :Client 用
subprocess启动 Server 进程,通过标准输入输出交换 JSON 行。适合本地工具(npx、uvx 启动的包)。 - SSE(Server-Sent Events):Server 是一个 HTTP 服务,Client 先建 SSE 长连接,读取服务端推送的 POST endpoint 地址,之后用这个地址发 HTTP 请求。适合远程服务。
三、第一层:传输层
为什么不用官方 SDK
官方 MCP Python SDK 是全 async 设计,依赖 asyncio + anyio + httpx + pydantic,依赖链很重。CowAgent 的工具调用链是同步的,强行桥接 async 反而增加复杂度(asyncio.run() 在已有事件循环中会崩溃)。
实际上,MCP 用到的 JSON-RPC 子集很小------只有 initialize、tools/list、tools/call 三个方法,加上一个 notification。整个传输层用标准库手写完全可以搞定,最终代码约 300 行,零外部依赖。
stdio 传输实现
核心逻辑直接明了:
python
def _stdio_send(self, message: dict) -> dict:
raw = json.dumps(message) + "\n"
self._proc.stdin.write(raw)
self._proc.stdin.flush()
while True:
line = self._readline_with_timeout() # select + 30s 超时
data = json.loads(line.strip())
if "id" not in data: # notification,跳过
continue
return data
SSE 传输实现
SSE 有一个容易忽视的细节:Client 不能直接 POST 到 SSE 的 URL,而是需要先建立 SSE 连接,等 Server 推送一个 endpoint 事件,从中解析出真正的 POST 地址。不同 Server 的事件格式不统一,需要多格式兼容:
python
def _sse_discover_endpoint(self) -> str:
with urllib.request.urlopen(sse_req) as resp:
for raw_line in resp:
line = raw_line.decode("utf-8")
if line.startswith("data:"):
data = line[5:].strip()
if data.startswith("{"):
parsed = json.loads(data)
return parsed.get("uri") or parsed.get("url") or parsed.get("endpoint")
if data.startswith("http"):
return data
return urljoin(self._sse_url, data) # 相对路径
四个必须处理的坑
坑 1:stderr 缓冲区溢出
子进程的 stderr 如果没人消费,操作系统管道缓冲区(64KB)满了之后,子进程会阻塞在 write(stderr),整个进程卡死。解法是启动一个 daemon 线程持续 drain:
python
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stderr(self):
for line in self._proc.stderr:
if line.strip():
logger.debug(f"[MCP:{self.name}] stderr: {line.strip()}")
坑 2:notification 干扰响应读取
MCP Server 会主动推送不带 id 的 notification(例如进度通知)。如果把它当成请求的响应来解析,会拿到错误数据。解法是 _stdio_send 里用循环读,遇到无 id 消息直接跳过,直到匹配到有 id 的响应。
坑 3:readline 永久阻塞
MCP Server 崩溃或挂死时,proc.stdout.readline() 会永远等待。解法是用 select.select() 加超时:
python
def _readline_with_timeout(self, timeout=30) -> str:
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
if not ready:
raise TimeoutError(f"stdio read timed out after {timeout}s")
return self._proc.stdout.readline()
坑 4:env 覆盖系统环境变量
Config 里的 env 字典如果直接传给 subprocess.Popen,会替换掉整个进程环境,导致 PATH 丢失,npx 找不到。联调高德地图 MCP Server 时就踩了这个坑,API key 配置进去了,但 npx 启动直接报错。解法是合并继承:
python
env = {**os.environ, **extra_env} if extra_env else None
坑 5:并发读写错乱
多个 session 同时调用同一个 MCP Client 时,两个线程的 send 和 receive 可能交叉,A 线程的请求收到 B 线程的响应。解法是 _call_lock 把 send+receive 作为原子操作串行化:
python
with self._call_lock:
if self.transport == "stdio":
return self._stdio_send(message)
四、第二层:适配层
问题:工具元信息是运行时才知道的
Agent 内部的 BaseTool 通常这样定义:
python
class SearchTool(BaseTool):
name = "search"
description = "搜索互联网"
params = {...}
但 MCP 工具的 name、description、inputSchema 是运行时从 Server 动态拉取的,在类定义时根本不存在。
解法:用实例属性覆盖类属性
McpTool 在 __init__ 里把工具元信息写进实例属性,每个实例携带自己的信息:
python
class McpTool(BaseTool):
def __init__(self, client, tool_schema: dict, server_name: str):
self.client = client
self.server_name = server_name
self.name = tool_schema["name"] # 实例属性,覆盖类属性
self.description = tool_schema.get("description", "")
self.params = tool_schema.get("inputSchema", {})
def execute(self, params: dict) -> ToolResult:
result = self.client.call_tool(self.name, params)
return ToolResult.success(result)
这样 Agent 侧完全感知不到 MCP 的存在,MCP 工具和内置工具对它来说是同一种东西。
inputSchema 映射的重要性
MCP 用 JSON Schema 描述参数,LLM 根据这个 schema 决定如何构造调用参数。如果 required 字段、properties 里每个字段的 type 和 description 映射不准确,LLM 的调用成功率会显著下降。这一层虽然代码不多,但对最终效果影响很大。
五、第三层:加载层
这是整个接入中最复杂的一层,核心挑战是资源模型完全不同:内置工具是无状态的,随用随取;MCP 工具背后是一个长连接子进程,生命周期和进程绑定。
后台异步加载
MCP Server 的启动很慢------npx 第一次运行要下载包,有时需要数秒到数十秒。如果在 Agent 初始化时同步等待所有 Server 启动完成,用户发第一条消息时会明显卡顿。
解法是异步加载:_load_mcp_tools() 立刻返回,把实际的 Server 初始化工作扔到后台线程。Agent 启动后可以立即响应用户,MCP 工具在后台就绪后自动进入可用状态:
python
def _load_mcp_tools(self):
with self._mcp_lock:
if self._mcp_loaded: # 幂等:只加载一次
return
...
self._mcp_loaded = True
threading.Thread(
target=self._load_mcp_tools_async,
args=(mcp_servers_config,),
daemon=True,
name="mcp-loader",
).start()
单个 Server 失败不阻断其他
MCP Server 依赖外部环境------node 版本、API key、网络连通性------用户配错的概率很高。设计原则是让单个 Server 的失败不影响其他 Server 和内置工具:
python
for cfg in mcp_servers_config:
server_name = cfg.get("name")
try:
client = McpClient(cfg)
if not client.initialize():
self._mcp_status[server_name] = "failed"
continue # 跳过,继续下一个
...
self._mcp_status[server_name] = "ready"
except Exception as e:
self._mcp_status[server_name] = "failed"
logger.warning(f"[MCP] Server '{server_name}' load failed: {e}")
一个真实踩过的 bug:工具加载了但 Agent 不知道
第一版实现里,ToolManager 把 MCP 工具存进了 _mcp_tool_instances,但 AgentInitializer._load_tools() 只遍历 tool_classes(静态内置工具列表),MCP 工具根本没挂进 Agent 的 tool list。
全程没有任何报错,工具加载日志也显示正常,但 Agent 就是不用这些工具。端到端联调才发现问题所在。修复是在 _load_tools() 末尾追加一个遍历:
python
# 挂载 MCP 工具
for mcp_tool in tool_manager._mcp_tool_instances.values():
tools.append(mcp_tool)
热重载:mcp.json 改动自动生效
生产环境中,用户可能随时添加或删除 MCP Server。为此实现了差量热重载:每次 Agent 初始化时做一次廉价的文件签名检查(os.stat + sha256),有变化才重新解析配置,然后 diff 出新增、删除、修改的 Server,只对变化的部分做操作,未改动的 Server 保持运行:
python
def refresh_mcp_if_changed(self):
new_sig = self._read_mcp_json_signature()
if new_sig == self._mcp_signature:
return # 快速路径:文件未变,直接返回
added = [n for n in new_by_name if n not in old_by_name]
removed = [n for n in old_by_name if n not in new_by_name]
changed = [n for n in new_by_name if n in old_by_name
and new_by_name[n] != old_by_name[n]]
for name in removed + changed:
self._teardown_mcp_server(name) # 关闭旧进程
# 新增和修改的 Server 在后台启动
threading.Thread(target=self._load_mcp_tools_async, args=(to_start,)).start()
McpClientRegistry:单例管理所有进程句柄
MCP Client 持有子进程句柄,是重资源,只能初始化一次。McpClientRegistry 以单例模式管理所有 Client,进程退出时 shutdown_all() 统一清理,避免孤儿进程残留:
python
class McpClientRegistry:
_instance = None
_instance_lock = threading.Lock()
def __new__(cls):
with cls._instance_lock:
if cls._instance is None:
...
return cls._instance
六、第四层:配置层
兼容 Claude Desktop / Cursor 的配置格式
MCP 生态里已经有 Claude Desktop 和 Cursor 作为事实标准,它们用的是 mcpServers dict 格式:
json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
CowAgent 原生格式是 list:
json
{
"mcp_servers": [
{"name": "filesystem", "type": "stdio", "command": "npx", "args": [...]}
]
}
_normalize_mcp_configs() 统一做格式归一化,用户可以直接复制 Claude Desktop 的配置文件使用:
python
def _normalize_mcp_configs(raw) -> list:
if isinstance(raw, list):
return raw
if isinstance(raw, dict):
result = []
for name, cfg in raw.items():
entry = {"name": name, **cfg}
if "type" not in entry:
# Claude Desktop 不写 type,按规则推断
entry["type"] = "sse" if "url" in entry else "stdio"
result.append(entry)
return result
独立 mcp.json 文件
配置读取优先级:~/cow/mcp.json > config.json 里的 mcp_servers。
两者分开是有意为之:config.json 是 Agent 的整体配置,改动牵连广;mcp.json 只管工具,改动频率高且风险低。用户可以随时修改 mcp.json 增删 Server,配合热重载机制,不需要重启服务。
七、端到端验证
高德地图 MCP Server(stdio + env 注入):
json
{
"name": "amap",
"type": "stdio",
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {"AMAP_MAPS_API_KEY": "your-key"}
}
初始化成功,拿到 12 个工具(路线规划、POI 搜索、地理编码等)。这里验证了 env 合并继承的修复------去掉 {**os.environ, ...} 之后 npx 直接报找不到命令。
Chrome DevTools MCP Server(stdio + 并发稳定性):
json
{
"name": "chrome",
"type": "stdio",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp", "--port", "9222"]
}
初始化成功,拿到 29 个工具。多轮对话验证了 _call_lock 并发保护------在此之前偶发过响应错位的问题,加锁后消失。
接入前后对比:
| 接入前 | 接入后 | |
|---|---|---|
| 工具数量 | 固定(代码里写死) | 动态(随 MCP Server 配置) |
| 新增工具 | 改代码 + 重部署 | 改 mcp.json |
| 生态兼容 | 无 | 直接用 Claude Desktop / Cursor 配置 |
八、总结与延伸
整体架构回顾,由下往上四层职责清晰:
javascript
配置层 ─── mcp.json + _normalize_mcp_configs + 热重载
│
加载层 ─── ToolManager + McpClientRegistry + 后台异步 + 生命周期
│
适配层 ─── McpTool(实例属性 + inputSchema 映射)
│
传输层 ─── JSON-RPC 2.0 over stdio / SSE(纯标准库)