"""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())