/mcp with an Obol virtual key and it sees one MCP
server whose tool catalog is the union of every connector in the workspace,
filtered to what that key may call.
The gateway uses the official Rust SDK (rmcp) for MCP types and for the
client side, and implements the server side itself. The server owns session
identity, the per-key tool table, fan-out across upstreams, and error rendering —
the three things an off-the-shelf MCP server framework would take over
(ADR-0010).
Transport
/mcp is a Streamable HTTP MCP endpoint.
initialize negotiates protocolVersion (a known version is echoed, otherwise
the latest supported is returned), returns an Mcp-Session-Id of the form
mcs_…, and every response echoes MCP-Protocol-Version.
Responses are rendered as SSE, including JSON-RPC error responses. The
HTTP-level status matrix:
Bodies on
/mcp are capped at 8 MiB. Errors that occur before the MCP server is
reached (authentication, freeze, environment, scope) are rendered as a JSON-RPC
error object with the Obol error envelope as error.data.
Tool namespacing
Every tool is namedconnector.tool and split on the first dot (ADR-0015).
stripe.create_refund is connector stripe, tool create_refund;
stripe.charges.list is connector stripe, upstream tool charges.list. A
connector prefix therefore cannot contain a dot, and neither side may be empty.
The delimiter is a wire contract, not formatting: it appears in every Cedar
policy entity (Obol::Tool::"stripe.create_refund"), every key allowlist, and
every receipt. What reaches the upstream is the unprefixed upstream_name.
tools/list is filtered, and everything listed is callable
A tool appears intools/list only when all of the following hold for the
calling key:
- The CEL prefilter passes: the tool’s namespaced name matches the key’s
allowed_toolsglobs, the tool’s environment matches the key’s, and — in prod — a destructive tool is hidden from listing unless the key names it verbatim rather than by glob. - Cedar returns
Allowfor thelistaction on that tool under the pinned policy publication. - The tool is call-reachable: Cedar would not categorically deny
callon it. For a capability, at least one provider must be reachable by this key.
Progressive tools
A workspace with hundreds of tools should not dump them all into a model’s context. A key whosetools_mode is progressive gets a tools/list of
exactly two synthetic tools:
search_tools— takes aquerystring and returns up to 20 matches ranked over the namespaced names of the tools visible to that key, so a query equal to a connector prefix surfaces that connector first. Capability cards matching the description are interleaved into the same answer.call_tool— takesname(fully qualified) andarguments.
call_tool is rewritten by the gateway into a plain tools/call with the inner
name, which then runs the normal path: prefilter, Cedar, approval, dispatch.
Progressive mode is a context-size feature, never a permission bypass.
search_tools is itself answered locally by the gateway, but still enters the
same call boundary, so header parsing, idempotency echo, and receipts are
identical to an ordinary tools/call.
A third synthetic tool,
find_capability, is offered in full mode only — it
is the MCP mirror of POST /v1/route, which an MCP-only client cannot otherwise
reach. Each synthetic tool is callable exactly where it is listed:
find_capability in progressive mode and search_tools in full mode both fall
through to the unknown-tool response.Unknown tool, and why denial looks the same
Atools/call on a name that is malformed, absent from the snapshot, not
routable, hidden by CEL, or denied by Cedar renders the same response:
JSON-RPC error -32602, message Unknown tool, with no data and no echo of
the requested name. Bodies are byte-identical for the same JSON-RPC id.
This is deliberate. A distinct “forbidden” error would turn tools/call into a
catalog oracle that any key could enumerate. Debuggability comes from the
receipt, which records the decision and the matched policies for the workspace
owner to read.
The same reasoning extends one level up: when a key can reach no provider at all
for a capability, the capability is invisible to it and it gets the unknown-tool
answer. A key that can reach some provider, but where none is currently
eligible, gets the useful no_eligible_route error instead — it already knows
the capability exists.
Invocation flow
Every authenticatedtools/call enters one gateway-owned invocation lifecycle.
The order matters and is fixed.
Two properties are worth calling out:
- The route decision is recorded before authorization. A call that Cedar denies or holds for approval still records which provider the router picked and which policy refused it.
- The result is not exposed until the audit envelope is acknowledged. A
post-dispatch audit failure leaves the canonical state
InFlightand returns a retryablenot_ready; a same-key duplicate cannot redispatch (ADR-0032).
Idempotency
tools/call reads an optional client Idempotency-Key header. Whether supplied
or generated, the effective key is returned on the HTTP response, and tool
results also carry the effective key, the receipt id, and the verification state
in MCP _meta. A repeat of the same key with a different request hash is an
idempotency_conflict. See Idempotency.
What the response proves
The receipt attached to an invocation always names its evidence class. On a native route the gateway executed the request itself and can record a gateway-observed outcome. On a federated catalog route the call ran at a broker the gateway did not observe, so its evidence isuntrusted or
broker_attested and can never be verified. Read
Receipts before treating a receipt as proof of effect.
Approvals
A destructive tool in prod requires approval unless policy explicitly grants it. When Cedar returnsApprovalRequired, the gateway:
- Writes an
Approvalticket to the approvals stream, with a 24-hour TTL. - Returns JSON-RPC error
-32003withdataset to an error envelope whoseapprovalobject carriesapproval_id,tool,expires_at, andinbox_url.
The gateway verifies the token as a service-signed approval token before the MCP
server runs, and binds it to the specific call inside the invocation lifecycle.
A resumed call whose resolved destination differs from the approved one is an
idempotency_conflict — an approval for one provider cannot be spent on
another. A valid approval token cannot create its own execution lease: if the
matching claim was never acquired, the invocation stays InFlight and the call
returns not_ready. See Approvals.
JSON-RPC error codes
Obol-specific codes live in the-32000 to -32099 range.
Upstream fan-out
Remote MCP connectors are reached through a pooled rmcp client keyed by connection id and credential fingerprint, with a 45-second tools cache. Fan-out across upstreams merges responses with a configurable failure mode, and tool names are namespaced and deduplicated on the way out. When a dispatched tool declares anoutput_schema, the relayed
structuredContent is validated against it. A violation is a dispatch failure
that leaves verification inconclusive rather than passing an off-schema response
to the model, and nothing derived from the response reaches the error or the
log. See Connectors.
Security notes
- Session ids are bearer credentials. Only their last four characters are ever logged, and a cross-workspace presentation renders exactly like an unknown session.
- Vendor credentials are applied only on the outbound hop and marked as sensitive headers. A client URL may therefore carry a secret in a query parameter and is never logged.
- The
x-obol-upstream-authorizationhand-off to a trusted worker is never logged, and only the worker executor ever adds it. - JSON-RPC method names are mapped to a fixed metric-label allowlist before they
reach Prometheus; anything unrecognized is labeled
unknown.