02 - 工具调用协议:从 JSON Schema 到 MCP 双协议栈
学习目标
理解 CowAgent 如何统一管理内置工具与 MCP 工具,实现双协议栈集成、异步加载、热重载与在线同步。
前置知识
工具调用协议的通用原理见 工具调用协议 与 MCP 协议。本章聚焦 CowAgent 的具体实现。
项目实践
工具定义协议
CowAgent 的内置工具通过 BaseTool 定义统一的三元组接口:
name:工具名称(大小写敏感)description:工具描述params:JSON Schema 格式的参数定义
工具加载通过 ToolManager 完成,支持两种来源:
- 内置工具:从
agent.tools.__all__和agent/tools/目录动态加载 Python 类 - MCP 工具:从
~/cow/mcp.json或config.json的mcp_servers字段加载
MCP 异步加载模型
MCP 服务器启动(npx/uvx 等)需要数秒到数十秒,CowAgent 采用后台线程异步加载:
关键设计:_mcp_loaded 标记在启动线程前就设置为 True,确保并发初始化的 Agent 不会重复派生子进程。
MCP 热重载
CowAgent 实现了按需检测 + diff 式重载的热更新机制:
| 步骤 | 操作 |
|---|---|
| 检测 | 每次 Agent 创建后,检查 mcp.json 的 mtime + 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。提前设置确保了至多一次语义。