跳转到内容

02 - 工具调用协议:从 JSON Schema 到 MCP 双协议栈

学习目标

理解 CowAgent 如何统一管理内置工具与 MCP 工具,实现双协议栈集成、异步加载、热重载与在线同步。

前置知识

工具调用协议的通用原理见 工具调用协议MCP 协议。本章聚焦 CowAgent 的具体实现。

项目实践

工具定义协议

CowAgent 的内置工具通过 BaseTool 定义统一的三元组接口:

  • name:工具名称(大小写敏感)
  • description:工具描述
  • params:JSON Schema 格式的参数定义

工具加载通过 ToolManager 完成,支持两种来源:

  1. 内置工具:从 agent.tools.__all__agent/tools/ 目录动态加载 Python 类
  2. MCP 工具:从 ~/cow/mcp.jsonconfig.jsonmcp_servers 字段加载

MCP 异步加载模型

MCP 服务器启动(npx/uvx 等)需要数秒到数十秒,CowAgent 采用后台线程异步加载

关键设计:_mcp_loaded 标记在启动线程前就设置为 True,确保并发初始化的 Agent 不会重复派生子进程。

MCP 热重载

CowAgent 实现了按需检测 + diff 式重载的热更新机制:

步骤操作
检测每次 Agent 创建后,检查 mcp.jsonmtime + sha256 是否变化
diff解析新配置,对比 _mcp_active_configs,识别新增/移除/编辑的服务器
重载移除的服务器调用 shutdown(),新增/编辑的服务器在后台线程启动
同步sync_mcp_into_agent() 将新工具注入到当前 Agent,移除的工具从 Agent 剔除
# 伪代码 — 热重载核心逻辑
def refresh_mcp_if_changed(self):
new_sig = self._read_mcp_json_signature() # (mtime, sha256)
if new_sig == self._mcp_signature:
return # 快速路径:文件未变
new_configs = self._load_mcp_configs()
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)
# 后台启动新增和编辑的服务器
threading.Thread(target=self._load_mcp_tools_async, args=(to_start,)).start()

在线工具同步

sync_mcp_into_agent() 在 Agent 运行期间 reconciliate 工具注册表:

  • 支持 list[BaseTool](默认 Agent)和 dict[str, BaseTool](streaming Agent)两种表示
  • 添加新工具时仅添加 MCP 工具,不影响内置工具
  • 移除工具时仅移除 MCP 工具,保留内置工具

这个机制使得 MCP 工具的加载不会影响已开始的对话——新工具在下一个 turn 自动可用。

问题与规避

问题CowAgent 的规避策略
MCP 服务器启动慢阻塞首条消息后台线程异步加载,内置工具立即可用
多个 Agent 并发初始化重复启动 MCP_mcp_loaded 标记 + _mcp_lock 序列化检查
mcp.json 编辑后工具不生效每次 Agent 创建后检测签名变化 + sync_mcp_into_agent()
MCP 服务器崩溃影响其他服务器逐个初始化,单个失败不影响其余

设计取舍

为什么 MCP 加载用线程而非 asyncio?

CowAgent 整体使用 threading 管理并发(多渠道、MCP 加载、记忆同步等),因为 IM 渠道 SDK(微信、飞书等)大多是同步 API。如果 MCP 用 asyncio,需要在 thread 和 async 之间桥接,增加复杂度。

为什么 _mcp_loaded 在启动线程前就设置为 True

这是防止重复加载的防御性设计。如果在线程启动后才设置,并发初始化的两个 Agent 可能都看到 _mcp_loaded = False 并各自启动一个 loader。提前设置确保了至多一次语义。

参考来源