Skip to content

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 coarse admin/editor/viewer→admin/analyst/viewer actor projection (Decision 3) applies to this MCP plane only and does not carry Warden approver (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 main it also serves MCP resources (veltro://cases/{id}, veltro://alerts/{id}, veltro://pipelines/{id}, veltro://runs/{id}, through resources/templates/list and resources/read), prompts (triage, hunt, tune-rule, through prompts/list and prompts/get), streamed progress (notifications/progress frames 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. /mcp is served by the veltro-identity image built from apps/identity (compose/veltro.yml, routed at nginx/veltro.conf:457), not by the VectorFlow image in VELTRO_ROLE=identity, and the catalog is the Veltro-owned, digest-pinned MCP_TOOL_CATALOG in apps/identity/src/authority/catalog.ts — 78 tools since veltro#1160 added chad_decide_deployment_request (77 before), generated from the Product Peer v1 authority matrix (retired by ADR-016 §36; the catalog’s own MCP_TOOL_FAMILIES is now the record) and asserted by apps/identity/src/authority/__tests__/mcp-cutover.test.ts:39. The 78-tool figure in the 2026-09-04 amendment and its VF proof path src/server/mcp/__tests__/catalog.test.ts:117 are historical: that file exists only at the VF pin 0.18.6, and VF main carries 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 in apps/identity/src/authority/{resources,http}.ts on main, and not in the pinned image: VELTRO_VERSION=0.2.23 = identity-v0.2.23 (6d4297b6) has this apps/identity/src/authority/ tree, with no resources.ts: catalog.ts, contracts.ts, http.ts, issuer-origin.ts, mcp.ts, oidc.ts, peers.ts, router.ts, runtime.ts — and no veltro://, notifications/progress or toolAllowedByToken anywhere under apps/; all three arrived afterwards in 70c9f26 (#973), so serving them requires the pin bump. The 77-tool catalog itself is in the pinned tree: catalog.ts is byte-identical at 6d4297b6 and 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_policy names 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/token for an opaque vmca_ access token (one hour) and a rotating vmcr_ 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 (migration 023_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-resource paths and RFC 8414 /.well-known/oauth-authorization-server now answer from apps/identity/src/authority/oauth-http.ts, and a 401 from /mcp carries WWW-Authenticate: Bearer resource_metadata=... pointing at them. The resource is fixed at ${VELTRO_IDENTITY_PUBLIC_ORIGIN}/mcp; VELTRO_MCP_AUDIENCE is 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, so tools/list can 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 in apps/identity/src/authority/suite-tools.ts, outside MCP_TOOL_CATALOG and MCP_TOOL_FAMILIES. See Decision 6 for how it’s gated.

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:

  1. a personal access token minted in Veltro Settings → Agent Access, stored only as a SHA-256 hash, shown once, revocable, and optionally expiring; or
  2. 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.

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.

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