Skip to content

feat(mcp): auto-wire bundled mcp_serve at worker boot (closes #82)#83

Merged
PowerCreek merged 1 commit into
mainfrom
issue-82-mcp-stdio-autowire
May 24, 2026
Merged

feat(mcp): auto-wire bundled mcp_serve at worker boot (closes #82)#83
PowerCreek merged 1 commit into
mainfrom
issue-82-mcp-stdio-autowire

Conversation

@PowerCreek

Copy link
Copy Markdown

Closes #82.

Summary

Layer 2 of the post-#67 cascade. After #79 (plugin register(ctx) wiring) + #81 (mcp_serve packaging), the bundled MCP server ships and G2/G3/G4 plugins load — but no MCP server entry pointed at mcp_serve, so workers booted with zero MCP-side tools. This adds the missing wiring with zero operator config.

Three edits

  1. mcp_serve.pyif __name__ == "__main__": block so python -m mcp_serve runs the existing stdio server. Honors --verbose / -v for subprocess debugging.

  2. hermes_cli/mcp_autowire.py (new)ensure_internal_mcp_server() idempotently inserts a hermes-internal entry under mcp_servers in the active profile config. Uses sys.executable + ["-m", "mcp_serve"] so the spawn survives venv-not-on-PATH installs (the typical container shape). Opt-out via HERMES_DISABLE_INTERNAL_MCP=1.

  3. tui_gateway/entry.py — calls ensure_internal_mcp_server() BEFORE the read_raw_config().mcp_servers gate, so the gate sees the freshly-written entry on the same boot and discover_mcp_tools() picks it up.

Correction to issue #82 design

The issue body proposed writing a .mcp.json file (Claude-Code-style separate config). The actual Hermes config lives at ~/.hermes/config.yaml :: mcp_servers: (the same location tools/mcp_tool.py::_load_mcp_config already reads via hermes_cli.config.load_config). This PR targets the real config.

Tests (tests/test_internal_mcp_autowire.py — 20 cases, all pass)

  • HERMES_DISABLE_INTERNAL_MCP truthy variants (1/true/yes/on/...) all opt out
  • Falsy variants (""/0/false/no/off) do not opt out
  • Idempotent: re-call with entry present is a no-op
  • Existing operator MCP servers (Linear/Notion/...) preserved across auto-write
  • Creates mcp_servers key when absent in config
  • Command is sys.executable (NOT literal "python") — catches venv regression
  • Args are ["-m", "mcp_serve"] — catches spawn-shape regression
  • Source-level: mcp_serve.py has __main__ guard invoking run_mcp_server
  • Source-level: entry.py calls ensure_internal_mcp_server() before the read_raw_config() gate (catches ordering regression where auto-wire runs after the gate snapshot and never takes effect)

Smoke

$ ssh dev 'cd /workspace/hermes-agent && timeout 3 python3 -m mcp_serve < /dev/null'
exit=0

stdio loop closes cleanly on stdin EOF — proper stdio MCP server behavior.

Test plan

  • After merge, rebuild the worker container so the wheel includes the new module + entry.py call
  • Spawn a fresh worker session with no operator-side mcp_servers config
  • Verify ~/.hermes/profiles/<profile>/config.yaml now contains mcp_servers.hermes-internal after first boot
  • Verify worker can invoke grafted_context_fetch (G4) without operator wiring
  • Set HERMES_DISABLE_INTERNAL_MCP=1, boot fresh worker, verify NO hermes-internal entry is auto-written

🤖 Generated with Claude Code

Layer 2 of the #67 cascade: post-#79 (register-ctx) + #81 (mcp_serve
packaging), the bundled MCP server now ships and the G2/G3/G4 plugins
load — but no MCP server entry pointed at it, so workers booted with
zero MCP-side tools. This adds the missing wiring.

Three edits:

1. mcp_serve.py — `if __name__ == "__main__":` block so
   `python -m mcp_serve` runs the existing stdio server. Honors
   `--verbose`/`-v` for subprocess debugging.

2. hermes_cli/mcp_autowire.py (new) — ensure_internal_mcp_server()
   idempotently inserts a `hermes-internal` entry under `mcp_servers`
   in the active profile config. Uses sys.executable + [-m, mcp_serve]
   so the spawn survives venv-not-on-PATH installs (the typical
   container-deploy shape). Opt-out via HERMES_DISABLE_INTERNAL_MCP=1.

3. tui_gateway/entry.py — calls ensure_internal_mcp_server() BEFORE
   the read_raw_config()::mcp_servers gate, so the gate sees the
   freshly-written entry on the same boot and discover_mcp_tools()
   picks it up.

Issue body proposed `.mcp.json` (Claude-Code-style separate file);
the actual config file is ~/.hermes/config.yaml under the
`mcp_servers` key (the same location `tools/mcp_tool.py` reads via
`hermes_cli.config.load_config`). This patch targets the real config.

Tests (tests/test_internal_mcp_autowire.py — 20 cases):
- HERMES_DISABLE_INTERNAL_MCP truthy variants all opt out
- Idempotent: re-call is a no-op
- Existing operator MCP servers (Linear/Notion/...) preserved
- Creates `mcp_servers` key when absent
- Command is sys.executable (not literal "python")
- Args are [-m, mcp_serve] (catches the spawn-shape regression)
- Source-level: mcp_serve has __main__ guard invoking run_mcp_server
- Source-level: entry.py calls ensure_internal_mcp_server() BEFORE
  the read_raw_config gate (catches ordering regression)

Smoke verified: `timeout 3 python3 -m mcp_serve < /dev/null` → exit 0
(stdio handshake; closes loop cleanly on stdin EOF).

After container rebuild + worker spawn, poly-explorer should have
end-to-end tool access (final layer of the cascade started at #67).
@PowerCreek PowerCreek merged commit a995617 into main May 24, 2026
@PowerCreek PowerCreek deleted the issue-82-mcp-stdio-autowire branch May 24, 2026 07:46
PowerCreek added a commit that referenced this pull request May 24, 2026
#83 placed ensure_internal_mcp_server() in tui_gateway/entry.py — but
that file is only on the path for the TUI-gateway subprocess. Direct
`hermes --provider X --model Y` invocations land in hermes_cli/main.py
(per [project.scripts] hermes = "hermes_cli.main:main") and skip the
autowire entirely, leaving config.yaml without hermes-internal and
workers without G2/G3/G4 MCP tools.

This adds the same autowire call inside hermes_cli/main.py's CLI
startup block, between discover_plugins() and discover_mcp_tools().
The block is gated on args.command in {None, chat, acp, rl} so it
fires for the bare invocation and all agent-running subcommands but
NOT for management commands (`hermes mcp add`, `hermes hooks list`,
etc. — those don't need the autowire).

Ordering: autowire must run BEFORE discover_mcp_tools so the freshly-
written hermes-internal entry is visible in the same boot. The
load_config mtime-cache in hermes_cli/config.py auto-invalidates on
save_config, so the second read sees the new entry.

The #83 tui_gateway/entry.py call stays — it's correct for the TUI
path. Idempotent: calling both is safe.

Tests (tests/test_autowire_cli_path.py — 3 cases, all pass):
- hermes_cli/main.py contains the call at all
- Call appears BEFORE the discover_mcp_tools import (ordering)
- Call is INSIDE the _AGENT_COMMANDS-gated block (no fire for
  management commands)

The existing 20-case suite from #83 still passes — 23 total green.
PowerCreek added a commit that referenced this pull request May 24, 2026
…loses #86) (#87)

Post-#82/#83/#85 the autowire surfaces ~36 MCP tools from
hermes-internal, pushing fresh workers to 52 total tools and into the
tool-paralysis ceiling (text responses with affirmation pattern, no
tool_call emission for verticals that should be one-shot).

#75 (HERMES_TOOLS_SUBSET) was supposed to let operators narrow the
surface per worker, but it had three gaps for MCP tools:

1. Subset filtering only ran in agent_init.py:838 (built-in tool path)
2. cli.py:9790 (/reload-mcp + auto-reload on config change) re-assigns
   agent.tools without re-applying the filter — regression risk where
   any post-init MCP server reload nukes the subset
3. Even when filtering at agent.tools, the registry still carried the
   unwanted MCP tools, costing schema-conversion + collision-check
   work per tool per boot

Fix: apply the allow-list at MCP tool registration in
tools/mcp_tool.py::_register_server_tools. Excluded tools never enter
the registry, so both initial discovery AND /reload-mcp paths honor
the subset uniformly + the registry stays clean.

Three edits:

1. hermes_cli/tool_subset.py (new) — shared helpers `get_subset_allow()`
   + `is_tool_allowed()`. Single source of truth for env parsing +
   allow-list semantics so the two call sites (agent_init + mcp_tool)
   can't drift in casing/whitespace/empty-vs-missing handling.

2. tools/mcp_tool.py::_register_server_tools — read the subset once
   per server registration (O(1) check per tool, not O(N) env parse),
   apply inside both the per-tool loop AND the utility-tools loop
   (list_resources/read_resource/list_prompts/get_prompt).

3. agent/agent_init.py — refactored the inline #75 filter to use the
   shared helper. Behavior unchanged; dedupes parsing logic.

Subset compares against the **prefixed** MCP name
(`mcp_<server>_<tool>`) — exact match, predictable behavior. Fuzzy /
unprefixed matching is a separate feature request.

Tests (tests/test_mcp_subset_filter.py — 14 cases, all pass):
- get_subset_allow: unset/empty/whitespace-only/single/multi/padded/
  MCP-prefixed parse cases
- is_tool_allowed: None passes through, exact match, exclusion, no
  substring matching, MCP prefixed vs unprefixed (contract test)
- Source-level: _register_server_tools imports the shared helper +
  calls it inside main loop
- Source-level: same call inside the utility-tools loop (regression
  catcher for "fix the main loop, forget utility tools")
- Source-level: agent_init.py uses shared helper, no stale inline
  parser

37 total green (14 new + 20 from #83 + 3 from #85).

Final layer of the post-#67 cascade. After this lands + container
rebuild, workers can be configured with HERMES_TOOLS_SUBSET to a 5-tool
surface that includes their specific MCP needs (e.g.
"doc_view,mcp_hermes-internal_grafted_context_fetch,..."), out of
tool-paralysis range.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layer 2: auto-wire mcp_serve as worker-session MCP server (no operator config required)

1 participant