Skip to content

Publish a spec-compliant MCP endpoint, and collapse the tool registry to one source

Date: 2026-07-14 Status: open Supersedes: none Superseded-by: none — current

pebble (the devarno.cloud MCP gateway) set out to proxy petrova as a read-only fleet-governance plane. It could not. Two findings surfaced while wiring it, and the second is the serious one.

1. petrova publishes no MCP surface. petrova.host/mcp 404s. The live endpoint is /api/rpc (via the /rpc/api/rpc redirect in vercel.json), and it speaks petrova’s own JSON-RPC dialect, not MCP: the tool name is the method, there is no initialize, and there is no tools/call. Its tools/list returns bare {name} stubs with no inputSchema — a client can see the tools but cannot learn how to call them. Any MCP client opens with initialize and gets -32601 back. The only spec-compliant surface was stdio, which a hosted gateway cannot reach. The alternative on offer was for every consumer to hand-transcribe petrova’s 42 tool schemas into its own repo and language, and keep them in step by hand forever.

2. The tool registry had forked three ways, and the fork serving production was the most incomplete one. host/src/http.ts:7-11 documents this bug being fixed once — a forked handler map there had silently lost 7 federation readers and 9 act.* verbs — and closes with “do not fork this map”. It had since regrown twice: host/src/mcp.ts split TOOL_DEFS (names+schemas) from HANDLERS (names+functions) into two literals kept in step by hand, and dashboard/src/pages/api/rpc.ts — the handler actually serving petrova.host — declared its own map again.

Measured, not inferred. stdio exposed 39 tools; production exposed 31. Production was missing 11: all 7 federation readers (petrova.ralph.*, petrova.kahn.*, petrova.stratt.*) plus petrova_install_playbook, petrova_onboard, declare_baseline and wire_integration. It exposed 3 that stdio did not (petrova.acts.get, petrova.sweeps.trigger, petrova.sweeps.last_run). The kahn/ralph/stratt sources were even constructed in that file’s getCtx() — the tools were simply never registered. Nothing failed, because nothing compared the two lists.

That drift also created a security-relevant trap for any consumer enforcing a read-only surface. petrova.sweeps.trigger mutates — it fires a GitHub workflow_dispatch — and it carries no petrova.act. prefix. A downstream proxy denying petrova.act.* (the obvious rule, and the one pebble’s docs originally suggested) would let it straight through.

Collapse the registry to a single source, then publish MCP from it.

host/src/registry.ts is now the one definition per tool, carrying name, description, inputSchema, handler and a mutating flag in one object. A map of names→schemas and a map of names→functions can always disagree; a list of objects carrying both cannot. Every transport — stdio, host/src/http.ts, /api/rpc, and the new /api/mcp — derives its view from it. The union is 42 tools, restoring the 11 that production had lost.

dashboard/src/pages/api/mcp.ts is a new spec-compliant MCP endpoint: Streamable HTTP in JSON response mode, with initialize (negotiating the protocol version rather than pinning one), notifications/initialized → bodyless 202, tools/list with real inputSchemas, and tools/call. It is served at /mcp via a vercel.json redirect, alongside /api/rpc, which is unchanged for its existing callers. Tool failures are reported in-band with isError: true, not as JSON-RPC errors — those two channels mean opposite things (the call ran and failed, versus the call never ran), and conflating them is how a caller acts on a failure it never noticed.

mutating: true is now the authoritative read-only filter, and MUTATING_TOOLS exports it. Consumers must filter on the flag, not on the name prefix.

  • Leave petrova as-is; hand-transcribe its 42 tool schemas into pebble — a tool registry maintained by hand, in a second language, in a second repo, on a second deploy cadence. This repo has now lost tools to exactly that failure three times; doing it across a language boundary would be worse.
  • Proxy the stdio transport from pebble — a fully compliant MCP server with real schemas already exists over stdio, but it is unreachable from a hosted gateway without spawning petrova as a subprocess inside pebble’s container.
  • Mount the official SDK’s StreamableHTTPServerTransport — it expects node IncomingMessage/ServerResponse, while Astro hands routes Web Request/Response, and its session handling wants state a Vercel function does not keep between invocations. The JSON-response mode implemented here is spec-permitted and stateless.
  • Add /mcp without de-forking first — would have created fork #4, and the new endpoint would have inherited whichever subset of tools it happened to import.

For code:

  • New host/src/registry.ts — canonical registry (42 tools), HANDLERS, TOOLS_LIST, MUTATING_TOOLS. act.* verbs stay lazily imported, preserving the read plane’s independence from the @petrova/cli bundle.
  • host/src/mcp.ts and host/src/http.ts now import it; neither declares tools.
  • dashboard/src/pages/api/rpc.ts de-forked — same dialect, registry-sourced. It gains the 11 tools production was missing.
  • New dashboard/src/pages/api/mcp.ts (spec MCP) and dashboard/src/lib/tool-ctx.ts (shared ToolContext builder, so the two routes cannot fork their context either).
  • vercel.json — adds the /mcp/api/mcp redirect.
  • Tool failures over stdio now return isError: true rather than throwing a protocol error.

For docs:

  • pebble’s CLAUDE.md corpus example must use endpoint: /api/mcp and deny petrova.sweeps.trigger in addition to petrova.act.*. Denying the act.* prefix alone is not a read-only surface.
  • host/README.md and root CLAUDE.md claim “11 read-only tools”. That was already stale before this change (39 handlers, 16 mutating) and is now 42/17.

For in-flight phases:

  • None. No phase state is touched.

For invariants:

  • No MR additions. host/tests/registry.test.ts enforces the single-registry rule mechanically: it fails the build if any transport declares its own HANDLERS map, which is the only way this bug returns. The prose comment “do not fork this map” did not hold on its own — it was ignored twice.
  • MCP specification — Streamable HTTP transport; isError on tools/call results; initialize version negotiation.
  • host/src/http.ts:7-11 — the first fork, and the comment that failed to prevent the next two.
  • pebble CLAUDE.md — “a hand-maintained tool registry in a second language and a second repo will fall out of sync with the upstream.”
  • Verified end-to-end: pebble’s RemoteMCPProvider against this endpoint discovered 42 tools, denied 17 (16 act.* + sweeps.trigger), exposed 25 including the 7 federation readers, and round-tripped petrova.registry.query.
  • Subagent: claude-opus-4-8 (pebble ↔ petrova MCP wiring, 2026-07-14)
  • Human: pending review