Point an IDE or agent at /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 named connector.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 in tools/list only when all of the following hold for the calling key:
  1. The CEL prefilter passes: the tool’s namespaced name matches the key’s allowed_tools globs, 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.
  2. Cedar returns Allow for the list action on that tool under the pinned policy publication.
  3. The tool is call-reachable: Cedar would not categorically deny call on it. For a capability, at least one provider must be reachable by this key.
The third check is what makes invariant 6 hold in both directions — a key never sees a tool it cannot call. See Policy overview.

Progressive tools

A workspace with hundreds of tools should not dump them all into a model’s context. A key whose tools_mode is progressive gets a tools/list of exactly two synthetic tools:
  • search_tools — takes a query string 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 — takes name (fully qualified) and arguments.
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

A tools/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 authenticated tools/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 InFlight and returns a retryable not_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.
Idempotency is always Obol’s. A catalog broker’s own retry knobs generate duplicate writes, so broker-side retries are disabled on every federated call.

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 is untrusted 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 returns ApprovalRequired, the gateway:
  1. Writes an Approval ticket to the approvals stream, with a 24-hour TTL.
  2. Returns JSON-RPC error -32003 with data set to an error envelope whose approval object carries approval_id, tool, expires_at, and inbox_url.
This is the one deliberate exception to existence hiding — the agent is meant to surface it to a human. To resume, the client repeats the call with two headers: 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 an output_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-authorization hand-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.