MCP Gateway
The MCP server at POST /v1/mcp exposes Sable as tools. The MCP
Gateway is the other direction: you register someone else's MCP server, and
Sable hands you a proxy URL that governs, meters, and receipts every tool call
your agent makes through it.
It is a tool firewall with a ledger. An agent pointed at the proxy URL can only call the tools you allowed, spends against a budget you set, and leaves a signed record of every call it made.
What it does, and what it does not
Every tools/call through the proxy is:
- Allowlist-checked before the upstream is dialed. A refused tool never
reaches the third-party server — the refusal is a
403withpolicy_denied, decided at the gateway. - Metered. One
mcp_callusage event per call, debited at the server's per-call price. It counts against the key's spend cap, its circuit breaker, and any mandate it was minted under, because it goes through the same billing path as everything else. - Receipted. A signed, metadata-only receipt of kind
mcp_callcarrying the tool name, a sha256 fingerprint of the arguments, a fingerprint of the result, the latency, and the policy stamp — verifiable through the publicPOST /v1/receipts/verify.
What it is not: Sable governs and receipts the call. It does not sandbox the upstream server, cannot see what that server does with the arguments once they arrive, and cannot verify that its result is correct. The proof it gives you is "this tool was called with these arguments, under these rules, and cost this much" — not "the tool behaved."
Register a server
Registration is session-authed (it stores a credential). The
auth_header you give is the header the upstream wants, and is AES-GCM sealed
at rest — Sable opens it only at dial time, never logs it, and destroys it when
you deregister.
curl https://api.buildsable.com/v1/mcp-servers \
-H "authorization: Bearer $SABLE_SESSION" \
-H 'content-type: application/json' \
-d '{
"name": "github-tools",
"url": "https://example.com/mcp",
"auth_header": "Bearer ghp_your_upstream_token",
"allowed_tools": ["search_issues", "read_file"],
"price_micro_usd_per_call": 200
}'The response carries a proxy_url:
{
"id": "mcp_9f2c…",
"name": "github-tools",
"allowed_tools": ["search_issues", "read_file"],
"price_micro_usd_per_call": 200,
"enabled": true,
"proxy_url": "https://api.buildsable.com/v1/mcp/servers/mcp_9f2c…"
}
Omit allowed_tools and every tool the upstream lists is permitted — the
allowlist is the point of the feature, so set one. price_micro_usd_per_call
defaults to the deployment's SABLE_MCP_PROXY_MICRO_USD_PER_CALL (100 µ$ =
$0.0001) and can only be raised above it, so an expensive upstream tool shows up
honestly in budgets.
Check it works before pointing an agent at it — POST /v1/mcp-servers/:id/test
runs initialize + tools/list against the upstream and returns the tool
names it advertises, plus which of them your allowlist permits.
Connect an agent
The proxy path is API-key authed, not session-authed. That is deliberate: it
is what puts every call under a specific sk-sable_ key's budget, policy,
circuit breaker, and the account kill switch.
claude mcp add --transport http sable-github \
https://api.buildsable.com/v1/mcp/servers/mcp_9f2c… \
--header "authorization: Bearer $SABLE_API_KEY"tools/list through the proxy is filtered to the effective allowlist, so an
agent never even sees a tool it may not call.
Two allowlists, intersected
A call is permitted only if the tool is in both:
- the server's allowlist (set at registration, edited any time), and
- the calling key's policy
allowed_tools, when that key carries a policy.
| Server list | Key policy allowed_tools | Effective |
|---|---|---|
| not set | not set | every tool the upstream lists |
[a, b] | not set | [a, b] |
| not set | [b, c] | [b, c] |
[a, b] | [b, c] | [b] |
[a] | [c] | nothing — every call refused |
A policy with no allowed_tools rule (only a token ceiling, say) contributes
nothing: a policy about token limits must not silently become an empty tool
list.
The receipt
{
"v": 1,
"kind": "mcp_call",
"request_id": "mcp_5c1b…",
"server_id": "mcp_9f2c…",
"tool": "search_issues",
"unit": "calls",
"quantity": 1,
"status": "ok",
"is_error": false,
"latency_ms": 412,
"cost_micro_usd": 200,
"actions": {
"count": 1,
"root": "3f9a…",
"calls": [{ "tool": "search_issues", "args_fp": "8c21…", "args_len": 48 }]
},
"result_fp": "a70e…",
"result_len": 1264,
"policy": { "id": "pol_…", "sha256": "…" },
"node_id": "gateway",
"created_at": "2026-09-04T10:31:02Z"
}
It comes back three ways: the x-sable-receipt, x-sable-receipt-sig and
x-sable-receipt-signer response headers, and inside the JSON-RPC result at
result._meta.sable_receipt.
actions is the same action attestation shape the chat path
uses — the tool name plus a sha256 prefix of name|arguments. Anyone holding
the same arguments recomputes the fingerprint; nobody holding only the receipt
can recover them. result_fp does the same for what came back. Tag a call into
an agent run by putting sable_run_id in the request's
params._meta; Sable strips it before forwarding.
Metering and status
status on the usage event and the receipt is one of:
| Status | Meaning | Billed |
|---|---|---|
ok | The upstream returned a result. | yes |
tool_error | The upstream returned a result with isError: true — the tool ran and failed. | yes |
upstream_error | The upstream returned a JSON-RPC error object. | yes |
error | Sable could not reach the upstream, it timed out, or its answer was unusable. | no |
The first three are billed because the upstream did the work; a transport failure is recorded at zero cost so the attempt is visible without charging for it.
Methods
| Method | Behavior |
|---|---|
tools/call | Allowlist-checked, metered, receipted. |
tools/list | Forwarded unmetered; the result is filtered to the effective allowlist. |
initialize, ping, notifications/*, prompts/list, prompts/get, resources/list, resources/read, resources/templates/list | Forwarded unmetered. |
everything else (sampling/*, roots/*, elicitation/*, logging/*, completion/*) | Refused with JSON-RPC -32601. |
The proxy governs tool calls; forwarding the rest blind would be a different, ungoverned product.
Limits and honest caveats
- Transport: Streamable HTTP (JSON-RPC over POST) only.
Mcp-Session-IdandMCP-Protocol-Versionare relayed both ways so stateful upstreams work. If an upstream answers a POST withtext/event-stream, Sable reads the events and returns the final JSON-RPC response — progress notifications inside that stream are not relayed. The legacy HTTP+SSE transport is not supported. - SSRF: upstream URLs are validated at registration and again at dial
time (a name can be re-pointed in between), redirects are refused, and in
production the URL must be
https. - Bounds: 60-second upstream timeout, 4 MiB response cap, 50 servers per account, 256 tools per allowlist.
- §3: tool arguments and results cross the gateway in-frame only. They are never persisted and never logged — only the sha256 fingerprints above reach the receipt. Tool names are metadata and do appear, in the receipt and in your usage ledger.
- The proxy sees whatever the agent sends. Registering a server hands Sable a credential for it; deregistering destroys that credential, but any call already made was already made.
Endpoints
| Method | Path | Auth |
|---|---|---|
| POST | /v1/mcp-servers | session |
| GET | /v1/mcp-servers | session |
| PATCH | /v1/mcp-servers/:id | session |
| DELETE | /v1/mcp-servers/:id | session |
| POST | /v1/mcp-servers/:id/test | session |
| POST | /v1/mcp/servers/:id | sk-sable_ key |
Manage them in the portal at Portal → MCP Gateway.