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
- Embedded in existing server — MCP runs inside the same cpp-httplib server as the REST API and dashboard. It reuses auth middleware, RBAC, rate limiting, CORS, and audit logging with zero duplication.
- Module:
server/core/src/mcp_server.hpp/mcp_server.cpp— mirrorsRestApiV1pattern (injected store pointers, same callback signatures). - JSON-RPC helpers:
mcp_jsonrpc.hpp(header-only) — parse/build JSON-RPC 2.0 envelopes. - Tier policy:
mcp_policy.hpp(header-only) — static allow-lists per tier, checked before RBAC. - Output serialization: Uses local
JObj/JArrstring builders (same pattern asrest_api_v1.cpp) to avoid the 56GB nlohmann template bloat.nlohmann::jsonis used for parsing only.
Security Model
-
Three authorization tiers enforced before RBAC:
readonly(read only),operator(+ tag writes, auto-approved executions except the ~50plugin.actionpairs a compiledExecuteGatemarksAdminOrApproval/AlwaysApproval(#1398) — those still denyoperator-tier execute_instruction with no approval provenance, at the shared dispatch chokepoint below the MCP-tier layer),supervised(all ops via approval workflow) — except the #520/#4031 server-administration set (Enrollment,OidcConfig), denied Read at every tier includingsupervised(see the bullet below). -
MCP tokens use the existing API token system (
api_token_store) with a newmcp_tiercolumn. MCP tokens require mandatory expiration (max 90 days). -
Approval workflow: Operations that
requires_approval(tier, type, op)returns true for are routed to theApprovalManager. People approve/reject in the dashboard Approvals page or with RESTPOST /api/approvals/{id}/approve|reject, which require an interactive cookie session, an enrolled MFA factor with a fresh proof (#6079), a human (not engine) principal, and reviewer ≠ submitter (docs/user-manual/instructions.md, “Who can approve”). The check runs on top of the central step-up check, which runs first and passes an account with no factor; the typed reasons below are what the REST approver rule returns once the central check has passed (with the step-up window above0the central check answers first: a SAML session gets its generic403, a deleted or deactivated local account gets the generic401MFA step-up required, an OIDC session with no MFAamrunderrequired, oradmin-onlyfor an administrator, gets the generic401step-up, and an unreadable local MFA store gets503withoutretry_after_ms, countedroute="mfa_stepup"). A local account with no enrolled factor is refusedmfa_not_enrolledin every--mfa-enforcementmode (a non-admin cannot enrol itself underoptionaloradmin-only; an administrator enrols it, seedocs/ops-runbooks/auth-db-recovery.md, “Enrolling a non-admin approver”); an OIDC session needs an MFA-methodamrfrom the identity provider at sign-in, and with none it is refused403mfa_proof_missing, while a proof older than the window gets a401step-up withchallenge_url=/auth/oidc/start(the provider issued an ID token with an MFAamrwithin the window; the MFA itself may predate the token, because the provider can reuse its own session, and no second factor is presented again at approval; real re-authentication is a planned follow-up); and the proof window ismin(--mfa-step-up-window-secs, 300), 300 when that is0, so--mfa-step-up-window-secs=0does not disable it. SAML approvers are blocked (saml_mfa_unsupported, #4935), and a live session whose account row is deleted, deactivated or invalid is refusedaccount_unavailable. MCP cannot approve (ADR-1005 exception ledger entry 2026-10-18, #6061): the MCP transport runs no MFA step-up, soapprove_request/reject_requestalways return a typed refusal, whatever credential the caller used, unless read-only mode, a service-scoped token or the tier check refuses first with-32004/-32003(“human review is a human-session act”); listing pending approvals via MCP is unchanged. All holders ofApproval:Readsee all pending approvals (both AI-initiated and human-initiated). -
Bound approval tickets on the read tools (#6062):
list_pending_approvalsreturns the same rows asGET /api/v1/approvalsthrough one shared builder (approval_row_json). A ticket that binds a frozen request (the reserved Guardian rule-deploy surface; nothing in this release mints one) addsrequest(object ornull),request_digestandexcluded_roots, plusrequest_unreadable/excluded_roots_unreadableflags when the stored values cannot be shown. They are optional properties of the typed output schema, readable under the unchangedApproval:Readgate with no management-group confinement. Approving over MCP stays refused (above), so these fields exist for an agentic worker to READ what a human is asked to approve, not to act on it. -
Kill switch:
--mcp-disablerejects all/mcp/v1/requests withkMcpDisabledJSON-RPC error.--mcp-read-onlyblocks non-read tools. -
Server administration surfaces stay REST-only (#520).
AuthRoutes::require_admin’s own comment states the rule: “MCP tokens are for fleet management (queries, instruction execution) and must not be used to administer the server itself (settings, users, TLS, OIDC).” Every capability gated byrequire_admin(dashboard-fragment admin routes and any RBAC securable minted specifically to preserve that admin-only posture) is a candidate for this exclusion, decided explicitly per capability rather than defaulted either way. #4028 (api-parity programme #2146) confronted this directly for the eight Settings read-twins (GET /api/v1/settings/tls|https|gateway|server-config|mcp|data-retention|analytics+ the hardenedGET /api/v2/agent/plugin-policy- split from its now-deprecated/api/v1/predecessor by #4144, seedocs/api-versioning-policy.md) rather than silently widening or silently skipping MCP entirely: no MCP tool was added for any of them, even read-only ones - read-only settings visibility is judged a meaningfully different exposure than the fleet-query surface this rule was written to keep MCP confined to, and admitting it needs its own security-guardian-reviewed amendment to this rule, not a side effect of a routine REST-twin PR. #4031 makes the identical call for the enrollment/OIDC-config read twins (auto-approve rules, pending-agent visibility, OIDC SSO config): they ship REST-only, with the ledger (scripts/ci/api-parity/*.json) carrying the three fragment rows as datedstatus: "exception:#520 revisit:<date> ..."withrest_v1_twinnaming the v1 route andmcp_twin: null(a deliberate #520 no-MCP decision is a dated exception due for re-confirmation at its revisit date (no open issue tracks it yet), not a one-sided twin); the eight #4028 settings rows are ledgered asexception:#520 revisit:2027-04-30. The MCP-side exclusion itself is also recorded here and at each route’s RBAC-securable seeding comment. A future twin PR touching anadmin_fn_-gated capability should not diverge from this posture without a stated, reviewed reason. No MCP tool is also not, by itself, enough: an MCP token can still call a REST route its tier admits, andrequire_permission’s topology-floor legacy-role fallback would otherwise admit an admin-owned MCP token the same way it admits an interactive admin session.tier_allows()(mcp_policy.hpp) is where #4028/#4031 actually close that:TlsConfig/PluginSigning/ServerConfig/AnalyticsConfig/Enrollment/OidcConfigare denied at every tier, for every operation, so an MCP token 403s before it ever reaches that fallback.docs/auth-architecture.md(“Settings read-twins”) has the full securable/floor/audit account. The two AD/Entra directory-sync reads (list_directory_users/get_directory_status) are NOT part of this exclusion - they were already RBAC-gated (Directory:Read), notadmin_fn_-gated, so #520 does not apply and both get real MCP twins. -
Boot-time registration validator (#2383):
McpServer’s constructor throws (fatal — the server refuses to boot,Fatal exception: MCP tool security registration invalid ...) unless the served tool names (catalogue().served: the legacykTools[]entries plus the registry records), the security registrations (kToolSecurityRows[]plus each record’s securable/operation, served ascatalogue().security), the--mcp-read-onlywrite set (kWriteToolsRaw[]plus every registry record whoseoperation != "Read", served ascatalogue().writes) and thekToolAnnotationkey set (plus every record) agree exactly (both directions, duplicates rejected; a registry name that is also a legacykTools[]name is theduplicate served tool name 'X'offence), and every registered (securable, operation) pair is inside the closed RBAC catalogues mirrored fromrbac_store.cpp’s seed (binding test:test_rbac_store.cpp“seeded catalogues match the MCP C8 validator mirrors”). Dispatch carries a defense-in-depth pre-gate: a served-but-unregistered tool is deniedkInternalError(-32603, “tool security registration missing — denied fail-closed”; audit reasontool security registration missing correlation_id=<cid>— SIEM rules should prefix-match, the correlation id also appears in thespdlog::errorline and the A4 envelope — plus theyuzu_mcp_tool_security_misconfig_totalcounter) rather than silently skipping the tier/approval gate; an unknown tool name still returnskMethodNotFounduntouched, even if a stray registration row exists for it. Message shape: the exception lists the first 25 offences (sorted;kMaxOffencesShowninmcp_server.cpp) followed by; ... and N more (all logged individually above); every offence, shown or not, is also logged individually withspdlog::errorbefore the throw, so a very broken build produces a bounded message and a complete log. ThekToolAnnotationkey-set check (#2422) is new: a served tool with no annotation entry (served tool 'X' has no kToolAnnotation entry), an entry naming no served tool (kToolAnnotation entry 'X' names no served tool) and a repeated entry (duplicate kToolAnnotation entry 'X') are boot offences. Before it a missing entry only logged a warning and served the fail-safe annotation fallback, so a fork that carries its own tools and upgrades fromdevcan newly refuse to boot until its annotation table is complete. Registry records are also checked when the catalogue is first built (see “Adding a tool, registry path”): a null handler, a null string field, a name outside^[a-z][a-z0-9_]*$or aneffect/operationmismatch throwsstd::logic_errornaming the record. That build happens in theServerImplconstructor (it pre-seeds the approval-gated metrics from the catalogue), so those rejections apply even under--mcp-disable. Note: with--mcp-disabletheMcpServerconstructor never runs, so the #2383 validator above is skipped and a misregistered build boots and only fails when MCP is re-enabled (accepted residual — CI trips the same validator in every test fixture). Runbook:docs/ops-runbooks/mcp-tool-registration-recovery.md. -
Service-scoped token default-deny at C8 (#2298 PR 3 — “the flip”): every tool security registration (a
kToolSecurityRows[]row or a registry record) now also carries aServiceScopeClass(denied|confined|global_safe, defaultdenied). The check runs inside the same C8 block, immediately after the row lookup and BEFOREtier_allows/approval: a service-scoped token (session->token_scope_servicenon-empty) on adenied-class tool is refusedkPermissionDeniedstructurally, before tier or approval ever run — the MCP-side twin ofAuthRoutes::require_permission’s (REST-side) service-scoped default-deny branch (docs/auth-architecture.md“Service-scoped token fleet-wide confinement”).confinedandglobal_safeboth proceed past this check;confinedis NOT a claim the tool is actually usable by a service-scoped caller — it means the tool’s own downstream mechanism (perm_fn/scoped_perm_fn, or as of #3290fleet_read_fn_/require_fleet_read) provides the real confinement. For mostconfinedtools today that downstream mechanism is still aperm_fn/scoped_perm_fncheck under the seeded-empty allow-list, so the tool commonly still 403s downstream;query_installed_softwareand, as of #1700/#3290 Phase 2,get_agent_detailsare the exceptions so far — theirfleet_read_fn_confinement is real, not a still-denying placeholder (docs/auth-architecture.md“Phase 2 progress”).global_saferows are boot-validated againstkServiceScopeGlobalSafe(service_scope_policy.hpp) — aglobal_saferow with no matching allow-list entry is a boot failure, the same fail-fast posture as the #2383 validator above. WideningkServiceScopeGlobalSafeis a security decision (security-guardian sign-off), never inferred from adding a tool. Full design:docs/adr/1006-service-scope-default-deny.md. -
Pre-approval input-schema validation (#2405): inside the C8 approval block, tool arguments are validated against the tool’s served
inputSchemabefore an approval ticket is minted and before a recall consumes one — a schema-invalid supervised call can no longer waste an admin’s approval or burn a one-time ticket (one check above the mint/recall fork, so the two paths cannot diverge; the degraded no-ApprovalManagerdeny still answers first, and C7/tier denials still precede validation so an unauthorized caller learns nothing about argument validity). The denial iskInvalidParams(-32602) with an A4 envelope whose audit row anderror.datacarry the same correlation id, plus theyuzu_mcp_tool_args_invalid_total{tool}counter (label bounded — only approval-gated tools reach the gate;server.cpppre-seeds exactly that set viaapproval_gated_tool_names()). Schema validation (type/enum/pattern/shape) exists ONLY on the approval-gated path — a deliberate consequence: the same argument shape can be stricter on the supervised tier than on operator (e.g. a typedparamsvalue is-32602supervised, while operator-tier keeps the pre-existing handler coercion viadump()); strict typing on the maker-checker path is the product decision, not an accident.maxLength/maxItemssize bounds specifically are a narrower case:execute_instruction(#2437, next bullet) and 19 more tools (#4353 follow-up, two bullets down) additionally enforce their own bounds in the handler, on every tier - a schema-declaredmaxLengtha tool does NOT list in one of those two bullets is genuinely advisory-only off the approval-gated path; do not assume it is enforced without checking. Enforcement is themcp_input_schemasubset compiler (server/core/src/mcp_input_schema.{hpp,cpp}): a CLOSED keyword catalogue —type/properties/required/enum/minimum/maximum/minLength/maxLength/pattern(RE2, JSON-Schema search semantics)/items/minItems/maxItems/additionalProperties(schema-form orfalse)/anyOf-of-required-alternatives, withdescription/defaultignored — deliberately not a conforming JSON-Schema validator; do not claim conformance. Yuzu deviations:integeris STRICT (1.0rejected —param_intonly honors integer-typed JSON, so accepting an integral float would validate an argument the handler then silently defaults; out-of-int64 integers rejected for the same reason) andminLength/maxLengthcount bytes (formaxLengththat is stricter than counting codepoints; forminLengthit is looser above 1, and exact atminLength: 1— the not-empty case). Violation paths never carry caller-derived text — a map key validated viaadditionalPropertiesis reported as the wildcard*, so audit rows and envelopes stay free of untrusted argument content.approval_idis control-plane, not tool input: a present non-string one is rejected at/approval_id; a string one passes every gated schema (declared ondelete_tag/quarantine_device, tolerated as an undeclared property on the rest). Handler-side validation stays as defense in depth. A pre-existing approved-but-unconsumed ticket whose arguments a later-tightened schema rejects becomes unrecallable fail-closed (never consumed); approval tickets also lazily expire after 7 days (pending, and approved-unconsumed post-review — PR #1796), so such a stray ticket ages out. Post-consume (“semantic”) burn (#2444 item 3): args that pass the schema still consume a ticket, and the handler’s own business/state check can still reject them — that residual class is alertable viayuzu_mcp_approval_burned_total{tool,reason}, wired via aBurnGuardRAII object that inspects the ACTUAL JSON-RPC response at function-scope exit (a per-request flag set only afterconsume_ticket()succeeds) — deliberately NOT wired insidemcp_audit, since several handlers’ business-rejection paths (e.g.revoke_certificate’s “serial not found”) never callmcp_auditat all and would otherwise silently under-count.reasonis a single literal (handler_reject) today. Unlike the pre-consume counters above, this one fires strictly after a ticket is spent. It does not interact with the per-submitterkMcpSubmitterPendingCap(25):pending_count_for()counts onlystatus='pending'rows andconsume_ticket()never touchesstatus, so a ticket already left the pending bucket at admin-approval time — before it can ever reach this burn class. -
Server-side input bounds on
execute_instruction(#2437): themaxLength/maxItemsin this tool’s served schema are enforced on every path by the handler before dispatch, not only on the approval-gated path where the C8 gate validates them. That distinction mattered: the operator tier executes with no approval, so it had neither the schema check nor a handler check — the tier that needs no human in the loop was the unbounded one. Each rejection iskInvalidParams(-32602), an audit row (result=denied,detail=input bound exceeded: <reason>), andyuzu_mcp_tool_args_too_large_total{tool,reason}over a closedreasonset. Bounds are byte counts (matching the compiler’smaxLength-counts-bytes deviation):plugin/action/eachagent_idsentry ≤ 128 B,scope≤ 8192 B, eachparamsvalue ≤ 64 KiB,agent_ids≤ 10000 entries — plus two the closed subset cannot express:params≤ 32 keys and eachparamskey ≤ 256 B.-
Those bounds borrow
kMaxParamCountPerStep/kMaxParamKeyLen/kMaxParamValueLenfrombundle_service.hpp, so the two execute surfaces agree on what a param may be — shape and value size. They used to disagree 8× on value size (8 KiB here, 64 KiB for a bundle step); that was not a decision, it was an accident of which constant was reached for, and it was documented as deliberate before anyone checked why. The smaller figure was also never enforced on the operator tier, so keeping it would have broken content-carrying plugin actions (afilesystemwrite, a script body) that worked in practice. -
The cost of that alignment, stated rather than dropped: a bundle’s per-step value cap was sized knowing it is amplified ×N by fan-out, and
execute_instructionfans out too. Raising the per-call params ceiling from 256 KiB to 2 MiB multiplies what ascope:"__all__"dispatch copies per agent by 8×, andagent_registry’s gateway path queues a fullCommandRequestcopy per routed agent. That queue is unbounded independently of this change and is tracked separately; MCP nonetheless remains the tighter path, because the REST twins accept the sameparamswith no bound at all. -
Not published, but NOT a ticket burn. The closed subset has no
maxProperties/propertyNames, so C8 cannot derive these two from the schema — insteadcheck_exec_instruction_shapeis called explicitly inside the C8 block, above the mint/recall fork, so a 33-param supervised call is denied-32602before a ticket is minted or consumed. What remains open is discoverability: the two bounds are absent fromtools/list, so a client learns them only by being refused. Publishing them needs the catalogue extended (#2444). -
REST twin asymmetry, recorded here so it is findable:
POST /api/commandandPOST /api/instructions/{id}/executeaccept the same{plugin, action, params, scope, agent_ids}shape under the sameExecution:Executeand apply no bounds at all. #2437 closes this class on the MCP surface only; the REST twins are tracked as a follow-up.
-
-
Server-side input bounds on 19 more tools (#4353 follow-up): #4353 added
maxLengthto 20 tools’ served schemas (create_management_group/update_management_group/add_management_group_member/get_management_group/list_management_group_roles/assign_management_group_role/check_permission/unlock_account, and the 12 result-set toolslist_result_setsthroughdelete_result_set). Gate 2 review of that PR found the SAME gap #2437 had already closed forexecute_instruction: a schemamaxLengthalone is validated only on the approval-gated path, and every one of those 20 tools is reachable where that path is never taken, for the SAME root reason -requires_approval()returnsfalseimmediately for an EMPTYmcp_tier, and/mcp/v1/’sauth_fn(require_auth) admits a plain RBAC session or a non-MCP-tiered API token the same way any REST route does, so that caller class never reaches C8’s schemavalidate()on ANY tool (the identical classmint_upload_grant, PR #3135, has, and it was not fixed there). 11 of the 19 are ALSO never approval-gated at a non-empty tier (readonly/operatorRead, andInfrastructure:Writefor the result-set mutators, absent fromrequires_approval()’s list entirely); 3 (create_result_set_from_echo_query,create_result_set_from_instruction_result,reevaluate_result_set) are approval-gated at supervised tier only, so an operator-tier call also skips it; the remaining 5 (create_management_group,update_management_group,add_management_group_member,assign_management_group_role,delete_result_set) are gated whenevermcp_tieris non-empty, but the empty-tier gap above still reaches them.unlock_accountis the ONE tool of the 20 that needed nothing further: it already denies an emptymcp_tieroutright (Gate 6, #2146 B4 fix) and itsusernamealready goes throughis_valid_username(), whose own enforced limit IS this field’s 64-byte schema bound. The 19 gap tools now carry the same handler-side check shape asexecute_instruction-kInvalidParams(-32602), an audit row (detail = input bound exceeded: <what>), andyuzu_mcp_tool_args_too_large_total{tool,reason="arg_too_large"}- via one shared responder (reject_field_too_largeinmcp_server.cpp) rather thanexecute_instruction’s own per-tooltoo_largelambda, since each of these 19 branches rejects on 1-6 simple fields rather than schema-inexpressible shape rules;reasonis a single fixed literal per tool (not per-field, unlikeexecute_instruction) because no alerting need here justifies that breakdown.create_result_set_from_instruction_result’sparamsalso gained the same 64 KiB per-valuemaxLength, 32-key count cap, and 256-byte key-length capexecute_instructionalready enforces for the equivalent field (Gate 2 MEDIUM finding on the value bound; the count/key caps were a Gate 3 cpp-expert finding on the fix itself - an earlier revision copied only the value bound fromexecute_instruction’s three-part precedent).- REST twin asymmetry, recorded here so it is findable (same convention as the
execute_instruction/#2437 bullet above): several of these 19 tools state their own REST v1 twin by name in theirtools/listdescription (e.g.update_management_groupmirrorsPUT /api/v1/management-groups/{id}; the result-set tools mirrorPOST /api/v1/result-sets/...and friends). Almost none of those REST routes apply an equivalent per-field length bound - the management-group REST routes rely only on the route-class-wide request-body-size cap (body_cap_policy.hpp), not a per-field one. Several narrow exceptions, none of which close the general gap: one pre-existing (POST /api/v1/result-sets/from-echo-queryalready length-checked its ownsqlfield before this bullet was written) and several added by #4373 across two governance/review rounds (POST /api/v1/result-sets/{id}/re-eval’sinstruction_id/paramsrecheck on itskInstructionResultbranch;POST /api/v1/result-sets/from-instruction-result’s own creation-time bound on the same two fields, closing the more directly exploitable one-step variant a Gate 2 sibling-handler review found; and all four result-set create routes’namefield, plussource_kindon the one route that has it (POST /api/v1/result-sets, the generic create route - the other three don’t accept a caller-suppliedsource_kind), which now match MCP’skResultSetNameMaxLen/kResultSetSourceKindMaxLencaps exactly, found by a PR reviewer after the type-confusion fix on those same fields shipped without the matching length bound). Theinstruction_id/paramschecks mirrorreevaluate_result_set’s check logic and bound values (#4394), not its observability: MCP’s rejection is audited and metriced viareject_field_too_large, REST’s new rejection (viars_err) has neither, tracked as #4408. Every other REST create route otherwise remains unbounded per-field - closing that is a separate, not-yet-scoped follow-up. The asymmetry now also runs the other way on one point: REST’sparamsbound checks (both the{id}/re-evalrecheck and thefrom-instruction-resultcreation-time check) refuse a present-but-non-objectparamsoutright; MCP’s twin tools (create_result_set_from_instruction_result,reevaluate_result_set) still gate their equivalent checks onparams.is_object()with no rejection branch, so a non-objectparamson MCP silently proceeds with an effectively-empty parameter map instead of refusing. Untracked. - REST twin asymmetry, TARGETING — CLOSED (#2500). The bullet above is about argument size, which remains open on REST. Targeting does not.
POST /api/commandandPOST /api/instructions/{id}/executeused to widen a supplied-but-empty target to the whole fleet, a class #2437 had closed on MCP only./api/commanddropped non-stringagent_idsentries inextract_json_string_arrayand then fell through tosend_to_allwhen the list emptied, so{"agent_ids":[1,2,3]}from a client emitting numeric device ids dispatched fleet-wide under plainExecution:Execute— and audited as a success; the destructive-action guard that would have caught it is reached only forkDestructiveActionSecurable, which holds exactly one entry (echo.purge_source)./api/instructions/{id}/execute400d on[1,2,3], but only becauseget<std::string>()threw into a generic catch, and it broadcast on[], on a non-arrayagent_ids, and on a non-stringscope. Both routes now refuse every one of those shapes with a400, ayuzu_server_dispatch_target_rejected_total{route,reason}increment and an audit row (result=denied,detail=reason=<reason>) — while an omitted target still means “the whole fleet” on both, which is the half of the rule a careless fix breaks. The rule and its closed reason set live inserver/core/src/dispatch_target_shape.hpp, the same functioncheck_exec_instruction_shapenow delegates to, so MCP and REST cannot drift on what a target is. Still distinct from #1788, which covers scope confinement: fixing that would bound the blast radius to an operator’s visible devices but would still let a specific-looking target list become “everything you can see”. Destructive-classified pairs are the one exception to “omitted means the fleet” — see the next bullet. - MCP
execute_instructiongains the Destructive-class targeting refusal REST already had — CLOSED (#3685)./api/command’s Destructive-class gate (one hardcodedecho.purge_sourcerow historically, generalized to the full registry-classified catalogue in the #1.9 dispatch-chokepoint work) had no MCP equivalent: a Destructiveplugin.actionpair (21 rows today, e.g.echo.purge_source,filesystem.delete_lines,registry.delete_key) reached the shared dispatch chokepoint withscope/broadcast targeting — including the omitted-target__all__normalization the bullet above documents as intended for every OTHER pair. An authorized caller (for most Destructive rows — 19 of 21, which are alsoAdminOrApproval— an admin or approved caller; forecho.purge_sourceandecho.rollup, gatedExecuteGate::None, merely an ordinary grant on the row’s resolved securable, e.g.Infrastructure:Delete) dispatched normally and succeeded fleet-wide, with no denial at all. A caller holding no grant for the row’s securable WAS refused by the pre-existing chokepoint, but that denial surfaced only as the ambiguoussent=0/no_agents_reachedenvelope an offline/unreachable agent also produces — never a discriminated refusal naming the reason, and not the exposure that mattered here. It now refuses with the same-32602/kInvalidParamsshape and the SAME message REST’s/api/command400 arm uses (dispatch_destructive_gate.hpp’s shared constant):destructive action requires explicit in-scope agent_ids; broadcast and scope fan-out are refused. Two gate sites, both required: the C8 pre-mint block (tool_name=="execute_instruction", before an approval ticket is created or consumed — so a Destructive call on thesupervisedtier never wastes an admin’s approval or burns a ticket on a call that was always going to be refused) and the main-handler backstop (the same pure function, the same shape-checked inputs, so the two sites are provably unable to disagree — it exists becauseoperatortier’srequires_approval()is false, so C8’s entire approval block, including this check, is skipped for it entirely). The capability classifier (ClassifyFn, wired unconditionally at boot fromCommandCapabilityRegistry::classify) is fail-closed when unwired: if it is ever unset, EVERYexecute_instructioncall — not only Destructive ones — is refusedkInternalErrorwith a distinguishable “classifier unavailable” denial, rather than silently reaching dispatch unclassified. A wired-but-inconclusive classify result (Unclassified/Ambiguous— the different case from unwired) was handled DIFFERENTLY at the two gate sites when this gate shipped: REST’s/api/commandswitch keeps Policy B today (not independently denied — falls through to the shared dispatch chokepoint’s own denial), and the C8 pre-mint site denies a classify-miss LOCALLY, minting no ticket — reusing the chokepoint’s ownunclassified/ambiguousdenial reasons (yuzu_server_dispatch_denied_total{reason}, not the targeting-refusal series) rather than a new taxonomy. The main-handler backstop’s own Policy B fall-through is superseded by #3687 below: a classify-miss reaching the main handler is now denied locally there too, by the pre-dispatch authorization dry run, rather than falling all the way through todispatch_fn. C8 is the one site where Policy B’s own reasoning (an early denial would only duplicate, never prevent, the chokepoint’s evidence) did not fully apply even before #3687: falling through there let a classify-miss call mint an approval ticket and wait on a human before the chokepoint denied it anyway on actual dispatch — a real approval burned on a call that was always going to be refused. Counted onyuzu_server_dispatch_target_rejected_total{route="mcp",reason="destructive_untargeted"}(mutually exclusive with theroute="command"REST arm — a call refuses at exactly one ofmcp/command, never both) and audited (result=denied). The auditdetailstring carries areason=prefix at both gate sites (reason=destructive_untargeted <plugin>:<action>) — Gate 8 round 3 added it at the C8 pre-mint site to match C8’s ownClassifyMissarm above; the main-handler backstop’s equivalent detail omitted it until governance Gate 4 consistency-auditor caught the drift (Wave 7 PR7.2b), which brought the two in line.POST /api/dashboard/executenow enforces this gate as a third caller (route="dashboard");POST /api/instructions/{id}/executenow enforces this gate too (Wave 7 PR7.2b, both the whole-request/api/workflows/{id}/executepreflight and the instruction-execute route itself); MCPexecute_bundlestill does not — that remains a tracked follow-up. - MCP
execute_instructiondiscriminates every dispatch-chokepoint denial reason instead of collapsing them intono_agents_reached— CLOSED (#3687). The bullet above closed ONE denial reason (Destructive-untargeted, a targeting-SHAPE refusal that never reaches the shared chokepoint at all). Every reason the chokepoint itself can produce —Unclassified/Ambiguous/AnonymousOperator/Forbidden/ApprovalRequired/KillSwitched(DispatchDenialReason,agent_registry.hpp) — was, until now, reachable only by falling all the way through the main handler todispatch_fn, which enforced the denial correctly but reported it as the SAMEagents_reached:0/no_agents_reachedenvelope an empty target set or an offline/unreachable agent also produces (the gapdocs/security-reviews/1398-dispatch-approval-gate-design.md’s Decision 7 sign-off row describes in its 2026-08-28 correction — see that row, and its 2026-09-02 follow-up line, for the historical framing).execute_instruction’s handler now runs a pre-dispatch DRY RUN — the exact sameclassify_and_authorize_dispatch(agent_registry.hpp) callServerImpl::build_classified_commanditself makes, via the SAME injected RBAC binder (ServerImpl::dispatch_has_permission), pluskill_switch_denial(agent_registry.hpp) — the SAME extracted functionfinalize_classified_commanditself calls for the per-action kill switch. Neither half is a re-implemented slice: an earlier round of this change re-expressed the kill-switch boolean inline instead of sharingfinalize_classified_command’s own extracted check (a governance finding — that shape is exactly the drift classfinalize_classified_command’s own prior M6/wave1 extraction exists to prevent), corrected to share the extracted function like the classify+authorize half always did. Two gate sites run this dry run, both required (Gate 6 governance finding UP-5): the C8 pre-mint block (tool_name=="execute_instruction", before an approval ticket is minted or consumed — without this, a supervised-tier caller who failed specific-securable RBAC or hit a kill switch still minted or consumed a real human-approved ticket before being denied, reopening the exact ticket-waste class #3685’s own C8 extension exists to prevent) and the main-handler backstop (the original site, unchanged in shape). At C8 specifically,ApprovalRequiredis NOT a denial to act on — a caller reaching C8 for an approval-gated pair is there BECAUSE the pair requires approval, so the switch falls through to the existing mint/recall logic for that one reason and denies locally for every other. All of this runs BEFOREdispatch_fnis ever called. No target-agent list is needed: classification and authorization are decided from(plugin, action, caller)alone. On denial,dispatch_fnis never invoked (no phantom created-then-cancelled execution row, no bridge reservation) and the caller gets a JSON-RPC error naming the reason:kInvalidParamsforUnclassified/Ambiguous(byte-identical message to the C8 pre-mint site and REST),kPermissionDeniedfor the other four — deliberately NOTkApprovalRequired(-32006) forApprovalRequired, since that code’s own contract mandates anapproval_id/status_urlto poll and this dry run mints no ticket (Decision 7: “no new ticket-mint surface”,docs/security-reviews/1398-dispatch-approval-gate-design.md). Every denial’serror.datacarries a machine-readablereasonfield (the same snake_caseto_string(DispatchDenialReason)vocabulary as theyuzu_server_dispatch_denied_total{reason}metric and themcp.execute_instruction|deniedaudit detail this dry run also emits) so an agentic caller can branch programmatically instead of string-matchingmessage. This is NOT a new enforcement point:dispatch_fnstill re-runs the identical chokepoint decision internally a moment later and remains the surface that actually dispatches — the dry run only changes what a refused caller is TOLD. The authorizer (McpServer::AuthorizeDispatchFn) is wired unconditionally at boot, next to the Destructive-targeting classifier above, and is fail-closed when unwired, same posture as that classifier. REST’s/api/commanddoes NOT get the same full discrimination for free:build_classified_command’s denial handling collapsesForbidden/AnonymousOperator/KillSwitchedinto one genericpermission denied: <securable>:<operation>403 message (only the auditreason=and the metric label discriminate those three there) — full per-reason response-body discrimination is an MCP-only improvement in this PR. Superseded by #3893 below: at the time this shipped, onlyexecute_instructiongained this dry run;execute_bundleandquarantine_devicewere unaffected, tracked as a follow-up. - MCP
execute_bundleandquarantine_devicegain the same pre-dispatch dry runexecute_instructiongot from #3687 — CLOSED (#3893, Doomgoose review of #3893’s first round, blocking findings 1+2). Issue #3687’s own acceptance criterion 2 named “every MCP dispatch call site (execute_instruction, execute_bundle, any future dispatch-capable tool)” as in scope; the first round of this PR coveredexecute_instructiononly. Bothexecute_bundleandquarantine_devicedispatch through the exact samedispatch_fnclosure (ServerImpl::dispatch_confined→build_classified_command, unconditionally, for every plugin/action pair) asexecute_instruction— so the real chokepoint was ALREADY denying a disallowed call for both tools correctly; the gap was purely that neither ran a discriminating pre-check, so a denied call either looked like a SUCCESS (execute_bundle—BundleOrchestrator::DispatchResultcarries only{correlation_id, expected}, no per-step outcome, so the handler unconditionally returned a JSON-RPC success naming the requested step count regardless of whether every step was about to be denied) or got misleading retry advice (quarantine_device— a permanent RBAC/kill-switch denial was mapped tounconfirmed_retryable, the SAME hint an offline device gets, so the caller would retry forever). A new shared helper,dispatch_pairs_for(tool_name, args)(mcp_server.cpp, next to the call sites ofdescribe_dispatch_denial, which now lives indispatch_denial_text.hppso the REST quarantine route shares it, #6065 PR-Q), is the ONE place naming which(plugin, action)pairs a tool’s call will actually dispatch —{plugin, action}forexecute_instruction, the fixedquarantine/quarantinepair forquarantine_device, and every parsed step’s(plugin, action)forexecute_bundle(viavalidate_bundle_steps) — empty for a non-dispatch approval-gated tool (revoke_certificate,rotate_kek, …) or when a dispatch-capable tool’s own args don’t parse yet (that tool’s own, clearer validation denies later). The C8 pre-mint block is now generalized off this helper (looped over every returned pair,ApprovalRequiredstill falling through to mint/consume, same as before) instead of beingexecute_instruction-only, so a supervised-tierexecute_bundle/quarantine_devicecall that fails RBAC or hits a kill switch is refused before a ticket is minted or consumed — the same ticket-waste class #3687 already closed forexecute_instruction. The main-handler pre-checks are per-tool (notdispatch_pairs_for-driven, since each needs its own placement relative to other handler-local state) but genuinely deny onApprovalRequiredtoo, matchingexecute_instruction’s own main-handler backstop’s posture (no ticket exists to poll at this dry-run point). Forexecute_bundle: every step is pre-checked, ALL-OR-NOTHING — a single denied step refuses the whole call beforebundle_orch->dispatch(...)runs at all, rather than attempting partial per-step dispatch/denial (which would require wideningBundleOrchestrator::DispatchResult, out of scope here);caller.approval_provenanceis stamped fromapproval_ticket_just_consumedexplicitly here, sinceauthorize_dispatch_fn_reads it directly andbundle_orch->dispatch(...)’s own ticket-consumption parameter is separate. Forquarantine_device: the pre-check runs BEFORE the store write (quarantine_store->quarantine_device(...)), not after — a denial after the write would leave a persisted-but-undispatched quarantine record, the same phantom-isolation class #3127 already fixed once for this handler;quarantine_caller’s construction was hoisted earlier in the handler (to right after theSecurity:Executescope gate passes) to make this possible, and is reused unchanged at both existingdispatch_fncall sites further down. Everydeny_dispatch_authorizationdenial (all three tools, both gate sites) now also logs aspdlog::warnline matchingbuild_classified_command’s own denial-log format (Doomgoose review, Important finding) — before this round, a request refused at either dry run produced FEWER log lines than the same denial reachingdispatch_fndirectly would have. - Per-OS kill switches add two zero-reach causes (#5294).
kill_switched_os(permanent,retry_after_ms: null) means every target runs an OS for which a per-OS kill switch is OFF;os_gate_unreadable(retryable,retry_after_ms: 5000) means agent presence could not be read while a per-OS switch is OFF, so the dispatch was refused before targeting. The fixed cascade order isinvalid_scope,containment_unreadable,route_unreadable,os_gate_unreadable,quarantined,plugin_not_found,kill_switched_os, thenno_agents_reached, with one caveat:os_gate_unreadableis decided BEFORE the scope expression is parsed (the presence read refuses the dispatch up front), so it can never co-occur withinvalid_scopeand a malformed scope sent while presence is down answersos_gate_unreadable;containment_unreadableis set inside the same pass that parses the scope, soinvalid_scopestill outranks it;/api/commandand the legacy forwarder return the same tworeasonvalues.get_plugin_kill_switch/set_plugin_kill_switchtake an optionalos(windows,linux,darwin). Neither cause is ever produced for a protective action (the quarantine plugin’squarantine/unquarantine/status/whitelistand__guard__.push_rules): those are exempt from the per-plugin/action kill switch, the per-OS switch and its presence read, and the plugin-presence filter, including on a degraded plugin-config store. A kill-switch row for a protective pair is still accepted and displayed, butget_plugin_kill_switch’s state for it is informational and is not the dispatch decision (enabled=false can be shown while the action is still delivered). execute_instruction’sagents_reached:0envelope now discriminates the reason instead of collapsing every zero-reach cause intono_agents_reached— CLOSED (#3424/#3511). #3687’s own comment on this code path named the residual gap directly: after the pre-dispatch authorization dry run closed the RBAC/kill-switch/approval class, “a wider DispatchFn return… would be needed to discriminate [quarantine/offline] programmatically” for the reasons that live INSIDEdispatch_confined_armsitself rather than in the pre-dispatch check. Two such reasons existed: (1) #3424 — a quarantine denial (permanent policy refusal) or a fail-closed containment gate (transient systemic degradation) both surfaced as the SAMEagents_reached:0/no_agents_reachedshape an offline device produces; (2) #3511 — dispatching a plugin absent from every target’s reported inventory returnedagents_reached: 1(a normal-looking SUCCESS) with the failure (“plugin not found”) visible only on a laterquery_responsescall. Both close via the SAME mechanism:dispatch_confined_arms(dispatch_confined_arms.hpp) gained a plugin-presence filter —plugin_absent(aid), the direct sibling of the pre-existingcontained(aid)quarantine check, checked immediately after it (a quarantined-and-absent agent reports quarantined, the stronger fact) — sourced fromAgentRegistry::ids_missing_plugin(plugin, action)(one locked registry pass per dispatch, same lifecycle asmake_containment_gate). An agent with an EMPTY reported plugin inventory is never flagged absent — fail-open, since absence of DATA is not evidence of absence of the PLUGIN (a freshly-registered or gateway-relayed agent must not be wrongly withheld). The richer per-arm result (ArmDispatchResult, now carryingunknown_plugin/unknown_plugin_countalongside the pre-existingdenied_quarantined/denied_quarantined_count) used to be collapsed to a barestd::pair<std::string, int>at the ONE seam everyDispatchFn-family closure shares (ServerImpl::dispatch_confined) — the exact widening #3424 was deferred for at #881 review time as “a cross-cutting change to a core signature with several production call sites.” That widening landed here:ConfinedDispatchOutcome(dispatch_confined_arms.hpp, moved fromdispatch_scope_ladder.hppso the 15 independently-declaredDispatchFn/CommandDispatchFn/ConcurrencyDispatchFntypedefs acrossmcp_server.hpp,bundle_orchestrator.hpp,dashboard_routes.hpp,deployment_engine.hpp,dex_routes.hpp,preflight_routes.hpp,echo_tree_routes.hpp,preflight_runner.hpp,policy_evaluator.hpp,quarantine_containment_reconciler.hpp,workflow_routes.hpp(×2),schedule_runner.hpp(×2), andrest_api_v1.hppcan include the lighter header — 13 files, 15 typedefs, corrected from an earlier miscount that read one typedef per file) is the ONE return type all of them now share, carryingcommand_id/sent/denied_quarantined/denied_quarantined_count/containment_unreadable/unknown_plugin/unknown_plugin_count/not_sent. Every non-MCP/REST consumer (dashboard, dex/device, deployment, echo-tree, preflight, bundle orchestrator, workflow, schedule, policy evaluator, quarantine reconciler) updated MECHANICALLY only — structured-binding/.first/.secondaccess became field access, no messaging change — deliberately: widening every caller’s user-facing text to the new discriminators was explicitly out of scope for these two issues’ acceptance criteria, andecho_tree_routes.cpp’secho.configureprobe workaround (compensating for an RBAC denial that happens BEFOREdispatch_confined_armsever runs) is untouched for the same reason — this widening does not reach that denial class at all.execute_instruction’s zero-reach branch now emits astatusenum instead of a single constant —invalid_scope(PR #3939 review fix round: permanent,retry_after_ms: null— the supplied scope expression itself failed to parse, a caller error the fleet’s state cannot change; checked FIRST, since a malformed request is a different class of fact than anything about target reachability),containment_unreadable(retryable,retry_after_ms: 5000— the gate itself is degraded, not a per-target fact),quarantined(permanent,retry_after_ms: null),plugin_not_found(permanent,retry_after_ms: null— matches the agent’s own"plugin not found: <name>"wording; NOTE this reads an agent’s REPORTED inventory, populated once at registration and not refreshed until its management connection next reconnects, so a plugin installed after the agent’s last reconnect can produce this status until the next one even though the plugin is genuinely present), or theno_agents_reachedcatch-all (offline devices, or the residual #3687 approval-required race) — checked in that priority order (> 0, not== every target, so a MIXED failure across targets is never miscategorized as the weakest applicable reason), withagents_quarantined/agents_unknown_plugincounts riding on EVERY zero-reach response regardless of which status matched, so a mixed cause is never invisible.invalid_scopeis, as of the same fix round, ALSO checked by the dashboard exec console (dashboard_routes.cpp) andPOST /api/instructions/{id}/execute(workflow_routes.cpp) — both reach the sameConfinedDispatchOutcome::scope_parse_errorfield via a scope expression the caller supplies;POST /api/commandand the legacy forwarder do NOT need an equivalent branch:/api/command’s Scope arm resolves and 400s on a parse error via its own separate, pre-existingresolve_scope_targets/ladder.parse_errorcheck before ever reaching the shared zero-reach cascade, and the legacy forwarder always dispatches viaDispatchArm::Broadcast, which never parses a scope expression at all. The published output schema’soneOfgrew from 2 branches (success /no_agents_reached) to 6, each still fully self-contained (ownproperties/required/additionalProperties:false— the #2712 discipline this file’s own comment already explains) withretry_after_msas a per-branchconst, not a generic integer, so schema drift from the handler’s actual literal is a validation failure, not a silent divergence.POST /api/commandandforward_legacy_command(REST’s/api/command-family 503 responses:command_routes.cpp;forward_legacy_command:server.cpp) gained the identicalreason="plugin_not_found"branch alongside their pre-existingcontainment_unreadable/quarantinedones — closing the same REST/MCP asymmetry class.claude/routed-concerns.md’s “Dispatch targeting” row warns about, for this ONE new discriminator, using dataArmDispatchResultalready carried into those two handlers directly (no signature widening needed there — REST bypasses the collapsed pair entirely). MCPexecute_bundleis NOT affected — its denial reasons are already covered by #3893’s separate pre-dispatch-dry-run mechanism.quarantine_deviceis NOT subject to the plugin-presence filter (protective-action exemption): the quarantine plugin’s four actions and__guard__.push_rulesare the protective set (yuzu::protective::is_protective_action,common/include/yuzu/protective_actions.hpp), andAgentRegistry::ids_missing_pluginreturns nothing for them, so a reapply reaches a device whose registration-time inventory is stale or genuinely lacks the plugin and the agent answers with its own plugin-not-found response rather than the server withholding it silently.ReapplyDispatchFnkeeps its deliberately narrower pair return; widening it toConfinedDispatchOutcomeremains a separate follow-up.- Targeting is type-checked and an empty target set is refused. A non-string
agent_idsentry orscope, a supplied-but-emptyagent_ids(published asminItems: 1) or emptyscope, are all rejected rather than dropped — because a target set that empties out falls into the “nothing specified” default, which means the entire fleet, and the caller is told it succeeded. The supervised tier was protected by C8’sitems.type=string; the operator tier, which executes with no human in the loop, was not. Every rule the schema cannot express lives in one pure function (check_exec_instruction_shape) called from BOTH the C8 gate (before a ticket is minted or consumed) and the handler (for the ungated tiers, which never reach C8) — adding a rule at only one of those sites reintroduced a ticket burn twice during review. - The schema literals in
kTools[]and the constants inmcp_input_bounds.hppare one contract in two places, and a test reads the served schema back and asserts each literal equals its twin — so bumping one without the other fails a test rather than silently reopening the gap. The charset is deliberately not narrowed toexecute_bundle’s[a-z0-9_]identifier rule: dotted server actions are live shipped content (content/definitions/workflow_management.yamlshipsplugin: server,action: workflow.list), so importing that rule would reject working calls.
- REST twin asymmetry, recorded here so it is findable (same convention as the
-
Adding a tool (checklist): a new tool must land in all of:
kTools[](schema — which must compile under themcp_input_schemaclosed keyword catalogue above, with everyrequiredname declared inproperties; an unsupported keyword or malformed operand is a boot failure through the #2383 validator’s 4th raw sequence, so the C8 gate can never meet a schema it only partially enforces; an approval-gated tool’s schema must NOT setadditionalProperties:falseunless it also declaresapproval_id— otherwise its own recall argument would fail validation and every ticket it mints would be unrecallable; the boot validator enforces this rule, not just this checklist),kToolAnnotation(served hints),kToolSecurityRows[](securable/operation drawn from the seeded RBAC catalogues; read back ascatalogue().security),kWriteToolsRaw[]iff its operation is non-Read (read back ascatalogue().writes), plus its dispatch branch and per-handlerperm_fn. A missing table entry is a boot failure (and a mass[mcp]test failure), by design. Any future per-tool metadata table must be folded intovalidate_tool_security_registration— never a second validator. Separately,ToolDef::output_schema_json(#2972’s completeness gate,test_mcp_server.cpp’stools/listcontract test) — not boot-fatal like the tables above, but CI-fatal: a new tool without one fails that test unless explicitly added to thekOutputSchemaExemptallowlist there (a visible, reviewable line, not a silent gap). Add a schema whenever the tool’s result shape is stable; only join the exemption list for a genuinely not-yet-designed result shape. Anecosystemenum is written"enum":["@@ECOSYSTEM_ENUM@@"](or"@@ECOSYSTEM_ENUM_OR_EMPTY@@"where a present-empty value selects rows with no ecosystem), never a hand-typed array:build_catalogue()expands the token fromkEcosystemFamilies(software_catalog_rules.hpp) and refuses to boot on one it cannot expand;test_mcp_server.cpp’s template provenance test pins the unexpanded text, so add the tool to that file’s closedkEcosystemEnumToolslist (and tokEcosystemWithEmptyToolsfor the_OR_EMPTYform) or three CHECKs fail. Prose that lists the ecosystems uses the bare, unquoted@@ECOSYSTEM_LIST@@(the provenance test pins exactly one such sentence,query_installed_software’s). The refusal covers every served description, input schema and output schema (registry records included), so avoid@@and@ECOSYSTEM_as unrelated literal text anywhere in them. -
Adding a tool, registry path (#2422, partial): a tool may instead live in its own translation unit as ONE
ToolRecord(server/core/src/mcp_tool_registry.hpp), replacing thekTools[]/kToolSecurityRows[]/kWriteToolsRaw[]/kToolAnnotationrows and theif (tool_name == "...")dispatch branch of the checklist above. Only the tools that have been moved use it (discover_routes, inserver/core/src/mcp_tools_discovery.cpp, is the first); every other tool is still on the legacy chain, so the checklist above remains the rule for them and #2422 stays open until they are migrated. The full step list, with the declaration, include, family and ledger steps, isdocs/api-twin-recipe.md“The registry route”.- Where it goes:
server/core/src/mcp_tools_<domain>.cpp(the file name must matchmcp_tools_[a-z0-9_]+\.cpp) holdsconstexpr ToolRecord kToolRecords[] = {...};in an anonymous namespace (same array name in every file, one{"name",line per record, positional initialisers, no preprocessor lines inside the array) and exports a<domain>_tool_records()accessor declared inmcp_tool_registry.hpp. List that accessor inregistered_tool_records()inmcp_server.cppand add the file toserver/core/meson.build. There is no self-registration:yuzu_server_core_libis a static library, which drops an unreferenced registrar. - What a record carries: name (grammar
^[a-z][a-z0-9_]*$), description, input and output schema JSON,securable/operation,ServiceScopeClass,ToolEffect,idempotent,title, and a handler named exactlyvoid handle_<name>(const ToolCallContext& ctx)(the parameter must be calledctx, which is what the retry-hints gate scans for) in the same file. Write-set membership is derived (operation != "Read"), so there is nokWriteToolsRaw[]row to keep in step.requires_approval()inmcp_policy.hppis NOT a record field and is not boot-enforced, so a destructive twin still needs that decision made separately (docs/api-twin-recipe.mdsection 3). - What still runs before the handler: knownness, the C7 read-only guard, C8 service-scope default-deny, tier and approval run in the existing chain first (the registry lookup sits after the C8 block and just before the
list_agentsbranch). The #2405 input-schema check runs only inside the approval-gated branch, so for a tool that is not approval-gated the handler receivesctx.argsunvalidated beyond the central check below (it is guaranteed to be a JSON object, so the handler does not testis_object(), but its keys, types and ranges are unchecked and it must validate them itself). A handler gated byctx.perm_fnmakes its OWNtier_allowsandperm_fncalls, which must stay in step with the record’ssecurable/operation(the record drives the generic tier, approval, C7 and C8 gates; the handler repeats the pair, so the two can drift). A CONFINED read gated byctx.deps->fleet_read_fn(the ADR-0017 admit-then-filter gate, which already covers the tier ladder and RBAC) must call NEITHERtier_allowsNORctx.perm_fnfor that pair: stacking either is a blocking review defect (authz_gates.hpp,require_fleet_readis self-sufficient). Either kind builds errors withctx.a4_errorand audit rows withctx.audit(default arguments do not survive thestd::functionbinding, so pass every argument;-1forretry_after_mswhen there is none, otherwise a/*retry_after_ms=*/comment next to the value, which is whatcheck-mcp-retry-hints.pymatches). The context BORROWS request-frame objects: a handler must finish synchronously and must never capture or storectx. If a handler returns without writing a response, the dispatch emits an A4 internal error (-32603) and afailureaudit row instead of an empty 200. - What a handler cannot reach, and what is fixed: the context carries the request, response, JSON-RPC id, arguments, tier,
perm_fn,audit,a4_error,tool_resultanddeps, aToolDeps(mcp_tool_deps.hpp) of provider closures (thefleet_read_fngate, the domain-verbaudit_fn, the roster, CI-detail, DEX-score and quarantine-list closures thatHardwareRoutesalso receives, and the/cryptoclosures: five forwards to the sameCryptoApiinstanceCryptoRoutesserves,crypto_access_fn, andusername_fn), and nothing else: no store, registry or agent access, and no write-capable callable beyondaudit_fn(audit rows only) and a closure that meets the criterion inmcp_tool_deps.hpp(today onlycrypto_set_dashboard_layout_fn, which writes the caller’s own dashboard layout): a pure forward of the same*Apimethod the REST twin calls, the gate stays central, and the handler writes its “attempted” audit row before calling it and refuses to run without an audit sink.depsisnullptrwhen unwired and a closure may be empty, so a handler checks both and answers an internal error. A lexical test fails anmcp_tools_*.cppthat includes a*_store.hpp,agent_registry.hpp,mcp_server.hpp,auth_db*orpg/header. The seam-closure CI gate (scripts/ci/check-seam-closure.py) additionally checks the TRANSITIVE include closure of those files and of the sharedmcp_tool*.hppheaders against its store-header patterns (a store owner no pattern names passes, and it proves includes, not linkage), so a row type a registry file needs lives in a store-free*_types.hpp, never in a store header. Central arguments check: after knownness, C7, C8, tier and approval and before the context is built, the registry branch refuses a non-objectargumentsvalue (scalar, array ornull) for every registry tool withkInvalidParams(-32602) and an A4 envelope, writing adeniedaudit row (mcp.<tool>) that shares the envelope’s correlation id; a tier-denied caller still gets its tier error first. This provesis_object()only, not the #2405 schema check, soenum/minimum/maximumin a non-approval-gated tool’s advertised schema are still the handler’s own job. There is deliberately no metric for it (yuzu_mcp_tool_args_invalid_totalis bounded to the approval-gated set). Tools are compiled in-tree; there is no runtime or plugin registration, and a fork adding a domain file must editregistered_tool_records()itself. - What validates and serves it:
catalogue()inmcp_server.cppmerges the legacy tables with the registry records and feeds the boot validator,tools/list, C7/C8 and the testonly accessors. Building it rejects a malformed record withstd::logic_errornaming the record: a null handler, a null string field (name, description, schema, securable, operation or title), a name outside the grammar, or aneffect/operationmismatch (ToolEffect::ReadOnlywith a non-Readoperation, or a non-ReadOnly effect withRead). The validator is the samevalidate_tool_security_registration(see the boot-validator bullet above for the message shape and the newkToolAnnotationkey-set offences). Served order is tool-name order (the catalogue sorts the merged set once, inbuild_catalogue), so a tool moving from the legacy tables to the registry does not move intools/list; clients key by name, andtools/listadvertiseslistChanged:false. - A name must not appear in both. A registry name that is also a legacy
kTools[]entry is a boot-time duplicate-served-name offence. A leftovertool_name == "..."branch for a moved tool is dead code thattests/unit/server/test_mcp_dispatch_coverage.cppfails, as it does a legacy tool with no branch; the same test also fails amcp_tools_*.cppfile whose accessor is missing fromregistered_tool_records(). - CI gates that see it:
scripts/ci/check-seam-closure.pychecks the include closure of everymcp_tools_*.cpp(see “What a handler cannot reach” above);scripts/ci/check-api-parity.pyreads thekToolRecords[]slice of everymcp_tools_*.cppas well askTools[], andscripts/ci/check-mcp-retry-hints.pyfinds a new registry tool’svoid handle_<name>(const ToolCallContext& ctx) {block and scans a new or changedmcp_tool*.hppheader as a tripwire. Both fail closed on a registry file they cannot scan: a file namedmcp_tools_*.cppbut outsidemcp_tools_[a-z0-9_]+\.cpp, a missing anchor, an empty slice, a record opener that is not{"<name>",with a grammar name, and (retry hints) for a new tool, a registry tool with nohandle_<name>(const ToolCallContext& ctx)definition (the retry-hints check runs over tools new in the compared range).
- Where it goes:
-
Reserved on-behalf-of headers rejected (ADR-1005): MCP registers on the same httplib instance as REST, so the pre-routing chokepoint rejects any
/mcp/v1/request carrying a reserved on-behalf-of header with403before the JSON-RPC layer runs — seedocs/auth-architecture.md(“On-behalf-of assertions rejected”) for the reserved-name list. -
Transport request-body bound (#2437; the encoding half generalized to every route by #2407 D4): every path under the
/mcp/prefix (so/mcp/v1/,/mcp/v1, and a future/mcp/v2/by default — the cap matches the scope the auth chokepoint admits) admits only request bodies it can size in advance, enforced in the pre-routing chokepoint — which httplib runs before it reads the body, so a rejection costs a header parse rather than megabytes of buffering. Three possible rejections, all counted byyuzu_mcp_body_too_large_total{reason}and logged (throttled, sanitized) since there is no principal to audit pre-auth. The third is ALSO counted by the generalyuzu_body_cap_rejected_total{path_class="mcp",reason="unsupported_encoding"}(seedocs/user-manual/metrics.md) — the general counter is the fleet-wide view, and the MCP-specific series is kept so the existing #2437 alert does not silently lose an arm when a compressed body is refused:413,reason=over_cap— a declaredContent-Lengthabove 4 MiB.411,reason=unmeasurable— a request carrying framing this server is not the sole interpreter of: anyTransfer-Encoding(any value, any method), or a POST/PUT/PATCH with noContent-Length. The rule is deliberately broader than “reject chunked”, because matching httplib’s parsing by hand is what failed the first time: it testedTransfer-Encodingwith a case-sensitive substring while httplib compares case-insensitively, soTransfer-Encoding: Chunkedwas admitted as a measurable body and then read as chunked under the 100 MB default — one capital letter defeating the cap.415— anyContent-Encodingother thanidentity. As of this change this is no longer folded into the411above as an MCP-specific rule: it moved to the general pre-routing gate (body_cap_policy.hpp/server.cpp) and applies UNCONDITIONALLY to every route class, not only/mcp/, independent of a class’srequires_measurablebit. Same underlying reason as before: this build compilesCPPHTTPLIB_BROTLI_SUPPORT, httplib decompresses transparently and bounds only the decompressed size against its global limit, soContent-Lengthon aContent-Encoding: brbody measures the compressed bytes and is no bound at all on what gets buffered. JSON-RPC clients send an identity-encoded body with aContent-Lengthand are unaffected; a proxy or streaming stack that re-frames requests will be refused, which is a deliberate trade. Ordering, settled: the cap is one lambda invoked from two sites — inside the health-probe branch, so the four probe paths are capped while staying exempt from the on-behalf-of guard and the rate limiter, and after both of those for every other route.- This is a per-path bound, deliberately not the server-global
set_payload_max_length, which would also cap the multipart certificate upload and content distribution on the same httplib instance. Those keep the 100 MB default. - 4 MiB is a deliberate clamp, not a ceiling derived from every tool. It sits above a maximal
execute_instruction— ~3.27 MiB decoded, about 18% headroom, down from 62% before theparamsvalue cap was aligned withexecute_bundle’s 64 KiB. Read that 18% before widening anyexecute_instructionfield bound again: the remaining room is real but no longer generous, and the sizingstatic_assertinmcp_input_bounds.hppfails the build rather than letting a maximal legitimate call start 413-ing. It stays deliberately below whatexecute_bundle’s own validator accepts:validate_bundle_stepspermits 32 steps × 32 params × 64 KiB ≈ 64 MiB, and one saturated step is already ~2 MiB, so a 2-step saturated bundle (~4.02 MiB) is refused before its validator runs. That product bound was a per-step memory guard for the agent fan-out, never an advertised aggregate — but the asymmetry is real, is not shared by the REST twinPOST /api/v1/bundles, and is pinned by a test. For multi-MiB bundle params, use the REST twin or raise the cap deliberately. - Encoding caveat: the per-field
execute_instructionargument caps bound decoded bytes while this bounds the raw body, and legal\uXXXXescaping can inflate a payload several-fold. The tolerance is 1.22× raw-to-decoded (it was 2.63× before theparamswidening — quantified here rather than described, because the adjective outlived the number it was calibrated to). Plain ASCII and base64 (1.00×), Windows paths at ~8% backslashes (1.08×), JSON-in-a-string at ~12% quotes (1.12×) and text with 3% newlines (1.03×) all fit. An all-quote payload (2×) or raw control bytes (6×) do not, and a simultaneously-saturated maximal call built from them is refused with a413telling the caller to reduce the arguments. So: the headroom absorbs realistic escaping of a maximalexecute_instruction; it is not a proof against a maximally-escaped one. - What “maximal” bounds: the ~3.27 MiB figure covers the declared fields.
execute_instruction’s root schema does not setadditionalProperties: falseand undeclared properties are deliberately tolerated, so a schema-valid call can legitimately exceed it, up to the 4 MiB transport cap. The figure answers “does a legitimate maximal call fit”, not “is 3.27 MiB the largest accepted body”. - JSON nesting is bounded at 32 levels, rejected as a
-32700parse error before the document is built.nlohmann’sdump()is recursive (its parser and destructor are not), so a ~0.95 MiB body of nested arrays — a quarter of the cap, comfortably admitted by it — segfaults the process, and callersdump()caller-supplied values. Guarding an individualdump()would miss every other recursive traversal, so the tree is never constructed. The deepest legitimate MCP request isexecute_bundleat about seven levels. - This bound covered only the live
/mcp/request body, EXTENDED (json-dump-depth-guard fix).reevaluate_result_setstill parsed-then-dumped a row’ssource_payload, read from a table SHARED with REST, with no depth bound of its own: a row poisoned via REST’s separate (now also fixed) write paths, or any future write path, could be re-dumped here and crash the process on a read.reevaluate_result_setnow checks the storedsource_payloadagainst the samekMcpMaxJsonDepth(32) before parsing it, refusing with-32602and never dispatching. See rest-api.md’s “Result Sets” section for the parallel fix on REST’s four creation routes and its own{id}/re-eval, plusResultSetStore::mark_failed(a write path with no HTTP request/response of its own to answer with an error; today it has no production caller at all, only unit tests), which heals a poisonedpendingrow rather than re-dumping it. #4493 closed the remaining gap:mark_failed’s heal only ever matchedstatus = 'pending', so amaterialized/failedrow poisoned by any write path had no way back to a safe state either.reevaluate_result_set(and REST’s{id}/re-evaltwin) now also call the new, status-agnosticResultSetStore::heal_poisoned_payloadon this exact rejection branch - the triggering re-eval attempt still fails, but the row’ssource_payloadis discarded in place (never itsstatusor members) so a later read never re-detects the same poison, PROVIDED the heal write itself commits - a rare heal-write failure leaves the row poisoned and a later attempt hits the same guard again. - Deployment caveat: a rejected body is never read off the socket. httplib stamps
Connection: closeon any status ≥ 400 so a conforming client closes, but if a reverse proxy is ever placed in front of Yuzu, an unreadContent-Lengthbody is a textbook request-smuggling primitive between proxy and origin. The shipped rigs expose the server directly; front it with care.
-
Audit: Every MCP tool call logged with
action: "mcp.<tool_name>"andmcp_toolfield onAuditEvent. -
Response-collection scope (
query_responses) — #1634, FIXED.query_responsesis now gated byfleet_read_fn_/require_fleet_read(ADR-0017 admit-then-filter), not the old flat globalResponse:Readcheck the inert per-row filter sat behind — a management-group-confined operator is admitted and sees only their in-scope agents’ rows, real cross-operator isolation. The visible-agent set is resolved (distinct_agent_ids/distinct_agent_ids_by_execution) and pushed intoResponseStore::query/query_by_executionas SQLagent_id = ANY(...)beforeLIMIT/OFFSET(ADR-0017 INV-3), not as a post-fetch filter — a capped/paginated read that filtered afterLIMITcould otherwise hand a confined caller a short or empty page despite visible rows existing past the hidden ones the cap already truncated (a real defect an adversarial review found in the first #1634 migration pass, fixed same-branch). A corrupt/load-failedrbac.dbstill fails closed (zero rows) via the same gate. When rows ARE dropped,query_responsesemits a second audit rowresult=denied(detailcarries the distinct dropped-agent count) in addition to theresult=successrow — a SIEM rule must treat the two as a pair for one call (informational access-boundary evidence for CC6.1, not a failed call). RBAC-off → unrestricted (legacy-open) for management-group confinement, for a human principal; an engine principal is never legacy-open, so with RBAC off it is refused or sees no rows (#6204); rows written by Forensics-tier plugins are the exception and stay behindForensics:Read(see the Forensics-tier bullet below). The result object carriesaudit_persisted:falseif any of that call’s audit rows could not persist, andresult_truncated_by_cap:trueif the caller’s own scoped query hit the row cap (now signals THIS caller’s page was truncated, not a raw-then-filtered cap hit that could fire entirely inside another operator’s rows). (The same real gate now also coversaggregate_responses, REST/executions/{id}/visualization+ the legacy/api/responses/*family,GET /api/v1/executions/{id}, REST/api/v1/events, dashboard/sse/executions/{id}, and MCPget_execution_status/list_executions;query_responses/aggregate_responses/get_execution_status/list_executionsare reclassifiedServiceScopeClass::confined.list_executionshas a weaker mechanism — execution rows carry no singleagent_id, so a confined caller is restricted to their own dispatches rather than a real per-row visible-agent check. Confined since: RESTGET /api/v1/execution-statistics/agentsand its MCP twinget_execution_statistics_by_agentgate on the same chokepoint (#3526;docs/auth-architecture.md’s “Execution-statistics per-agent rollup”, which also states the RBAC-on limit of that confinement); the workflow executions LIST fragment half of #3526 andsummarize_working_setkind=executionare migrated too (docs/auth-architecture.md’s “Sixth migration”); the.../definitions/get_execution_statistics_by_definitionpair and the fleet summary carry no agent identity and keep the plain gate. Still open, tracked separately:/fragments/resultshas no audit trail#3528. Seedocs/auth-architecture.md’s “Third migration (#1634)” for the full account.) (#3344 note, FIXED:query_responses’retry_after_mspoll hint used to readexecution_trackerdirectly, gated only byResponse:Readand resolved before the scope filter — a confined caller with zero visible agents on an out-of-scopeexecution_idcould learn “non-terminal” via the hint’s presence alone even though every response row was filtered out. The hint is now suppressed (nullopt, not false — same convention as the instruction_id-only unknowable case) whenever the caller’s resolved in-scope agent set for that execution is empty.) -
query_responsesstrictstatus/limit(#4644).statusandlimitare read withparam_int_strict: a non-integer (float, string, boolean, or an explicitnull, which used to mean the default), astatusoutside-1..2147483647, and an unsigned value aboveINT64_MAX(whichget<int64_t>()wraps to-1, the “any” sentinel) are-32602instead of a silently different filter. The unsigned guard lives inparam_int_strictitself, so it applies to every other caller too (no fixed count is stated here;grep param_int_strict server/core/src/mcp_server.cppis the inventory).limitis still clamped to[1,1000]. Counter:yuzu_server_response_param_rejected_total{surface="mcp"}. -
query_responses/aggregate_responses- #2146 A2-R2 (REST v1 twin programme).query_responsesrows now carry the response’s ownid,instruction_id,error_detail,plugin, andreceived_at_ms(server ingest wall-clock in epoch ms; 0 on legacy pre-v3 rows) alongside the pre-existingagent_id/execution_id/status/output/timestamp- via a shared builder,response_query_row_json(response_query_model.hpp), that the new REST v1 twin below also calls, so the two surfaces cannot drift on row shape.aggregate_responsesgained anop_columninput (timestamp/status/id, defaultid) - previously accepted no such parameter and silently operated onResponseStore::aggregate()’s own default operand column regardless of what a caller wanted summed/averaged/min/max’d; an invalid value is nowkInvalidParams, validated against the same allow-listResponseStore::allowed_op_column()the REST reference handler (response_routes.cpp) already used. Neither change touches the tools’ RBAC gate, confinement, or audit posture (both stillResponse:Readviafleet_read_fn_, resolve-then-scope,audit_persisted:falseon a dropped audit row) - this is a field/parameter widening only. New REST v1 twins for the command/instruction-ID-keyed family this PR also ships (docs/user-manual/rest-api.md’s “Command/Instruction Responses” section):GET /api/v1/responses/{id}(twin ofquery_responses’instruction_idpath),GET /api/v1/responses/{id}/aggregate(twin ofaggregate_responses),GET /api/v1/responses/{id}/export(no MCP twin yet). All three are DISTINCT from the pre-existing execution-ID-keyedGET /api/v1/executions/{id}/responses(execution_response_row_json,execution_model.hpp) - that builder stays narrower and unchanged by this PR. -
Forensics tier on the response tools (#4990). Rows written by Forensics-tier plugins (
app_usage,browser_inventory,execution_artifacts,privacy_permissions,remote_access_audit; the set is derived from the capability catalogue, never hand-kept) are served only to a caller who also holdsForensics:Read. This narrows theResponse:Readgate ofquery_responses,aggregate_responses,get_bundle_resultand the agent count inpreview_management_group_agent_count, and it holds with RBAC enforcement off, whereForensics:Readis administrator-only (authz_topology_floor.hpp). For any other caller the rows are absent with no marker, and counts, totals andresult_truncated_by_capare computed over the rows that caller may read, so an empty or short result does not prove that no data exists. Only a globalForensics:Readadmits these rows (an MCP-tier or service-scoped token included); there is no management-group scoped read tier on these tools and a hand-authored scoped grant reads nothing from them (get_agent_app_usage, a separate data set, keeps its own per-device scoped gate). When the server cannot determine the caller’s Forensics authority (the authorization store is unavailable; MCP tokens, API tokens, sessions and engine principals alike, while a service-scoped token is default-denied with a definitive refusal) the call fails with a retryablekInternalErrorcarryingretry_after_msand is auditedfailure; it does not return a partial result. If a whole-store outage fails the tool’s earlierResponse:Readgate first, that gate’s own answer is returned instead. A read that serves Forensics-tier rows also writesresponse.forensics.read(surface=mcp, orsurface=bundleforget_bundle_result); if that row cannot persist the data is still served and the result carriesaudit_persisted:false. A call made with an API token, an MCP token or an engine principal that does not hold a globalForensics:Readalso leaves oneauth.permission_requireddenied row per call. The server’s own readers apply the same tier: a policy whose check runs a Forensics-tier plugin is scoredunknownwith an emptycheck_result(visible throughget_policy_agent_statuses),create_result_set_from_instruction_resulton such a command creates a set with no members for any creator, andget_execution_status,list_executionsandget_execution_childrenshow the failure text of a Forensics-tier command only as a short machine token (at most 96 bytes) or the fixed valueforensics_error. Dispatch authority and read authority are separate:execute_instructionof a Forensics-classified pair admits an administrator or an approved ticket, but its rows are returned only to a caller who also holdsForensics:Read; without it the result is empty and looks like a run still in flight, and the tool description says so. Forget_bundle_resultretry_after_msstays on an incomplete bundle, so a worker must bound its polling with its own deadline (each poll refreshes the manifest window). Audit posture differs by tool, on purpose: the response tools follow the per-surface convention inrest_audit.hpp(MCP sets and proceeds,audit_persisted:false), whileget_agent_app_usageis fail-closed because its whole payload is behavioral data (see its entry below). Per-tool wording is indocs/user-manual/mcp.md.
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 withkApprovalRequired(-32006). Itserror.dataextends 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_msis honest here, notnull(#3344,kMcpApprovalPollRetryMsinmcp_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 anApprovalManagerapproval (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 / RESTPOST /api/approvals/{id}/approveon 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 returnedapproval_idas 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_idexcluded 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’sdefinition_idand arguments (#2442), and (f) the recalling principal is the ticket’s own submitter (#2442) — a Viewer who merely read the ticket id offGET /api/approvalscannot 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 samekApprovalRequiredenvelope (keep pollingstatus_url); a rejected/expired/mismatched/consumed ticket returnskPermissionDenied(-32003) (an approved ticket past its 7-day lifetime gets the distinct messageapproval expired (past its 7-day lifetime)).approve_request/reject_requestare a separatekPermissionDenied(-32003) (unless read-only mode, a service-scoped token or the tier check refuses first with-32004/-32003) whose message is the stable prefixapprover_assurance_required:<reason>(<reason>isnon_interactive_session,engine_principalornon_human_principal; #6061; the MFA reasonsmfa_not_enrolled,mfa_proof_missing,saml_mfa_unsupportedandaccount_unavailablebelong to the REST route only), returned after theApproval:Approvecheck withretry_after_msnull and amcp.<tool>deniedaudit row whose detail isapproval_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 answersapproval_id does not match this tool and arguments, ticket untouched), andconfirm_engine_rotation’s recall additionally runs a pre-consume precondition (#2443) that rechecks live rotation state before consuming the ticket; a precondition failure also returnskPermissionDenied(-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’sexecute_instruction/revoke_certificate/issue_code_signing_cert/execute_bundle, plusdelete_tag(operator + supervised) andquarantine_device(supervised). Degraded path: if the server has noApprovalManager(a stripped deploy / test harness), it cannot mint a pollable ticket, so it falls back to akTierDenied(-32004) with noapproval_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):
| Constant | Value | Covers |
|---|---|---|
kMcpStoreFaultRetryMs | 5000 | Generic transient store/subsystem fault on an error-shaped response (REST 503 parity) |
kMcpStoreFaultShortRetryMs | 2000 | Plugin-config / API-token rotation family (REST Retry-After: 2 parity) |
kMcpProviderWarmupRetryMs | 5000 | A DEX/app-perf provider still initializing at server start |
kMcpResultPollRetryMs | 2000 | Success-shaped result-not-ready poll — get_execution_status, query_responses, get_bundle_result |
kMcpApprovalPollRetryMs | 30000 | Approval-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)
-
Read-only tools (the authoritative, complete table is
docs/user-manual/mcp.md— this list is illustrative, not a count):list_agents,get_agent_details,query_audit_log,list_definitions,get_definition,export_definition,list_product_packs,get_product_pack,query_responses,aggregate_responses,query_inventory,list_inventory_tables,get_agent_inventory,query_installed_software,query_software_catalogue,get_software_versions,get_crypto_libraries,list_crypto_library_devices,export_crypto_cbom,list_crypto_widgets,get_crypto_dashboard_layout,get_crypto_ca_stats,get_tags,search_agents_by_tag,list_policies,get_compliance_summary,get_fleet_compliance,list_management_groups,get_execution_status,get_execution_children,list_executions,list_schedules,validate_scope,preview_scope_targets,list_pending_approvals,get_pending_approval_count,get_guardian_schemas,get_guardian_status,list_guardian_rules,list_guardian_events,get_guardian_rule_status,get_guardian_device_guards,list_dex_signals,get_dex_signal_scope,get_dex_signal_detail,get_dex_device_score,get_dex_device_app_perf,get_dex_app,list_dex_apps,get_dex_catalogue,get_dex_catalogue_group,get_dex_device_history,get_dex_observation,get_dex_health,get_dex_trends,get_dex_overview, the DEX-perf + network tools,list_issued_certs, and the Echo read twins (list_echo_process_tree_devices,list_echo_capture_sources_devices,list_echo_retention_paused, #4027)query_software_catalogue/get_software_versions(M.9) are the fleet software title catalogue and per-version drill, shaped by the same pure model as their REST twins (software_catalogue_model.hpp). They areServiceScopeClass::denied(the rollup is fleet-wide and cannot be scoped to a service tag), need a globalInventory:Readfor the fleet view, admit a confined caller only for an in-scopeagent_id, take strict input (out-of-range values arekInvalidParams, not clamped), and audit asmcp.<tool>on every call that reaches the store. A degraded read, and the unfilteredget_software_versionswhile the catalogue is building, are retryablekInternalErrors (never an empty success; the building state is normally the window before the first hourly refresh but is permanent for a fleet above the ~6 000-endpoint refresh ceiling (#5545), a database role withoutTEMPORARY, or a deletedcatalog_rollup_metaid = 1row); the out-of-scope per-device drill carriesdevices_omitted: 1.get_crypto_libraries/list_crypto_library_devices(/cryptoslice 1) are the fleet cryptographic-library inventory and its per-family device drill, shaped by the same pure model as their REST twins (crypto_library_model.hpp) and read through the sameCryptoApiseam as the/cryptopage. Gated solely onfleet_read_fn_(Inventory:Read);ServiceScopeClass::confined, because the composed management-group and service-scopeVisibleSetis pushed into the SQL, so a management-group-confined caller gets a real filtered answer; a service-scoped token is refused (403) while RBAC enforcement is off (the default) and, with it on, is admitted within the ITServiceOwner ceiling and sees only the devices tagged with its IT service. They report package-name presence and versions only and never judge a library; an empty result means “no matching package rows in the caller’s visible inventory”, not that the library is absent. Strict input (REST clamps, MCP refuses): a non-objectargumentsvalue is refused, andecosystemis the software catalogue twins’ closed vocabulary plus""(the exact empty ecosystem);ecosystem/versionbounds equal the ingest per-field bound (1,024); the summary is byte-budgeted (names cut to 256 bytes, 256 KiB) with the cut always flagged (the 2,000-row store ceiling flags every familyversions_truncated; a bucket with more than 8 names sayspackage_names_truncated); a degraded read is a retryablekInternalError, never an empty success; audit asmcp.<tool>, set-and-proceed (audit_persisted: false).export_crypto_cbom(/cryptoCBOM export) is the CycloneDX 1.7 export of the same summaryget_crypto_librariesserves, shaped bycrypto_cbom.hpp(also the REST twin’s shaper) and gated, scoped and bounded identically (fleet_read_fn_,ServiceScopeClass::confined, the sharedCryptoApiseam). It is STRICT beyond its siblings: a non-objectargumentsand any key other thanfamily/ecosystemarekInvalidParams. Components arelibraryonly, with nocryptoProperties/strength/classification field (ADR-1005 D2); a cut is flagged inyuzu:crypto:*properties, never silent. Audited asmcp.export_crypto_cbom.- The
/cryptodashboard widget twins (ADR-0070 D13) share the pure cataloguecrypto_widget_catalogue.hppand the sameCryptoApiinstance as their REST twins under/api/v1/crypto/.list_crypto_widgets,get_crypto_dashboard_layoutandset_crypto_dashboard_layoutare owner-scoped (keyed by the session user, the result-set precedent):InfrastructureRead/Writeis their tier class only, there is no RBAC gate in the handler, and a service-scoped token isdenied(the REST layout routes refuse it the same way and apply the same tier check).list_crypto_widgets’available/availabilityflags come from ONE non-auditing access callback shared with the REST catalogue and the dashboard fragment, per gate class: library widgets probe the fleet-read gate (so a management-group-confined operator with a scopedInventory:Readgrant sees them as available), Yuzu-issued counts probe the same gate and reportconfinedfor a confined caller, anddrill_availableprobesSecurity:Read; probes are deduplicated per (gate class, securable, operation).set_crypto_dashboard_layout(Destructive, idempotent, a write tool:operationWrite, so the write set derives it) overwrites the caller’s previous layout, takes an optionalexpected_version(a stale one is a non-retryablekInvalidParams), keeps ids another server version stored, writes anattemptedaudit row first and changes nothing if it cannot persist, and reports a lost-connection commit as a retryablesave outcome unknownerror.get_crypto_ca_statsreads Yuzu’s OWN internal-CA inventory (never the fleet’s certificates, ADR-0070 D6) behindInventory:Readand is withheld from a confined caller (kPermissionDenied, ADR-0070 R2); every count is computed againstas_of(the database clock, R4) over a closed, versioned window set and carriesrule_id,criterion,rule_set_versionandas_of; it is never shared or cached (every call reads at request time, bounded by the/cryptoread admission limit), while the unfiltered, unconfined library summary behind the library widgets is shared per process for up to 60 s; a degraded read is a retryablekInternalErrorand acrypto.ca.stats.viewfailurerow, never zeros. The certificates behind a count arelist_issued_certswith filter arguments (Security:Read, withheld from a confined caller and from one holding noInventory:Readgrant, strict input, keyset paging bynext_cursorwithoffsetrefused, a pinnedas_ofbounded on the database clock, auditedcrypto.ca.certificates.view); without them that tool lists the inventory with offset paging, under the same refusal (confined or no grant) and the same audit (targetinventory).set_crypto_dashboard_layoutwithoutexpected_versionis a first save only (a stored layout is a non-retryable conflict). - Where the seven
/cryptotools live (#6558 PR 2): all seven (get_crypto_libraries,list_crypto_library_devices,export_crypto_cbom,list_crypto_widgets,get_crypto_dashboard_layout,set_crypto_dashboard_layout,get_crypto_ca_stats) are registry records inserver/core/src/mcp_tools_crypto.cpp, the pilot carve of themcp_server.cppmulti-family TU. Their gates, scope classes and audit verbs are unchanged. An unwired provider, audit sink or caller identity answers the A4 internal-error envelope with anmcp.<tool>|failurerow where an audit sink exists (previously a bare-32603and no row); the four tools that write acrypto.*.viewrow (get_crypto_libraries,list_crypto_library_devices,export_crypto_cbom,get_crypto_ca_stats) refuse when the audit sink is unwired instead of succeeding row-less; and a rejected argument leaves anmcp.<tool>|deniedrow. A healthy server wires every provider, so only the last is visible in normal operation. A handler reachesCryptoApionly throughToolDepsforwarding closures (crypto_library_summary_fn,crypto_library_devices_fn,crypto_ca_stats_fn,crypto_dashboard_layout_fn,crypto_set_dashboard_layout_fn), built inserver.cppfrom the samecrypto_api_the REST routes use, pluscrypto_access_fnandusername_fn(the layout is keyed on the caller’s own username, re-resolved from the request because the registry path carries no session). One behaviour difference: a non-objectargumentsis now refused by the dispatch chain’s central check (generic-32602and anmcp.<tool>denial row) before the handler runs, so for the fourInventory:Readtools (the three confined reads andget_crypto_ca_stats) it precedes theInventory:Readgate; the error code is the same, and the message text is unchanged for the four tools that already used it (the three no-argument tools previously said “<tool>takes no arguments”). Two internal differences are not visible in a response: a domain audit row goes through the sharedtry_persist_auditkernel, so a throwing audit sink is caught (and the layout write refuses to run without a sink) instead of propagating, and the caller’s username is re-resolved from the request.list_issued_certs(the CA-certificate list) still readsCryptoApiand stays inmcp_server.cpp. query_installed_softwareis the typed daily-sync software-inventory read (ADR-0016), gated solely onrequire_fleet_read(#3290 Phase 2) — a real, effective management-group + service-scope filter, not a placeholder — distinct from the genericquery_inventory/get_agent_inventory(generic blob store,Infrastructure:Read).- Generic
query_inventoryandget_agent_inventoryincluderesult_truncated_by_cap;truemeans the row limit or 8 MiB aggregate payload budget was reached and the returned content is incomplete. - DEX read tools (
list_dex_signals/get_dex_signal_scope/get_dex_signal_detail) are the MCP parity for the/api/v1/dex/*REST surface — sameGuaranteedStateStoreaggregations, gated onGuaranteedState:Read, with awindowof24h/7d/30d/alland an optionalosfilter (all/windows/linux/macos) onlist_dex_signals+get_dex_signal_detailthat scopes the rollup / drill-down lists to one OS (the drill-down echoes the appliedosand keepsby_oscross-OS), matching the dashboard and REST twins. The audit boundary mirrors REST: the rollup and per-OS scope are fleet aggregates (only the genericmcp.<tool>tool-call audit), whileget_dex_signal_detailreturns a most-affected devices list (behavioral) and additionally emits adex.signal.viewaudit (target_type=ObsType) so one SIEM filter catches the dashboard, REST and MCP behavioral-access surfaces alike.obs_typeis validated against[A-Za-z0-9._-]{1,64}(malformed →kInvalidParams). When thedex.signal.viewresult=successaudit row cannot persist,get_dex_signal_detailset-and-proceeds and carriesaudit_persisted:falsein the result (absent on success — consumers key on absence), matching thequery_responses/revoke_certificateconvention; JSON-RPC has no header channel, so this is the MCP equivalent of the RESTSec-Audit-Failedheader (#1647). The RESTdex.signal.viewsibling instead fails closed — different surface, different posture.get_dex_signal_detail(this route’sdevices[]names every affected agent_id fleet-wide, with no single agent_id to confine a service-scoped token’s own service-tag scope against) is denied outright to a service-scoped API token, same asget_dex_app/get_dex_overviewbelow. - Per-device DEX tools (
get_dex_device_score/get_dex_device_app_perf, #4035, api-parity #2146 Batch A) close the two MCP-only gaps their REST twins (GET /api/v1/dex/devices/{id}andGET /api/v1/dex/devices/{id}/app-perf) already had:get_dex_device_app_perfisdocs/api-twin-recipe.md§4’s own named “no MCP twin yet” worked example. Both are per-device behavioral PII, so both gate on the ancestor-aware SCOPEDGuaranteedState:Readgate (the samescoped_perm_fnshapequery_software_licenses/get_dex_group_app_perfuse, NOT the globalperm_fn) — a scope gate left unwired fails closed (kInternalError, “scope gate not configured”), never a legacy-open global read. Both call the SAME shared builder function their REST twin calls (dex_read_model.hpp’sbuild_dex_device_score_model/dex_device_score_jsonanddex_device_app_perf_json, respectively) so the REST and MCP response shapes cannot drift apart (the recipe’s Rule 1). Both emit the SAME domain-verb audit their REST twin does (dex.device.view/dex.device.app_perf.view) plus the genericmcp.<tool>tool-call audit (the dual-audit conventionget_dex_signal_detailabove already established) — set-and-proceed,audit_persisted:falsesurfaced in the body on a dropped evidence row (never a failed call; REST fails closed 503 instead, a different surface’s posture).get_dex_device_score’sscoreis-1when unavailable (no store);get_dex_device_app_perf’s optionalappnarrows to one app name (omit for every app). get_agent_app_usage(wave 7 PR7.2) is the MCP twin ofGET /api/v1/forensics/agents/{id}/app-usage: one agent’s per-executable app-usage projection (exe_key,first_seen/last_seen— within Echo’s retained usage window, 31 days default, operator-tunable, NOT a full run history — and a trailing-30-dayrun_count/total_secondswindow, pluscollected_at). Gated on the same ancestor-aware SCOPEDForensics:Readgate the REST route takes (thescoped_perm_fnshapequery_software_licenses/get_dex_device_app_perfuse, NOT the globalperm_fn) — a scope gate left unwired fails closed (kInternalError, “scope gate not configured”), and the same #1717 fail-closed guard (a corrupt/load-failed rbac.db refuses rather than falling through to a legacy-open read). RBAC-off,Forensics:Readis floored to Administrator-only (authz_topology_floor.hpp) — an ordinary cookie session is deniedadmin role required, an admin session is admitted. Dual audit, fail-closed posture — deliberately stronger than the set-and-proceed DEX twins above: emits the SAME domain-verb audit the REST twin does (app_usage.agent.view,target_type=Agent) plus the genericmcp.get_agent_app_usagetool-call audit (the dual-audit conventionget_dex_signal_detail/the per-device DEX tools above already established) — but unlike those set-and-proceed twins, BOTH run BEFORE the payload is built and a dropped row on EITHER WITHHOLDS the data entirely with an internal error (never served withaudit_persisted:false) — this data is behavioural PII (what ran, when, how often), matching theengine_principal.credential.revealprecedent rather than the set-and-proceed majority. A store/pool/query degrade (get_agent_last_used→nullopt) is likewise an error, never success with an empty list.get_dex_catalogue(ADR-0031 WS-A4 PR-1) is the Catalogue View 1 family-cards resource — the first REST/MCP twin for the per-family health score / online-denominator coveragerender_dex_catalogue_fragmenthas always computed with no public resource behind it. Optionalos/window; calls the SAME shared builder (build_dex_catalogue_model,dex_read_model.hpp) its REST twinGET /api/v1/dex/cataloguecalls (Rule 1). Fleet aggregate — no per-agent identity anywhere in the response, so NOT audited on success, matchingget_dex_catalogue_group’s posture below. WS-A4 PR-1 fix round: a degraded fleet signal-summary read (store closed / pool-acquire timeout / query error) returns a retryablekInternalErrorinstead of a result — never a fabricated healthy, zero-event catalogue (mirrorsget_dex_device_score’s own #4855 degrade posture;GuaranteedStateStore::dex_signal_summary_checkedis the new type-distinguishable read this uses).- The 8 genuinely-new DEX twins (
get_dex_app,list_dex_apps,get_dex_catalogue_group,get_dex_device_history,get_dex_observation,get_dex_health,get_dex_trends,get_dex_overview; #4035, api-parity #2146 Batch A) each call the SAME shared pure builder (dex_read_model.hpp) their REST twin calls, so the two response shapes cannot drift (Rule 1).list_dex_apps/get_dex_catalogue_group/get_dex_health/get_dex_trendsare fleet aggregates with no per-agent identity and are NOT audited, matching their dashboard fragments’ own posture.get_dex_device_history(per-device SCOPED, samescoped_perm_fnshape asget_dex_device_score) emitsdex.device.view— the SAME verbget_dex_device_scoreuses for a DIFFERENT capability on the same device (a deliberate reuse: the dashboard fragment already audits this exact signal-history capability under this exact verb).get_dex_observation(per-device SCOPED) emitsdex.observation.view; a foreign or guessedevent_idreturnskInvalidParams— indistinguishable from a genuinely-absent one (no oracle), matching the dashboard fragment’s own contract.get_dex_appandget_dex_overvieweach return a most-affected/top-devices list (individual-identifying), alongside the aggregate crash/health fields — but ALL of it, list included, is a fleet-wide aggregate with no per-caller confinement (WS-A4 PR-1 Gate 7 fix round: an earlier per-callervisible-set resolver was retired — it was dormant for a management-group-confined-only caller, who is denied outright below by the bare permission gate before it would ever run, and it wrongly narrowed a JIT-elevated administrator instead of giving them the unfiltered view elevation earns; every admitted caller now sees the same unfiltered list).try_persist_auditunderdex.app.view/dex.overview.viewrespectively fires on every admitted call, deliberately carrying MORE rigor than their own dashboard fragments (which audit only the service-scoped-token DENIAL on these two, not an ordinary success — a tracked ADR-0017 INV-3 gap of their own, deferred to the dashboard-rewire follow-up PR). Both tools deny a service-scoped API token outright — there is no single agent_id to confine the token’s own service-tag scope against — classifiedconfinedin the C8 tier table (a REAL per-request-shaped denial, not a label with no mechanism behind it: the tool’s owndeny_fleet_wide_service_scopedcall is that mechanism).get_dex_overview’s outputSchema additionally carries the ADR-0031 WS-A4 PR-1 additive fieldsconnected_platforms,busiest_family(nullwhen nothing fired in-window),busiest_family_events— the same “N platform(s)” coverage caption and busiest-family teaser its dashboard fragment already renders.
-
Write/execute tools (the authoritative table is
docs/user-manual/mcp.md):execute_instruction— dispatches plugin commands to agents (auto-approved foroperatortier for most pairs, but denied for the subset a compiled per-pair gate marks approval-required regardless of MCP tier, #1398 — see the tier-table note above; that denial — and every other dispatch-chokepoint denial reason — is now a discriminated JSON-RPC error naming the reason, not theno_agents_reached-shaped result an offline/unreachable agent also produces: CLOSED by #3687, see the targeting-bullet cluster above;supervisedtier is approval-gated via the ticket-then-recall flow; if neitherscopenoragent_idsis provided, targets all agents; this is a raw(plugin, action)dispatch with no resolvedInstructionDefinition, so it is NOT covered by per-device concurrency enforcement — ADR-1007 — a deliberate gap, not an oversight: onlyPOST /api/instructions/:id/executeresolves aconcurrency_modeto gate on — scheduled fires are NOT gated as of WS-3 3.3: they now dispatch through the durable command outbox on the plain confined path, deliberately without the ADR-1007 per-device claim (restoring it for the outbox path is tracked as a follow-up)) —execute_bundle(below),revoke_certificate,issue_code_signing_cert, and the five Issue-13.5 write toolsset_tag,delete_tag,approve_request,reject_request,quarantine_device(#289).- The five write tools (#289 / Issue 13.5) are the last-mile agentic write surface:
set_tag(Tag:Write, fires the agent tag-push),delete_tag(Tag:Delete, approval-gated on operator + supervised),approve_request/reject_request(Approval:Approve, supervised tier; always refused with a typed error (unless read-only mode, a service-scoped token or the tier check refuses first with-32004/-32003), since approval needs an interactive session with an enrolled MFA factor and a fresh proof (#6079) and the MCP transport runs no MFA step-up (ADR-1005 exception ledger entry 2026-10-18, #6061) — the tools stay registered, the tier/permission mapping isApproval:Approve, and a person approves in the dashboard or over REST; reviewer ≠ submitter is store-enforced there), andquarantine_device(Security:Execute, supervised, approval-gated — records the quarantine and dispatches the live quarantine-plugin isolation). The approval-gated members flow through the ticket-then-recall mechanism documented under Error envelope above.
- The five write tools (#289 / Issue 13.5) are the last-mile agentic write surface:
-
Live-query bundle tools (
execute_bundle/get_bundle_result) — MCP parity for thePOST/GET /api/v1/bundlesREST surface (ADR-0011).execute_bundle(write/execute,Execution:Execute) fans one instruction into 1–32 plugin actions on one device via server-side async fan-out and returns{bundle_id, agent_id, expected}immediately (poll withbundle_id, which is NOT a trackedexecution_id);get_bundle_result(Response:Read, plusForensics:Readfor the output of a Forensics-tier step, see the Forensics-tier bullet near the top of this file) collates to{complete, received, succeeded, expected, steps[]}in request order. Use instead of Nexecute_instructioncalls when refreshing a device (N round-trips → 1). The agent is unchanged — each step is an ordinary command under onebundle-…correlation id; per-stepbundle.<plugin>.<action>audit (target_type=Agent) mirrors REST, and collate enforces an ownership (IDOR) guard. Bundles are caller-polled, not in the executions drawer; v1 manifests are per-surface + in-memory (durable Postgres store is a committed follow-up — ADR-0011). -
Periodic access review tools (
export_access_review/open_access_review/record_attestation/get_access_review/list_access_reviews/close_access_review) — MCP twins of the/api/v1/access-reviews*REST surface (SOC 2 CC6.2; full doc:docs/auth-architecture.md“Periodic access reviews”).export_access_review,get_access_review, andlist_access_reviewscarryreadOnlyHint:true.record_attestationcarriesdestructiveHint:true— it is an UPSERT: a second call for the same(campaign_id, principal_type, principal_id, role_name)overwrites the prior reviewer’s decision/reviewer/justification, with no retained history of the earlier decision. The read tools (export_access_review,get_access_review,list_access_reviews) carryreadOnlyHint:true, andopen_access_reviewisdestructiveHint:false(it mints a new campaign but touches no existing state).close_access_reviewcarriesdestructiveHint:true— a one-wayopen→closedlifecycle transition (no reopen path) that permanently freezes outstanding attestations (corrected 2g PR 2, was shippedfalse).record_attestation’sdecision:"flagged_revoke"still records evidence only and never itself revokes the underlying grant (flag ≠ revoke — acting on a flag is a separate, explicit role-unassignment or engine-principal-revoke call; the destructive part is the decision-overwrite, not a live grant mutation). JSON only: the REST?format=csvexport has no MCP equivalent — a worker that wants a CSV download uses the REST endpoint directly. All six are gated on a global, dedicatedAccessReview:Read/AccessReview:Attest(notAuditLog:Read/AuditLog:Attest— an earlier round gated on the latter, which over-disclosed the fleet-wide grant population to theOperator/PlatformEngineerroles; seedocs/security-reviews/access-reviews-2026-07-21.md“#2225 round 2”), not a management-group-confined read (see the #2225 chokepoint note indocs/user-manual/rest-api.md“Access Reviews”), and every one of them — reads included — structurally denies a caller whose own session is engine-classed (the shareddeny_engine_sessionbelt). -
Human API-token rotation tools (
rotate_api_token/confirm_api_token_rotation, P2 #11, SOC 2 CC6.3) — MCP twins ofPOST /api/v1/tokens/{id}/rotateand/confirm(ADR-1005 twin obligation; full doc:docs/user-manual/mcp.mdrows 70–71). Both gate onApiToken:Rotate, reachable at theoperatorMCP tier — deliberately NOT the engine-credential arm’sSecurity:Write/supervised-only posture, because rotation here is self-service:ApiTokenStore::rotate_token/confirm_token_rotationreject at the STORE layer unlessrequesting_user(alwayssession->username, never a tool argument) equals the resolved token row’s ownprincipal_id, so a caller can only ever rotate/confirm a token they own — the same “touches only your own resource” shape asoperator’s existingTag:Write/Tag:Deleteallowance.ApiToken:Rotateis DELIBERATELY DISTINCT fromApiToken:Write(round-4 security finding, corrected from a first fix attempt): mcp_server.cpp’s generic tools/call tier gate is keyed on the tool’s registered(securable_type, operation)pair and is the SAME functionAuthRoutes::require_permission/require_scoped_permissionconsult for REST — a securable-wideApiToken:Writeallowance foroperatorwould (and, for one round, did) also admit an operator-tier token to RESTPOST /api/v1/tokens(mint, caller-chosenmcp_tier, including an untiered/perpetual token) — a privilege escalation.ApiToken:Rotateis a new, separate RBAC operation seeded only toAdministrator/ApiTokenManager(the same populationApiToken:Writealready holds) and mapped ONLY by these two tools + the REST rotate/confirm routes;ApiToken:Writeitself, and thereforePOST /api/v1/tokens/its settings twin, is completely untouched — seemcp_policy.hpp’stier_allows()operator-tier comment for the full analysis. Neither tool is approval-gated (requires_approval()has noApiTokenrule, for either operation), matching the REST twin, which needs no admin approval either.rotate_api_tokencarriesdestructiveHint:true, idempotentHint:false(a grace-window re-serve is bounded-idempotent, not generally so — matchesrotate_engine_credential);confirm_api_token_rotationcarriesdestructiveHint:true, idempotentHint:true(the pinnedtoken_idmakes a same-args replay either confirm the pending pair once or error with no additional effect — matchesconfirm_engine_rotation’s #2384/#2404 reasoning). The successor always inherits the predecessor’sexpires_atverbatim —successor_expires_atis deliberately not exposed as a tool argument, so rotation stays lifetime-neutral CC6.3 evidence. Owner-vs-nonexistent is indistinguishable (kInvalidParams, “token not found”) — not an enumeration oracle, matching the REST route’s 404 posture. Audited on theapi_token.*family (api_token.rotate/api_token.reveal/api_token.confirm, targetApiToken) — one row per reveal, mint, or replay, plus adeniedrow on the owner-mismatch path — and the confirm outcome additionally incrementsyuzu_api_token_confirm_total{surface="mcp",result=...}, the sibling counter to the REST route’ssurface="rest"(same family,engine_store_error_class.hpp’s classifier). The successor is resolved via the sharedderive_rotation_successor(token_rotation_lookup.hpp) — scoped exactly to the predecessor rotated, never “any linked row of this principal”: the naive scan the engine tool uses (“first row with a linked predecessor”) is sound only under the engine arm’s ≤2-active-per-principal invariant, which the human arm explicitly does not share (its ceiling is ≤2 active per rotation group, so one principal can have several rotations in flight at once) — a round-3 review reproduced the resulting cross-token secret/id mismatch live against Postgres before this helper was extracted, and BOTHrotate_api_tokenand the REST twin call the SAME function rather than each re-deriving it. A lookup miss immediately after a successfulrotate_tokenis treated as an ambiguous, retryable read failure and fails CLOSED (kInternalError, never a success with an emptytoken_id) rather than hand the caller a one-time secret with nothing to confirm it against. #2963 (default-config reachability + de-escalation fix): the sharedrequire_permission/require_scoped_permissionchokepoint both this tool pair and the REST twins call now admitsApiToken:Rotateunder the legacy RBAC-off fallback for any non-admin owner (the legacy self-service allowlist,legacy_self_service_allow.hpp). This has live effect on the MCP transport too, not only REST —require_permission’smcp_tierbranch (auth_routes.cpp) does not returntrueon a passingtier_allows()check; it falls through to the RBAC/legacy branches below it (the branch’s own comment: “enforce the tier policy… then fall through to the standard RBAC/role check using the creator’s actual role”). So anoperator-tier token minted by a non-admin creator, under RBAC-off, now reaches and is admitted by the same legacy self-service allowlist REST uses — previously it was denied there (non-admin creator, legacy admin-only rule) even though its tier already permitsApiToken:Rotate. No authority gain: ownership and the tier/scope-equality guard in the store are what actually decide the outcome, unchanged. Separately,ApiTokenStore’s authority-inheritance guard now admits a caller holding NO standingmcp_tier/scope_serviceto rotate/confirm ANY of its own tokens regardless of that token’s own tier/scope — a single exception, not a lattice, since the successor still inherits the token’s own narrower tier/scope verbatim. This one IS moot for these two MCP tools specifically (unlike the RBAC-off allowlist above) — the separate#4309MCP-tier-less deny-outright runs AFTERrequire_permissionin both handlers and unconditionally rejects any caller whosemcp_tieris empty, before the store is ever reached, so the empty-tier case this exception exists for can never arrive at the store via these two tools; it is reachable only via the REST route’s plain-cookie-session callers. Full design record:docs/auth-architecture.md“Human API-token rotation”. -
Engine-principal mutation tools fail closed on an audit-persist failure (#3937, matching the REST twins #2466). The eight engine-principal MUTATION twins —
create_engine_principal,revoke_engine_principal,transfer_engine_principal_owner,mint_engine_credential,rotate_engine_credential,confirm_engine_rotation,assign_engine_role,unassign_engine_role— return a JSON-RPC503error, not a success result, when their audit row cannot persist (the error’sdatacarriesaudit_persisted:false;mcp_audit("error")makes a best-effort attempt to record the outcome — during an audit outage that write is itself likely to fail, so the durable server-side signal is the genericyuzu_server_audit_emit_failed_totalcounter + the warn log, not a guaranteedmcp.*|errorrow). This is a deliberate departure from the MCP READ convention above (get_dex_signal_detailetc. set-and-proceed withaudit_persisted:falseon a result): a privileged identity/credential MUTATION must never report success on an unrecorded audit row (ADR-1005 “mutations fail closed on audit failure”). It matches (a) the now-fail-closed REST engine-principal routes (#2466 — the mutation committed, so the error tells the caller to reconcile via a read, not retry) and (b) the in-MCP precedent set by the five plugin-config/secret/kill-switch mutation tools (set_plugin_configet al.).mint_engine_credential/rotate_engine_credentialadditionally WITHHOLD the one-time secret on this path — it is never built into the error body; the credential exists but is unusable, so the operator lists and rotates it (mint) or re-rotates within the grace window of about two minutes (rotate) once the audit store recovers. Scope note (deliberate, matching the plugin-config author’s own scoping): the two human-token twinsrotate_api_token/confirm_api_token_rotationand the ~20 other pre-existing set-and-proceed MCP write tools are NOT changed here — each matches its own REST sibling’s posture, and converting them is a separate, separately-reviewed decision.
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).
- Execution/fleet statistics + fleet visualization read tools (
get_execution_statistics/get_execution_statistics_by_agent/get_execution_statistics_by_definition/get_fleet_statistics/get_fleet_topology/get_host_topology, api-parity #2146 Batch B3) — the first MCP presence for either surface; both previously had a complete REST-only route set. The four statistics tools mirrorGET /api/v1/execution-statistics{,/agents,/definitions}andGET /api/v1/statisticsvia shared builder functions in the newexecution_statistics_model.hpp(Rule 1) — the REST handlers were refactored in the same PR to call the same functions, so the JSON shapes cannot drift. The four statistics tools call the degrade-awareExecutionTracker::try_get_fleet_summary/try_get_agent_statistics/try_get_definition_statistics, which returnstd::optional: a store fault (store not open, pool timeout, failed statement) is a retryable-32603kInternalErrorcarryingretry_after_ms(kMcpStoreFaultShortRetryMs, 2000), never a zeroed summary or an empty list (#4644; the REST routes answer503for the same fault). The plainget_*forms still collapse a fault to zero/empty and have no production caller. The unwiredExecutionTracker*pointer is a separate, permanent deployment-config condition and answerskInternalErrorwith no retry hint.get_execution_statistics_by_agentis confined (#3526, ADR-0017): it gates solely onfleet_read_fn_and pushes the visible-agent set into the rollup’s SQL before the GROUP BY and LIMIT, like its REST twin.get_execution_statistics_by_definitionis not confined (definition-level aggregates carry no agent identity). All four REST routes are unaudited on a successful read (fleet-wide/definition-wide metadata, not per-agent behavioural PII); their MCP twins deliberately diverge from that REST posture and each call the genericmcp_audit("success")convention instead (matching the majority of sibling read tools in this file, e.g.list_management_groups/list_upload_grants/list_plugin_config— see api-twin-recipe.md §4 for why REST/MCP audit postures are not required to agree), sots.audit_logcarries a plainmcp.get_execution_statistics|success-shaped row for each, not a domain verb. The two viz tools mirrorGET /api/v1/viz/fleet/topologyandGET /api/v1/viz/host/{id}/topologyexactly — sameFleetTopologyStorecache, same M-1machines_maxDoS cap (refuses an oversize snapshot outright, never truncates), same--viz-disable/yuzu_viz_disabledkill switch consulted BEFORE theResponse:Readgate (DEP-1 tier-before-permission,docs/fleet-viz-invariants.md), wired intoMcpServervia a newset_viz_deps(FleetTopologyStore*, OfflineRosterFn, const std::atomic<bool>* kill_switch)setter (server.cpp passes the same topology cache and kill switch the RESTVizRoutesregistration uses, plus anOfflineRosterFnclosure over the sameOfflineEndpointStore, so the two surfaces cannot disagree about cache state, offline-host staleness, or kill-switch state). The offline-host merge (merge_offline_topology,fleet_topology_store.hpp) was extracted out ofVizRoutes::handle_topology’s own inline block into a pure, shared function during this PR so both surfaces call the identical merge rule (Rule 1) rather than risk the two copy-on-write/online-set-dedup implementations drifting. Both are audited under the REST twins’ own domain verbs (viz.fleet_topology/viz.fleet_topology.invalidate/viz.host_topology, not the genericmcp.<tool>action, per api-twin-recipe.md §4’s “prefer the domain verb” guidance),audit_persisted:falsesurfaced in the body on a dropped row (REST’s own posture here is a plain fire-and-forget void audit call with no fail-closed check at all — the MCP twins are a strictly stronger transparency posture, not a weakened one). Both also increment the sameyuzu_viz_cache_hit_total/yuzu_viz_cache_miss_total/yuzu_viz_offline_hosts_total/yuzu_viz_oversize_response_total/yuzu_viz_topology_request_secondsmetricsVizRoutesdoes, keyed off the same sharedFleetTopologyStorecounters, so/metricscannot distinguish which transport drove a given cache fill or fan-out. Caveat readers should know: a “cache miss” refill is a liveecho.fleet_snapshotfan-out to every connected agent ONLY when no agent has ever heartbeat-pushed a snapshot yet (first boot, or a legacy/Echo-disabled fleet) —FleetTopologyStore’s fetcher is gated on itspushed_map being empty. In steady state (the normal case once agents are heartbeat-pushing), a TTL-expiry orfresh:true/fresh=1refill is an in-memory map walk with no network I/O; in that legacy/pre-push window, however, any refill trigger - a TTL expiry as much as an explicitfresh:true- fans out identically, because the fetch is gated on the (still-empty)pushed_map, not on which caller flag caused the cache miss. The practical difference between the two triggers there is pacing, not exclusivity: a TTL expiry naturally paces itself at the cache’s TTL, whilefresh:trueforcinginvalidate()carries no rate limit of its own (FleetTopologyStore::invalidate()is unconditional) and can be called back-to-back - see #4321 for the tracked follow-up. New tool families inmcp_orientation.cpp: “Execution statistics” and “Fleet visualization”. - Pre-flight & deploy read tools (
list_preflight_runs/get_deployment_preview, #4036, api-parity Batch A) — the first REST/MCP twins for the/autopre-flight ASSESS + deploy ACT stages, which previously had zero API surface at all. Both are owner-scoped (the caller’s own runs/deployments only) and read-only, mirroringGET /api/v1/preflight/runsandGET /api/v1/deployments/preview— the samepreflight_run_row_json/deploy_preview_jsonbuilder functions each REST handler calls, so the two transports cannot drift (api-twin-recipe.md Rule 1).list_preflight_runs(Infrastructure:Read) is the saved-runs-rail half of/fragments/autoonly — the config-options half (available management groups) is deliberately not duplicated here; it already has its own twin,list_management_groups.get_deployment_preview(SoftwareDeployment:Read) mirrors/fragments/auto/deploy’s go/warn preview, confirmed inert (reads onlyPreflightRunStore, never creates or advances a deployment). Both are plain 2-elementkToolSecurityRowsentries (ServiceScopeClass::deniedby default), so a service-scoped API token is denied structurally at the C8 chokepoint before either dispatch branch runs — notconfined, because owner-scoping bysession->usernameis not a service-scope confinement mechanism (a token sharing its creating principal’s username could otherwise read that principal’s runs for an unrelated service). Audit posture deliberately diverges by transport: the REST twins stay unaudited on a successful read (run scope/lifecycle metadata, not per-agent behavioural PII — matches the fragments’ own posture); the MCP tools call the genericmcp_audit("success")convention instead, matching the majority of sibling read tools in this file (list_management_groups,list_upload_grants,list_plugin_config) rather than the REST-side choice — see api-twin-recipe.md §4 for why REST/MCP audit postures are not required to agree.GET /fragments/auto/resultandGET /fragments/auto/deploy/resultremain out of scope (both are “mutating GETs” — the deploy one advances the engine, the pre-flight one persists a live grid on every poll — a different batch’s problem). - Deployment read tools (
list_deployments/get_deployment) — the read-only list and detail the live/autopipeline lacked, mirroringGET /api/v1/deploymentsandGET /api/v1/deployments/{id}through the samedeployment_row_json/deployment_detail_jsonbuilders. Owner-scoped,SoftwareDeployment:Read, plain 2-elementkToolSecurityRowsentries (ServiceScopeClass::denied, so a service-scoped token is refused at the C8 gate). They never advance the engine (unlike the dashboard’s/fragments/auto/deploy/resultpoll) and never return the installer URL or arguments (has_argsonly). A store fault is a retryablekInternalError, never an empty list. Distinct from the shelvedlist_software_deployments(capability 7.6,SoftwareDeploymentStore), which this does not wake. - Network discovery read tool (
list_discovered_devices, Estate) — hosts found by network discovery scans, mirroringGET /api/v1/network/discovered-devicesand the/fragments/network/discoveredfragment; all three call the samediscovered_devices_jsonbuilder and one read seam, so they cannot drift. Newest last-seen first, at most 500 rows, withsummary(total,subnets) over the filter and atruncatedflag; optionalsubnetandlimit. No managed flag is returned (discovery does not yet match hosts to enrolled agents). RequiresInfrastructure:Read; a plain 2-elementkToolSecurityRowsentry, so a service-scoped API token is denied structurally at the C8 chokepoint. Each call is audited asnetwork.discovered.view(set-and-proceed on MCP:audit_persisted: falseon a failed persist; the REST twin fails closed). A store fault is a retryable error, never an empty list. - Software sync tool (
sync_hardware_software, Estate) — the MCP twin ofPOST /api/v1/hardware/sync. Both call onesoftware_sync::Runnerinstance, so the per-operator cooldown, the single in-flight run, the 500-device cap and the audit-before-send rule bind both surfaces; the send goes through thedispatch_confined_armssystem-reserved arm, which intersects the target list with the caller’s ownExecution:Executevisible set. Denied to service-scoped tokens. - CA / offload-target / platform-license tools (#2146 Batch B5) - the authoritative per-tool table is
docs/user-manual/mcp.md, rows tagged(#2146 Batch B5)(a numeric row range is not stable across this batch’s sibling PRs, which all independently insert starting from the same base-row number); this bullet records the architecture-level facts a per-row description shouldn’t have to repeat.import_ca_chain(Security:Write, supervised-tier approval-gated) mirrorsPOST /api/v1/ca/import-chain’s subordinate-CA workflow; its error handling discriminates a validation rejection (bad/mismatched/non-chaining intermediate,kInvalidParams, no retry hint - retrying the same call cannot succeed) from a genuine post-validation store-persistence fault (kInternalError,kMcpStoreFaultRetryMs) - collapsing the two into one error shape was a Gate 4 finding against the first landing. The two offload-target write tools (create_offload_target/delete_offload_target,Infrastructure:Write) plus the read-onlylist_offload_target_deliveries(Infrastructure:Read) mirrorPOST/GET/DELETE /api/v1/offload-targets*exactly, including the REST twin’s own posture:create_offload_target’surlis NOT restricted to external hosts (no SSRF guard on this field today - a caller withInfrastructure:Writecan point deliveries at any reachable address, including loopback/internal network). Of the three platform-license tools,get_platform_licenseandlist_license_alerts(bothLicense:Read) mirror their respectiveGET /api/v1/license*routes; there is deliberately noremove_platform_licensetwin forDELETE /api/v1/license/{id}-LicenseStoreis dormant ondev(ADR-0048) and the twin is tracked to land alongside whichever future PR wires its construction, a recorded ledger exception rather than an oversight. - Self-description tool (
get_me, #2146) - the MCP twin ofGET /api/v1/me(ledger rowget.api-me, nowtwinned). A legacykTools[]read tool, no arguments, tier gateInfrastructure:Readonly with no RBAC prerequisite (thecheck_permissionpattern), audit verbmcp.get_me(set-and-proceed,audit_persisted: false); the REST route stays gate-less and audit-free. It returns the same keys as the REST route from one shared builder (me_response.hpp), includingprincipal_kind(humanorengine),auth_source(always present),mcp_tier,scope_service,rbac_roles(DIRECT assignments only, not group grants, and not access-review evidence) andrbac_roles_resolved(falseonly when the role-store read failed; the call still succeeds and omitsrbac_role/rbac_roles).rbac_roleis a deprecated display field (first direct assignment in role-name order).role/effective_roleare the legacy session view, not an authority summary.rbac_enabledis the cached view of the switch; the degraded state is not reported by this twin (tracked in #6033). Engine principals are looked up under principal typeengineand never get an invented role. ItsServiceScopeClassisdenied, so a service-scoped token is refused on MCP (the tool description points a refused caller to RESTGET /api/v1/me; the generic refusal body does not name it) although REST lets it describe itself: a documented asymmetry recorded as the oneGATE_PARITY_NO_GATE_ALLOWEDentry forGET /api/v1/me(ADR-1005 2026-10-24 entry, owner-confirmed), with an explicit self-only service-scope policy as the follow-up (#6432). Per-tool table row:docs/user-manual/mcp.md. - 12 resources:
yuzu://server/health,yuzu://compliance/fleet,yuzu://audit/recent,yuzu://guardian/schemas,yuzu://openapi,yuzu://scope-dsl(both 2g PR4, gatedInfrastructure:Readunlike the perm_fn-only legacy resources — see #2713 — and built from the SAME source as their REST/MCP-tool twins, A2’s shared-builder principle:yuzu://scope-dslis byte-identical toGET /api/v1/discover/scope-kindsanddiscover_scope_kinds;yuzu://openapiis byte-identical toGET /api/v1/openapi.jsonin its default form (without the per-group guides that?guides=trueadds on REST), whilediscover_routeswraps the same source in a distinct routes-catalog projection rather than serving it raw),yuzu://plugin-docs(the per-plugin documentation manifests ofdocs/plugin-readme-standard.mdrule 10 — build-embeddedkBundledPluginDocs, byte-identical toGET /api/v1/discover/plugin-docs, the same tier-then-perm gate as the two 2g PR4 resources;discover_pluginsjoins a per-plugindocssummary from the same manifests, catalog version 2 → 3, pointing at the per-plugin resource template below), plus the 5 Agentic Demo Layer resources below — plus 1 resource template,yuzu://plugin-docs/{name}(resources/templates/list, MCP spec 2025-06-18; samemanifest_by_namebuilder and tier-then-perm gate as the catalog resource above, byte-identical toGET /api/v1/discover/plugin-docs/{name}and to the matching element of the whole catalog; shipped for the plugin-docs family, #4108). No MCP capability change was needed — templates live under theresourcescapability theinitializeresponse already advertises. The MCPcompletionscapability remains deliberately deferred foryuzu://openapiandyuzu://scope-dsl: no consumer demand yet, and both stay single static URIs with no templating need. - 4 prompts:
fleet_overview,investigate_agent,compliance_report,audit_investigation - Settings UI section with enable/disable and read-only toggles
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
yuzu://about— product primer, glossary, and safe operating rules.yuzu://capabilities— what MCP can answer now, what may need live read-only dispatch, what requires external connectors, and what is unsafe without approval.yuzu://operating-model— classify → plan → read → narrow scope → dry run/read-only probe → request approval → execute → monitor.yuzu://demo/playbooks— deterministic incident/demo playbooks with live-fleet variants.yuzu://golden-prompts/enterprise-it-v1— versioned golden prompt/eval catalogue.
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.
get_fleet_posture_fast— compact cached posture summary. The cache TTL defaults to 30 seconds and responses includegenerated_at,data_age_seconds,partial, andmissing_sources.data_age_secondsreflects the real age of the cached snapshot at read time — it is recomputed per request, so a cache hit reports a non-zero age (it is not the value baked in at generation).classify_operational_question— classifies a question asanswerable_now,answerable_with_live_dispatch,requires_external_connector,unsafe_without_approval, oroutside_yuzu_scope. This classification is advisory only — a UX hint for the agentic worker, not a security control. It uses ASCII keyword matching that can be evaded by rephrasing or Unicode homoglyphs; never treat it as an authorization decision. Real enforcement is the MCP tier + RBAC check on each tool, and itsrecommended_next_toolsare always read-only.get_incident_playbook— returns a scenario workflow, first tool, safe tool path, connector gaps, and approval boundaries.scenariois matched exactly against a playbook name, category, or curated tag (e.g.openshift,teams,postgres) — not by loose substring, so a short/generic query returns “unknown scenario” rather than the wrong playbook.summarize_working_set— summarizes a fleet, agent, execution, or result-set working set into a bounded model-ready narrative with resource links. Theagentkind is management-group scoped (an out-of-scope agent is reported as not-present, never leaking its hostname/os); theexecutionkind is gated solely onfleet_read_fn_(require_fleet_read, not a plainExecution:Read): a confined caller seestargeted/respondedcounts projected to their visible agents, an execution outside their scope reads exactly like an absent one (same narrative; for a confined caller adeniedaudit row with the id neutralised and capped, nosuccessrow; an unconfined caller’s absent id stays an auditedsuccess), and a store degrade is an error withretry_after_msrather than “was not found” (#4753; hardening plus an admission change, not a fix for a reachable leak (an author-run probe, not independently adjudicated, found none) - seedocs/auth-architecture.md“Sixth migration”). Confinement applies only with RBAC enforcement on: with RBAC off (the shipped default) every authenticated non-service, non-engine caller whose tier allows it is unconfined (an engine principal is refused with 403 with RBAC off), so no scope-collapsedeniedaudit row is written (otherdeniedrows on this tool, such as a tier or permission-gate refusal or the service-scoped default-deny, are separate and can occur regardless of RBAC state), and a service-scoped token stays denied. Theidargument is advertised withmaxLength128, and a NUL byte in akind=executionid is rejected with an invalid-params error before any lookup. An execution the caller owns but that has no in-scope agent rows readstargeted=0 responded=0in the narrative, and the dashboard fragment shows0/0with the fleet-wide status badge: the same as the REST twin and SSE, and a one-bit aggregate disclosure (the execution exists and its fleet status) that is disclosed rather than hidden. The tool’s first gate is still a plainInfrastructure:Read, so a group-scoped-only operator still gets403; only a caller with a globalInfrastructure:Readplus a group-scopedExecution:Readis newly admitted to the confined view. The tool staysServiceScopeClass::denied. Tracked gap: thefleetandresult_setkinds return the whole-registry agent count from the agent list with no per-caller scope (top gateInfrastructure:Readonly; #4753). The same unscoped branch is also reached bykind=executionorkind=agentwith an empty id, or an unavailable execution tracker (not separately tracked).
No fabricated-data demo mode (ADR-0016). Yuzu demos run live against the real fleet and never return canned findings. The earlier
prepare_demo_scenariotool and itsmode=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. Theceo_demo_agentic_endpoint_managementprompt 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)
- 5 write tools shipped:
set_tag,delete_tag,approve_request,reject_request,quarantine_device(dispatch handlers +tools/listentries + RBAC/tier mapping). - Approval workflow re-dispatch shipped — supervised-tier (and operator-tier
delete_tag) execution after approval by a person on an interactive session (dashboard or REST; never MCP), via the ticket-then-recall flow (kApprovalRequired→ approve → recall withapproval_id; one-time consumption; an approved ticket unconsumed 7 days after review is refused at consume, using database time). Documented under Error envelope above. - SSE streaming for execution progress is available today via the shipped
GET /api/v1/eventsendpoint (sprint W5.1) — an agentic worker bridgesexecute_instruction’s returnedexecution_idto that SSE stream (see theexecute_instructionrow indocs/user-manual/mcp.md); that bridge remains supported. Direction change (2026-07-11): a spec-compliant MCP Streamable HTTP transport (sessions, GET SSE channel,notifications/progressfor long-running tools) is now a committed platform requirement — ADR-1005 execution plan Decision 15 / track 2f supersedes the earlier “no MCP-specific streaming transport is planned” stance recorded here.
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:
Mcp-Session-Idsessions.initializemints a principal-bound ≥128-bit CSPRNG session id and returns it as the additiveMcp-Session-Idresponse header. Sessions are never required (plain-POST clients are unaffected); when a client presents one on a later request it is validated (unknown / expired / bound-to-another-principal →404, indistinguishable from never-existed — no cross-principal oracle — and the client re-initializes).DELETE /mcp/v1/ends a session. State is in-memory and bounded (idle TTL, per-principal + global caps that reject rather than evict a live session); a server restart drops sessions and the client re-initializes, per spec.Originvalidation on every method against a configured allowlist (--mcp-allowed-origin; absent Origin allowed on this credential-gated endpoint, empty allowlist rejects any present Origin). The server-wide same-site gate additionally refuses a cookie-sessionPOSTorDELETEthat carries neitherOriginnorReferer, or a foreign one, before this handler runs, so a cookie-session write to this endpoint must carry a same-siteReferer(or anOriginthat both the gate and this allowlist accept); token callers are not gated. With--mcp-no-streamingthe MCP-levelOrigincheck does not run; the server-wide check applies either way, and its403is the REST error envelope, not a JSON-RPC error.MCP-Protocol-Versionnegotiation. Supported revisions2025-03-26and2025-06-18;initializeechoes the client’s requested version when supported, else the2025-03-26baseline. A present-but-unsupported header →400. The negotiated revision is recorded on theyuzu_mcp_initialize_protocol_total{revision}counter (bounded to the supported set) so a future revision deprecation has usage data (track 2g PR 1).initialize.instructionsorientation blob (track 2g PR 1, invariant A5 item 6). Theinitializeresult carries a static, operator-authoredinstructionsstring orienting a fresh client — what Yuzu is, the operating model, a tool-family index, and where discovery starts. It is single-sourced with theyuzu://about+yuzu://operating-modelresources (server/core/src/mcp_orientation.{hpp,cpp}) so the handshake and those resources cannot drift, and it is static compiled-in content only — never fleet-derived data, which would be a prompt-injection channel into every fresh client.- Notification POSTs answer
202(spec MUST; was204). - Kill switch
--mcp-no-streaming(no minting;GET/DELETE→405; plain JSON-RPC POST only — and it also degrades the streamed-POST arm back to plain JSON, so it is the kill switch for BOTH progress surfaces, not just the GET channel) and audit verbsmcp.session.open/mcp.session.close/mcp.session.reject(the last on every denial — origin, unknown session, cap, protocol-version). - New JSON-RPC error codes:
-32007unknown session (404),-32008origin rejected (403),-32009bad protocol version (400),-32010session cap (429). Each carries the shared A4error.data(acorrelation_id, the always-present nullableretry_after_ms, and aremediation), built by the singleerror_response_a4helper so the transport denials match the REST/tool-call A4 envelope.
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:
- Attach. Requires the session’s
Mcp-Session-Id(absent →400; unknown / expired / another principal’s →404) andAccept: text/event-stream(missing →406,-32011; wildcards like*/*deliberately do not opt in). Heartbeats every ~3 s keep intermediaries from idling the connection out — sent only when a tick has nothing else to deliver, since a pass that just wrote real frames has already proved the connection live;X-Accel-Buffering: nodefeats reverse-proxy response buffering. - Resume. Each session has its own bounded replay ring and its own event-id namespace starting at 1 (ids are never global, so a cursor from one session can never address another’s frames). A reconnect with
Last-Event-IDreplays exactly the frames it missed. If the cursor’s frames have already been evicted from the ring — or the cursor is one the server never issued — the session is terminated and the request404s: the client re-initializes, and durable results remain fetchable byexecution_id. There is no silent gap, and the GET’s answer is coherent with the client’s next POST (which404s too). - Revocation. The credential that opened a stream is re-validated on every heartbeat tick, so a revoked token or a signed-out session kills a live stream, not merely future attaches — the close frame says
credential_revoked. Latency is one tick on a single server. The re-validation work (the auth-store read plus the session-registry touch) is bounded to once per tick regardless of frame rate — a busy stream is woken by every published frame, but only the drain rides the wake, so backend load does not scale with how chatty the stream is. Across replicas it is bounded by the 60 s token-cache TTL plus a tick, and that bound is an API-token property: the token cache is process-local, so a revoke handled by one replica reaches another replica’s stream only when that replica’s cache entry expires and it re-reads the store. Cookie sessions are durable since HA WS-1a —AuthManager::validate_sessionroutes tovalidate_session_durablewhenever aSessionStoreis wired — so a cookie-authenticated stream can exist on another replica, and its bound is the 1 s durable-generation refresh (kSessionGenRefreshMs) plus a tick, widening to the 30 s stale-serve ceiling (kSessionGenStaleServeBoundMs) for as long as a Postgres brownout blocks generation refreshes. Treat CC6.2 revocation latency as bounded by that, not as instantaneous. A stream authenticated by an engine principal carries a second, shorter bound of the same kind: its per-tick backing-principal check is served from a 15 s liveness cache (#2367), so revoking the principal on one replica reaches another replica’s stream within that TTL plus a tick — on the replica handling the revoke it is one tick, the same bound as the token/session case above: the store invalidates its own cache synchronously with the write, so the next heartbeat tick reads through and observes the dead principal rather than serving a stale cached hit. An unreachable auth store is not a revocation: it is reported as indeterminate and buys a bounded 60 s grace window, after which the stream ends with the distinct reasonauth_unavailable. For an engine-authenticated stream that window is measured from the stream’s last authoritative credential confirmation, not from the tick the outage was first noticed — a cached answer keeps such a stream running but does not re-start its budget, so cache residency and the grace window cannot add up to more than the window itself, and a stream already riding a near-expired cache entry when the outage begins gets correspondingly less than the full 60 s. That guarantee is engine-specific: an API-token-authenticated stream still treats a token-cache hit as a fresh confirmation, so for those the token-cache residency and the grace window can still add (pre-existing, tracked as #2447). That distinction exists so an auth-backend blip cannot cut every stream on the fleet at once, and so an operator can tell a security event from an outage. - Caps. Every held-open SSE response pins one HTTP worker for its whole life, so concurrency is admission-controlled — and every streaming surface leases from the same budget (MCP GET, MCP streamed POST,
GET /api/v1/events, the dashboard executions drawer, the legacy/eventsstream), because the worker pool is what they all share (ADR-0034). Capacity is--max-sse-streams(default 128, and the worker pool is sized from it);--mcp-max-streams-per-principal(default 4) is an anti-monopoly policy for the MCP surface, not a capacity limit. A cap hit rejects the newcomer with429(-32012) carrying an honestretry_after_ms— it never evicts a live stream. Utilisation:yuzu_http_held_open_responses / yuzu_http_held_open_capacity. - Takeover. A second GET on a session supersedes the first (the old stream closes with
superseded) rather than being rejected: the common second GET is a client reconnecting across a zombie TCP the server has not noticed yet, and rejecting it would lock that client out for a full write-timeout. It is admitted without a cap check but still takes a lease — a superseded provider goes on pinning its HTTP worker until it drains, and an uncounted pinned worker is exactly the hole the budget exists to close. The pool is bounded structurally instead: a session holds at most one draining stream, so a third concurrent GET on the same session gets429 stream_handover_pendinguntil the handover completes, and the budget reserves two workers for every stream it admits. - Close reasons are wire-visible and audited (
mcp.stream.attach/mcp.stream.close):client_disconnect,superseded,session_terminated,credential_revoked,auth_unavailable. The close is delivered as a JSON-RPCnotifications/yuzu.stream_closednotification carried as a normalmessageon the stream (2f PR 3b) — not a bespokeevent: stream-closed, which a spec MCP client parsing JSON-RPC off the stream would never see. Itsparamscarry the machine-parseablereasonin the same A4 shape (reason/correlation_id/retry_after_ms/remediation) as every denial on this route, so a client has one error contract, not two. The frame is off-ring and id-less: a close is a point-in-time terminal signal, never replayed.
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.
- Streamed POST (2f PR 3b, “Streamed POST” below). Send an SSE-capable
Acceptalongside theprogressTokenand the POST response is the progress channel: frames arrive before the result, which is the ordering the MCP progress lifecycle expects. This closes the deviation recorded against #2439 in the A5 exception ledger (docs/agentic-first-principle.md). - GET channel (the 3a shape, still fully supported). Without an SSE
Acceptthe frames are delivered on the session’s GET channel after thetools/callresponse has retired the request. Stock SDK clients drop (they do not reject) frames for a retired token, so the fallback there is to poll byexecution_id. This stays the right shape for a client that keeps one GET channel open and would rather not hold a POST open per call.
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 sends | Server answers |
|---|---|
_meta.progressToken + Accept: text/event-stream | SSE stream on the POST response |
_meta.progressToken, no SSE Accept | plain JSON now, progress on the GET channel |
SSE Accept, no _meta.progressToken | plain JSON (the token is the real opt-in) |
| neither | plain 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:
- A result frame, then EOF. The normal case. No close frame follows it — that would be a second, contradictory terminal on a stream that already answered.
- A
notifications/yuzu.stream_closedframe, then EOF. Everything else. It carries areason, theexecution_id, andpartial: true.
Close reasons, and what a client should do
reason | Meaning | Client action |
|---|---|---|
completed | result delivered | nothing — you have the answer |
cap_expired | the response’s time budget elapsed; the execution continues | fetch by execution_id |
record_gone | this response could not continue — the bridge record was reaped (session/pressure teardown) or the server is shutting down; the execution continues | fetch by execution_id, or reconnect via GET to resume |
cancelled | your notifications/cancelled detached the response; the execution continues | fetch by execution_id if you still want it |
session_terminated | the session ended | re-initialize |
credential_revoked | your credential stopped being valid | re-authenticate |
auth_unavailable | the auth backend could not answer within the grace window | retry |
internal_error | a server-side fault ended the response | fetch by execution_id |
client_disconnect | the 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:
| Condition | Status | JSON-RPC error | Retry? |
|---|---|---|---|
per-principal or global stream cap (pre-admission StreamBudget, reason=post_per_principal_cap/post_global_cap) | 429 | -32012 | yes — 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 | -32012 | yes — 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 | -32012 | yes — 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 flight | 409 | -32600 | no — use a fresh id |
| session unknown or expired | 404 | -32007 | re-initialize |
| streaming disabled / shutting down | 200 | — | none needed; you get the plain response |
| server could not allocate | 200 | — | 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:
| Condition | Status | Meaning |
|---|---|---|
| the stream could not be established | 500 | the command IS running; fetch by execution_id, or retry without an SSE Accept |
| the response could not be completed after dispatch | 500 | the 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)
- Cross-surface / durable approval-ticket state (today the ticket lives in the shared
ApprovalManagerstore, which is durable, but the MCP recall is stateless — no per-worker session).