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.
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.
| 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. |
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.
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.
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.
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.
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.queryAdapted from examples/mcp-federation/sb.yml. The wire-format
struct is McpActionConfig in
crates/sbproxy-modules/src/action/mcp.rs.
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.ymlEvery 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.
| 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). |
| 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.)
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.
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.
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.
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: petsWhen 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.
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.
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.
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.
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.
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.
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.
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.
Defines JsonRpcRequest, JsonRpcResponse, JsonRpcError, the
standard error codes (-32600 through -32700), and the MCP Tool
shape. Source: crates/sbproxy-extension/src/mcp/types.rs.
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.
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.
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.
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.
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_onlyrbac_policies:
frontend:
default_allow: false
tool_access:
- principals:
- virtual_key: vk_frontend_* # trailing-* glob
allowed: [search, list_projects]rbac_policies:
legacy_open:
default_allow: true # opt back in to allow-by-defaulttools/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.
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: 5The 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_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.
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.
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.
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.
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 viaexperimental.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.