MCP 接入
职责
MCP(Model Context Protocol)是连接外部工具/数据源的通用协议。Hermes 既作为 MCP client 消费外部 MCP server 暴露的工具(经 mcp_tool 桥接进 ToolRegistry),也可作为 MCP server(mcp_serve.py)把自身工具暴露给其他 agent 客户端。这层让 Hermes 能接入第三方能力,不靠手写适配。
设计动机
为什么挑 MCP 而不是再发明一套工具协议?MCP 是 Anthropic 推的开放标准,Claude Code、Cursor、Codex 已经按它对接,生态现成。Hermes 当 client 可以复用几十个社区 MCP server,当 server 可以被任意 MCP 客户端驱动,免费拿到双向互操作。自己造协议能贴合内部 ToolRegistry,但失去的是整片生态。
关键文件
tools/mcp_tool.py— MCP client 桥接:把外部 MCP server 的工具注册进 ToolRegistrymcp_serve.py— Hermes 作为 MCP server 暴露自身工具(仓库根)registry.register:365-436— MCP 桥接的工具最终走同一注册路径register_plugin_override_policy:316-365— 控制 MCP/插件工具的命名空间准入optional-mcps/ 目录— 可选的 MCP server 实现
数据流
- 启动期(或运行期热加载)读 MCP 配置,
mcp_tool连接外部 MCP server。 - 拉取 server 暴露的工具清单,逐个经
registry.register:365注册为ToolEntry(命名空间隔离)。 - 命名空间准入由
register_plugin_override_policy:316控制。 - 主循环像对待内置工具一样收集 schema、分发 tool_call、回填结果。
- 反向:Hermes 可经
mcp_serve.py把自身工具暴露给其他 MCP client。
关键代码
桥接 MCP 工具进 ToolRegistry
连接成功后,_register_server_tools 把每个 MCP tool 转成 schema 并注册,handler 用闭包绑定 server 名与超时:
for mcp_tool in server._tools:
if not _should_register(mcp_tool.name):
continue
schema = _convert_mcp_schema(name, mcp_tool)
tool_name_prefixed = schema["name"]
existing_toolset = registry.get_toolset_for_tool(tool_name_prefixed)
if existing_toolset and not existing_toolset.startswith("mcp-"):
logger.warning("MCP '%s': tool '%s' collides with built-in — skipping",
name, mcp_tool.name)
continue
registry.register(name=tool_name_prefixed, toolset=toolset_name, schema=schema,
handler=_make_tool_handler(name, mcp_tool.name, server.tool_timeout),
check_fn=_make_check_fn(name), is_async=False,
description=schema["description"])工具名前缀化保证多个 server 之间不撞名,且和内置工具集做冲突检测:撞到非 mcp- 开头的内置工具集,直接跳过而不是默默替换。
工具调用桥接
主循环调到 MCP 工具时,registry 的 handler 转发到这个闭包。它先看断路器是否已开,再拿连接(必要时触发 stdio 重连),然后把同步调用丢到后台事件循环上等 MCP 返回:
def _handler(args: dict, **kwargs) -> str:
if _server_error_counts.get(server_name, 0) >= _CIRCUIT_BREAKER_THRESHOLD:
age = time.monotonic() - _server_breaker_opened_at.get(server_name, 0.0)
if age < _CIRCUIT_BREAKER_COOLDOWN_SEC:
remaining = max(1, int(_CIRCUIT_BREAKER_COOLDOWN_SEC - age))
return json.dumps({"error": (
f"MCP server '{server_name}' unreachable after "
f"{_CIRCUIT_BREAKER_THRESHOLD} failures. Retry in ~{remaining}s. "
f"Do NOT retry this tool yet — use alternative approaches.")},
ensure_ascii=False)
server = _get_connected_server_for_call(server_name)
if not server:
_bump_server_error(server_name)
return json.dumps({"error": f"MCP server '{server_name}' is not connected"},
ensure_ascii=False)错误信息里专门写「Do NOT retry this tool yet」,这是给模型读的:不让它在断路器开着的时候一直烧迭代重试。
反向:把 Hermes 自己暴露为 MCP server
mcp_serve.py 用 FastMCP 起一个 stdio server,把消息桥接工具注册出去,任何 MCP 客户端都能 list/read/send 消息:
mcp = FastMCP("hermes", instructions=(
"Hermes Agent messaging bridge. Use these tools to interact with "
"conversations across Telegram, Discord, Slack, WhatsApp, Signal, Matrix."))
@mcp.tool()
def conversations_list(platform=None, limit=50, search=None) -> str:
"""List active messaging conversations across connected platforms."""
...@mcp.tool() 装饰器即注册,不需要手写 schema——FastMCP 从类型注解里推断。
边界与失败
- MCP server 崩了:子进程死掉或 HTTP 端点挂了,
MCPServerTask自动重连,失败次数累计到阈值就跳闸断路器,后续 tool_call 直接返回错误,冷却过后才半开放行一次探针。 - stdio server 回收:进程闲置太久会被回收省资源,下次调用时
_request_lazy_reconnect先唤醒再转发,会有几十毫秒到秒级延迟。 - 工具名冲突:MCP 工具前缀化后撞到内置工具集直接跳过;两个 MCP server 重名则允许覆盖,走
both_mcp分支并打 debug 日志。
小结
MCP 是 Hermes 的「能力外接总线」。mcp_tool 让外部工具进入统一工具池,mcp_serve.py 让 Hermes 自己也能被外部调用。统一走 ToolRegistry,所以主循环无需关心工具来自内置还是 MCP——它只看到 schema 和 handler。