feat(mcp): auto-wire bundled mcp_serve at worker boot (closes #82)#83
Merged
Conversation
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).
This was referenced May 24, 2026
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.
This was referenced May 24, 2026
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.
This was referenced May 24, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 atmcp_serve, so workers booted with zero MCP-side tools. This adds the missing wiring with zero operator config.Three edits
mcp_serve.py—if __name__ == "__main__":block sopython -m mcp_serveruns the existing stdio server. Honors--verbose/-vfor subprocess debugging.hermes_cli/mcp_autowire.py(new) —ensure_internal_mcp_server()idempotently inserts ahermes-internalentry undermcp_serversin the active profile config. Usessys.executable+["-m", "mcp_serve"]so the spawn survives venv-not-on-PATH installs (the typical container shape). Opt-out viaHERMES_DISABLE_INTERNAL_MCP=1.tui_gateway/entry.py— callsensure_internal_mcp_server()BEFORE theread_raw_config().mcp_serversgate, so the gate sees the freshly-written entry on the same boot anddiscover_mcp_tools()picks it up.Correction to issue #82 design
The issue body proposed writing a
.mcp.jsonfile (Claude-Code-style separate config). The actual Hermes config lives at~/.hermes/config.yaml :: mcp_servers:(the same locationtools/mcp_tool.py::_load_mcp_configalready reads viahermes_cli.config.load_config). This PR targets the real config.Tests (
tests/test_internal_mcp_autowire.py— 20 cases, all pass)HERMES_DISABLE_INTERNAL_MCPtruthy variants (1/true/yes/on/...) all opt out""/0/false/no/off) do not opt outmcp_serverskey when absent in configsys.executable(NOT literal"python") — catches venv regression["-m", "mcp_serve"]— catches spawn-shape regressionmcp_serve.pyhas__main__guard invokingrun_mcp_serverentry.pycallsensure_internal_mcp_server()before theread_raw_config()gate (catches ordering regression where auto-wire runs after the gate snapshot and never takes effect)Smoke
stdio loop closes cleanly on stdin EOF — proper stdio MCP server behavior.
Test plan
mcp_serversconfig~/.hermes/profiles/<profile>/config.yamlnow containsmcp_servers.hermes-internalafter first bootgrafted_context_fetch(G4) without operator wiringHERMES_DISABLE_INTERNAL_MCP=1, boot fresh worker, verify NOhermes-internalentry is auto-written🤖 Generated with Claude Code