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
Context
Section titled “Context”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.
Decision
Section titled “Decision”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.
Alternatives considered
Section titled “Alternatives considered”- 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 nodeIncomingMessage/ServerResponse, while Astro hands routes WebRequest/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
/mcpwithout de-forking first — would have created fork #4, and the new endpoint would have inherited whichever subset of tools it happened to import.
Consequences
Section titled “Consequences”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/clibundle. host/src/mcp.tsandhost/src/http.tsnow import it; neither declares tools.dashboard/src/pages/api/rpc.tsde-forked — same dialect, registry-sourced. It gains the 11 tools production was missing.- New
dashboard/src/pages/api/mcp.ts(spec MCP) anddashboard/src/lib/tool-ctx.ts(sharedToolContextbuilder, so the two routes cannot fork their context either). vercel.json— adds the/mcp→/api/mcpredirect.- Tool failures over stdio now return
isError: truerather than throwing a protocol error.
For docs:
- pebble’s
CLAUDE.mdcorpus example must useendpoint: /api/mcpand denypetrova.sweeps.triggerin addition topetrova.act.*. Denying theact.*prefix alone is not a read-only surface. host/README.mdand rootCLAUDE.mdclaim “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.tsenforces the single-registry rule mechanically: it fails the build if any transport declares its ownHANDLERSmap, 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.
References
Section titled “References”- MCP specification — Streamable HTTP transport;
isErrorontools/callresults;initializeversion 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
RemoteMCPProvideragainst this endpoint discovered 42 tools, denied 17 (16act.*+sweeps.trigger), exposed 25 including the 7 federation readers, and round-trippedpetrova.registry.query.
Sign-off
Section titled “Sign-off”- Subagent: claude-opus-4-8 (pebble ↔ petrova MCP wiring, 2026-07-14)
- Human: pending review