181 lines
7.1 KiB
Python
181 lines
7.1 KiB
Python
"""Hermes-tools-as-MCP server for the codex_app_server runtime.
|
|
|
|
Under the codex app-server, codex owns the loop and its own tool list, so
|
|
Hermes' richer surface (web search, browser, vision, image gen, skills, TTS,
|
|
kanban handoff) would be unreachable. This module exposes a curated subset over
|
|
stdio MCP; codex registers it via ``~/.codex/config.toml [mcp_servers.hermes-tools]``.
|
|
Run with: ``python -m agent.transports.hermes_tools_mcp_server``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# JSON Schema type -> Python type mapping for signature generation
|
|
_JSON_TO_PY = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
|
|
|
|
|
|
def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]:
|
|
"""Build a KEYWORD_ONLY signature + annotations dict from a JSON schema's
|
|
``properties`` / ``required`` (optional params default to None)."""
|
|
props = (schema or {}).get("properties") or {}
|
|
required = set((schema or {}).get("required") or [])
|
|
params, annots = [], {}
|
|
for pname, pspec in props.items():
|
|
if pname.startswith("_"):
|
|
continue
|
|
py = _JSON_TO_PY.get((pspec or {}).get("type"), Any)
|
|
ann, default = (py, inspect.Parameter.empty) if pname in required else (Optional[py], None)
|
|
annots[pname] = ann
|
|
params.append(inspect.Parameter(pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default))
|
|
return inspect.Signature(params, return_annotation=str), annots
|
|
|
|
|
|
# Each name MUST match a registered Hermes tool that
|
|
# ``model_tools.handle_function_call()`` can dispatch.
|
|
# Deliberately NOT exposed: terminal/shell, read_file/write_file/patch,
|
|
# search_files/process, clarify — codex's built-ins cover them with codex's own
|
|
# approval UI; delegate_task/memory/session_search/todo — ``_AGENT_LOOP_TOOLS``
|
|
# need the running AIAgent context, which a stateless MCP callback lacks.
|
|
EXPOSED_TOOLS: tuple[str, ...] = (
|
|
"web_search",
|
|
"web_extract",
|
|
"browser_navigate",
|
|
"browser_click",
|
|
"browser_type",
|
|
"browser_press",
|
|
"browser_snapshot",
|
|
"browser_scroll",
|
|
"browser_back",
|
|
"browser_get_images",
|
|
"browser_console",
|
|
"browser_vision",
|
|
"vision_analyze",
|
|
"image_generate",
|
|
"skill_view",
|
|
"skills_list",
|
|
"text_to_speech",
|
|
# Kanban handoff tools: stateless (read HERMES_KANBAN_TASK, write kanban.db).
|
|
# Without them a codex-runtime worker can't report completion and hangs.
|
|
"kanban_complete",
|
|
"kanban_block",
|
|
"kanban_request_review",
|
|
"kanban_request_changes",
|
|
"kanban_comment",
|
|
"kanban_heartbeat",
|
|
"kanban_show",
|
|
"kanban_list",
|
|
# Orchestrator-only (the kanban tool gates them on HERMES_KANBAN_TASK unset).
|
|
"kanban_create",
|
|
"kanban_unblock",
|
|
"kanban_link",
|
|
)
|
|
|
|
|
|
def _build_server() -> Any:
|
|
"""Create the MCP server with Hermes tools attached (lazy imports so the module
|
|
imports without the mcp package; the clear error fires only when run)."""
|
|
try:
|
|
# mcp 2.0 renamed `mcp.server.fastmcp` to `mcp.server.MCPServer` (same surface).
|
|
from mcp.server import MCPServer
|
|
except ImportError as exc: # pragma: no cover - install hint
|
|
raise ImportError(f"hermes-tools MCP server requires the 'mcp' package: {exc}") from exc
|
|
|
|
from model_tools import get_tool_definitions, handle_function_call
|
|
|
|
mcp = MCPServer(
|
|
"hermes-tools",
|
|
instructions=(
|
|
"Hermes Agent's tool surface, exposed for use inside a Codex "
|
|
"session. Use these for capabilities Codex's built-in toolset "
|
|
"doesn't cover: web search/extract, browser automation, "
|
|
"subagent delegation, vision, image generation, persistent "
|
|
"memory, skills, and cross-session search."
|
|
),
|
|
)
|
|
|
|
# Authoritative Hermes schemas so MCP clients see the same parameter docs the model does.
|
|
all_defs = {
|
|
td["function"]["name"]: td["function"]
|
|
for td in (get_tool_definitions(quiet_mode=True) or [])
|
|
if isinstance(td, dict) and td.get("type") == "function"
|
|
}
|
|
|
|
def _make_handler(tool_name: str, schema: dict | None, description: str):
|
|
# The SDK derives the input schema from the callable's signature (no
|
|
# inputSchema parameter), so synthesize __signature__ from the Hermes JSON Schema.
|
|
sig, annots = _signature_from_schema(schema)
|
|
|
|
def _dispatch(**kwargs: Any) -> str:
|
|
try:
|
|
# Drop None so unset optionals aren't forwarded to the handler.
|
|
return handle_function_call(tool_name, {k: v for k, v in kwargs.items() if v is not None})
|
|
except Exception as exc:
|
|
logger.exception("tool %s raised", tool_name)
|
|
return json.dumps({"error": str(exc), "tool": tool_name})
|
|
|
|
_dispatch.__name__ = tool_name
|
|
_dispatch.__doc__ = description
|
|
_dispatch.__signature__ = sig
|
|
_dispatch.__annotations__ = {**annots, "return": str}
|
|
return _dispatch
|
|
|
|
exposed_count = 0
|
|
for name in EXPOSED_TOOLS:
|
|
spec = all_defs.get(name)
|
|
if spec is None:
|
|
logger.debug("skipping %s — not registered in this Hermes process", name)
|
|
continue
|
|
description = spec.get("description") or f"Hermes {name} tool"
|
|
params_schema = spec.get("parameters") or {"type": "object", "properties": {}}
|
|
handler = _make_handler(name, params_schema, description)
|
|
try:
|
|
mcp.add_tool(handler, name=name, description=description)
|
|
except TypeError:
|
|
# Older mcp SDK: decorator-style registration; __signature__ still drives schema.
|
|
mcp.tool(name=name, description=description)(_make_handler(name, params_schema, description))
|
|
exposed_count += 1
|
|
|
|
logger.info("hermes-tools MCP server registered %d/%d tools", exposed_count, len(EXPOSED_TOOLS))
|
|
return mcp
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
"""Entry point for `python -m agent.transports.hermes_tools_mcp_server`."""
|
|
argv = argv or sys.argv[1:]
|
|
verbose = "--verbose" in argv or "-v" in argv
|
|
logging.basicConfig(
|
|
level=logging.INFO if verbose else logging.WARNING,
|
|
stream=sys.stderr, # MCP uses stdio for protocol — logs MUST go to stderr
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
# Keep Hermes' own banners off stdout (the MCP wire).
|
|
os.environ.setdefault("HERMES_QUIET", "1")
|
|
os.environ.setdefault("HERMES_REDACT_SECRETS", "true")
|
|
|
|
try:
|
|
server = _build_server()
|
|
except ImportError as exc:
|
|
sys.stderr.write(f"hermes-tools MCP server cannot start: {exc}\n")
|
|
return 2
|
|
try:
|
|
server.run() # defaults to stdio transport, which codex spawns us on
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except Exception as exc:
|
|
logger.exception("hermes-tools MCP server crashed")
|
|
sys.stderr.write(f"hermes-tools MCP server error: {exc}\n")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|