Skip to content

feat(mcp): connect external MCP servers at startup (Phase 1) - #4

Merged
tyxben merged 1 commit into
mainfrom
feat/mcp-client
Jun 13, 2026
Merged

feat(mcp): connect external MCP servers at startup (Phase 1)#4
tyxben merged 1 commit into
mainfrom
feat/mcp-client

Conversation

@tyxben

@tyxben tyxben commented Jun 13, 2026

Copy link
Copy Markdown
Owner

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 outside tool_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 standalone setup_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:

  • 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 — verified live). 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 guarded — a hostile/buggy tool spec is skipped, never aborting the server or leaking the client.
  • _mcp_client published BEFORE connecting — 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 / follow-ups

  • Wired into the local server loop only — stdio transports are loop-bound, so relay-thread / Telegram-process MCP is a documented follow-up.
  • Upstream: Arcana MCPClient.connect() should wrap its spawn+handshake in try/finally so a failed connect can't orphan the subprocess.
  • Gmail/Calendar (npx MCP server + OAuth first-run) deferred.

Testing

uv run pytest -q540 passed (+12 mcp_bootstrap tests: config parsing, graceful degrade, timeout/failure skip, per-tool skip, collision skip, passed-client, shutdown-client). Live connect re-verified post-review.

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.
@tyxben
tyxben merged commit c9a5809 into main Jun 13, 2026
1 check passed
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.

1 participant