--- title: "MCP surface" description: "The /mcp endpoint: streamable HTTP MCP, tool namespacing, progressive tools, list filtering, the invocation flow, and approvals." --- 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. | Method | Behavior | |---|---| | `POST` | JSON-RPC request, notification, or client result | | `GET` | `405` with `Allow: POST, DELETE` — there is no server-initiated stream | | `DELETE` | `204`, deletes the session | `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: | Status | Cause | |---|---| | `200` | Normal response, rendered as `text/event-stream` | | `202` | Notifications and client results | | `400` | Batch request, unparsable body, or a missing session header | | `404` | Unknown, malformed, or other-workspace session id | | `406` | `Accept` does not include both `application/json` and `text/event-stream` | | `415` | Wrong `Content-Type` | | `503` | Session store error | 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](/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. ```mermaid flowchart TD A[tools/call] --> B[Parse tool name; malformed names enter under a fixed non-routable name] B --> C[Resolve target from the workspace snapshot] C --> D[Determine the effective idempotency key] D --> E[Claim the invocation in the idempotency store] E --> F{Claim outcome} F -->|duplicate, terminal| G[Replay the recorded result] F -->|duplicate, in flight| H[not_ready, no redispatch] F -->|claimed| I[CEL visibility check] I --> J[Admission: RPM] J --> K[Cedar 'call' with argument conditions] K --> L{Decision} L -->|Deny| M[Record Denied, render Unknown tool] L -->|ApprovalRequired| N[Emit Approval ticket, render approval_required] L -->|Allow| O[Vault unwrap the connection credential] O --> P[Dispatch through the connector's executor] P --> Q[verify_effect against the tool's EffectSpec] Q --> R[Commit receipt + verification event durably] R --> S[Respond] ``` 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](/receipts/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](/receipts/overview) 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: | Header | Value | |---|---| | `x-obol-approval-id` | The `approval_id` from the error envelope | | `x-obol-approval-token` | The token issued when a human approved it | 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](/policy/approvals). ## JSON-RPC error codes Obol-specific codes live in the `-32000` to `-32099` range. | Code | Meaning | |---|---| | `-32600` | `invalid_request` | | `-32602` | Unknown tool (malformed, absent, hidden, or denied) | | `-32603` | `internal` | | `-32001` | `unauthorized`, `key_revoked`, `key_expired` | | `-32002` | `policy_denied`, `tool_not_allowed`, `workspace_frozen`, `env_mismatch`, `model_not_allowed` | | `-32003` | `approval_required` | | `-32004` | `budget_exceeded` | | `-32005` | `rate_limited` | | `-32006` | `upstream_error`, `upstream_timeout` | | `-32007` | `not_ready` | | `-32008` | `idempotency_conflict` | | `-32009` | `no_eligible_route` | ## 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](/connectors/overview). ## 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`.