MCP gateway

July 20, 2026 ยท View on GitHub

Last modified: 2026-07-19

SBproxy ships an MCP (Model Context Protocol) gateway that speaks JSON-RPC 2.0 over HTTP POST. Configure the mcp action on an origin and the proxy serves the MCP method set (initialize, tools/list, tools/call, resources/list, resources/read, ping), federates one or more upstream MCP servers, and enforces gateway-level guardrails before any tools/call is forwarded.

This page is operator-facing. For the higher-level pitch, see features.md.

Wire shape

POST /  HTTP/1.1
Host: mcp.example.com
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "method": "initialize",
  "id": 1,
  "params": {}
}

initialize negotiates the protocol version (see below) and returns the server identity plus a capability advertisement. tools/list returns the aggregated tool catalogue across every federated upstream. tools/call routes by tool name to the owning upstream. resources/list and resources/read pass the federated resource surface through (the OpenAI Apps SDK / SEP-1865 UI-template path). ping returns "pong". Notifications (requests with no id) get a 202 Accepted. Unknown methods return JSON-RPC error -32601 (method_not_found). The gateway serves this from crates/sbproxy-core/src/server/action_dispatch.rs (handle_mcp_action); the wire enums are in crates/sbproxy-extension/src/mcp/types.rs.

Protocol version negotiation

The gateway serves the revisions in SUPPORTED_PROTOCOL_VERSIONS (2025-06-18 today). On initialize it echoes the client's requested protocolVersion when it is supported, otherwise it answers with the newest revision it does support and lets the client decide whether to continue. A post-initialize request carrying an unsupported MCP-Protocol-Version header gets a 400; a missing header follows the spec's assumed-version rule. 2025-03-26 is deliberately absent: that revision requires servers to accept JSON-RPC batches, which this gateway does not, so a batch body returns a specific invalid-request error rather than a silent mis-negotiation.

Minimal config

proxy:
  http_bind_port: 8080

origins:
  "mcp.example.com":
    action:
      type: mcp
      mode: gateway
      server_info:
        name: my-mcp
        version: "1.0.0"
      federated_servers:
        - origin: github.example.com
          prefix: gh
        - origin: postgres.example.com
          prefix: db
      guardrails:
        - type: tool_allowlist
          allow:
            - gh.search_repos
            - db.query

Adapted from examples/mcp-federation/sb.yml. The wire-format struct is McpActionConfig in crates/sbproxy-modules/src/action/mcp.rs.

mcp action fields

FieldTypeDefaultNotes
modestringgatewayOnly gateway is implemented today. Unknown values fail config validation.
server_info.namestringsbproxy-mcpReturned in initialize responses.
server_info.versionstring0.1.0Returned in initialize responses.
rbac_policiesmap<string, ToolAccessPolicy>{}Named tool-access labels referenced by federated_servers[].rbac.
federated_serverslistrequired, non-emptyUpstream MCP servers to aggregate.
guardrailslist[]Gateway-level safety checks.
progressive_discoveryboolfalseAdvertise search / execute meta-tools instead of the full catalogue (see examples/mcp-progressive-discovery).
oauthobjectunsetRFC 9728 auth discovery (see the OAuth section below and examples/mcp-oauth-discovery).
sessionsobjectunsetStreamable HTTP session management: {enabled, ttl} (see examples/mcp-sessions).
egressobjectunsetDefault OpenAPI REST egress policy. See mcp-archestra-guardrails.md.
token_compactionobjectunsetOpt-in compaction for large MCP text result blocks.
dual_llm_quarantineobjectunsetOpt-in dual-LLM judge quarantine for untrusted MCP text result blocks (enabled, endpoint, optional model / timeout). Fail closed; reason-code only.
refresh_intervalduration60sHow often the background task re-fetches upstream catalogues. Inbound requests always serve the cached snapshot; this is the only steady-state fan-out.
upstream_connect_timeoutduration5sTCP connect deadline per upstream exchange.
upstream_timeoutduration30sWhole-request deadline per upstream exchange (refreshes, calls, reads). Per-server timeout: can only shorten it for tools/call.
max_upstream_response_bytesinteger8388608Cap on upstream response bytes buffered per exchange.
tool_versioningobjectunsetVersion-bump gate plus the tool rollout plane (rollout: publishes several versions of one tool, resolved per consumer). See tool-versioning.md.
tool_pricingmap<string, float>{}Per-tool USD cost for the usage-sink attribution.
usage_sinkslist[]Sinks for MCP tool-call usage rows (same shapes as the AI path).

federated_servers[]

FieldTypeDefaultNotes
originstringrequiredFor an mcp server, a bare hostname (normalised to https://<host>/mcp) or a full URL. For an openapi server, the REST base URL.
typestringmcpmcp speaks MCP to the origin; openapi derives tools from a spec and dispatches tools/call as REST (see the OpenAPI section below).
spec / spec_pathobject / stringunsetInline OpenAPI spec or a path to one, for a type: openapi server. Read at config load; a bad spec fails startup.
prefixstringderived from hostNamespace prefix applied to every tool from this upstream. Tools become <prefix>.<tool>.
rbacstringunsetLabel referencing a key in rbac_policies. Validated at config-load time. Enforced on every tools/call.
timeoutdurationunsetCaps each tools/call dispatch. Accepts 250ms, 10s, 2m.
transportstringstreamable_httpstreamable_http, sse, or supervised local stdio.
command / argsstring / listunsetRequired command and optional arguments for transport: stdio.
egressobjectinheritedPer-server OpenAPI REST egress policy.
run_as_user_authboolfalseMint per-caller upstream Authorization via upstream_auth (never tool args).
upstream_authobjectunsetRequired when run_as_user_auth is true. See mcp-archestra-guardrails.md.

A rbac value that does not match a key in rbac_policies is a hard config error (see McpAction::from_parsed in crates/sbproxy-modules/src/action/mcp.rs).

guardrails[]

One entry type today, keyed by type:

guardrails:
  - type: tool_allowlist
    allow: [gh.search_repos, db.query]

Multiple tool_allowlist entries are unioned. An empty allow list denies every call. No guardrails means open access. Source: crates/sbproxy-modules/src/action/mcp.rs:McpGuardrailEntry.

Progressive discovery

Set progressive_discovery: true and tools/list advertises exactly two meta-tools, search and execute, instead of the full federated catalogue. The agent calls search with a query to find relevant tools, then execute with a tool name and arguments to invoke one. This keeps a large catalogue out of the model's context window. See examples/mcp-progressive-discovery.

OAuth auth discovery (RFC 9728)

With an oauth block, the gateway serves OAuth 2.0 Protected Resource Metadata at /.well-known/oauth-protected-resource, advertises a pointer to it in the discovery manifest, and challenges a credential-less MCP request with a 401 whose WWW-Authenticate header names that metadata URL, which is where the MCP auth discovery flow begins.

oauth:
  authorization_servers: ["https://issuer.example.com"]
  scopes_supported: ["mcp.read", "mcp.call"]

Token validation itself stays in the generic auth layer; this block only drives discovery and the challenge. A request that already carries an Authorization header is never re-challenged. See examples/mcp-oauth-discovery.

OpenAPI-backed servers

A federated_servers[] entry with type: openapi turns an existing REST API into governed MCP tools with no code: the gateway derives the tools from an OpenAPI spec and dispatches each tools/call as a REST request against the origin, substituting {path} parameters from the arguments and sending the rest as a query string (GET) or JSON body. The spec is read at config load (from inline spec: or spec_path:), so a bad or missing spec fails startup rather than the hot path. These tools live in the same registry as native MCP tools, so RBAC, quotas, the version gate, and usage attribution all apply.

federated_servers:
  - type: openapi
    origin: "https://api.internal"
    spec_path: "petstore.openapi.yaml"
    prefix: pets

Sessions

With sessions.enabled, the gateway issues an Mcp-Session-Id on initialize, requires it on every later request (400 when missing, 404 when unknown or expired, the client's cue to re-initialize), and ends a session on DELETE. A GET with Accept: text/event-stream opens the server-to-client stream that delivers notifications/tools/list_changed and notifications/resources/list_changed when the federated catalogue changes, which is what the listChanged capability advertises. Off by default: the gateway is otherwise stateless. See examples/mcp-sessions.

Usage attribution

Every tools/call records dispatch count and duration on sbproxy_mcp_tool_dispatch_*. With a tool_pricing map, the resolved USD cost also lands on sbproxy_mcp_tool_cost_usd_total, and with usage_sinks configured the gateway emits one usage row per call (provider mcp, the owning server as the model, the caller's principal and tenant, latency, cost) into the same sink stream as model spend, so tool spend is queryable next to it. Code-mode calls (from the emitted codemode.ts runtime) are attributed to the code-execution sandbox in the session ledger.

Submodules

The gateway is built on crates/sbproxy-extension/src/mcp/. The mcp action is a thin wrapper that translates YAML into calls into that library. Each submodule below is operator-visible either through a YAML knob or a runtime behaviour worth knowing about.

JSON-RPC dispatcher

Dispatches initialize, tools/list, tools/call, ping, resources/list, and resources/read. Notifications (no id) get a 202 Accepted. initialize answers with the configured server_info plus a capabilities block; it negotiates the protocol version and, when the host origin has agent_skills: configured, sets capabilities.experimental.agentSkillsUrl to the absolute URL of /.well-known/agent-skills/index.json (see agent-skills.md). The dispatcher lives in the runtime, not the extension library: crates/sbproxy-core/src/server/action_dispatch.rs (handle_mcp_action). The federation submodule below holds the tool aggregation, transports, and the injectable-source registry it calls into.

No direct YAML knobs. The server_info block on the action shapes the response.

types: protocol envelopes

Defines JsonRpcRequest, JsonRpcResponse, JsonRpcError, the standard error codes (-32600 through -32700), and the MCP Tool shape. Source: crates/sbproxy-extension/src/mcp/types.rs.

federation: aggregate upstream catalogues

Fetches tools/list from every entry under federated_servers and merges the results into one registry. Tool-name collisions are resolved by prefixing the later entry with its server name. The catalogue is stored in an ArcSwap so refreshes do not block in-flight tools/call traffic. Source: crates/sbproxy-extension/src/mcp/federation.rs:McpFederation.

Refresh failures on one upstream are logged at error level and the remaining upstreams still contribute to the merged catalogue.

streamable: Streamable HTTP transport

Default transport for upstreams. POST sends the JSON-RPC request; the server may answer with application/json or text/event-stream. Supports JSON-RPC batching via send_batch. Selected with transport: streamable_http (or omit transport entirely). Source: crates/sbproxy-extension/src/mcp/streamable.rs:send_request.

sse_client: legacy SSE transport

For upstreams that expose the older SSE handshake. Selected with transport: sse. The client posts to the SSE URL and parses events out of the response body; if the upstream replies with the two-leg handshake (an endpoint event followed by a POST to that endpoint), the client handles that path too. Source: crates/sbproxy-extension/src/mcp/sse_client.rs:send_via_sse.

access_control: principal-aware tool ACL

ToolAccessPolicy is the per-upstream ACL that gates every tools/call and filters tools/list. The policy reads off the inbound Principal (tenant, virtual key, team, project, role, sub), walks an ordered tool_access[] rule list, and either allows or denies the named tool. The policy is default-deny: an unknown caller (no matching rule) is denied; an empty allowed: [] is "deny all". Operators who want the legacy open-by-default behaviour add default_allow: true to the policy.

The legacy key_permissions: { key: [tools] } shape is gone. See migration-mcp-rbac.md for upgrade walk-throughs.

Per-team allowlist

rbac_policies:
  read_only:
    default_allow: false
    tool_access:
      - principals:
          - team: frontend            # exact match on attrs.team
            tenant_id: acme           # exact match on tenant_id
        allowed: [search_docs, list_projects]
      - principals:
          - role: admin               # any of attrs.roles
        allowed: ["*"]
federated_servers:
  - origin: github.example.com
    prefix: gh
    rbac: read_only

Virtual-key glob

rbac_policies:
  frontend:
    default_allow: false
    tool_access:
      - principals:
          - virtual_key: vk_frontend_*    # trailing-* glob
        allowed: [search, list_projects]

Legacy open behaviour

rbac_policies:
  legacy_open:
    default_allow: true               # opt back in to allow-by-default

tools/list RBAC filter

tools/list now returns only the subset of the federated catalogue the inbound principal can call. The legacy schema returned the full catalogue even when the matching tools/call would be denied, leaking tool names to callers that could not invoke them.

Per-tool quotas

tool_quotas[] enforces sliding-window quotas keyed on (tenant_id, principal_id, tool_name). A caller over quota gets JSON-RPC error code -32099; the upstream is never contacted.

rbac_policies:
  ops:
    default_allow: false
    tool_access:
      - principals:
          - role: admin
        allowed: ["*"]
    tool_quotas:
      - tool_name: delete_user
        principals:
          - team: frontend
        rate:
          per: 24h                   # accepts ms / s / m / h / d
          max: 5

The store is per-action and lives in process memory; SIGHUP reload rebuilds the action and resets the counters.

Source: crates/sbproxy-extension/src/mcp/access_control.rs:ToolAccessPolicy.

openapi_convert + rest_to_mcp: OpenAPI-backed servers

openapi_to_mcp_tools(spec) converts an OpenAPI 3.x spec into MCP tool definitions and openapi_to_routes(spec) derives the matching name -> (method, path) routing table. A federated_servers[] entry with type: openapi uses both: the gateway serves the derived tools and dispatches tools/call as REST against the origin (see the OpenAPI section above). Source: crates/sbproxy-extension/src/mcp/openapi_convert.rs.

Prompt-linked audit

When a subscriber is attached to the mcp_audit tracing target (the enterprise audit layer), each tools/call emits an mcp_audit event carrying the tool name, arguments, the SEP-1865 params.audit.cause when present, the upstream status, and the duration. The event is gated on that subscriber, so an OSS-only deployment pays nothing; there is no separate YAML knob. The per-call spend and behavioural record live in the session ledger below, not this event. Source: emit_mcp_prompt_audit in crates/sbproxy-core/src/server/action_dispatch.rs.

Session ledger

SBproxy sits on the tools/call path, so it can record what an agent did at the tool boundary, which tools, in what order, with what arguments, instead of leaving you to reconstruct it from a transcript. With the ledger enabled, each call appends one record to a session ledger: an append-only, newline-delimited JSON (NDJSON) artifact that behavioral evaluation can query directly. The record shape is the canonical session-ledger-v1 schema shared with mcptest, so a production capture and an mcptest run speak the same format.

A ledger is one header record per session followed by one tool_call record per call, in call order:

{"type":"header","schema_version":"v1","session_id":"01J0...","started_at":"2026-06-05T12:00:00Z"}
{"type":"tool_call","session_id":"01J0...","agent_id":"planner","hop_index":0,"tool_name":"get_weather","server":"weather","params":{"city":"sf"},"result":{"content":[...]},"is_error":false,"started_at":"2026-06-05T12:00:01Z","duration_ms":42,"caller":"direct"}

Each record carries the session id, the zero-based hop_index (the call's position in the session), the bare tool name and its server, the redacted arguments and result, an error flag, and the round-trip duration. agent_id comes from the resolved caller principal and is set on multi-agent runs. params and result are redacted with the same secret-stripping the access log uses, so keys and tokens never reach the artifact.

Turn it on with a top-level session_ledger: block:

session_ledger:
  enabled: true
  sink: file          # `logging` (default) or `file`
  path: ./ledger.ndjson   # required for `sink: file`

sink: logging emits each record as a structured session_ledger tracing line, so an existing log pipeline captures the ledger with no extra wiring. sink: file appends NDJSON to path, giving a single developer the same *.ndjson artifact mcptest writes. When the block is absent or enabled: false, the tools/call path pays a single atomic load and emits nothing.

End-to-end example

The full happy path lives at examples/mcp-federation/sb.yml. That fixture covers federated upstreams, prefix namespacing, tool_allowlist, and a curl recipe for initialize, tools/list, and tools/call. use-case-mcp-federation.md walks through that same fixture end to end, including a real type: openapi upstream that runs with no external dependency.

See also

  • use-case-mcp-federation.md: the solution guide โ€” problem, RBAC allowlist, and next steps.
  • migration-mcp-rbac.md: upgrade walk-through for the principal-aware ACL and default-deny flip.
  • agent-skills.md: Agent Skills manifest advertised via experimental.agentSkillsUrl.
  • features.md: feature overview that covers the MCP gateway in context.
  • scripting.md: CEL, Lua, JavaScript, and WASM hooks that shape MCP requests before dispatch.