feat: hermes mcp serve --tools — expose Hermes' tool surface to any MCP client

Promotes the curated hermes-tools MCP surface (previously reachable only
via the codex_app_server runtime) to a first-class flag on
'hermes mcp serve'. With --tools, external MCP clients (Claude Code,
Codex, Cursor, custom harnesses) get Hermes' web search/extract, browser
automation, vision, image generation, skills, and TTS tools alongside the
existing messaging bridge, under the credentials configured in the local
Hermes install.

- agent/transports/hermes_tools_mcp_server.py: factor tool registration
  into register_hermes_tools(mcp) so both entry points share one surface
- mcp_serve.py: create_mcp_server(include_tools=) + run_mcp_server flag
- hermes_cli/subcommands/mcp.py: --tools flag on 'mcp serve'
- hermes_cli/mcp_config.py: thread the flag through the dispatcher
- docs: mcp.md section with client config example
This commit is contained in:
Teknium 2026-08-10 10:41:13 -07:00
parent 03fa32c92d
commit 60938d5bab
No known key found for this signature in database
6 changed files with 126 additions and 28 deletions

View File

@ -149,34 +149,25 @@ EXPOSED_TOOLS: tuple[str, ...] = (
)
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
def register_hermes_tools(mcp: Any) -> int:
"""Register the curated Hermes tool surface onto an existing FastMCP server.
Shared by two entry points: the codex_app_server runtime's dedicated
``hermes-tools`` stdio server (``_build_server`` below) and
``hermes mcp serve --tools`` (``mcp_serve.py``), which folds these tools
into the conversation-bridge server so any MCP client gets Hermes'
web/browser/vision/image/skills/TTS capabilities alongside messaging.
Returns the number of tools actually registered (tools missing from the
current process's registry — unconfigured backends, disabled toolsets —
are skipped, matching the model-facing availability rules).
"""
# 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 = {
@ -242,6 +233,31 @@ def _build_server() -> Any:
exposed_count,
len(EXPOSED_TOOLS),
)
return exposed_count
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
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."
),
)
register_hermes_tools(mcp)
return mcp

View File

@ -1076,7 +1076,10 @@ def mcp_command(args):
if action == "serve":
from mcp_serve import run_mcp_server
run_mcp_server(verbose=getattr(args, "verbose", False))
run_mcp_server(
verbose=getattr(args, "verbose", False),
include_tools=getattr(args, "tools", False),
)
return
# Catalog subcommands live in mcp_picker / mcp_catalog. Import lazily so

View File

@ -36,6 +36,15 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
action="store_true",
help="Enable verbose logging on stderr",
)
mcp_serve_p.add_argument(
"--tools",
action="store_true",
help=(
"Also expose Hermes' tool surface (web search/extract, browser "
"automation, vision, image generation, skills, TTS) so external "
"MCP clients can use Hermes' connectors directly"
),
)
add_accept_hooks_flag(mcp_serve_p)
mcp_add_p = mcp_sub.add_parser(

View File

@ -587,8 +587,18 @@ class EventBridge:
# MCP Server
# ---------------------------------------------------------------------------
def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
"""Create and return the Hermes MCP server with all tools registered."""
def create_mcp_server(
event_bridge: Optional[EventBridge] = None,
include_tools: bool = False,
) -> "FastMCP":
"""Create and return the Hermes MCP server with all tools registered.
When ``include_tools`` is True, the curated Hermes tool surface
(web search/extract, browser automation, vision, image generation,
skills, TTS, kanban) is registered alongside the messaging bridge, so
external MCP clients (Claude Code, Codex, Cursor, ...) can use Hermes'
connectors and capabilities directly.
"""
if not _MCP_SERVER_AVAILABLE:
raise ImportError(
"MCP server requires the 'mcp' package. "
@ -601,9 +611,25 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
"Hermes Agent messaging bridge. Use these tools to interact with "
"conversations across Telegram, Discord, Slack, WhatsApp, Signal, "
"Matrix, and other connected platforms."
+ (
" Also exposes Hermes' own capabilities — web search/extract, "
"browser automation, vision analysis, image generation, "
"skills, and text-to-speech — as directly callable tools."
if include_tools
else ""
)
),
)
if include_tools:
# Fold in Hermes' curated tool surface. Registered first so the
# count reflects what this process can actually dispatch
# (unconfigured backends are skipped by availability checks).
from agent.transports.hermes_tools_mcp_server import register_hermes_tools
registered = register_hermes_tools(mcp)
logger.info("exposed %d Hermes tools over MCP", registered)
bridge = event_bridge or EventBridge()
# -- conversations_list ------------------------------------------------
@ -1003,7 +1029,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
# Entry point
# ---------------------------------------------------------------------------
def run_mcp_server(verbose: bool = False) -> None:
def run_mcp_server(verbose: bool = False, include_tools: bool = False) -> None:
"""Start the Hermes MCP server on stdio."""
if not _MCP_SERVER_AVAILABLE:
print(
@ -1018,10 +1044,15 @@ def run_mcp_server(verbose: bool = False) -> None:
else:
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
if include_tools:
# Keep Hermes' own banners/prints off stdout — it is the MCP wire.
os.environ.setdefault("HERMES_QUIET", "1")
os.environ.setdefault("HERMES_REDACT_SECRETS", "true")
bridge = EventBridge()
bridge.start()
server = create_mcp_server(event_bridge=bridge)
server = create_mcp_server(event_bridge=bridge, include_tools=include_tools)
import asyncio

View File

@ -1012,7 +1012,18 @@ class TestCliIntegration:
args = argparse.Namespace(mcp_action="serve", verbose=True)
from hermes_cli.mcp_config import mcp_command
mcp_command(args)
mock_run.assert_called_once_with(verbose=True)
mock_run.assert_called_once_with(verbose=True, include_tools=False)
def test_dispatcher_routes_serve_with_tools(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
mock_run = MagicMock()
monkeypatch.setattr("mcp_serve.run_mcp_server", mock_run)
import argparse
args = argparse.Namespace(mcp_action="serve", verbose=False, tools=True)
from hermes_cli.mcp_config import mcp_command
mcp_command(args)
mock_run.assert_called_once_with(verbose=False, include_tools=True)
# ---------------------------------------------------------------------------

View File

@ -871,10 +871,38 @@ The event queue is in-memory and starts when the bridge connects. Older messages
### Options
```bash
hermes mcp serve # Normal mode
hermes mcp serve # Normal mode (messaging bridge only)
hermes mcp serve --tools # Also expose Hermes' tool surface (see below)
hermes mcp serve --verbose # Debug logging on stderr
```
### Exposing Hermes' tools to other agents (`--tools`)
With `--tools`, the server additionally registers Hermes' curated tool
surface alongside the messaging bridge: `web_search`, `web_extract`, the
`browser_*` automation suite, `vision_analyze`, `image_generate`,
`skill_view`/`skills_list`, and `text_to_speech`. Any MCP client — Claude
Code, Codex, Cursor, a custom harness — can then use Hermes' configured
connectors and capabilities directly, under the credentials configured in
your Hermes install:
```json
{
"mcpServers": {
"hermes": {
"command": "hermes",
"args": ["mcp", "serve", "--tools"]
}
}
}
```
Tools whose backend isn't configured in your Hermes install (e.g. no web
provider) are skipped automatically, matching what Hermes' own model would
see. Switching models or clients no longer means leaving your tools behind:
the same web/browser/vision/skills stack follows you into any MCP-speaking
agent.
### How it works
The MCP server reads conversation data directly from Hermes's session store — `~/.hermes/state.db` is the primary source, with `sessions.json` kept only as a legacy fallback. A background thread polls the database for new messages and maintains an in-memory event queue. For sending messages, it uses the same internal send engine (`tools/send_message_tool.py`) that powers cron delivery and the `hermes send` CLI.