yuzu

MCP Server

Yuzu embeds an MCP server at POST /mcp/v1/ using JSON-RPC 2.0 transport. This allows AI models (e.g., Claude Desktop) to securely query fleet status, check compliance, investigate agents, and — with appropriate authorization — execute instructions on endpoints.

CLAUDE.md keeps only the load-bearing invariants (embed point, tier-before-RBAC ordering, kill switches, audit pattern). This document is the full architecture and tool reference.

Architecture

Security Model

Error envelope

JSON-RPC error responses from the denial paths (read-only mode, tier policy, approval-required, the #2383 missing-security-registration misconfig deny, and the #2405 pre-approval input-schema deny) carry a structured error.data field (A4, per docs/agentic-first-principle.md):

{ "correlation_id": "req-<hex-ms>-<hex-seq>", "retry_after_ms": null, "remediation": "use a higher-tier MCP token, or the REST API / dashboard" }

Supervised-tier / approval-gated operations — ticket-then-recall (#289). An operation for which requires_approval(tier, type, op) is true is answered with kApprovalRequired (-32006). Its error.data extends the A4 envelope with two extra fields:

{ "correlation_id": "req-<hex-ms>-<hex-seq>", "retry_after_ms": 30000,
  "remediation": "an admin must approve this approval_id (see status_url), then re-call this tool with the approval_id argument to execute",
  "approval_id": "<32-hex>", "status_url": "/api/v1/approvals/<32-hex>" }

retry_after_ms is honest here, not null (#3344, kMcpApprovalPollRetryMs in mcp_retry.hpp) — this IS retryable, on human timescales, and approval minting is deduplicated so a recall before the ticket resolves returns the same pending ticket rather than minting a second one.

Before ANY of the below — mint, dedup, recall lookup, consume — the arguments must pass the #2405 input-schema validation (Security Model above); a schema-invalid call answers kInvalidParams (-32602) with nothing minted and nothing consumed. The first call mints an ApprovalManager approval (definition_id = "mcp.<tool>", submitted_by = the caller, scope_expression = the canonical JSON of the tool arguments) and returns the envelope above. A person approves it (dashboard Approvals page / REST POST /api/approvals/{id}/approve on an interactive session with an enrolled MFA factor and a fresh proof (#6079); an MCP or API token cannot approve; the store requires the reviewer’s account name to differ from the submitter’s, which does not prove two different people when one person holds two identities). The caller then re-calls the same tool passing the returned approval_id as an argument; the server verifies the approval is (a) approved, (b) for this exact tool (definition_id), (c) for these exact arguments (canonical-args match, approval_id excluded from the comparison), and (d) not yet consumed, then atomically consumes it (one-time; a replay of a consumed ticket, or a concurrent second recall, is rejected — the mutating op runs at most once). The consume step additionally requires (e) the ticket was minted by the MCP surface itself, not a REST/schedule mint that happens to share this tool’s definition_id and arguments (#2442), and (f) the recalling principal is the ticket’s own submitter (#2442) — a Viewer who merely read the ticket id off GET /api/approvals cannot redeem it. (e) and (f) are enforced only at this consume step, not at the pending-poll lookup above, and their refusal reads identically to an ordinary replay — see -32003 below for why. (A wrong-tool/arguments mismatch is a DIFFERENT, earlier refusal — a distinct message, at (b)/(c) above, not this one; it is not part of the anti-oracle group.) A successful consume lets the call through to the handler. A recall against a still-pending ticket returns the same kApprovalRequired envelope (keep polling status_url); a rejected/expired/mismatched/consumed ticket returns kPermissionDenied (-32003) (an approved ticket past its 7-day lifetime gets the distinct message approval expired (past its 7-day lifetime)). approve_request / reject_request are a separate kPermissionDenied (-32003) (unless read-only mode, a service-scoped token or the tier check refuses first with -32004/-32003) whose message is the stable prefix approver_assurance_required:<reason> (<reason> is non_interactive_session, engine_principal or non_human_principal; #6061; the MFA reasons mfa_not_enrolled, mfa_proof_missing, saml_mfa_unsupported and account_unavailable belong to the REST route only), returned after the Approval:Approve check with retry_after_ms null and a mcp.<tool> denied audit row whose detail is approval_id=<id> approver_assurance:<reason>. every recall’s pre-consume precondition also checks that the ticket the store read still matches the recalled tool and arguments (a mismatch answers approval_id does not match this tool and arguments, ticket untouched), and confirm_engine_rotation’s recall additionally runs a pre-consume precondition (#2443) that rechecks live rotation state before consuming the ticket; a precondition failure also returns kPermissionDenied (-32003) but with a distinct, deliberately generic message (“approval cannot be redeemed right now…”) and — unlike the four causes above — leaves the ticket UNCONSUMED and still recallable. This generic gate governs every approval-gated tool — the supervised tier’s execute_instruction / revoke_certificate / issue_code_signing_cert / execute_bundle, plus delete_tag (operator + supervised) and quarantine_device (supervised). Degraded path: if the server has no ApprovalManager (a stripped deploy / test harness), it cannot mint a pollable ticket, so it falls back to a kTierDenied (-32004) with no approval_id. Note: the per-tool RBAC check (perm_fn) runs on the recall, after the ticket is consumed, so a token that passes the MCP tier check but fails RBAC can mint→approve→then 403 (burning the ticket) — a rare consequence of the deliberate tier-then-RBAC two-gate split.

correlation_id is a per-error token (req-<hex-ms>-<hex-seq>, the same format as the REST X-Correlation-Id header) returned to the caller in the error body, so a client can cite a stable handle when reporting a failure. As a general rule it is not persisted to the audit log today — the audit row for a denied call (mcp.<tool>) is written separately and does not carry the token — so server-side correlation relies on any spdlog line the handler emits at that moment, not on audit.db. Several sites are the deliberate exception and stamp the SAME token into the audit detail (... correlation_id=<cid>, the one-cid pattern — mint it once, log it in both places): the #2383 misconfig deny, the #2405 input-schema deny, and — as of #3685/#3687 — every pre-dispatch denial on execute_instruction’s Destructive-targeting and dispatch-authorization gates (both the C8 pre-mint site and the main-handler backstop). This list is not exhaustive and not meant to be enumerated exactly here — grep mcp_server.cpp for correlation_id=" + inside an mcp_audit(...) call for the current, authoritative set; new pre-dispatch denial sites are expected to keep growing it. retry_after_ms is null on tier-denial errors (a token’s tier is fixed — the denial is not retryable as-is), but non-null on approval-required (-32006, #3344: it IS retryable, on human timescales — see above); remediation carries an actionable hint — escalate to a higher-tier token, or use the REST API / dashboard. Per-tool validation errors (e.g. the dex-perf tools) populate correlation_id, a null retry_after_ms, and a field-specific remediation. Parse error.code for the error class and error.data.correlation_id for client-side traceability.

retry_after_ms floors (#3344)

Every retry_after_ms emission — error-shaped (A4 error.data) and, for the three result-poll tools below, success-shaped (in the tool’s own structured payload) — is a named constant in server/core/src/mcp_retry.hpp, each carrying a mechanical derivation comment (no dispatch-to-first-result latency histogram exists to measure from — yuzu_command_duration_seconds is full command completion, not first-result):

ConstantValueCovers
kMcpStoreFaultRetryMs5000Generic transient store/subsystem fault on an error-shaped response (REST 503 parity)
kMcpStoreFaultShortRetryMs2000Plugin-config / API-token rotation family (REST Retry-After: 2 parity)
kMcpProviderWarmupRetryMs5000A DEX/app-perf provider still initializing at server start
kMcpResultPollRetryMs2000Success-shaped result-not-ready poll — get_execution_status, query_responses, get_bundle_result
kMcpApprovalPollRetryMs30000Approval-ticket poll (-32006) — human-timescale, anti-amplification derived

Several constants share a numeric value with a neighbour — that is coincidental historical pricing, not a shared derivation, and each is kept a distinct symbol so one can be re-tuned later without dragging the other.

The three result-poll tools are counted at yuzu_mcp_poll_total{tool,result} (result is ready or not_ready, per served verdict, excluding pre-verdict denials) so the floors above can be data-tuned from real poll-rate evidence instead of ossifying as guessed constants. See docs/observability-conventions.md.

Phase 1 (Implemented)

ADR-1005 parity gap on the human token surface - CLOSED (#2146 Batch B4). rotate_api_token/confirm_api_token_rotation were the first ApiToken MCP tools; list_api_tokens, create_api_token, and revoke_api_token are now MCP twins of GET /api/v1/tokens, POST /api/v1/tokens, and DELETE /api/v1/tokens/{id} respectively, closing the gap this note used to record. list_api_tokens/create_api_token are unconditionally self-scoped to the calling principal, matching REST exactly (there is no admin all-owner-token view on this route - that capability exists only as the HTMX dashboard fragment /fragments/settings/api-tokens, which has no REST v1 route yet and therefore no MCP twin, out of scope for B4). revoke_api_token additionally allows an elevated/admin session to act on another user’s token, mirroring DELETE /api/v1/tokens/{id}’s own JIT-elevation allowance. Unlike ApiToken:Rotate (reachable at the operator tier, never approval-gated - see its own documented rationale in mcp_policy.hpp), revoke_api_token (ApiToken:Delete) and create_api_token (ApiToken:Write) are both supervised-only AND approval-gated: Delete is unconditionally approval-gated at that tier, and ApiToken:Write was added to mcp_policy.hpp’s supervised-tier requires_approval() list in this same batch (gov security-guardian fix round) - before that fix, a supervised-tier caller could self-mint a fresh, untiered, non-expiring credential via either transport (the policy table is shared with REST) with neither MFA step-up nor human approval; ApiToken:Rotate’s ungated rationale (self-service, authority-inheriting, lifetime-neutral) does not transfer to minting a brand-new credential. Also new in this batch: check_permission (MCP twin of POST /api/v1/rbac/check, a self-check with no RBAC gate of its own - matching that route’s deliberate zero-perm_fn posture exactly) and unlock_account (MCP twin of POST /api/v1/users/{name}/unlock, UserManagement:Write, approval-gated), plus Create/Read/Update/membership-add/role-list/role-assign management-group tools (create_management_group, get_management_group, update_management_group, add_management_group_member, list_management_group_roles, assign_management_group_role) - Delete-class management-group operations (delete group, remove member, unassign role) are not in this batch. list_management_group_roles requires REST’s leading ManagementGroup:Read gate (#2376) in addition to the compound gate it and assign_management_group_role both mirror (a fleet-wide permission OR the caller holding ITServiceOwner on the group in question).

Agentic Demo Layer

Yuzu also exposes a v1 MCP-native demo and incident-orientation layer for LLM clients. It is intentionally endpoint-evidence first: OpenShift, KVM/libvirt, Postgres/Oracle, Teams/Zoom, registry/build-cache, and similar platform internals are labelled as connector gaps unless the facts are already present in Yuzu inventory/responses or supplied by the user.

Resources

High-level tools

These tools are read-only and available to the readonly MCP tier, subject to normal RBAC checks. They advertise outputSchema and return both legacy MCP content[] text and structuredContent.

No fabricated-data demo mode (ADR-0016). Yuzu demos run live against the real fleet and never return canned findings. The earlier prepare_demo_scenario tool and its mode=curated “DEMO DATA” path are retired. Realism comes from constructing a real environment that genuinely exhibits a condition (a staged device with a real pending reboot, a really-degraded link, a really-crashing service), then observing it live and remediating it live through the normal tier/RBAC + approval path. The ceo_demo_agentic_endpoint_management prompt drives that live flow.

Prompts

Additional task-native prompts are exposed through prompts/list: ceo_demo_agentic_endpoint_management, fleet_health_briefing, investigate_collaboration_quality_issue, investigate_endpoint_security_client_outage, investigate_patch_or_reboot_risk, investigate_container_or_build_failure, investigate_java_gateway_or_node_service_degradation, investigate_database_client_or_host_bottleneck, and prepare_remediation_plan. User-supplied prompt arguments are wrapped as untrusted data; closed-enum prompt arguments (e.g. the CEO demo’s mode) are instead normalized server-side to a known-safe value, so caller text never reaches the model as task instructions.

Golden Prompt Pack

enterprise-it-v1 covers enterprise incident topics: OpenShift, KVM/libvirt, Chisel/Ubuntu containers, Docker buildx, Node, Spring Cloud Gateway/Java, Postgres/Oracle, Teams/Zoom, Windows/macOS endpoint operations, and security clients such as CrowdStrike, Check Point, zScaler, and Cisco Secure Client. Each fixture records the expected first tool, allowed tool path, pass/fail rubric, safety behavior, and curated/live support.

Phase 2 (Implemented — #289 / Issue 13.5)

Phase 2.5 (Implemented — MCP Streamable HTTP transport, track 2f PR 1 + PR 2)

The session-lifecycle + transport pre-check half of the Streamable HTTP transport is live (ADR-1005 execution plan Decision 15 / track 2f PR 1). Shipped:

Session state is in-memory only and deliberately does not gate /readyz. Operator reference: docs/user-manual/server-admin.md (flags + Upgrade Notes) and docs/user-manual/mcp.md.

The GET SSE channel (PR 2)

GET /mcp/v1/ is the session’s server→client channel. It replaces the PR-1 405 placeholder:

PR 2 gave the channel heartbeats and replayed frames; PR 3a shipped its first producer, the progress bridge (notifications/progress - see “Progress bridge (2f PR 3a)” below). Metrics: yuzu_mcp_sessions_active, yuzu_mcp_sessions_opened_total, yuzu_mcp_streams_active, yuzu_mcp_streams_handover_pending, yuzu_mcp_streams_cap (the EFFECTIVE cap after the boot clamp), yuzu_mcp_stream_replay_ring_evictions_total, yuzu_mcp_stream_frames_dropped_total (frames dropped before reaching a connection — usually a slow consumer’s per-connection queue overflow, also a rare producer-side post-commit allocation failure (#2366); recoverable via Last-Event-ID), yuzu_mcp_stream_frames_too_large_total (a frame exceeded the per-frame byte cap and was truncated before entering the ring — counted rather than only logged, because an unbounded warn-per-publish is a log-flood vector), yuzu_mcp_stream_rejects_total{reason}, yuzu_mcp_stream_closes_total{reason} (alert on auth_unavailable and internal_error), yuzu_mcp_stream_publish_failures_total (a publish() call failed before the frame committed — pre-commit only; the frame was never published and no event id was consumed. A post-commit sink-enqueue failure instead counts as a normal frames_dropped_total/events-dropped gap. Non-fatal by construction but a near-miss for a worker-thread std::terminate — ticket on any increase() > 0 over 15 m, do not page). Progress-bridge families (2f PR 3a): yuzu_mcp_bridge_records_active, yuzu_mcp_bridge_reject_total{reason}, yuzu_mcp_bridge_degrade_total{reason}, yuzu_mcp_bridge_listener_failures_total, yuzu_mcp_bridge_mailbox_drops_total (retired #2412 - kept registered at zero; the bounded arming mailbox it counted was replaced by a single latest-wins progress slot), yuzu_mcp_bridge_progress_suppressed_total (#2438/#2412 - progress candidates that never reached the wire: H1 monotonic-suppression drops and latest-wins supersedes), yuzu_mcp_bridge_projector_cycles_total, yuzu_mcp_stream_terminal_publish_failures_total, yuzu_mcp_stream_final_unpinned_total, yuzu_mcp_stream_pin_displaced_total, plus yuzu_mcp_bridge_forced_expire_total{disposition} (#2489 - which way the ring-only pressure hatch DECIDED to settle each record it force-expired: none / fallback_final / synthesize_unavailable, a distinction that previously existed only in audit; counted at the decision, so pair it with the publish-failure family for delivery), yuzu_mcp_bridge_pressure_budget_exhausted_total (a pressure pass stopped on its per-invocation victim budget with the cap still exceeded; the rest roll to the next tick) and the teardown-completeness families yuzu_mcp_bridge_teardown_incomplete_total{reason}, yuzu_mcp_bridge_teardown_retry_total{outcome} (#2513 - a later sweep’s retry attempt recovered or exhausted its budget), yuzu_mcp_bridge_projection_degraded_total (#2528 - a projection claim released without the record lock, so the settle was lossy) and yuzu_mcp_maintenance_tick_failures_total{tick} (#2487; on-call procedure in docs/ops-runbooks/mcp-bridge-teardown-recovery.md) - each with its alerting posture in docs/user-manual/metrics.md.

Shutdown: httplib re-checks its shutdown flag between provider calls, so stop() drains live GET streams within about one heartbeat tick (~3 s, in parallel). Since #3042, ServerImpl::stop() also close-signals every live MCP session first (McpSessionRegistry::shutdown(), called before web_server_->stop()) — this wakes a waiting GET pump immediately (the same mechanism a DELETE or idle-GC close uses) rather than leaving it to notice on its next tick, and it delivers a clean session_terminated close frame instead of a bare connection drop. A stream whose peer has stopped reading is bounded instead by the 30 s socket write timeout, which neither mechanism can shorten — the pump is mid-write, not waiting.

A streamed POST also drains well inside its response cap — but by polling, not by a wake. Its response installs through the identical res.set_chunked_content_provider mechanism (McpPostPump::pump_once, bounded per call by the same cfg_.tick, ~3 s, as the GET pump — see mcp_stream.cpp), so httplib’s between-provider-call shutdown check applies here too: on an ordinary stop() a streamed POST’s response ends within about one tick, the same bound as GET, not the kMcpStreamedPostCapDefault (120 s) cap. But the mechanism differs from GET’s: a streamed-POST sink is a standalone SseSinkState that McpSessionRegistry::shutdown()’s close() call never touches, so it has no wake to receive — it instead notices on its own next session_alive_() tick check that the registry has drained, and closes with session_terminated at that point. Either way the underlying execution is unaffected: closing the response stream does not cancel it, and its result stays fetchable by execution_id.

The 120 s cap governs a separate, unrelated case: a long-running execution during ordinary (non-shutdown) operation. If the cap expires while the server keeps running, the bridge runs ONE further drain pass that delivers the progress already latched, then settles the response on the pass after it (#2739) — so a single streamed-POST call can legitimately hold its worker for up to the cap plus those pump ticks plus its socket-write time; that is a client-side read-timeout concern, not a shutdown-sizing one. (A terminal latching inside that window is delivered as a normal completion, which only ends the response sooner; progress latched after the drain pass parks to the replay ring for GET resume rather than the wire.)

What genuinely bounds graceful shutdown, for either GET or streamed POST, is a stream stuck mid-write to a blackholed or drip-feeding peer — bounded by the 30 s socket write timeout, since neither close-signal above reaches a pump that is already mid-write rather than waiting. Size a container termination grace period and TimeoutStopSec with that write-timeout worst case in mind, not the 120 s cap: the shipped systemd unit and every shipped compose file that runs yuzu-server use 210 s (see docs/user-manual/server-admin.md’s Sizing bullet) — a comfortably conservative margin over the write-timeout bound. Tracked for a bounded drain (a pump stop-token) — the residual write-blocked case — in the follow-ups below.

Progress bridge (2f PR 3a)

Shipped. When a tools/call for execute_instruction carries _meta.progressToken, the server correlates that request to its execution and projects ExecutionEventBus events onto the session’s GET channel as notifications/progress (progress/total = agents responded / agents targeted), followed by the terminal result.

Lifecycle shape — two, and the client chooses.

Degradation is silent and safe. If the bridge cannot correlate a request (capacity, unknown session, allocation failure), the call falls back to the plain response - which is self-sufficient, carrying execution_id - and counts yuzu_mcp_bridge_degrade_total{reason}. Live progress is an enhancement, never a dependency.

Monotonicity. progress is strictly increasing on the wire, as the MCP spec requires: the projector holds a per-record watermark and suppresses any snapshot that would not advance it.

A pin released to admit a new call (#2740). A streamed POST whose peer dies before the final is written leaves that final pinned: the prompt release happens only once the final reaches the wire, and the remaining routes — a GET resume acking past the pinned id, or session death — both need a channel a POST-only client does not have. Four such calls used to exhaust a session’s four streamed slots permanently, with a 429 telling the client to wait for calls that had already ended. Admission now reclaims a slot first: the session’s OLDEST parked record whose committed final no wire took delivery of is unpinned, counted (yuzu_mcp_bridge_pin_displaced_for_admission_total) and audited (mcp.bridge.pin_displaced_for_admission), and the new call is admitted. That final is unpinned, not erased — it stays in the ring until ordinary eviction and stays fetchable by execution_id, so a resume can often still collect it. Live calls are never displaced, so the concurrency limit holds by construction (with two bounded exceptions, both leaving a session transiently one call over its cap for the lifetime of the over-admitted call: #2795, a release that loses a race to another release route still admits (counted as yuzu_mcp_bridge_pin_release_raced_total; do not alert - a client can reach it by racing its own resume); and a release whose own attempt throws, which is contained and counted as yuzu_mcp_bridge_pin_release_failed_total - alert on that counter). Orphan pins — a final whose owning record a teardown erased without unpinning — are reclaimed the same way and are checked FIRST, and are the case that matters most: no route a POST-only client can reach releases them (a GET resume cursor does clear them, but that needs a channel such a client does not have), since the pin-ack sweep and the delivered-final path both need a record and a cursor-less GET resume releases nothing. A refusal therefore now means the reclaim found nothing to take. Two refusals are possible and they are answered differently. With a charge outstanding — calls genuinely in flight — the answer stays “wait for one to finish”. With every slot held by a pin, the reclaim failed for one of three reasons: a final still being written by a live pump, a transient decline while one of the session’s records is mid-projection, or a slot genuinely stuck (an unreleasable pin the scan could not attribute). The first two clear on retry, and the client cannot tell which case it is in, so that refusal says retry first and only then suggests collecting outstanding results by resuming with Last-Event-ID or re-initializing.

Terminal durability. A terminal frame is pinned in the replay ring so a reconnecting client can still receive it; the publish_final -> fallback -> poison ladder (Decision 15(f)), with yuzu_mcp_stream_terminal_publish_failures_total and yuzu_mcp_stream_final_unpinned_total, makes any failure to do so visible rather than silent. The exemption covers the most recent pinned terminals up to the per-session slot count, not an unbounded set. Exhausting the slots used to mean admission accounting had drifted — the bridge admits streamed records against the same count the array is sized to — though since #2740 the reclaim releases a pin deliberately - which does NOT put the session over cap and cannot itself cause a displacement - while its two accepted residuals (#2795 raced, #2805 contained throw) each can, so drift is now a conclusion to reach rather than assume — and in that degraded state the oldest pin yields to the newest, since the newest is the terminal a client is likeliest still waiting to resume. That is reported by yuzu_mcp_stream_pin_displaced_total (alertable on > 0); a displaced terminal is still committed and stays in the ring until ordinary eviction reaches it - it loses its eviction EXEMPTION, nothing more. Not “delivered”: these frames are ring-only, and nothing delivers one except a resume. Results always remain durably fetchable by execution_id.

Streamed POST — SSE on the response (2f PR 3b)

Shipped, ON by default (--mcp-enable-streamed-post; pass --no-mcp-streamed-post to opt out). The four defects that gated the on-by-default flip — #2739 (response cap), #2740 (pin-slot lockout), #2785 (resume ids on POST frames) and #2789 (per-principal reject coverage) — and the operational/documentary prerequisites (#2791 concurrent test coverage, #2792 wedge-alert semantics and runbook, #2793 customer-manual entries) are all fixed and merged. A tools/call for execute_instruction that carries _meta.progressToken and an SSE-capable Accept gets its POST response held open as an SSE stream: notifications/progress frames as the work happens, the JSON-RPC result last, then EOF. This is the spec’s progress-before-response ordering.

Opting in

Client sendsServer answers
_meta.progressToken + Accept: text/event-streamSSE stream on the POST response
_meta.progressToken, no SSE Acceptplain JSON now, progress on the GET channel
SSE Accept, no _meta.progressTokenplain JSON (the token is the real opt-in)
neitherplain JSON — byte-identical to pre-2f

Accept: text/event-stream;q=0 opts in. RFC 9110 reads q=0 as “not acceptable”, and this is a deliberate deviation: a client that sends a progressToken and then disclaims the media type is contradicting itself, and honouring the token is the useful reading. The token, not the header, is the gate.

Response shape

Headers are Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no, X-Content-Type-Options: nosniff, and X-Correlation-Id.

X-Accel-Buffering is understood by nginx only. Envoy, HAProxy, ALB and Cloudflare each need their own no-buffering opt-out — without it the proxy buffers the stream, the server cannot detect that the client has gone, and the response runs to its full cap holding a worker and an admission slot. Same caveat as the GET channel (docs/user-manual/server-admin.md). Frames are JSON-RPC objects under the default message event type. Progress frames are strictly increasing (see Monotonicity above). The stream ends one of two ways:

Close reasons, and what a client should do

reasonMeaningClient action
completedresult deliverednothing — you have the answer
cap_expiredthe response’s time budget elapsed; the execution continuesfetch by execution_id
record_gonethis response could not continue — the bridge record was reaped (session/pressure teardown) or the server is shutting down; the execution continuesfetch by execution_id, or reconnect via GET to resume
cancelledyour notifications/cancelled detached the response; the execution continuesfetch by execution_id if you still want it
session_terminatedthe session endedre-initialize
credential_revokedyour credential stopped being validre-authenticate
auth_unavailablethe auth backend could not answer within the grace windowretry
internal_errora server-side fault ended the responsefetch by execution_id
client_disconnectthe peer went away (recorded server-side; you will not see this frame)n/a

The invariant behind half that table: closing a response never cancels work. A dispatched command keeps running on real agents, and its result stays durably fetchable by execution_id (get_execution_status, query_responses). notifications/cancelled detaches your stream, not the fleet change you asked for.

Admission and refusals

A streamed POST holds an HTTP worker open for its lifetime, so it leases from the same budget as the GET channel (ADR-1005 Decision 15(h)) and is capped per principal. Refusals are answered, not degraded — a client that asked for a stream is told it is not getting one:

ConditionStatusJSON-RPC errorRetry?
per-principal or global stream cap (pre-admission StreamBudget, reason=post_per_principal_cap/post_global_cap)429-32012yes — Retry-After + retry_after_ms (30s: a streamed slot is held until the work finishes, so the GET figure would just cause a retry storm)
server-wide bridge record cap (reserve()’s own global_record_cap, Decision 15(d) — bounded in-memory state; distinct check from the pre-admission budget above, reason=post_record_cap; fixed at 256, not currently operator-configurable by any flag or env var — unlike the row above, there is no dial to raise; a sustained condition shows as yuzu_mcp_bridge_records_active approaching 256)429-32012yes — Retry-After + retry_after_ms (30s, same as the row above); or retry without an SSE Accept for a plain response
this session’s streamed-call cap (reason=post_pin_slots)429-32012yes — retry. Admission first reclaims a slot from an undelivered final (#2740), so a retry can succeed before any call finishes. A refusal that survives that means either every slot is a call genuinely in flight (“wait for one to finish” is then true) or the reclaim found nothing takeable — see “A pin released to admit a new call” above
request id already in flight409-32600no — use a fresh id
session unknown or expired404-32007re-initialize
streaming disabled / shutting down200—none needed; you get the plain response
server could not allocate200—none needed; you get the plain response

Two further shapes are 500s raised after dispatch — the only refusals where the work is already running. Both carry execution_id in error.data alongside the correlation id, because the recovery is to fetch that execution, never to retry a mutating call:

ConditionStatusMeaning
the stream could not be established500the command IS running; fetch by execution_id, or retry without an SSE Accept
the response could not be completed after dispatch500the command IS running; fetch by execution_id

The last two admission conditions degrade rather than fail: the plain response is self-sufficient (it carries execution_id), so answering it beats advising a retry into a resource failure. Every refusal happens before the execution row is created, so a rejected call has dispatched nothing.

Resuming, and the one case you cannot resume

Streamed frames are also committed to the session’s replay ring, so a dropped connection can resume on the GET channel with Last-Event-ID. The result frame is pinned: ring eviction skips it, so a client that reconnects after a long gap still collects its answer.

If the ring has moved past your cursor, the server answers kGap rather than replaying a shortened history — a silently-short replay would leave you believing you had the lot. Recovery from a gap is re-initialize plus fetch-by-execution_id, not a retry of the original call: re-issuing a mutating tools/call would run it twice.

Metrics

yuzu_mcp_post_streams_active (held-open streamed responses — a separate series from the GET gauge, because the two have different lifetimes), yuzu_mcp_stream_rejects_total{reason="post_*"} for admission denials, yuzu_mcp_cancel_notifications_total{outcome}, and the bridge families listed above. Alerting posture for each is in docs/user-manual/metrics.md.

Phase 3 (Planned)