feat(mcp): connect external MCP servers at startup (Phase 1) - #4
Merged
Conversation
Roboot can now consume external MCP servers as first-class agent tools. Config lives under `mcp_servers:` in config.yaml → arcana MCPServerConfig. Tools register as `<server>.<tool>` (dotted), so they land outside tool_guard's native snapshot and a WRITE-classified MCP tool gates by default (Phase-0 side-effect-first gate). Validated live against messageinfra: 6 tools (get_briefing/get_digest/get_status/get_trends/search_items/trigger_fetch). - mcp_bootstrap.py: parse_mcp_configs() (config → MCPServerConfig, malformed entries skipped) + connect_mcp_servers() (connect → convert → register with per-server AND per-tool graceful degrade, name-collision guard). - server.py: background MCP connect in the startup event (after _get_runtime so the native snapshot precedes MCP registration); disconnect_all on shutdown. - config.example.yaml + CLAUDE.md: documented (messageinfra example; Gmail/Cal = npx + OAuth, deferred). API note: Arcana 1.0 has NO Runtime(mcp_servers=)/connect_mcp — the real primitive is the standalone setup_mcp_tools(configs, registry) (memory fixed). Hardening from a 2-lens adversarial review (gating verified sound end-to-end; issues were all resource-cleanup): - Dropped the external asyncio.wait_for around connect(): cancelling Arcana's connect() mid-handshake orphans the spawned subprocess (it registers the connection only after success and has no try/finally cleanup). Now bounded by the server's timeout_ms; the residual spawn-then-fail leak is an upstream Arcana gap — logged so it's observable, documented for an upstream fix. - Per-tool registration is guarded (a hostile/buggy tool spec is skipped, never aborting the server or leaking the client). - MCPClient is created and published to _mcp_client BEFORE connecting, so a shutdown racing an in-flight bootstrap still disconnect_all()s partial state. - Bootstrap task handle retained (3.11 GC-cancel footgun) and cancelled on shutdown. - Name-collision guard: a duplicate dotted name is skipped + warned, not silently overwritten. Scope: wired into the local server loop only (stdio transports are loop-bound); relay-thread / Telegram-process MCP is a documented follow-up. 540 pytest green (+12 mcp_bootstrap tests). Live connect re-verified post-review.
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.
What & why
Phase 1 of the roadmap: Roboot can now connect external MCP servers and use their tools as first-class agent tools. Builds directly on Phase 0 (gate-by-side-effect) — MCP tools register under dotted
<server>.<tool>names, so they land outsidetool_guard.set_native_tools()and a WRITE-classified MCP tool gates by default in CONFIRM mode.Validated live against messageinfra: connected and registered 6 tools (
get_briefing,get_digest,get_status,get_trends,search_items,trigger_fetch).Changes
mcp_bootstrap.py(new):parse_mcp_configs()(config →MCPServerConfig, malformed entries skipped) +connect_mcp_servers()(connect → convert → register, with per-server and per-tool graceful degrade + name-collision guard).server.py: background MCP connect in the startup event (after_get_runtime()so the native snapshot precedes MCP registration);disconnect_all()on shutdown.config.example.yaml+CLAUDE.md: documented (messageinfra example; Gmail/Calendar = npx + OAuth, deferred).API note: Arcana 1.0 has no
Runtime(mcp_servers=...)/connect_mcp— the real primitive is the standalonesetup_mcp_tools(configs, registry). Used that.Adversarial review → hardening
A 2-lens review (lifecycle/correctness + safety/gating) confirmed the gating is sound end-to-end (native snapshot provably precedes MCP registration; dotted names can never collide with native/keyed tools; config-trust is config.yaml-only with no relay/telegram/chat path; no secret leakage). All findings were resource-cleanup, fixed here:
asyncio.wait_foraroundconnect(): cancelling Arcana'sconnect()mid-handshake orphans the spawned subprocess (it registers the connection only after success and has no try/finally cleanup — verified live). Now bounded by the server'stimeout_ms. The residual spawn-then-fail leak is an upstream Arcana gap — logged so it's observable, documented for an upstream fix._mcp_clientpublished BEFORE connecting — a shutdown racing an in-flight bootstrap stilldisconnect_all()s partial state.Scope / follow-ups
MCPClient.connect()should wrap its spawn+handshake in try/finally so a failed connect can't orphan the subprocess.Testing
uv run pytest -q→ 540 passed (+12mcp_bootstraptests: config parsing, graceful degrade, timeout/failure skip, per-tool skip, collision skip, passed-client, shutdown-client). Live connect re-verified post-review.