"""Hermes-tools-as-MCP server for the codex_app_server runtime. When the user runs `openai/*` turns through the codex app-server, codex owns the loop and builds its own tool list. By default, that means Hermes' richer tool surface — web search, browser automation, delegate_task subagents, vision analysis, persistent memory, skills, cross-session search, image generation, TTS — is unreachable. This module exposes a curated subset of those Hermes tools to the spawned codex subprocess via stdio MCP. Codex registers it as a normal MCP server (per `~/.codex/config.toml [mcp_servers.hermes-tools]`) and the user gets full Hermes capability inside a Codex turn. Scope (what we expose): - web_search, web_extract — Firecrawl, no codex equivalent - browser_navigate / _click / _type / — Camofox/Browserbase automation _snapshot / _screenshot / _scroll / _back / _press / _vision - delegate_task — Hermes subagents - vision_analyze — image inspection by vision model - image_generate — image generation - memory — Hermes' persistent memory store - skill_view, skills_list — Hermes' skill library - session_search — cross-session search - text_to_speech — TTS What we DO NOT expose (codex has equivalents): - terminal / shell — codex's own shell tool - read_file / write_file / patch — codex's apply_patch + shell - search_files / process — codex's shell - clarify, todo — codex's own UX Run with: python -m agent.transports.hermes_tools_mcp_server Spawned by: CodexAppServerSession.ensure_started() when the runtime is active and config opts in. """ from __future__ import annotations import json import logging import os import sys from typing import Any, Optional logger = logging.getLogger(__name__) # Tools we expose. Each name MUST match a registered Hermes tool that # `model_tools.handle_function_call()` can dispatch. # # What we deliberately DO NOT expose: # - terminal / shell / read_file / write_file / patch / search_files / # process — codex's built-ins cover these and approval routes through # codex's own UI. # - delegate_task / memory / session_search / todo — these are # `_AGENT_LOOP_TOOLS` in Hermes (model_tools.py:493). They require # the running AIAgent context to dispatch (mid-loop state), so a # stateless MCP callback can't drive them. Hermes' default runtime # keeps these working; the codex_app_server runtime cannot. 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 worker handoff tools — gated on HERMES_KANBAN_TASK env var # (set by the kanban dispatcher when spawning a worker). Without these # in the callback, a worker spawned with openai_runtime=codex_app_server # could do the work but couldn't report completion back to the kernel, # making it hang until timeout. Stateless dispatch — they just read # the env var and write to ~/.hermes/kanban.db. "kanban_complete", "kanban_block", "kanban_comment", "kanban_heartbeat", "kanban_show", "kanban_list", # NOTE: kanban_create / kanban_unblock / kanban_link are orchestrator- # only — the kanban tool gates them on HERMES_KANBAN_TASK being unset. # They're exposed here for orchestrator agents running on the codex # runtime that need to dispatch new tasks. "kanban_create", "kanban_unblock", "kanban_link", ) def _build_server() -> Any: """Create the FastMCP server with Hermes tools attached. Lazy imports so the module can be imported without the mcp package installed (we degrade to a clear error only when actually run).""" try: from mcp.server.fastmcp import FastMCP except ImportError as exc: # pragma: no cover - install hint raise ImportError( f"hermes-tools MCP server requires the 'mcp' package: {exc}" ) from exc # Discover Hermes tools so dispatch works. from model_tools import ( get_tool_definitions, handle_function_call, ) mcp = FastMCP( "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." ), ) # Pull authoritative Hermes tool schemas for the ones we expose, so # MCP clients see the same parameter docs Hermes gives the model. 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" } 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": {}} # FastMCP wants a Python callable. Build a closure that takes the # arguments dict, dispatches via handle_function_call, and returns # the result string. We use add_tool() for full control over the # input schema (FastMCP's @tool() decorator inspects type hints, # which we can't get from a JSON schema at runtime). def _make_handler(tool_name: str): def _dispatch(**kwargs: Any) -> str: try: return handle_function_call(tool_name, kwargs or {}) 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 return _dispatch try: mcp.add_tool( _make_handler(name), name=name, description=description, # FastMCP accepts JSON schema directly via the # input_schema parameter on newer versions; older # versions use parameters_schema. Try both for compat. ) except TypeError: # Older mcp SDK signature — fall back to decorator-style. handler = _make_handler(name) handler = mcp.tool(name=name, description=description)(handler) 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 log_level = logging.INFO if verbose else logging.WARNING logging.basicConfig( level=log_level, stream=sys.stderr, # MCP uses stdio for protocol — logs MUST go to stderr format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) # Quiet mode: keep Hermes' own banners off stdout (which is 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 # FastMCP runs with stdio transport by default when launched as a # subprocess. try: server.run() except KeyboardInterrupt: return 0 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())