Skip to content

Latest commit

 

History

History
745 lines (613 loc) · 32.5 KB

File metadata and controls

745 lines (613 loc) · 32.5 KB

MCP gateway

Last modified: 2026-08-08

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, prompts/list, prompts/get, ping), federates one or more upstream MCP servers, and enforces gateway-level guardrails before any tools/call is forwarded. The full method-by-method breakdown, including what the gateway deliberately does not serve, is in Protocol coverage below.

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 catalog 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). prompts/list and prompts/get do the same for the federated prompt surface. 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 coverage

Method Served Notes
initialize yes Negotiates the protocol version, advertises capabilities.
ping yes Returns "pong".
tools/list yes Federated catalog, namespaced, RBAC-filtered per caller.
tools/call yes Routed to the owning upstream behind the guardrails, RBAC, and per-tool quotas.
resources/list yes Federated resource surface.
resources/read yes Routed to the upstream that owns the URI.
prompts/list yes Federated prompt catalog, namespaced the way tools are.
prompts/get yes Routed by namespaced prompt name to the owning upstream.
completion/complete no -32601. Argument autocompletion is not proxied.
logging/setLevel no -32601. Gateway log level is operator config, not a client knob.
roots/list no -32601. A client-side method; the gateway has no client of its own to ask.
sampling/createMessage no -32601. Server-initiated, so it needs a transport story the gateway does not have yet.
elicitation/create no -32601. Server-initiated, same reason as sampling.

Prompt namespacing

A federated prompt is namespaced on the same rules a federated tool is. The first upstream to publish a given prompt name keeps it bare, and the next upstream to publish that name is advertised as <prefix>.<name>, with the . separator tools use rather than the / resources use. Setting namespace: always on an upstream prefixes every prompt from it whether or not anything collided.

Whatever name the gateway advertises is the name that routes, so a client calls prompts/get with the name it read out of prompts/list. The upstream still receives the name it published and never has to know the gateway renamed anything, which is the contract resources/read already holds for resource URIs.

An upstream contributes no prompts in three cases: it declared no prompts capability during its handshake, its prompts/list failed, or it is OpenAPI-backed (type: openapi), which is a REST spec with no prompts to publish. None of the three fails the aggregate call. One upstream without prompts does not blank the prompts of the upstreams that have them, which is how the tool and resource catalogs already behave.

Capability advertisement

initialize advertises capabilities.prompts only when at least one federated upstream declared that capability on its own handshake. A gateway federating nothing but OpenAPI servers, or nothing but MCP servers that serve only tools, advertises no prompts capability, and a client reading the handshake knows not to ask.

The advertised object is {"listChanged": false}. The gateway's server-to-client stream pushes notifications/tools/list_changed and notifications/resources/list_changed and nothing else, so true there would promise notifications that never arrive. This is the same rule that keeps 2025-03-26 out of the supported version list: advertising something whose contract the gateway breaks is worse than not advertising it.

Prompt access control

Prompts have no ACL of their own. prompts/list and prompts/get are gated by the rbac_policies entry already bound to the owning upstream, at server granularity. A caller reaches a server's prompts when that server's policy allows the caller at least one tool the server currently advertises. A caller denied every tool on an upstream sees none of its prompts in prompts/list, and a prompts/get naming one of them answers unknown prompt rather than confirming it exists.

Two edges follow from that definition. An upstream with no rbac label resolves no policy and its prompts are readable, exactly as its tools are callable; config compile refuses an unlabeled upstream once any rbac_policies are declared, so this branch is the no-RBAC deployment rather than a forgotten label. An upstream that publishes prompts but no tools gives the policy nothing to decide against, so the policy's own default_allow answers, and binding that server to a policy with default_allow: true makes its prompts readable.

The tool_allowlist guardrail does not participate. It caps what the gateway will call, which is a different question from who the caller is.

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.

Calling it

Eight examples exercise different parts of this page. The one that matches the config above, and the one used here, is examples/mcp-federation/, because federation is what the mcp action is for and because it is self-contained: it ships its own upstream. For sessions, RBAC and quotas, progressive discovery, OAuth discovery, or tool versioning, use the example named for that feature.

It runs as two processes. The first is a mock REST API that stands in for a real service; the second is the gateway that federates it:

sbproxy serve -f examples/mcp-federation/upstream.yml &
sbproxy serve -f examples/mcp-federation/sb.yml

Every call below is an HTTP POST of a JSON-RPC envelope to the same URL. The Accept header must offer both application/json and text/event-stream, because the Streamable HTTP transport chooses between them per response:

curl -s -X POST http://127.0.0.1:8080 \
  -H 'Host: mcp.example.com' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-demo","version":"1.0.0"}}}'
{"jsonrpc":"2.0","result":{"capabilities":{"tools":{"listChanged":true}},"protocolVersion":"2025-06-18","serverInfo":{"name":"my-mcp","version":"1.0.0"}},"id":1}

serverInfo echoes the configured server_info, and the gateway answers this locally without contacting any upstream, so initialize succeeds even with every federated server down.

tools/list is where federation shows:

{"jsonrpc":"2.0","id":2,"result":{"tools":[{"description":"Search repositories by query.","inputSchema":{"properties":{"q":{"type":"string"}},"required":["q"],"type":"object"},"name":"gh.search_repos"}]}}

One tool, not two. The config federates two servers: gh, an OpenAPI-backed server pointed at the mock, and db, pointed at postgres.example.com, a reserved placeholder that does not resolve. The catalog degrades per server rather than failing as a whole, so db is dropped with a log line and gh still answers. A federated catalog that silently shrinks is the failure mode to watch for here: check tools/list against the servers you configured, not against what your client happens to need.

Note that gh.search_repos came from an OpenAPI document. Its inputSchema was derived from the spec's parameters, with no MCP server written for it.

Calling it dispatches a real HTTP request to the mock:

-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"gh.search_repos","arguments":{"q":"sbproxy"}}}'
{"jsonrpc":"2.0","result":{"content":[{"text":"[{\"full_name\":\"soapbucket/sbproxy\",\"name\":\"sbproxy\",\"stars\":4200},{\"full_name\":\"soapbucket/docs\",\"name\":\"docs\",\"stars\":12}]","type":"text"}],"isError":false},"id":3}

The upstream's JSON is returned as a string inside a text content block, which is what MCP specifies. A client parses that string; it is not a nested JSON object.

The two failure shapes are distinct and worth telling apart. A tool the tool_allowlist guardrail blocks never leaves the proxy:

{"jsonrpc":"2.0","error":{"code":-32602,"message":"tool 'gh.delete_repo' is blocked by tool_allowlist guardrail"},"id":4}

-32602 is JSON-RPC "invalid params": the gateway is saying the requested tool is not one it will accept. A tool that is simply not in the registry reports differently:

{"jsonrpc":"2.0","error":{"code":-32603,"message":"tool call failed: unknown tool: db.query"},"id":5}

-32603 is "internal error", and unknown tool here is a consequence of db never answering tools/list, not of db.query being forbidden. Both are 200 OK at the HTTP layer, as JSON-RPC requires; the error lives in the envelope. A client that checks HTTP status alone sees every one of these as a success.

mcp action fields

Field Type Default Notes
mode string gateway Only gateway is implemented today. Unknown values fail config validation.
server_info.name string sbproxy-mcp Returned in initialize responses.
server_info.version string 0.1.0 Returned in initialize responses.
rbac_policies map<string, ToolAccessPolicy> {} Named tool-access labels referenced by federated_servers[].rbac.
federated_servers list required, non-empty Upstream MCP servers to aggregate.
guardrails list [] Gateway-level safety checks.
progressive_discovery bool false Advertise search / execute meta-tools instead of the full catalog (see examples/mcp-progressive-discovery).
oauth object unset RFC 9728 auth discovery (see the OAuth section below and examples/mcp-oauth-discovery).
sessions object unset Streamable HTTP session management: {enabled, ttl} (see examples/mcp-sessions).
egress object unset Default OpenAPI REST egress policy. See mcp-gateway-guardrails.md.
token_compaction object unset Opt-in compaction for large MCP text result blocks.
dual_llm_quarantine object unset Opt-in dual-LLM judge quarantine for untrusted MCP text result blocks (enabled, endpoint, optional model / timeout). Fail closed; reason-code only.
refresh_interval duration 60s How often the background task re-fetches upstream catalogs. Inbound requests always serve the cached snapshot; this is the only steady-state fan-out.
upstream_connect_timeout duration 5s TCP connect deadline per upstream exchange.
upstream_timeout duration 30s Whole-request deadline per upstream exchange (refreshes, calls, reads). Per-server timeout: can only shorten it for tools/call.
max_upstream_response_bytes integer 8388608 Cap on upstream response bytes buffered per exchange.
tool_versioning object unset Version-bump gate plus the tool rollout plane (rollout: publishes several versions of one tool, resolved per consumer). See tool-versioning.md.
tool_pricing map<string, float> {} Per-tool USD cost for the usage-sink attribution.
usage_sinks list [] Sinks for MCP tool-call usage rows (same shapes as the AI path).

federated_servers[]

Field Type Default Notes
origin string required For an mcp server, a bare hostname (normalized to https://<host>/mcp) or a full URL. For an openapi server, the REST base URL.
type string mcp mcp speaks MCP to the origin; openapi derives tools from a spec and dispatches tools/call as REST (see the OpenAPI section below).
spec / spec_path object / string unset Inline OpenAPI spec or a path to one, for a type: openapi server. Read at config load; a bad spec fails startup.
prefix string derived from host Namespace prefix applied to every tool from this upstream. Tools become <prefix>.<tool>.
rbac string unset Label referencing a key in rbac_policies. Required on every server once rbac_policies is non-empty. Validated at config-load time. Enforced on every tools/call.
timeout duration unset Caps each tools/call dispatch. Accepts 250ms, 10s, 2m.
transport string streamable_http streamable_http, sse, or supervised local stdio.
command / args string / list unset Required command and optional arguments for transport: stdio.
egress object inherited Per-server OpenAPI REST egress policy.
headers map<string, string> {} Static headers attached to every REST request a type: openapi server dispatches, e.g. a shared service credential. Values pass through ${VAR} interpolation; keep secrets in the environment. Rejected on non-openapi servers.
run_as_user_auth bool false Mint per-caller upstream Authorization via upstream_auth (never tool args).
upstream_auth object unset Required when run_as_user_auth is true. See mcp-gateway-guardrails.md.

A rbac value that does not match a key in rbac_policies is a hard config error, and so is a server with no rbac label at all while rbac_policies is non-empty; the error names the unlabeled server's origin. Deliberate allow-all for one upstream is still expressible: bind it to a policy with default_allow: true. An action that declares no rbac_policies keeps the open behavior for every server. (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 catalog. 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 catalog 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

When the REST upstream wants a shared service credential, declare it as a static headers: entry on the server. The value resolves through ${VAR} config interpolation at load time, rides on every dispatched REST request (including authorized redirect hops), and never appears in tool arguments. A per-caller header minted by run_as_user_auth wins over a static header of the same name, and declaring an authorization entry alongside run_as_user_auth is a config error.

federated_servers:
  - type: openapi
    origin: "https://api.internal"
    spec_path: "petstore.openapi.yaml"
    prefix: pets
    headers:
      authorization: "Bearer ${PETS_API_TOKEN}"

One self-referential use of this is pointing an openapi server at the gateway's own admin API, which turns the admin surface into governed MCP tools; admin-mcp.md walks through that setup end to end.

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 catalog 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.

Trace-context propagation

Every tools/call and resources/read the gateway forwards carries the trace context of the request that caused it, so a tool call in an upstream's logs can be joined back to the agent run that made it.

The context travels in the JSON-RPC body, inside the params._meta block that SEP-414 defines, under the key names traceparent and tracestate:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "id": 1,
  "params": {
    "name": "gh.search_repos",
    "arguments": {"q": "sbproxy"},
    "_meta": {
      "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
    }
  }
}

Those key names are bare. MCP otherwise requires a DNS-style prefix on _meta keys, and SEP-414 carves out an explicit exception for trace context: a namespaced spelling such as io.modelcontextprotocol.traceparent would break trace and log correlation for every tool that already knows what traceparent means. An upstream reading _meta for trace context should read these names exactly, with no prefix.

The body carries it rather than an HTTP header because one of the three transports has no headers at all. A transport: stdio upstream is a local child process that receives a line of JSON on stdin and nothing else, which is the same reason run-as-user credentials are refused on that transport. Putting the context in the body means an upstream sees the identical field whether it is reached over Streamable HTTP, over SSE, or over stdio.

A type: openapi upstream is the one exception, and only because it is not MCP on the wire. Those calls dispatch as plain REST requests with no JSON-RPC body to hold a _meta block, so they carry the same context in a standard traceparent HTTP header instead. Redirects that the egress policy authorizes carry it too.

Turning it on

There is no MCP-side knob. The context comes from the proxy's own tracing, so it appears once proxy.observability.telemetry.enabled is true:

proxy:
  observability:
    telemetry:
      enabled: true
      endpoint: "http://otel-collector:4317"

With telemetry off there is no trace to propagate, and the gateway sends no _meta block rather than an empty or placeholder one, so an upstream can tell an untraced call from a malformed one. See observability.md for the rest of that block.

What does not carry it

Catalog refreshes do not. The tools/list, resources/list, and initialize calls the federation makes on its own refresh schedule are gateway housekeeping, not work done for a caller. Attributing a background refresh to whichever request happened to be in flight when the timer fired would be worse than leaving it uncorrelated: the result would be wrong rather than absent.

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 behavior worth knowing about.

JSON-RPC dispatcher

Dispatches initialize, tools/list, tools/call, ping, resources/list, resources/read, prompts/list, and prompts/get. Notifications (no id) get a 202 Accepted. initialize answers with the configured server_info plus a capabilities block; it negotiates the protocol version, advertises prompts only when a federated upstream declared it, 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 catalogs

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 catalog is stored in an ArcSwap so refreshes do not block in-flight tools/call traffic. Source: crates/sbproxy-extension/src/mcp/federation.rs:McpFederation.

The resource and prompt registries are built by the same refresh pass and stored the same way. Each cycle probes every MCP upstream's initialize exactly once and reuses that one answer for every registry that needs to know what the upstream supports, so federating another surface costs no extra handshake. The prompt pass then asks only the upstreams that declared a prompts capability.

Refresh failures on one upstream are logged and the remaining upstreams still contribute to the merged catalogs.

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 behavior 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 behavior

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 catalog the inbound principal can call. The legacy schema returned the full catalog 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: 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, 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 a deployment that attaches none pays nothing; there is no separate YAML knob. The per-call spend and behavioral 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.