MCP Integration
Responsibility
MCP (Model Context Protocol) is a universal protocol for connecting external tools/data sources. Hermes acts both as an MCP client consuming tools exposed by external MCP servers (bridged into ToolRegistry via mcp_tool) and as an MCP server (mcp_serve.py) exposing its own tools to other agent clients. This lets Hermes pull in third-party capabilities without baking them into the core.
Design Motivation
Why pick MCP instead of inventing another tool protocol? MCP is an open standard pushed by Anthropic; Claude Code, Cursor, and Codex already speak it, so the ecosystem is already there. As a client, Hermes can reuse dozens of community MCP servers; as a server, Hermes can be driven by any MCP client — bidirectional interop for free. A homegrown protocol might fit the internal ToolRegistry more snugly, but you lose the whole ecosystem.
Key Files
tools/mcp_tool.py— MCP client bridge: registers tools from external MCP servers into ToolRegistrymcp_serve.py— Hermes as an MCP server exposing its own tools (repo root)registry.register:365-436— MCP-bridged tools go through the same registration pathregister_plugin_override_policy:316-365— controls namespace admission for MCP/plugin toolsoptional-mcps/ directory— optional MCP server implementations
Data Flow
- At startup (or via runtime hot reload) MCP config is read and
mcp_toolconnects to external MCP servers. - The list of tools exposed by a server is pulled and each is registered as a
ToolEntryviaregistry.register:365(namespaced to avoid collisions with built-in tools). - Namespace admission is controlled by
register_plugin_override_policy:316. - The main loop gathers schemas, dispatches tool_calls, and backfills results just as for built-in tools.
- Reverse direction: Hermes can expose its own tools to other MCP clients via
mcp_serve.py, letting editors/other agents call them.
Key Code
Bridging MCP tools into ToolRegistry
Once connected, _register_server_tools converts each MCP tool to a schema and registers it; the handler is a closure binding the server name and timeout:
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"])Tool names are prefixed so multiple servers never collide, and conflict detection runs against the built-in toolset: if it hits a non-mcp- built-in toolset, it skips rather than silently replacing.
Tool-call bridge
When the main loop invokes an MCP tool, the registry handler forwards to this closure. It first checks whether the circuit breaker is open, then grabs a connection (triggering a stdio reconnect if needed), then submits the sync call onto a background event loop to wait for the MCP reply:
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)The error message explicitly says "Do NOT retry this tool yet" — that is for the model to read, so it does not keep burning turns retrying while the breaker is open.
Reverse: exposing Hermes itself as an MCP server
mcp_serve.py uses FastMCP to start a stdio server and registers the messaging bridge tools, so any MCP client can list/read/send messages:
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."""
...The @mcp.tool() decorator is the registration — no manual schema writing; FastMCP infers it from type annotations.
Boundaries and Failure
- MCP server crashes: if a subprocess dies or an HTTP endpoint goes down,
MCPServerTaskreconnects automatically; once failure counts cross a threshold the circuit breaker trips, subsequent tool_calls return an error immediately, and only after cooldown does a single probe call go through. - stdio server reclamation: an idle process gets reclaimed to save resources; the next call triggers
_request_lazy_reconnectto wake it before forwarding, adding tens of milliseconds to seconds of latency. - Tool name conflicts: a prefixed MCP tool that hits a built-in toolset is skipped; two MCP servers with the same name are allowed to override, going through the
both_mcpbranch with a debug log.
Summary
MCP is Hermes's "capability bus". mcp_tool lets external tools enter the same tool pool as built-ins; mcp_serve.py lets Hermes itself be called by external clients. Everything goes through ToolRegistry, so the main loop does not care whether a tool is built-in or MCP.