MCP 接入
職務
MCP(Model Context Protocol)は外部ツール (tool) /データソースを接続する汎用プロトコル。Hermes は MCP client として外部 MCP server が露出するツールを消費し(mcp_tool で ToolRegistry に橋渡し)、また MCP server として(mcp_serve.py)自身のツールを他の agent クライアントに露出できる。この層により、Hermes は第三者の能力を手書きアダプタ (adapter) なしで接入できる。
設計動機
なぜ独自のツールプロトコルを再発明せず MCP を選ぶのか? MCP は Anthropic が推すオープン標準で、Claude Code、Cursor、Codex が既にこれに接続しており、エコシステムが出来上がっている。Hermes が client になれば数十のコミュニティ MCP server を再利用でき、server になれば任意の MCP クライアントに駆動される——双方向の相互運用性をただで手に入れられる。自前のプロトコルは内部 ToolRegistry にはフィットするが、失うのはエコシステム全体だ。
主要ファイル
tools/mcp_tool.py— MCP client 橋渡し:外部 MCP server のツールを ToolRegistry に登録mcp_serve.py— Hermes が MCP server として自身のツールを露出(リポジトリルート)registry.register:365-436— MCP 橋渡しのツールは最終的に同じ登録パスを通るregister_plugin_override_policy:316-365— MCP/プラグイン (plugin) 工具の名前空間准入を制御optional-mcps/ ディレクトリ— 任意の MCP server 実装
データフロー
- 起動期(または実行期ホットロード)に MCP 設定を読み、
mcp_toolが外部 MCP server に接続する。 - server が露出するツール一覧を取得し、
registry.register:365で一つずつToolEntryとして登録する(名前空間隔離で内蔵ツールとの衝突を回避)。 - 名前空間准入は
register_plugin_override_policy:316で制御する。 - 主ループは内蔵ツールと同様に schema を集め、tool_call をディスパッチ (dispatch) し、結果を戻す。
- 逆方向:Hermes は
mcp_serve.pyで自身のツールを他の MCP client に露出する。
主要コード
MCP ツールの ToolRegistry への橋渡し
接続成功後、_register_server_tools が各 MCP ツールを 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- で始まらない内蔵ツールセットとぶつかったら黙って置き換えずスキップする。
ツール呼び出し (tool dispatch) の橋渡し
主ループが 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」と書くのは、モデルに読ませるためだ——サーキットブレーカーが開いている間にイテレーション (iteration) を焼き尽くして再試行し続けるのを止める。
逆方向: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 かを意識しなくてよい。