从零给 AI Agent 接入 MCP 工具生态

从零给 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/listtools/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 子集很小------只有 initializetools/listtools/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 工具的 namedescriptioninputSchema 是运行时从 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 里每个字段的 typedescription 映射不准确,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(纯标准库)
相关推荐
wyc是xxs9 小时前
npm包推荐
前端·npm·node.js
programhelp_9 小时前
Ramp OA 四关全过,CodeSignal OOD 完整复盘
linux·前端·python
ZengLiangYi9 小时前
系统托盘 + 窗口状态持久化:Electron 细节
前端·electron
李铁蛋zs9 小时前
AI 前端开发 Prompt 模板库
前端·vue.js·prompt
Muen10 小时前
Swift-属性包装器
前端
qq_25183645710 小时前
基于java Web快乐岛儿童网站设计与实现
java·开发语言·前端
Crystal32810 小时前
App wgt 热更新 — 开发笔记(uniapp)
前端·uni-app·app
newAir10 小时前
前端转 AI 应用开发 · 02 | 5 分钟用 Python 调通大模型(async + 阿里云 Coding Plan)
前端·人工智能
来一碗刘肉面10 小时前
使用Tailwind CSS 创建一个新项目
前端·css
Ruihong10 小时前
VuReact v1.8.4 发布:Vue 迁移 React 编译器迎来稳定性大修,这些坑终于被填平了
前端·vue.js·react.js