ADR-006: Suite MCP agent plane
Source of truth:
docs/adr/006-veltro-mcp-agent-plane.md(synced at build time — edit the original, not this copy). Status: Accepted (2026-07-17). Adds a user-attributed agent control plane at the suite origin, extends ADR-004’s suite-service token contract, and preserves every product’s standalone authentication path.
Amended by ADR-016: the MCP server name, instructions, resource schemes, prompts and OAuth scopes follow the installed products.
Amended (2026-09-04): the shipped catalog is 78 tools per VF
src/server/mcp/__tests__/catalog.test.ts:117; the “74 (25/33/16)” figure in Decision 1 and the earlier “77 (26/35/16)” amendment are both historical. The count is asserted by that test, so this line is a pointer, not a second source. The coarseadmin/editor/viewer→admin/analyst/vieweractor projection (Decision 3) applies to this MCP plane only and does not carry Wardenapprover(see ADR-008, issue #131). Preserving the initiating human analyst as first-class attribution on each receiver is an approved target (issue #141). No behavior below is retracted.
Amended (2026-09-05, plan §6.4): the plane is no longer tools-only. On this repository’s
mainit also serves MCP resources (veltro://cases/{id},veltro://alerts/{id},veltro://pipelines/{id},veltro://runs/{id}, throughresources/templates/listandresources/read), prompts (triage,hunt,tune-rule, throughprompts/listandprompts/get), streamed progress (notifications/progressframes on a Streamable-HTTP SSE response when the client supplies_meta.progressToken), and per-token tool allowlists. None of that half is in a pinned suite release yet — see the availability note in the 2026-09-06 amendment. See Decision 6; Decision 1’s “tools only” sentence is historical.
Amended (2026-09-06): the plane’s implementation and its catalog moved into this repository.
/mcpis served by theveltro-identityimage built fromapps/identity(compose/veltro.yml, routed atnginx/veltro.conf:457), not by the VectorFlow image inVELTRO_ROLE=identity, and the catalog is the Veltro-owned, digest-pinnedMCP_TOOL_CATALOGinapps/identity/src/authority/catalog.ts— 78 tools since veltro#1160 addedchad_decide_deployment_request(77 before), generated from the Product Peer v1 authority matrix (retired by ADR-016 §36; the catalog’s ownMCP_TOOL_FAMILIESis now the record) and asserted byapps/identity/src/authority/__tests__/mcp-cutover.test.ts:39. The 78-tool figure in the 2026-09-04 amendment and its VF proof pathsrc/server/mcp/__tests__/catalog.test.ts:117are historical: that file exists only at the VF pin0.18.6, and VFmaincarries neither it nor the CHAD/Warden tool modules. The resources, prompts, streamed progress, and per-token allowlists named in the 2026-09-05 amendment live inapps/identity/src/authority/{resources,http}.tsonmain, and not in the pinned image:VELTRO_VERSION=0.2.23=identity-v0.2.23(6d4297b6) has thisapps/identity/src/authority/tree, with noresources.ts:catalog.ts,contracts.ts,http.ts,issuer-origin.ts,mcp.ts,oidc.ts,peers.ts,router.ts,runtime.ts— and noveltro://,notifications/progressortoolAllowedByTokenanywhere underapps/; all three arrived afterwards in70c9f26(#973), so serving them requires the pin bump. The 77-tool catalog itself is in the pinned tree:catalog.tsis byte-identical at6d4297b6and at this head. Decision 1’s placement sentence is historical; nothing else below is retracted.
Amended (2026-09-25, #1046): option 2 of Decision 2 is now either an OAuth 2.1 access token that Veltro identity issues itself, or, when
veltro.mcp_policynames an external issuer, a token from that issuer. Identity is its own authorization server for/mcp: an MCP client registers through RFC 7591 dynamic registration (public clients only), sends the human through the normal suite sign-in and a consent page at/oauth/authorize, and redeems a PKCE S256 code at/oauth/tokenfor an opaquevmca_access token (one hour) and a rotatingvmcr_refresh token. The refresh lifetime is 30 days from consent and rotation does not extend it; presenting a refresh token that was already rotated away revokes the grant. Tokens carry exactly the scopes checked on the consent page, capped by the catalog, the realm MCP policy and the human’s live product grants, and only their hashes are stored (migration023_identity_oauth_mcp_clients). Each consent is a connected app in the console under MCP tokens, where the user can revoke it. The RFC 9728 metadata this decision described was never served before this change; both/.well-known/oauth-protected-resourcepaths and RFC 8414/.well-known/oauth-authorization-servernow answer fromapps/identity/src/authority/oauth-http.ts, and a 401 from/mcpcarriesWWW-Authenticate: Bearer resource_metadata=...pointing at them. The resource is fixed at${VELTRO_IDENTITY_PUBLIC_ORIGIN}/mcp;VELTRO_MCP_AUDIENCEis still read nowhere. Like OIDC bearers, OAuth grants carry no per-token tool allowlist (Decision 6).
Amended (2026-09-29, #1161): the product catalog stays at 78 tools and the pinned digest is unchanged. Identity adds one tool of its own on top,
veltro_list_approvals, sotools/listcan show 79. It lists the approvals waiting on the caller across the installed products (ADR-016 D29) by reading the same set as Settings > Approvals, and it lives inapps/identity/src/authority/suite-tools.ts, outsideMCP_TOOL_CATALOGandMCP_TOOL_FAMILIES. See Decision 6 for how it’s gated.
Context
Section titled “Context”Veltro’s browser plane already has one identity authority and one session, while its product-to-product plane has short-lived, scope-bound suite tokens. Agents still had to scrape UI state, hold unrelated product API keys, or bypass product RBAC to manage pipelines, detection content, runbooks, runs, and cases.
The agent plane needs one public endpoint, the same effective permissions as the human user, actionable product authorization errors, and no new shared god-token. It must not move product logic or approval policy into the platform layer.
Decision 1 — The identity-role VectorFlow image serves /mcp
Section titled “Decision 1 — The identity-role VectorFlow image serves /mcp”The Veltro identity deployment role in the VectorFlow codebase serves a
stateless Streamable HTTP MCP endpoint at ${PUBLIC_BASE_URL}/mcp. This follows
the existing placement of suite identity, launcher, SCIM, and audit surfaces in
that deployment role. Nginx routes /mcp explicitly with buffering disabled and
long timeouts; it remains absent when VELTRO_INTEGRATED is disabled or
VELTRO_ROLE is not identity.
Version 1 exposed tools only — no MCP resources, prompts, server-initiated messages, or resumable sessions — and registered 74 curated tools: 25 VectorFlow, 33 CHAD, and 16 Warden. Both halves of that sentence are now historical: the catalog count is asserted by the test named in the 2026-09-04 amendment, and resources, prompts, and streamed progress are shipped per Decision 6. Server-initiated messages and resumable sessions remain unshipped.
The MCP layer composes existing APIs and tRPC routers. It does not duplicate business logic. VectorFlow calls execute through a real per-user tRPC context; CHAD and Warden calls traverse their normal HTTP authorization dependencies.
Decision 2 — Per-user credentials and principal resolution
Section titled “Decision 2 — Per-user credentials and principal resolution”A suite user can authenticate with either:
- a personal access token minted in Veltro Settings → Agent Access, stored only as a SHA-256 hash, shown once, revocable, and optionally expiring; or
- an OAuth access token from the suite’s configured OIDC issuer.
RFC 9728 protected-resource metadata is published at both
/.well-known/oauth-protected-resource and the path-suffixed
/.well-known/oauth-protected-resource/mcp. The MCP audience is
VELTRO_MCP_AUDIENCE, defaulting to ${PUBLIC_BASE_URL}/mcp. OAuth tokens are
verified from the issuer’s JWKS with exact issuer and audience checks; their
email claim is resolved to an active Veltro user. Roles always come from the
current Veltro database record, so revocation and role changes take effect on
the next call.
There is no static MCP credential. Each request yields the same principal shape: user id/email/name, organization, organization role, suite role, and auth method.
Decision 3 — Extend the suite-service token plane for actor calls
Section titled “Decision 3 — Extend the suite-service token plane for actor calls”ADR-004’s token contract gains svc="veltro" and five isolated product scope
families (eleven concrete scopes):
chad:rules:{read,write,deploy}chad:index-config:{read,write}warden:runbooks:{read,write}warden:runs:{read,write}warden:cases:{read,write}
Within each family, write implies read only. chad:rules:deploy also implies
chad:rules:read, but deploy and write do not imply each other; lifecycle and
authoring authority remain independent.
Every token in a chad:* or warden:* family must include non-empty
actor_email and a valid actor_suite_role. Such tokens are still short-lived
(60 seconds), single-scope HS256 JWS values with iss="veltro-suite" and
aud="suite-service". The veltro-suite-auth 0.2 contract and cross-language
golden vectors enforce this in TypeScript and Python.
CHAD and Warden verify the route’s exact required scope, JIT-resolve the actor to
a real product user, and then run their existing RBAC dependencies. Suite roles
map through normal provisioning (admin→admin, editor→analyst,
viewer→viewer); no grant is stored on the product user merely because an MCP
call arrived. Unknown or inactive actors fail closed. Warden’s existing
approver/admin check remains an additional requirement for approval tools.
Decision 4 — Product boundaries remain authoritative
Section titled “Decision 4 — Product boundaries remain authoritative”The catalog deliberately preserves lifecycle boundaries:
- VectorFlow deploy uses the release/deploy-request lifecycle, including canary and approval decisions; it does not call the destructive batch shortcut.
- CHAD rule deploy/undeploy/rollback and correlation lifecycle calls use CHAD’s native routes and permissions. Field mappings and ingest tokens remain under index-configuration authorization, not generic rule access.
- Warden runbook save is versioned, runbook trigger remains case-scoped, and approve/deny preserve Warden approver/admin enforcement.
- Lists are bounded (default 25, maximum 100) and omit credential material; single-object reads return full product objects.
Product 401/403 responses are returned to the MCP client with the product name, required permission, and status. There is no silent privilege fallback.
Decision 5 — Audit and tenancy
Section titled “Decision 5 — Audit and tenancy”Each tool call writes a mcp.tool_called Veltro audit event with the user,
authentication method, tool name, result, duration, IP address, and strictly
allowlisted target identifiers. Arbitrary YAML, rule bodies, tokens, and unknown
fields are redacted. CHAD and Warden also retain their native actor audit trails.
Organization context is loaded from the principal’s active Veltro membership and
wrapped in the existing per-org execution context. The current suite is
single-tenant (default); callers cannot select another organization.
Decision 6 — Resources, prompts, progress, and per-token tool allowlists
Section titled “Decision 6 — Resources, prompts, progress, and per-token tool allowlists”Resources are references, not a second read API. The four published
resources are veltro://cases/{id}, veltro://alerts/{id},
veltro://pipelines/{id}, and veltro://runs/{id}. A resources/read is
executed by replaying the pinned read-only catalogue tool for that kind
(warden_get_case, chad_get_alert, vf_get_pipeline, warden_get_run)
through the product-peer MCP path, so scope, native role, team selection,
allowlist, product RBAC, and audit are identical to the equivalent tools/call.
URIs parse as exactly one bounded path segment: no query string, fragment,
traversal, or second segment reaches a product.
resources/list is intentionally empty. Every suite resource is parameterised by
a product record id, so there is no static set to enumerate, and identity will
not fan out unbounded product listings on discovery. Clients enumerate with the
list tools (warden_list_cases, chad_list_alerts, warden_list_runs,
vf_list_pipelines) and then read the URI. resources/templates/list applies
the caller’s coarse scope, role, and token-allowlist discovery checks. A read
still performs argument-dependent unique-team selection and product RBAC.
Prompts are inert text. triage, hunt, and tune-rule are rendered by
identity, never by a model, and carry no product data: the records they concern
are attached as MCP resource_link content, which the client must read back
through the audited resource path. Prompt arguments are bounded, exactly the
declared names, and rejected when they contain control characters. Each render
writes mcp.prompt_rendered; a refusal writes mcp.prompt_rejected.
Progress is observed, not invented. When a client supplies
_meta.progressToken on tools/call or resources/read, the POST is answered
as a Streamable-HTTP SSE stream carrying notifications/progress frames at three
real milestones — authority resolved, product-peer call started (re-emitted per
transport attempt), product answered — followed by the single JSON-RPC response.
Clients that do not ask keep the buffered JSON body.
Per-token tool allowlists narrow one personal access token to named tools
(personal_access_tokens.allowed_tools; NULL means “every tool the token’s
scopes and the human’s live grants already allow”). The allowlist is intersected
with scope and grant checks and can only remove authority. The console refuses an
entry outside the token’s own scopes, so the allowlist never widens the token.
A call outside the allowlist is refused with JSON-RPC -32003 and
error.data.code = "tool_not_allowlisted", writes mcp.tool_rejected (or
mcp.resource_rejected), and never reaches the product. Non-allowlisted tools
are also hidden from tools/list, resources/templates/list, and prompts/list.
Federated (OIDC) bearers are not identity-minted, so they carry no allowlist.
veltro_list_approvals is identity’s one suite tool. It isn’t forwarded to
a product. Identity answers it from each installed product’s pending-approvals
peer route. A caller sees it and may call it when, for at least one installed
product, the token carries that product’s decide scope
(vectorflow:deployments:decide, chad:rules:deploy, warden:runs:write),
the human holds approver or admin there, and the token allowlist doesn’t leave
it out. A call reads only those products, or the one named in its optional
product argument. Naming a product outside that set is refused with
grant_forbidden. The result is one JSON text item,
{approvals, unavailable, truncated}, soonest to expire first. Each approval
names the tool that decides it, or null when only the product’s app can. Every
call writes one mcp.tool_called or mcp.tool_rejected row. The console’s
allowlist picker lists it with no product; any decide scope reaches it.
Consequences
Section titled “Consequences”- Agents get one discoverable endpoint and one per-user credential while product RBAC and approval policy remain the source of truth.
- The identity image now knows the curated HTTP shapes of CHAD and Warden; those mappings must change with product APIs.
- OAuth requires a configured issuer and an access token whose audience matches the MCP resource. Personal tokens remain the homelab-friendly fallback.
- The shared suite secret signs both browser sessions and short-lived internal actor tokens, so its existing crown-jewel handling and rotation consequences are unchanged.
- Standalone VectorFlow, CHAD, and Warden expose no MCP actor path and retain their native login, JWT, API-key, and permission behavior.