0c15d3d0b2
M2 delivers the read-only MCP capability broker gateway and four Day-1 infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027) all pass. 656 tests green. M1 non-regression verified. MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests). Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec). Two-track LLM smoke (Track A mock-path P0 gate passes). CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification. Phases shipped: P0 pre-execution v0.1.0 P1 Wave F — MCP gateway v0.1.1 P2 Wave G — Proxmox v0.1.2 P3 Wave H — SSH/Linux v0.1.3 P4 Wave I — Git adapters v0.1.4 P5 Wave J — SSE+smoke+UI v0.1.5 P6 Final — review+ship v0.1.6 ← milestone release ---ci--- phase: 6 milestone: v0.2 status: complete phase_role: final milestone_complete: true requirements: covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027] partial: [] ---/ci---
630 lines
75 KiB
Markdown
630 lines
75 KiB
Markdown
# Plan — Milestone 2 (v0.1, M2: MCP Layer & Day 1 Adapters)
|
||
|
||
Source spec: `.ciagent/steer-m2-spec.md` (v1.0, locked 2026-08-25, Sarah Chen). All 9 open questions resolved; spec delta applied to REQ-015/021/027 acceptance criteria, Section 5 constraints, Section 9 M2→M3 contract freeze.
|
||
M2 scope: **REQ-015..027 (13 REQs)**. MCP capability broker gateway + 4 Day-1 adapters (Proxmox, SSH/Linux via Relay Agent, GitHub, Gitea) + SSE streaming + token-bucket rate limiting + LLM smoke + Postgres 16 CI/RLS verification. M2 acceptance gate: spec §6 (15 items).
|
||
Architecture: `.ciagent/ARCHITECTURE.md` (M2 MCP broker section written). Decisions: `.ciagent/CLARIFY.md` (D-001..D-007; D-006 GitHub scopes, D-007 MCP transport). Research: `.ciagent/RESEARCH.md` (R-001..R-009, 7 flagged risks integrated below). Personas: `.ciagent/PERSONAS.md` (M2 roster: backend, data, frontend, lead, security, go-engineer for Wave H only). Requirements: `.ciagent/REQUIREMENTS.md` (REQ-015..027 with acceptance criteria + spec delta).
|
||
|
||
> **Note:** This PLAN.md overwrites the M1 PLAN.md. The M1 plan is preserved in git history (commit prior to M2 overwrite). M1 is COMPLETE; all 17 M1 REQs (001-014, 038, 039, 040) must remain passing through M2 (M1 non-regression — gate item 1).
|
||
|
||
---
|
||
|
||
## Phase mapping
|
||
|
||
M2 ships on the v0.1.x patch line (M1's previous minor). Phase 0 seeds `v0.1.0`; each execution phase ships a progressive patch; **the final phase's patch (`v0.1.6`) IS the M2 milestone release.** The milestone merge to `main` happens at the final phase.
|
||
|
||
| Phase | Wave | Branch | Patch tag | REQs covered |
|
||
|-------|------|--------|-----------|--------------|
|
||
| 0 | pre-execution | `phase/00-pre-execution` | `v0.1.0` | (this plan) |
|
||
| 1 | Wave F — MCP Gateway core | `phase/01-mcp-gateway` | `v0.1.1` | REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024 |
|
||
| 2 | Wave G — Proxmox adapter | `phase/02-proxmox-adapter` | `v0.1.2` | REQ-020, REQ-025 |
|
||
| 3 | Wave H — SSH/Linux adapter (Relay Agent) | `phase/03-ssh-adapter` | `v0.1.3` | REQ-021, REQ-026 (full) |
|
||
| 4 | Wave I — Git adapters | `phase/04-git-adapters` | `v0.1.4` | REQ-022, REQ-023, REQ-027 |
|
||
| 5 | Wave J — SSE integration + LLM smoke + adapter UI | `phase/05-sse-integration` | `v0.1.5` | REQ-017 integration, M2 gate item 8 (LLM smoke) |
|
||
| 6 | Final — Review + Audit + Ship | `phase/06-final-review-ship` | `v0.1.6` ← **M2 milestone release** | all M2 (sign-off) |
|
||
|
||
**Wave 0 prerequisites (not a spec REQ; MUST complete before Wave F):**
|
||
- **[G-011 P0] Build the CI/CD pipeline as an explicit Wave 0 task.** Create `.gitea/workflows/ci.yml` (Gitea Actions — the repo's forge is Gitea at `git.cloudinit.dev`; Gitea Actions is GitHub Actions-compatible, same YAML syntax + `secrets.*` context + service containers) with two jobs: `test-pglite` (default) + `test-postgres` (service container + `DB_MODE=pg`). Postgres 16 service container with `coreci_app` (no BYPASSRLS) + `migrator` (BYPASSRLS) role setup via `setup-ci-roles.sql` (R-009). GitHub smoke PAT as Gitea Actions repository secret (`secrets.GITHUB_SMOKE_PAT` — a GitHub API token for the Track-B real-GitHub adapter smoke, stored in Gitea's secret store). Caching for pnpm + go modules. Coverage upload. **This is M2 work, not an external prerequisite.** Without it, 9 of 15 gate items are unprovable.
|
||
- **[G-022 P1] Run the full M1 test suite against Postgres 16 in Wave 0.** After CI is provisioned, run the *entire* M1 test suite with `DB_MODE=pg`. Any M1 RLS failure (cross-tenant SELECT, missing `FORCE ROW LEVEL SECURITY`, wrong `WITH CHECK`) is an M1-regression P0 that blocks M2 until fixed. Wave 0 is the *first* real-RLS test of M1.
|
||
- **Real GitHub PAT available in CI** (ephemeral or test-org-scoped, stored as repository secret) for the Wave I real-target smoke + Wave J LLM smoke (gate item 8). Required for the optional real-GitHub smoke track (G-018); the P0 mock-path smoke does not depend on it.
|
||
|
||
---
|
||
|
||
## Grill fixes applied (G-011..G-022)
|
||
|
||
This plan was grilled (`.ciagent/GRILL.md`, verdict FAIL → auto-resolved at full autonomy with all 12 binding fixes applied). The 12 binding fixes (continuing from M1 GRILL's G-001..G-010) are integrated below and flagged inline as `[G-NNN]`. Summary:
|
||
|
||
- **G-011 (P0):** Build the CI/CD pipeline as an explicit Wave 0 task (not a "prerequisite"). Applied above.
|
||
- **G-012 (P1):** Own the M1 `AuditEventType` union extension as an explicit M1-file edit in Wave F (type widening, not "additive — no schema change" at the TS layer).
|
||
- **G-013 (P1):** Expand the cross-layer SSH test to a divergence matrix in Wave H (both-reject, both-accept, broker-rejects-Go-accepts, Go-accepts-broker-rejects). Tighten the Go deny list to include bare `rm` or validate `systemctl status` trailing args.
|
||
- **G-014 (P1):** Document the known closed-tool-set gaps in the Settings → Adapters UI help text in Wave J.
|
||
- **G-015 (P1):** Correct the INV-7 framing: the closed 9-tool registry (REQ-015) is the primary boundary; the write-blocklist is a backstop for adapter bugs. Document in Wave F PROTOCOL.md.
|
||
- **G-016 (P1):** Separate the write-blocklist into two enforcement models (method blocklist for Proxmox/Gitea; scope-via-403 for GitHub). Document GraphQL/PVE-GET-with-side-effects future risks in PROTOCOL.md.
|
||
- **G-017 (P1):** Add a 7th MCP conformance test over stdio transport in Wave F (`stdio-interop.test.ts`) — proves an external MCP client can connect.
|
||
- **G-018 (P0):** Add a `github-mock` adapter and a two-track LLM smoke in Wave J. Mock-path (canned repos) is the P0 gate; real-path (real GitHub) is optional/allow-failure.
|
||
- **G-019 (P0):** Harden `packages/llm-mock` pattern matching (regex set, not 2-word conjunction) + add retry policy for real-GitHub smoke in Wave J.
|
||
- **G-020 (P1):** Ship a documented `McpAdapter` interface in Wave F that both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement.
|
||
- **G-021 (P1):** Add M1-relay-WS regression test in Wave H + restructure the Go agent reader goroutine to dispatch on `type` before unmarshaling.
|
||
- **G-022 (P1):** Run the full M1 test suite against Postgres 16 in Wave 0. Applied above.
|
||
|
||
---
|
||
|
||
## Flagged risk mitigations (from RESEARCH R-001..R-009)
|
||
|
||
For each of the 7 flagged risks, the wave that addresses it and how:
|
||
|
||
| # | Risk | Wave | Mitigation |
|
||
|---|------|------|-----------|
|
||
| R-001 | Synthetic MCP `initialize`/`initialized` handshake for in-process adapters (lowest-confidence gate item 15) | **Wave F** | Implement a lightweight synthetic lifecycle exchange at adapter registration (broker → `{method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}`, adapter → `{capabilities:{tools:{}}}`). Produce `packages/mcp/PROTOCOL.md` + 6 conformance tests in `tests/mcp-conformance/` (gate item 15 artifact). |
|
||
| R-002 | PVEAuditor role introspection gap (PVE has no clean "what role does this token have" endpoint) | **Wave G** | Broker validates "token works for reads" (`GET /api2/json/version` + `GET /api2/json/nodes`) at submit, NOT "token lacks writes." Document the introspection gap in the Settings → Adapters UI help text ("Create a token with PVEAuditor role"). The broker write-method blocklist (reject POST/PUT/DELETE) is the load-bearing INV-7 boundary. Confidence 0.70 on this sub-point. |
|
||
| R-004 | GitHub fine-grained PAT scope introspection gap (no public API to list a fine-grained PAT's granted scopes) | **Wave I** | Submit-time: `github_pat_` prefix check (reject classic PATs) + `GET /user` (validates token + implicit `metadata:read`). Per-invocation: handle 403 with `X-Accepted-GitHub-Permissions` header (e.g., `actions=read` required) → HTTP 403 "insufficient scope" + `adapter.capability_invoked` with `result=failure` (NOT `write_rejected` — no write attempted). Document in UI. |
|
||
| R-005 | Gitea runtime verification (docs page required JS; findings from spec + GitHub-mirroring conventions) | **Wave I** | Verify `GET /api/v1/version`, `GET /api/v1/user/repos`, `GET /api/v1/repos/{owner}/{repo}/actions/runs` against a running Gitea 1.22+ AND a <1.22 instance during Wave I. Confirm `read:repository` scope behavior (≥1.22) and the `Authorization: token <token>` header. Confidence 0.72 → raise by runtime verification. |
|
||
| R-003 | Cross-layer SSH defense-in-depth test (two independent whitelist implementations: TS broker layer 1, Go agent layer 2) | **Wave H** | Add a cross-layer test asserting a non-whitelisted command (`rm -rf /`) is rejected by BOTH the broker (layer 1, TS) AND the Relay Agent `CheckCommand` (layer 2, Go). Keep the two implementations semantically in sync but code-independent (a bug in one doesn't bypass the other). |
|
||
| R-006 | 30s stream-not-opened timeout (orphan adapter calls if client never opens the SSE stream) | **Wave F** | Stream manager enforces a 30s "stream not opened" timeout: if `GET /api/mcp/stream/:correlationId` isn't called within 30s of `POST /api/mcp/invoke`, cancel the adapter call and delete the correlation context. Plus a 60s max-stream lifetime safety net for orphaned contexts. |
|
||
| R-009 | Wave 0 RLS assertions (M1 pen-test has placeholder `expect(true).toBe(true)` because PGlite doesn't enforce RLS on SELECT) | **Wave 0** | Deliver `packages/db/scripts/setup-ci-roles.sql` (`coreci_app` no BYPASSRLS, `migrator` BYPASSRLS). Parameterize the pen-test by `DB_MODE` (`pglite` default, `pg` in CI). Replace placeholder with real RLS WITH CHECK assertions gated on `DB_MODE=pg`. Two CI jobs: `test-pglite` + `test-postgres` (service container). |
|
||
|
||
**Additional research decisions integrated (D-M2-R001..R009, all above 0.6 threshold):** D-M2-R004 GitHub classic-PAT rejection by prefix; D-M2-R005 Gitea version-aware scope routing; D-M2-R006 SSE no-audit-on-client-cancel; D-M2-R007 token-bucket refund-on-tenant-fail + no-audit-on-429; D-M2-R008 `packages/llm-mock` import-guarding (eslint `no-restricted-imports` + build-time grep).
|
||
|
||
---
|
||
|
||
## User-Facing Surface (MVP/UX §1)
|
||
|
||
M2 ships **TWO user-facing surfaces** in the existing M1 dashboard (`apps/control-plane/app/(dashboard)/`). Non-UI surfaces (the MCP broker gateway, the 4 adapters, the `packages/llm-mock` CI-only smoke provider, the `mcp_adapters` Postgres table) are backend and verified by tests, not by user demonstration.
|
||
|
||
### Surface 1 — Settings → Adapters configuration UI (`/dashboard/settings/adapters`)
|
||
- **Adapter type picker:** Proxmox / GitHub / Gitea / SSH (the 4 Day-1 adapter types; closed set, no "custom adapter" option).
|
||
- **Per-adapter config forms:** type-specific fields:
|
||
- Proxmox: `host` (HTTPS URL), `PVEAuditor` token (secret), `allowSelfSigned` toggle (R-002 — common for customer PVE labs).
|
||
- GitHub: `host` (defaults to `api.github.com`), fine-grained PAT (secret). Help text: "Create a fine-grained PAT with `metadata:read` + `actions:read` minimum. Classic PATs (`ghp_`) are rejected."
|
||
- Gitea: `host` (customer Gitea URL), read-only token (secret), `allowSelfSigned` toggle. Help text: "Gitea ≥1.22 requires `read:repository` scope. Gitea <1.22 accepts any token (broker enforces write-method blocklist)."
|
||
- SSH: `hostname`, `port` (default 22), Relay registration token (secret; resolves to the M1 Relay Agent target). Help text: "Install the M1 Relay Agent on the target host first; paste the registration token here."
|
||
- **SecretProvider-backed credential entry:** all secrets enter via `SecretProvider.put` (INV-3); the DB stores only `secret_ref`. The UI never displays the raw secret after submit (redacted, edit-only).
|
||
- **Role/scope validation on submit (REQ-025, REQ-026, REQ-027):** the broker validates the token at submit time and returns a structured pass/fail. On failure → HTTP 422 with role/scope-violation error, no config persisted, UI shows the error inline.
|
||
- **"Test connection" button:** invokes the `test_connection` capability (REQ-016) through the closed tool registry; returns structured pass/fail within 5s (spec Journey 1 Step 7).
|
||
- **Audit event confirmation:** after a successful save, the UI shows "Saved (audit event `adapter.configured` appended)."
|
||
- **Multi-target support (REQ-024):** the config form supports multiple adapters of the same type (e.g., 2 Proxmox hosts). Each row has a `target_id` (display name) so the Test-Call UI's target picker can disambiguate.
|
||
- **PVEAuditor introspection gap (R-002):** the Proxmox config form's help text documents that the broker validates "token works for reads," not "token lacks writes," and the operator is responsible for creating a PVEAuditor-scoped token.
|
||
|
||
### Surface 2 — Test-Call UI (`/dashboard/test-call`)
|
||
- **Capability picker:** the closed 9-tool set from `GET /api/mcp/tools` (REQ-015). Tools are grouped by adapter type in the dropdown. Per-tenant policy may disable individual tools (greyed out in the picker).
|
||
- **Argument forms:** each tool's arguments are rendered from the tool's JSON Schema `inputSchema` (REQ-015) — required fields marked, type-validated on submit. Invalid args → inline validation error (mirrors the broker's HTTP 400 schema-validation error, Edge 4).
|
||
- **Target picker for multi-target tenants (REQ-024):** when a tenant has ≥2 adapters of the same type, the UI surfaces a target picker. If the user submits a same-type capability without selecting a target, the broker returns HTTP 400 "target required" (Edge 3) and the UI prompts for selection.
|
||
- **SSE stream consumer (REQ-017):** the UI opens an `EventSource` on `GET /api/mcp/stream/:correlationId` and renders events as they arrive (`id`, `event`, `data` fields per SSE spec). Terminal events `done` (completion) / `error` (failure) close the stream.
|
||
- **Staleness indicator for inventory calls:** for `list_*` capabilities (inventory, 60s TTL cache), the UI shows "cached Xs ago" when the result is served from cache. For live capabilities (non-`list_*`), no staleness indicator (fresh result). (Spec Journey 2 Step 6.)
|
||
- **Result rendering:** tool results render as JSON in a collapsible tree. Errors render with the `isError: true` flag surfaced.
|
||
|
||
### Non-UI surfaces (verified by tests, not user demo)
|
||
- The MCP broker gateway (`packages/mcp`): registry, router, write-blocklist, rate-limiter, stream-manager, translator, in-process transport.
|
||
- The 4 adapters (`packages/mcp/adapters/{proxmox,ssh,github,gitea}`).
|
||
- The `packages/llm-mock` CI-only LLM smoke provider (devDependency, import-guarded).
|
||
- The `mcp_adapters` Postgres table (tenant-scoped, RLS).
|
||
- The 5 gateway REST+SSE endpoints (M2→M3 contract freeze, spec §9).
|
||
|
||
---
|
||
|
||
## Happy Path (MVP/UX §2)
|
||
|
||
> **Given** a fresh M2 deployment with M1 complete (SSO, BYOM, Relay Agent, audit, RLS, secrets operational) and Wave 0 prerequisites met (CI Postgres 16 with role setup, real GitHub PAT available in CI),
|
||
> **when** Sam (Tenant Admin) configures and validates all four Day-1 adapters and runs a Test-Call,
|
||
> **then** the M2 acceptance gate (spec §6) passes: all 4 adapters respond to a test call, multi-target scoping is functional, read-only enforcement is verified at the broker (INV-7), LLM smoke passes, M1 non-regression.
|
||
|
||
### BDD steps (Given/When/Then) — written BEFORE EXECUTE
|
||
|
||
**Adapter config + validation (Journey 1):**
|
||
- [ ] **Given** Sam is signed in via WorkOS SSO (M1) as `admin`, **when** Sam navigates to Settings → Adapters and clicks "Add adapter", **then** the adapter type picker shows exactly 4 types (Proxmox, GitHub, Gitea, SSH).
|
||
- [ ] **Given** Sam selects "Proxmox" and enters `host` + a `PVEAuditor` token, **when** Sam submits, **then** the broker validates the token (`GET /api2/json/version` + `GET /api2/json/nodes` succeed — R-002), `SecretProvider.put` stores the token, the `mcp_adapters` row is inserted under `withTenant` + RLS, `adapter.configured` is appended to `audit_log`, and the UI confirms save in <5s.
|
||
- [ ] **Given** a Proxmox adapter is configured, **when** Sam clicks "Test connection", **then** the broker invokes `test_connection` through the closed tool registry, returns a structured pass/fail within 5s, and `adapter.test_connection.succeeded` (or `.failed`) is appended.
|
||
- [ ] **Given** Sam submits a Proxmox token that fails `GET /api2/json/version` (invalid token), **when** the broker validates, **then** submission returns HTTP 422 with role-violation error and no config is persisted (REQ-025).
|
||
- [ ] **Given** Sam submits a GitHub classic PAT (`ghp_...`), **when** the broker validates scope, **then** submission returns HTTP 422 "fine-grained PAT required" and no config is persisted (D-006).
|
||
- [ ] **Given** Sam submits a GitHub fine-grained PAT (`github_pat_...`) that passes `GET /user`, **when** the broker validates, **then** the config is persisted with `validated=true` (implicit `metadata:read`); `actions:read` is validated per-invocation (R-004).
|
||
- [ ] **Given** Sam submits a Gitea token for a Gitea ≥1.22 instance, **when** the broker calls `GET /api/v1/user/repos?limit=1` and it returns 403, **then** submission returns HTTP 422 "insufficient scope — `read:repository` required" (R-005).
|
||
- [ ] **Given** Sam submits a Gitea token for a Gitea <1.22 instance, **when** the broker validates, **then** the token is accepted (any valid token) with the broker-side write-method blocklist as the security backstop.
|
||
- [ ] **Given** Sam submits an SSH adapter config (hostname + Relay registration token), **when** the broker stores the token via `SecretProvider.put`, **then** the `mcp_adapters` row is persisted and `adapter.configured` is appended (REQ-026).
|
||
|
||
**Test-Call capability invocation (Journey 2):**
|
||
- [ ] **Given** a Proxmox adapter is configured and rate limits are not exceeded, **when** Sam invokes `proxmox.list_vms` (with `node` arg) via the Test-Call UI, **then** the broker mints a ULID correlation ID, returns `{correlationId, streamUrl}`, the UI opens an SSE stream, the adapter calls `GET /api2/json/nodes/{node}/qemu`, the stream emits `tool_result` events and terminates with `done`, and `adapter.capability_invoked` is appended with `correlation_id`.
|
||
- [ ] **Given** `proxmox.list_vms` was invoked within the last 60 seconds, **when** Sam invokes it again, **then** the broker returns the cached result with a "cached Xs ago" staleness indicator in the UI (inventory TTL cache, LRU).
|
||
- [ ] **Given** Sam invokes a non-`list_*` capability (e.g., `proxmox.get_node_metrics`), **when** the call completes, **then** the UI shows the fresh result with NO staleness indicator (live path).
|
||
- [ ] **Given** Sam invokes `github.list_repos` via the Test-Call UI, **when** the adapter calls `GET /user/repos?per_page=100` (real GitHub PAT), **then** the normalized repo list streams back and the UI renders it.
|
||
- [ ] **Given** Sam invokes `ssh.run_whitelisted_command` with `command: "uptime"`, **when** the broker validates (layer 1) and dispatches to the Relay Agent, **then** the agent's `CheckCommand` (layer 2) passes, `exec.Command("uptime")` runs (no shell), and the output streams back.
|
||
|
||
**Write rejection at broker (Edge 1, INV-7):**
|
||
- [ ] **Given** a malicious payload attempts `proxmox.shutdown_vm` via `POST /api/mcp/invoke`, **when** the request reaches the broker, **then** the broker's write-method blocklist rejects with HTTP 403, `adapter.write_rejected` is appended, and the adapter is NEVER invoked (verified by test per adapter at the M2 gate).
|
||
|
||
**SSH whitelist violation (Edge 7, defense-in-depth):**
|
||
- [ ] **Given** Sam attempts `ssh.run_whitelisted_command` with `command: "rm -rf /"`, **when** the broker validates (layer 1, 6-command subset), **then** the broker rejects with HTTP 403 + `adapter.write_rejected` (the command is not in the 6-command subset) — the Relay Agent is never reached. **And** the cross-layer test (R-003) asserts that IF the broker were bypassed, the Relay Agent `CheckCommand` (layer 2) would also reject.
|
||
|
||
**Multi-target disambiguation (Edge 3):**
|
||
- [ ] **Given** a tenant has 2 Proxmox adapters configured (target_a, target_b), **when** Sam invokes `proxmox.list_vms` without selecting a `target_id`, **then** the broker returns HTTP 400 "target required" with a list of available targets, and the UI surfaces the target picker.
|
||
|
||
**Rate limit (Edge 5):**
|
||
- [ ] **Given** Sam has exceeded 60 req/min (user bucket), **when** Sam invokes any capability, **then** the broker returns HTTP 429 with `Retry-After` header and no adapter call is made (REQ-019).
|
||
- [ ] **Given** the tenant has exceeded 300 req/min (tenant bucket), **when** any user in that tenant invokes a capability, **then** the broker returns HTTP 429 with `Retry-After` and refunds the user token (fairness, D-M2-R007).
|
||
|
||
**SSE client disconnect (Edge 8):**
|
||
- [ ] **Given** an SSE stream is open on `GET /api/mcp/stream/:correlationId` and the client disconnects mid-stream, **when** the broker detects the `EventSource` close (`req.signal` abort), **then** the in-flight adapter call is cancelled, the correlation context is deleted, and NO audit event is appended for the client-side cancellation (spec Edge 8).
|
||
|
||
**LLM smoke (M2 gate item 8, P0 — not deferrable):**
|
||
- [ ] **Given** CI starts the control plane with `packages/llm-mock` as the BYOM endpoint and a real GitHub adapter (real PAT, test-org-scoped), **when** the smoke test sends `POST /v1/chat/completions` with `tools=[github.list_repos]` and prompt "List my GitHub repositories.", **then** llm-mock returns `tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}]`, the broker translates to MCP `tools/call`, routes to the GitHub adapter, the adapter calls `GET /user/repos` (real GitHub), the broker translates the result to an OpenAI tool message, llm-mock synthesizes a grounded response, and the test asserts the response contains real repo names from the CI test org.
|
||
|
||
---
|
||
|
||
## UX Acceptance Criteria (MVP/UX §3)
|
||
|
||
Pass/fail criteria (all must pass for M2 ship):
|
||
|
||
- [ ] **Adapter config saves in <5s** with validation feedback (success or HTTP 422 role/scope-violation error inline).
|
||
- [ ] **"Test connection" returns in <5s** with structured pass/fail; `adapter.test_connection.{succeeded,failed}` audit event appended.
|
||
- [ ] **Test-Call SSE stream starts within 100ms** of `POST /api/mcp/invoke` returning `{correlationId, streamUrl}` (NFR: SSE chunk delivery <100ms).
|
||
- [ ] **Staleness indicator** shows "cached Xs ago" for cached inventory (`list_*`) calls; no indicator for live calls.
|
||
- [ ] **Write attempts rejected at broker with 403 before adapter invocation** (INV-7 — verified by a test per adapter at the M2 gate; the adapter is never invoked for write methods).
|
||
- [ ] **SSH non-whitelist commands rejected at broker (layer 1) AND Relay Agent (layer 2)** (defense-in-depth, R-003 cross-layer test).
|
||
- [ ] **Multi-target picker surfaces** when ≥2 same-type adapters are configured; submitting without `target_id` returns HTTP 400 "target required."
|
||
- [ ] **Rate limit 429 with `Retry-After`** when user >60/min OR tenant >300/min; no adapter call made on 429.
|
||
- [ ] **LLM smoke returns grounded response** with real repo names from the CI test org (P0 gate item 8 — not deferrable to M3).
|
||
- [ ] **M1 non-regression:** all M1 tests still pass (gate item 1).
|
||
- [ ] **Coverage ≥ 80%** on new M2 modules (`packages/mcp/**`, `packages/llm-mock`, adapter UI components) (gate item 3).
|
||
|
||
---
|
||
|
||
## Wave F — MCP Gateway core (Phase 1)
|
||
|
||
**Goal:** The MCP capability broker gateway: closed tool registry, adapter router, write-method blocklist enforcer (INV-7 at broker — the load-bearing safety boundary), token-bucket rate limiter, SSE stream manager, OpenAI↔MCP translator, in-process custom transport, synthetic MCP `initialize` handshake (R-001). `mcp_adapters` table with RLS. **No adapter implementations yet** (those are waves G/H/I) — Wave F ships the broker with stub adapters for testing.
|
||
|
||
**Depends on:** Phase 0, Wave 0.
|
||
**REQs covered:** REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024.
|
||
**Personas:** backend-engineer (broker modules), data-engineer (table + RLS), security-engineer (sign-off on write-method blocklist + INV-7 at broker).
|
||
**Patch tag:** `v0.1.1`. **Branch:** `phase/01-mcp-gateway`.
|
||
|
||
### Tasks
|
||
|
||
1. **`mcp_adapters` table + migration + RLS** (data-engineer)
|
||
- Migration `packages/db/migrations/0002_mcp_adapters.sql`: `mcp_adapters (id uuid PK, tenant_id uuid, adapter_type text CHECK in (proxmox,ssh,github,gitea), target_id text, config jsonb, secret_ref text, validated boolean, created_at timestamptz, updated_at timestamptz)`. UNIQUE `(tenant_id, adapter_type, target_id)`. RLS policy: tenant-scoped SELECT/INSERT/UPDATE/DELETE with `WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid)`. `ALTER TABLE ... FORCE ROW LEVEL SECURITY`.
|
||
- Verify against real Postgres 16 in CI (Wave 0 `DB_MODE=pg` job), not PGlite.
|
||
|
||
2. **`packages/mcp/registry.ts`** — closed 9-tool registry with JSON Schema inputSchemas (backend-engineer)
|
||
- 9 tools (REQ-015 frozen set): `proxmox.list_vms` (inventory, `{node: string}`), `proxmox.get_vm_status` (live, `{node: string, vmid: integer}`), `proxmox.get_node_metrics` (live, `{node: string}`), `ssh.run_whitelisted_command` (live, `{command: string}`), `github.list_repos` (inventory, `{}`), `github.get_recent_ci_runs` (live, `{owner: string, repo: string, per_page?: integer, status?: string}`), `github.get_workflow_run` (live, `{owner: string, repo: string, run_id: integer}`), `gitea.list_repos` (inventory, `{}`), `gitea.get_recent_ci_runs` (live, `{owner: string, repo: string, limit?: integer}`).
|
||
- Each tool: `{name, description, inputSchema}` (JSON Schema object with `type:"object"`). Export `MCP_PROTOCOL_VERSION = "2025-06-18"`.
|
||
- Per-tenant policy: `disabledTools: Set<toolName>` — may disable individual tools but never add new ones (closed registry). Registry metadata `isInventory: boolean` is the cache authority (NOT MCP `annotations`, which are advisory/untrusted per R-001).
|
||
- Argument validation: any tool call with args not matching `inputSchema` → HTTP 400 with schema-validation error (Edge 4) BEFORE adapter invocation.
|
||
|
||
3. **`packages/mcp/router.ts`** — adapter router (backend-engineer)
|
||
- Resolves `(tenant_id, adapter_type, target_id)` tuples to adapter instances (REQ-016). Reads `mcp_adapters` under `withTenant` + RLS. Routing errors (unknown adapter, target offline) → HTTP 404 with structured error.
|
||
- Multi-target scope disambiguation (REQ-024): if a tenant has ≥2 adapters of the same type and no `target_id` is provided, return HTTP 400 "target required" with a list of available targets (Edge 3).
|
||
|
||
4. **`packages/mcp/write-blocklist.ts`** — per-adapter write-method blocklist enforcer (security-engineer + backend-engineer) — **INV-7 BACKSTOP** [G-015, G-016]
|
||
- **[G-015] Framing correction:** The closed 9-tool registry (REQ-015) is the PRIMARY INV-7 boundary — `proxmox.shutdown_vm` is not a tool and cannot be routed. The write-method blocklist is defense-in-depth against adapter bugs (an adapter mistakenly constructing a non-GET). Security review must audit BOTH the registry (closed enumeration) AND the blocklist (method reject). Document this framing in `PROTOCOL.md` and the module docstring.
|
||
- **[G-016] Two enforcement models:** (a) **Method blocklist** (Proxmox/Gitea: pre-dispatch HTTP-method check — reject POST/PUT/DELETE/PATCH; REST-specific). (b) **Scope-via-403** (GitHub: runtime 403 + `X-Accepted-GitHub-Permissions` handling, per R-004 — NOT a pre-dispatch method check, because GitHub fine-grained PAT scopes are not introspectable). These are distinct mechanisms; do not conflate them.
|
||
- **[G-016] Future risks documented in PROTOCOL.md:** "The method blocklist is REST-specific. A future GraphQL adapter (not in M2) needs a different enforcement model (operation allowlist, not HTTP method — GraphQL uses POST for both queries and mutations). PVE has some GET endpoints with side effects; the 3 M2 endpoints (`/nodes`, `/nodes/{node}/qemu`, `/nodes/{node}/qemu/{vmid}/status/current`, `/nodes/{node}/status`) are verified read-only. An endpoint allowlist (only permit specific paths) is the M3+ evolution if the tool set grows."
|
||
- Per-adapter blocklist (spec §5): Proxmox POST/PUT/DELETE; SSH non-whitelist commands (6-command subset per REQ-021); GitHub scopes outside `metadata:read`+`actions:read` (validated per-invocation via 403 + `X-Accepted-GitHub-Permissions`); Gitea POST/PUT/DELETE/PATCH on all endpoints.
|
||
- 100% of write attempts rejected at broker with HTTP 403 + `adapter.write_rejected` audit event; adapter NEVER invoked. **Verified by a test per adapter at the M2 gate.**
|
||
- Order of enforcement (R-007): auth → tenant resolve → RBAC → **rate-limit check** → **write-method blocklist** → adapter resolve → invoke. Rate limit is the outermost gate; write-blocklist is the INV-7 gate.
|
||
|
||
5. **`packages/mcp/rate-limiter.ts`** — token-bucket, in-memory (backend-engineer)
|
||
- Per user (capacity=60, refill=1/sec) AND per tenant (capacity=300, refill=5/sec). Both must pass (AND logic). Refund user token on tenant-fail (fairness, D-M2-R007). O(1) check <5ms (NFR).
|
||
- `RateLimiter` interface `Promise`-returning now (M2 sync impl wrapped in Promise) so M3 can swap in `RedisRateLimiter` with no signature change (D-M2-R007). Redis migration path documented in code comments.
|
||
- On reject: HTTP 429 + `Retry-After: <seconds>` header (RFC 7231). Body `{error:"rate_limited", retryAfterSec}`. **No adapter call made. Do NOT audit 429s** (not adapter events; could amplify a flood — log at warn level instead).
|
||
|
||
6. **`packages/mcp/stream-manager.ts`** — SSE stream manager (backend-engineer)
|
||
- In-memory `Map<correlationId, CorrelationContext>`. Context: `{correlationId, tenantId, userId, adapterType, toolName, controller?, abortController, createdAt}`.
|
||
- ULID correlation IDs (26-char, lexicographically sortable) minted at `POST /api/mcp/invoke` (add `ulid` npm dep to `packages/mcp`). SSE event format: `id: <ulid>-<seq>\nevent: tool_result\ndata: {"content":[...],"isError":false}\n\n`. Terminal events `done`/`error`.
|
||
- **30s stream-not-opened timeout (R-006):** if `GET /api/mcp/stream/:correlationId` isn't called within 30s of `POST /invoke`, cancel the adapter call and delete the context. Plus 60s max-stream lifetime safety net.
|
||
- Client disconnect (Edge 8): on `req.signal` abort, cancel in-flight adapter call (AbortController), delete context, **NO audit event for client-side cancellation.** Guard with a `closed` flag (abort may fire after normal close).
|
||
- Backpressure: cap controller queue at 100 events; if exceeded, cancel with "client too slow."
|
||
|
||
7. **`packages/mcp/translator.ts`** — OpenAI↔MCP translation (backend-engineer)
|
||
- `tool_calls[i].function.name` → `params.name`; `JSON.parse(tool_calls[i].function.arguments)` → `params.arguments` (parsed JSON object — **pitfall:** OpenAI sends `arguments` as a JSON string; MCP expects an object; handle parse failures as protocol errors, not tool execution errors).
|
||
- MCP `result.content[].text + isError` → OpenAI tool message `{role:"tool", tool_call_id, content}`. `isError:false` → `content: result.content[0].text` (concatenate if multiple). `isError:true` → `content: "ERROR: " + result.content[0].text` (M2 convention; OpenAI has no native `isError`).
|
||
|
||
8. **`packages/mcp/transport/in-process.ts`** — in-process custom MCP transport with synthetic `initialize`/`initialized` handshake (R-001) (backend-engineer)
|
||
- JSON-RPC 2.0 messages (`tools/list`, `tools/call`) passed in-process between broker and TS adapter modules (no wire serialization, but shape must match). D-007.
|
||
- Synthetic lifecycle exchange at adapter registration (R-001 mitigation): broker → `{method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}`, adapter → `{capabilities:{tools:{}}}`. Cheap; produces clean conformance evidence.
|
||
|
||
9. **API routes** (backend-engineer + frontend-engineer for route handlers)
|
||
- `GET /api/mcp/tools` — list tools (MCP `tools/list` facade; returns 9-tool closed set; per-tenant disabled tools filtered).
|
||
- `POST /api/mcp/invoke` — invoke a capability; mints ULID, creates correlation context, kicks off adapter call async, returns `{correlationId, streamUrl}`. Order: auth → tenant → RBAC → rate-limit → write-blocklist → resolve → invoke.
|
||
- `GET /api/mcp/stream/[correlationId]/route.ts` — SSE stream (`runtime = "nodejs"`, `dynamic = "force-dynamic"` per R-006). `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`.
|
||
- `POST /api/mcp/adapter` — configure an adapter (J1 Step 3). Validates role/scope at submit (REQ-025/026/027), `SecretProvider.put`, INSERT under `withTenant` + RLS, `adapter.configured` audit.
|
||
- `PATCH/DELETE /api/mcp/adapter/:id` — update/remove adapter config.
|
||
- All routes use M1 patterns: `requireAdmin`/`requireRead` auth guard (mirrors `apps/control-plane/app/api/byom/route.ts`), `withTenant`, `appendAudit`.
|
||
|
||
10. **Audit event types** (backend-engineer) [G-012]
|
||
- **[G-012] M1-file edit (type widening, NOT "additive — no schema change" at the TS layer):** Extend `AuditEventType` in `packages/db/src/audit.ts` (M1 source file) with `adapter.configured | adapter.test_connection.succeeded | adapter.test_connection.failed | adapter.capability_invoked | adapter.write_rejected`. The DB column (`audit_log.event_type`) is `TEXT` with no CHECK constraint, so no DB migration. But `appendAudit(client, event: AuditEvent)` is typed to `event.eventType: AuditEventType` — passing the new types is a TS compile error without the union extension. This is a backward-compatible type widening (M1 tests still pass).
|
||
- New event types: `adapter.configured`, `adapter.test_connection.succeeded`, `adapter.test_connection.failed`, `adapter.capability_invoked`, `adapter.write_rejected`. All hash-chained via M1 `appendAudit` (`packages/db/src/audit.ts`). Include `correlation_id` field for capability invocations.
|
||
|
||
11. **Multi-target scope disambiguation (REQ-024)** (backend-engineer)
|
||
- Broker returns HTTP 400 "target required" with a list of available targets when ≥2 same-type adapters exist and no `target_id` is provided (Edge 3).
|
||
|
||
12. **MCP conformance verification artifact (R-001)** (backend-engineer + security-engineer) [G-017]
|
||
- `tests/mcp-conformance/` (**7** tests, all must pass — gate item 15):
|
||
1. `tools-list.test.ts` — `GET /api/mcp/tools` returns 9 tools with `{name, description, inputSchema}` matching REQ-015 exactly. Snapshot the full `tools/list` response.
|
||
2. `tools-call-happy.test.ts` — mock adapter invocation returns MCP result shape `{content:[{type:"text",text}], isError:false}` via SSE.
|
||
3. `tools-call-error.test.ts` — mock adapter `isError:true` returns MCP error shape via SSE `error` terminal event.
|
||
4. `tools-call-invalid-args.test.ts` — args failing `inputSchema` → HTTP 400 schema-validation error (broker rejects before adapter).
|
||
5. `translator.test.ts` — OpenAI `tool_calls` ↔ MCP `tools/call` bidirectional translation, including `arguments` string→object parse and `isError`→content prefix.
|
||
6. `lifecycle.test.ts` — in-process custom transport synthetic `initialize`/`initialized` handshake preserves JSON-RPC 2.0 envelope.
|
||
7. **[G-017] `stdio-interop.test.ts`** — connects to the broker via **stdio transport** (the real-transport path used by the LLM smoke), issues a real `tools/list` JSON-RPC request over stdin/stdout, asserts the response is a valid JSON-RPC 2.0 envelope with the 9 tools, then issues a `tools/call` for a mock adapter and asserts the result shape. **This is the test that proves an external MCP client can connect** — moves the lowest-confidence axis (0.80) to evidence-backed. Optionally: run the official MCP inspector against the broker as a CI step.
|
||
- `packages/mcp/PROTOCOL.md` documenting: pinned spec version `2025-06-18` with links to the three spec pages (tools, transports, lifecycle); transports used (in-process custom, stdio for LLM smoke, REST facade + SSE for UI — NOT Streamable HTTP, compliant as custom transport); JSON-RPC 2.0 shapes preserved; OpenAI ↔ MCP translation contract; synthetic lifecycle handshake. **[G-015]** INV-7 framing (registry is primary, blocklist is backstop). **[G-016]** Two enforcement models + GraphQL/PVE-GET future risks.
|
||
- `MCP_PROTOCOL_VERSION = "2025-06-18"` constant exported from `packages/mcp` and asserted in the conformance test header.
|
||
|
||
13. **Stub adapters for testing** (backend-engineer) [G-020]
|
||
- **[G-020] `McpAdapter` interface** shipped in `packages/mcp/types.ts`: both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement this interface. The interface specifies: `tools/list() → Promise<Tool[]>`, `tools/call(name: string, args: Record<string, unknown>) → Promise<McpResult>`, and a registration mechanism. F's stubs implement it; G/H/I's real adapters implement it. The router (T3) accommodates both in-process module adapters and WebSocket-backed adapters (the SSH adapter wraps a WebSocket round-trip inside `tools/call`). **This makes F→G/H/I a contract handoff, not a code-reading exercise — enables real parallelism.**
|
||
- Minimal mock adapters (one per type: proxmox, ssh, github, gitea) that the broker can route to, for testing the broker in isolation (waves G/H/I plug in real adapters). Each stub: registers via synthetic `initialize`, responds to `tools/list` with its tool subset, responds to `tools/call` with a canned `{content:[{type:"text",text:"stub"}], isError:false}` or `isError:true` for error tests. All stubs implement `McpAdapter`.
|
||
|
||
### Must-haves (verify before ship)
|
||
- [ ] Closed tool registry: 9 tools with `name`, `description`, `inputSchema` (JSON Schema per MCP 2025-06-18).
|
||
- [ ] Write-method blocklist: 100% of write attempts rejected at broker with 403 + audit event, adapter never invoked (test per adapter type with stubs).
|
||
- [ ] Rate limiter: 60/min user + 300/min tenant enforced, HTTP 429 + `Retry-After`, refund-on-tenant-fail, no audit on 429.
|
||
- [ ] SSE stream: per-call, ULID correlation IDs, terminal events `done`/`error`, 30s stream-not-opened timeout (R-006), <100ms chunk delivery.
|
||
- [ ] Client disconnect (Edge 8): in-flight adapter call cancelled, no audit event for client-side cancellation.
|
||
- [ ] Multi-target: ≥2 same-type adapters without `target_id` → HTTP 400 "target required" with available targets list.
|
||
- [ ] MCP conformance artifact: `PROTOCOL.md` + 6 tests passing (gate item 15).
|
||
- [ ] Synthetic `initialize`/`initialized` handshake for in-process adapters (R-001).
|
||
- [ ] OpenAI↔MCP translator: `tool_calls` → `tools/call` (with `JSON.parse(arguments)`); `result.content + isError` → tool message.
|
||
- [ ] `mcp_adapters` table with RLS (tenant-scoped, verified against real Postgres 16 in CI per Wave 0).
|
||
- [ ] Audit events: `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected` — all hash-chained via M1 `appendAudit`.
|
||
- [ ] Coverage ≥ 80% on `packages/mcp`.
|
||
- [ ] M1 non-regression: all M1 tests still pass.
|
||
- [ ] Security-engineer sign-off on write-method blocklist + INV-7 at broker (blocks ship on P0/P1 finding).
|
||
|
||
---
|
||
|
||
## Wave G — Proxmox adapter (Phase 2)
|
||
|
||
**Goal:** Read-only Proxmox VE adapter with PVEAuditor role validation. 3 capabilities: `proxmox.list_vms` (inventory), `proxmox.get_vm_status` (live), `proxmox.get_node_metrics` (live).
|
||
**Depends on:** Wave F (broker).
|
||
**REQs covered:** REQ-020, REQ-025.
|
||
**Personas:** backend-engineer (adapter), security-engineer (sign-off on PVEAuditor validation + write-blocklist).
|
||
**Patch tag:** `v0.1.2`. **Branch:** `phase/02-proxmox-adapter`.
|
||
|
||
### Tasks
|
||
|
||
1. **`packages/mcp/adapters/proxmox/client.ts`** (backend-engineer)
|
||
- PVE API client (HTTPS, port 8006, cookie/token auth). Uses API Token auth (NOT ticket/cookie — stateless, no 2h expiry, no CSRF needed for tokens per R-002): `Authorization: PVEAPIToken=USER@REALM!TOKENID=UUID` header on every GET.
|
||
- `pveGet(host, token, path, allowSelfSigned)`: global `fetch` with `AbortSignal.timeout(10_000)` (10s upstream NFR, mirrors `packages/byom/src/validator.ts` pattern). `allowSelfSigned` per-adapter config flag → `https.Agent({rejectUnauthorized: false})` for customer PVE labs (R-002 pitfall).
|
||
- Unwrap `{data: <payload>}` response shape. Check both `!res.ok` AND `body.data === null` (PVE returns null data for some not-found cases). 5xx → HTTP 502/504 to caller (transient upstream error, NOT write rejection).
|
||
- Endpoints (R-002, verified): `GET /api2/json/nodes`, `GET /api2/json/nodes/{node}/qemu`, `GET /api2/json/nodes/{node}/qemu/{vmid}/status/current`, `GET /api2/json/nodes/{node}/status`. **Pitfall:** `/api2/json/qemu` is NOT a valid endpoint (qemu is under a node). No version branching needed (endpoints stable PVE 6.x–8.x, R-002).
|
||
|
||
2. **`packages/mcp/adapters/proxmox/adapter.ts`** (backend-engineer)
|
||
- Implements the 3 capabilities (GET endpoints only — never POST/PUT/DELETE):
|
||
- `proxmox.list_vms` (inventory): `GET /api2/json/nodes/{node}/qemu` (requires `node` arg; returns VMs on that node). `inputSchema: {node: string (required)}`.
|
||
- `proxmox.get_vm_status` (live): `GET /api2/json/nodes/{node}/qemu/{vmid}/status/current`. `inputSchema: {node: string, vmid: integer}`.
|
||
- `proxmox.get_node_metrics` (live): `GET /api2/json/nodes/{node}/status`. `inputSchema: {node: string}`.
|
||
- Registers via synthetic `initialize` handshake (Wave F transport). Responds to `tools/list` with the 3 proxmox tools; responds to `tools/call` with `{content:[{type:"text", text: JSON.stringify(normalizedResult)}], isError:false}`.
|
||
|
||
3. **`packages/mcp/adapters/proxmox/validate.ts`** (backend-engineer + security-engineer)
|
||
- PVEAuditor role validation at submit time (REQ-025, R-002): call `GET /api2/json/version` (any valid token) → token is valid; then `GET /api2/json/nodes` → token has at least `Sys.Audit` (read access). If both succeed → `validated=true`. If either fails → HTTP 422 with role-violation error, no config persisted.
|
||
- **R-002 documented gap:** PVE has no clean "what role does this token have" introspection endpoint. The broker validates "token works for reads," NOT "token lacks writes." True `PVEAuditor` enforcement is the operator's responsibility at token creation time. Document in the Settings → Adapters UI help text: "Create a token with PVEAuditor role. The broker validates read access; the write-method blocklist (POST/PUT/DELETE → 403) is the load-bearing safety boundary." Confidence 0.70 on this sub-point.
|
||
- Record the PVE version (from `GET /api2/json/version` during `test_connection`) in the `mcp_adapters.config` JSON column for diagnostics. No version branching (R-002).
|
||
|
||
4. **SecretProvider integration for Proxmox token (INV-3)** (backend-engineer)
|
||
- `SecretProvider.put(tenantId, "proxmox:<targetId>", token)` on config; `SecretProvider.get` on invocation. DB stores only `secret_ref`. Token never logged; `SecretValue.unwrap()` passed directly to the fetch `Authorization` header.
|
||
|
||
5. **Audit events for Proxmox adapter** (backend-engineer)
|
||
- `adapter.configured` (on save), `adapter.test_connection.{succeeded,failed}` (on "Test connection"), `adapter.capability_invoked` (on each capability call, with `correlation_id`, params hash, result status), `adapter.write_rejected` (if POST/PUT/DELETE somehow reached the broker — belt-and-suspenders). All via M1 `appendAudit`.
|
||
|
||
6. **Tests** (backend-engineer + security-engineer)
|
||
- Mock PVE API (no live Proxmox in CI — use a mock fetch responder). Validate the 3 capabilities call only GET endpoints. Validate write-method blocklist: POST/PUT/DELETE → 403 + `adapter.write_rejected` at broker (adapter never invoked). Validate PVEAuditor validation: token failing `GET /version` → 422; token failing `GET /nodes` (no `Sys.Audit`) → 422. Validate 5xx from PVE → HTTP 502/504 (not write rejection). Validate `allowSelfSigned` flag.
|
||
|
||
7. **Inventory TTL cache (60s, LRU) for `proxmox.list_vms`** (backend-engineer)
|
||
- In-memory cache keyed by `(tenantId, targetId, toolName, argsHash)`. `list_*` capabilities cached for 60s; live capabilities (`get_vm_status`, `get_node_metrics`) never cached. Cache hit → return cached result with staleness metadata (`cachedAt: timestamp`) so the SSE event / UI can show "cached Xs ago."
|
||
|
||
### Must-haves
|
||
- [ ] 3 capabilities call only GET endpoints (never POST/PUT/DELETE).
|
||
- [ ] PVEAuditor validation: token validated at submit (`GET /version` + `GET /nodes`); UI documents the introspection gap (R-002).
|
||
- [ ] Token stored via `SecretProvider.put`; DB holds only `secret_ref`.
|
||
- [ ] Write-method blocklist: POST/PUT/DELETE → 403 + `adapter.write_rejected` audit event at broker (adapter never invoked).
|
||
- [ ] Inventory cache: `list_vms` served from 60s TTL cache on repeat calls; staleness surfaced.
|
||
- [ ] Coverage ≥ 80% on `packages/mcp/adapters/proxmox`.
|
||
- [ ] PVE 7.x and 8.x supported (no version branching needed per R-002).
|
||
- [ ] Security-engineer sign-off on PVEAuditor validation + write-blocklist.
|
||
|
||
---
|
||
|
||
## Wave H — SSH/Linux adapter (Phase 3)
|
||
|
||
**Goal:** Read-only SSH/Linux adapter via M1 Relay Agent. `ssh.run_whitelisted_command` capability. Defense-in-depth: broker validates command (layer 1) + Relay Agent `CheckCommand` (layer 2). **Full REQ-026** (M1 shipped the hook; M2 plugs the adapter in).
|
||
**Depends on:** Wave F (broker), M1 Relay Agent.
|
||
**REQs covered:** REQ-021, REQ-026 (full).
|
||
**Personas:** go-engineer (Wave H only — Relay Agent integration), backend-engineer (TS SSH adapter module), security-engineer (sign-off on defense-in-depth).
|
||
**Patch tag:** `v0.1.3`. **Branch:** `phase/03-ssh-adapter`.
|
||
|
||
### Tasks
|
||
|
||
1. **`packages/mcp/adapters/ssh/whitelist-check.ts`** (backend-engineer + security-engineer)
|
||
- Broker-side whitelist validation (layer 1): validates `command` against the 6-command subset BEFORE dispatch to the Relay Agent (REQ-021, defense-in-depth layer 1).
|
||
- 6 commands (spec §7 Q3, conservative subset of M1's broader whitelist):
|
||
- `uptime` — exact match (no args).
|
||
- `df -h` — exact match.
|
||
- `free -m` — exact match.
|
||
- `systemctl status <svc>` — prefix `systemctl status ` + service name (regex `^[a-zA-Z0-9_.-]+$`, max 64 chars — **pitfall:** sanitize to prevent injection like `systemctl status nginx; rm -rf /`).
|
||
- `journalctl -n <N>` — `journalctl -n ` + integer 1-500 (regex `^journalctl -n ([1-9][0-9]{0,2}|500)$`).
|
||
- `systemctl list-units --type=service` — exact match.
|
||
- `validateSshCommand(command: string): {ok: boolean, reason?: string}`. **Independent TS implementation** from the Go `CheckCommand` (R-003: two independent codepaths so a bug in one doesn't bypass the other). Layer 1 (6 commands) is stricter than layer 2 (M1's broader whitelist) — correct defense-in-depth.
|
||
- On rejection: HTTP 403 + `adapter.write_rejected` audit event; Relay Agent never reached.
|
||
|
||
2. **`packages/mcp/adapters/ssh/adapter.ts`** (backend-engineer)
|
||
- TS SSH adapter module; MCP `tools/call` in-process (Wave F transport), then sends a `tool_call` WebSocket message to the M1 Relay Agent (downstream WebSocket — D-007: the MCP JSON-RPC layer is in-process; the WebSocket to the Relay Agent is downstream transport, does not affect MCP conformance).
|
||
- `ssh.run_whitelisted_command` (live): `inputSchema: {command: string}`. Broker validates (layer 1) → resolve adapter → send `tool_call` over WebSocket to the connected Relay Agent for `target_id` → receive `tool_result` → return MCP result.
|
||
- Target routing (R-003): reverse index `targetsByTenant: Map<tenantId, Map<targetId, WebSocket>>` built on the M1 `connectedAgents` registry in `ws-server.ts`. If target offline → HTTP 404 (do NOT queue the call). Verify target belongs to the same tenant (RLS — `withTenant` + targets table `tenant_id`).
|
||
|
||
3. **`apps/relay-agent/wsclient/handler.go`** (go-engineer) [G-021]
|
||
- **[G-021] Reader goroutine restructure:** the M1 reader goroutine (`apps/relay-agent/wsclient/client.go:182-203`) currently unmarshals every message as `pongMessage` and `continue`s on parse failure — `tool_call` messages are silently dropped today. M2 must restructure the reader goroutine to **dispatch on `type` field BEFORE unmarshaling into a specific struct**: read `type` from the raw JSON, route `pong` to the existing handler, route `tool_call` to the new handler. The heartbeat `pongArrived` signaling must not break.
|
||
- Add `tool_call` message handler. Handler receives `{type:"tool_call", callId, command, timeoutMs}`, calls `CheckCommand(command)` (M1 G-004 contract, layer 2 — **signature `CheckCommand(cmd string) error` UNCHANGED**), executes via `exec.Command` with split argv (**NO shell** — `exec.Command("systemctl", "status", "nginx")`, never `sh -c "..."` — third enforcement layer against shell injection).
|
||
- Returns `{type:"tool_result", callId, stdout, stderr, exitCode}` (success) or `{type:"tool_result", callId, error:"whitelist rejected: ...", exitCode:-1}` (CheckCommand rejection) or `{type:"tool_result", callId, error:"timeout after 10s", exitCode:-1}` (timeout).
|
||
- 9.5s `exec.Command` timeout (R-003: agent times out 0.5s before the broker's 10s timeout so the agent returns a timeout result before the broker gives up → SSE stream closes cleanly).
|
||
|
||
4. **`apps/relay-agent/main.go`** (go-engineer)
|
||
- Wire the `tool_call` handler into the WebSocket message router. M1 non-regression: the `register`/`ping`/`pong` paths must continue to work; the heartbeat loop must not break.
|
||
|
||
5. **Cross-layer SSH test — divergence matrix (R-003)** (security-engineer) [G-013]
|
||
- **[G-013] Divergence matrix** (not just both-reject — also both-accept and divergence cases):
|
||
(a) Both reject `rm -rf /` (existing — `rm` not in 6-command subset at broker; `rm` not in M1 whitelist at Go).
|
||
(b) Both accept `systemctl status nginx` (new — proves both layers agree on a valid command).
|
||
(c) Broker rejects `systemctl status nginx rm -rf /` (regex fails on spaces in service name) — assert Go **also** rejects (currently Go accepts because `rm` is not in the deny list and the prefix `systemctl status` matches). **Fix:** tighten the Go deny list to include bare `rm` OR validate `systemctl status` trailing tokens against `^[a-zA-Z0-9_.-]+$` (matching the broker's regex). Choose option (ii) — validate trailing tokens — to make the two layers semantically equivalent for the 6-command subset.
|
||
(d) Go accepts `systemctl status nginx$(curl evil)` (deny list misses `$()`) — assert broker rejects (regex fails). Document that `exec.Command` with split argv runs `nginx$(curl evil)` as a literal service name (no shell expansion), so the Go layer is saved by the no-shell third layer, but the divergence is real and must be documented.
|
||
- All 4 cases pass on both layers; Go deny list / trailing-token validation tightened per (c).
|
||
|
||
6. **M1-relay-WS regression test (G-021)** (go-engineer + security-engineer)
|
||
- **[G-021]** Test asserting `register`→`registered` and `ping`→`pong` still work after the `tool_call` case is added to `ws-server.ts` `handleMessage` switch and the Go reader goroutine is restructured. This is an M1-non-regression test for the shared M1 files Wave H edits.
|
||
|
||
6. **10s upstream timeout** (backend-engineer + go-engineer)
|
||
- Broker: `AbortController` 10s on the `tool_call` → `tool_result` round-trip. On timeout → SSE `error` terminal event with "upstream timeout" + HTTP 504 semantics.
|
||
- Agent: `exec.Command` 9.5s context timeout (R-003 — agent times out first).
|
||
|
||
7. **SecretProvider integration for SSH registration token (INV-3)** (backend-engineer)
|
||
- `SecretProvider.put(tenantId, "ssh:<targetId>", relayRegistrationToken)` on config. The SSH adapter uses the M1 Relay Agent's existing auth (the registration token resolves to a connected target; the broker routes `tool_call` to that target's WebSocket).
|
||
|
||
8. **Audit events for SSH adapter** (backend-engineer)
|
||
- `adapter.configured` (on save), `adapter.test_connection.{succeeded,failed}` (on "Test connection" — e.g., ping the target via `uptime`), `adapter.capability_invoked` (on each `ssh.run_whitelisted_command` call, with `correlation_id`, command hash, result status), `adapter.write_rejected` (on broker layer-1 rejection).
|
||
|
||
### Must-haves
|
||
- [ ] Broker validates `command` against 6-command subset BEFORE dispatch (layer 1).
|
||
- [ ] Relay Agent `CheckCommand` validates at execution (layer 2, M1 G-004 contract unchanged).
|
||
- [ ] **[G-013]** Cross-layer divergence matrix (R-003): all 4 cases pass — both-reject `rm -rf /`, both-accept `systemctl status nginx`, broker-rejects-Go-also-rejects `systemctl status nginx rm -rf /` (after Go tightening), Go-accepts-broker-rejects `systemctl status nginx$(curl evil)` (documented divergence).
|
||
- [ ] **[G-021]** M1-relay-WS regression test: `register`→`registered` + `ping`→`pong` still work after `tool_call` addition + reader goroutine restructure.
|
||
- [ ] `tool_call` WebSocket message type added to Relay Agent (clean extension of M1 protocol; `register`/`ping`/`pong` still work).
|
||
- [ ] `CheckCommand(cmd string) error` signature UNCHANGED (G-004 contract lock).
|
||
- [ ] No shell in Go executor (`exec.Command` with split argv).
|
||
- [ ] 10s broker timeout + 9.5s agent exec timeout.
|
||
- [ ] `ssh.run_whitelisted_command` returns whitelisted command output via SSE.
|
||
- [ ] Coverage ≥ 80% on `packages/mcp/adapters/ssh` + relay-agent additions.
|
||
- [ ] Security-engineer sign-off on defense-in-depth (blocks ship on P0/P1 finding).
|
||
- [ ] go-engineer persona removed after Wave H ships; `apps/relay-agent/**` territory reverts to backend-engineer for M2 follow-up.
|
||
|
||
---
|
||
|
||
## Wave I — Git adapters (Phase 4)
|
||
|
||
**Goal:** Read-only GitHub + Gitea adapters. 5 capabilities: `github.list_repos`, `github.get_recent_ci_runs`, `github.get_workflow_run`, `gitea.list_repos`, `gitea.get_recent_ci_runs`.
|
||
**Depends:** Wave F (broker).
|
||
**REQs covered:** REQ-022, REQ-023, REQ-027.
|
||
**Personas:** backend-engineer (adapters), security-engineer (sign-off on scope validation).
|
||
**Patch tag:** `v0.1.4`. **Branch:** `phase/04-git-adapters`.
|
||
|
||
### Tasks
|
||
|
||
1. **`packages/mcp/adapters/github/client.ts`** (backend-engineer)
|
||
- GitHub REST API client (fine-grained PAT, `Authorization: Bearer <token>`, `Accept: application/vnd.github+json`, `X-GitHub-Api-Version: 2022-11-28` — stable GA version per R-004). Global `fetch` with `AbortSignal.timeout(10_000)`.
|
||
- `ghGet(path, token, query?)`: rate limit handling — observe `x-ratelimit-remaining`; if 0, do NOT make the call, return HTTP 429 to caller with `Retry-After: <seconds until x-ratelimit-reset>`. If GitHub returns 429 (or 403 with `x-ratelimit-remaining: 0`), back off exponentially (1s, 2s, 4s, max 3 retries) then surface 429.
|
||
- 403 with `X-Accepted-GitHub-Permissions` header (e.g., `actions=read` required) → `GithubScopeError` → broker surfaces HTTP 403 "insufficient scope" + `adapter.capability_invoked` with `result=failure` (NOT `write_rejected` — no write attempted; this is a scope mismatch, R-004).
|
||
|
||
2. **`packages/mcp/adapters/github/adapter.ts`** (backend-engineer)
|
||
- 3 capabilities (GET endpoints only):
|
||
- `github.list_repos` (inventory): `GET /user/repos?per_page=100`. Normalize to `{id, name, full_name, owner, private, description, html_url, default_branch, updated_at}`. `inputSchema: {}` (first page; multi-page is M3, R-004).
|
||
- `github.get_recent_ci_runs` (live): `GET /repos/{owner}/{repo}/actions/runs?per_page={per_page}`. Normalize to `{total_count, runs:[{id, head_branch, status, conclusion, html_url, created_at, actor}]}`. `inputSchema: {owner: string, repo: string, per_page?: integer (default 30, max 100), status?: string, branch?: string}`.
|
||
- `github.get_workflow_run` (live): `GET /repos/{owner}/{repo}/actions/runs/{run_id}`. `inputSchema: {owner, repo, run_id: integer}`.
|
||
|
||
3. **`packages/mcp/adapters/github/validate.ts`** (backend-engineer + security-engineer)
|
||
- Fine-grained PAT validation (D-006, R-004):
|
||
1. **Detect classic vs fine-grained:** classic PATs start with `ghp_`/`gho_`/`ghu_`; fine-grained start with `github_pat_`. Reject classic PATs at submit → HTTP 422 "fine-grained PAT required" (D-006: classic `repo` scope grants write).
|
||
2. **Validate token works + `metadata:read`:** `GET /user` with token. 401 → invalid token (HTTP 422). 200 → token valid; all fine-grained PATs require `metadata:read` implicitly, so a successful `GET /user` implies `metadata:read`.
|
||
3. **`actions:read` validated per-invocation:** when `github.get_recent_ci_runs`/`get_workflow_run` is called, if GitHub returns 403 with `X-Accepted-GitHub-Permissions` indicating `actions=read` required → HTTP 403 "insufficient scope" + audit `adapter.capability_invoked` with `result=failure`. Submit-time best effort (R-004 gap documented in UI help text: "ensure the PAT has `actions:read`").
|
||
|
||
4. **`packages/mcp/adapters/gitea/client.ts`** (backend-engineer)
|
||
- Gitea REST API client (`Authorization: token <token>` — **pitfall:** Gitea uses `token` not `Bearer`, R-005). Base URL = customer's Gitea host (`https://gitea.example.com/api/v1/`). `allowSelfSigned` per-adapter flag (customer Gitea often self-signed).
|
||
|
||
5. **`packages/mcp/adapters/gitea/adapter.ts`** (backend-engineer)
|
||
- 2 capabilities (GET endpoints only — no `gitea.get_workflow_run` in M2, deferred to v1.2+ per Q2):
|
||
- `gitea.list_repos` (inventory): `GET /api/v1/user/repos?limit=50`. Normalize to `{id, name, full_name, owner, private, description, html_url, default_branch, updated_at}`. `inputSchema: {}`.
|
||
- `gitea.get_recent_ci_runs` (live): `GET /api/v1/repos/{owner}/{repo}/actions/runs?limit={limit}`. `inputSchema: {owner, repo, limit?: integer (default 30, max 50)}`. **Pitfall:** Gitea Actions may be disabled (`actions.ENABLED=true` in app.ini) → 404; surface as "Gitea Actions not enabled on this instance" (HTTP 502, not write rejection).
|
||
|
||
6. **`packages/mcp/adapters/gitea/validate.ts`** (backend-engineer + security-engineer)
|
||
- Version-aware validation (R-005, spec §7 Q6):
|
||
1. `GET /api/v1/version` → parse `version`, compare major.minor to `1.22` (semver-ish; compare as integers). Record version in `mcp_adapters.config`.
|
||
2. Gitea ≥1.22: `GET /api/v1/user/repos?limit=1` → 200 = `read:repository` ok; 403 = insufficient scope → HTTP 422 "insufficient scope — `read:repository` required".
|
||
3. Gitea <1.22: `GET /api/v1/repos/search?limit=1` → 200 = token valid (any token accepted; no read-only scope available). Broker-side write-method blocklist (POST/PUT/DELETE/PATCH) is the security backstop.
|
||
|
||
7. **`packages/mcp/adapters/gitea/version-check.ts`** (backend-engineer)
|
||
- Gitea version detection + scope routing (R-005). **Verify against a running Gitea 1.22+ AND a <1.22 instance during Wave I** (R-005 action: confirm `GET /api/v1/version`, `GET /api/v1/user/repos`, `GET /api/v1/repos/{owner}/{repo}/actions/runs` against real instances; confirm `read:repository` scope behavior and `Authorization: token <token>` header).
|
||
|
||
8. **SecretProvider integration for Git tokens (INV-3)** (backend-engineer)
|
||
- `SecretProvider.put(tenantId, "github:<targetId>", pat)` / `SecretProvider.put(tenantId, "gitea:<targetId>", token)`. DB stores only `secret_ref`. Tokens never logged.
|
||
|
||
9. **Rate limit handling for GitHub** (backend-engineer)
|
||
- Observe `X-RateLimit-Remaining`; back off on 429 (R-004). M2 broker's own token-bucket (60/min user) is well below GitHub's 5000/hour, so the GitHub limit is unlikely to bind unless many tenants share a token (they shouldn't — per-tenant tokens).
|
||
|
||
10. **Inventory TTL cache (60s, LRU) for `github.list_repos` and `gitea.list_repos`** (backend-engineer)
|
||
- Same pattern as Proxmox (Wave G). `list_*` cached 60s; live capabilities (`get_recent_ci_runs`, `get_workflow_run`) never cached. Staleness surfaced.
|
||
|
||
11. **Audit events for Git adapters** (backend-engineer)
|
||
- `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected` (for Gitea POST/PUT/DELETE/PATCH blocklist violations). All via M1 `appendAudit`.
|
||
|
||
12. **Tests** (backend-engineer)
|
||
- GitHub: validated via real-target smoke in CI (real GitHub PAT, Wave 0 prerequisite). Validate 3 capabilities call only REST GET endpoints. Validate classic PAT rejection (`ghp_` prefix → 422). Validate fine-grained PAT (`github_pat_`) → `GET /user` → 200 = valid. Validate rate limit handling (mock 429 + `x-ratelimit-remaining: 0`).
|
||
- Gitea: validated via mock + verify against running instance (R-005). Validate 2 capabilities call only GET endpoints. Validate version-aware scope routing (≥1.22 `read:repository`; <1.22 any token + write blocklist). Validate `Authorization: token <token>` header. Validate Gitea Actions disabled → 404 → "not enabled".
|
||
|
||
### Must-haves
|
||
- [ ] GitHub: fine-grained PAT only (classic PAT rejected by `github_pat_` prefix); `metadata:read` + `actions:read` minimum (D-006).
|
||
- [ ] GitHub: 3 capabilities call only REST GET endpoints; rate limit handling (`X-RateLimit-Remaining`, backoff on 429); per-invocation 403 + `X-Accepted-GitHub-Permissions` handling (R-004).
|
||
- [ ] Gitea: version-aware scope validation (≥1.22 `read:repository`; <1.22 any token with broker write-method blocklist).
|
||
- [ ] Gitea: verify against running instance during Wave I (R-005).
|
||
- [ ] Write-method blocklist: GitHub scopes outside read-only → 403; Gitea POST/PUT/DELETE/PATCH → 403 + `adapter.write_rejected` at broker.
|
||
- [ ] Tokens stored via `SecretProvider.put`; DB holds only `secret_ref`.
|
||
- [ ] Inventory cache: `list_repos` served from 60s TTL cache on repeat calls; staleness surfaced.
|
||
- [ ] Coverage ≥ 80% on `packages/mcp/adapters/github` + `gitea`.
|
||
- [ ] Security-engineer sign-off on scope validation.
|
||
|
||
---
|
||
|
||
## Wave J — SSE integration + LLM smoke + adapter UI (Phase 5)
|
||
|
||
**Goal:** SSE integration end-to-end, `packages/llm-mock` CI-only LLM smoke provider, Settings → Adapters UI + Test-Call UI. **The LLM smoke (M2 gate item 8, P0 — not deferrable) must pass before M2 ships.**
|
||
**Depends on:** Wave F (broker) + at least Wave I (GitHub adapter for real-target smoke).
|
||
**REQs covered:** REQ-017 integration (SSE to UI), M2 gate item 8 (LLM smoke).
|
||
**Personas:** frontend-engineer (UI), backend-engineer (llm-mock + SSE integration).
|
||
**Patch tag:** `v0.1.5`. **Branch:** `phase/05-sse-integration`.
|
||
|
||
### Tasks
|
||
|
||
1. **`packages/llm-mock/`** — CI-only mock LLM provider (backend-engineer) [G-019]
|
||
- `devDependency` (not a production dependency) — R-008. Implements OpenAI-compatible `/v1/chat/completions` accepting the `tools` parameter (OpenAI tool definitions from the broker's `GET /api/mcp/tools`).
|
||
- **[G-019] Hardened pattern matching** (regex set, not 2-word conjunction — tolerates prompt wording drift):
|
||
- `/(list|show|get|display).*\b(repo|repositor)/i` → `tool_calls:[{id:"call_1", type:"function", function:{name:"github.list_repos", arguments:"{}"}}]`.
|
||
- `/(recent|latest|last).*\b(run|ci|workflow)/i` → `tool_calls:[{function:{name:"github.get_recent_ci_runs", arguments:'{"owner":"...","repo":"..."}'}}]`.
|
||
- Tests assert the pattern matches "Show me my GitHub repositories", "List my repos", "Get repositories", "What are my recent CI runs" — wording-tolerant.
|
||
- Accepts follow-up `tool` role messages (broker's translated adapter result). On second call (with a tool message present): synthesize grounded text — parse repo names from the tool message content, return `"Your repos are: <names>"`.
|
||
- Deterministic — no randomness; the smoke test asserts specific repo names appear (R-008).
|
||
- Reuse BYOM request/response types from `packages/byom/src/types.ts` (D-001 contract).
|
||
|
||
2. **`apps/control-plane/app/(dashboard)/settings/adapters/page.tsx`** — Settings → Adapters UI (frontend-engineer) [G-014]
|
||
- Adapter type picker (4 types), per-adapter config forms, SecretProvider-backed credential entry (redacted after submit, edit-only), role/scope validation on submit (REQ-025/026/027), "Test connection" button (REQ-016), audit event confirmation, multi-target support (target_id per row), PVEAuditor introspection gap help text (R-002), GitHub fine-grained PAT help text (D-006), Gitea version-aware help text (R-005).
|
||
- **[G-014] Closed-tool-set gap documentation in UI help text** (sets operator expectations pre-ship so the "additions require spec amendment" gate is politically enforceable):
|
||
- SSH: "M2 supports 6 diagnostic commands (`uptime`, `df -h`, `free -m`, `systemctl status`, `journalctl -n`, `systemctl list-units`). `ps`, `ss`, `top`, `ip` are deferred to v1.2+."
|
||
- Proxmox: "`list_vms` requires a `node` argument. A `list_nodes` tool is deferred to v1.2+."
|
||
- GitHub: "`list_repos` returns up to 100 repos (first page). Pagination, PR lists, and issue lists are deferred to v1.2+."
|
||
- Gitea: "`get_workflow_run` is deferred to v1.2+."
|
||
- Server components read via the API gateway (never bypass RLS — D-005 pattern).
|
||
|
||
3. **`apps/control-plane/app/(dashboard)/test-call/page.tsx`** — Test-Call UI (frontend-engineer)
|
||
- Capability picker (closed 9-tool set from `GET /api/mcp/tools`, grouped by adapter type, per-tenant disabled tools greyed out). Argument forms (rendered from JSON Schema `inputSchema`, required fields marked, type-validated). Target picker for multi-target tenants (REQ-024). SSE stream consumer (`EventSource` on `GET /api/mcp/stream/:correlationId`, renders events as they arrive, terminal `done`/`error` close the stream). Staleness indicator for inventory calls ("cached Xs ago"). Result rendering (JSON tree, `isError` flag surfaced).
|
||
|
||
4. **SSE consumer in Test-Call UI** (frontend-engineer)
|
||
- `EventSource` opens on the `streamUrl` from `POST /api/mcp/invoke`. Renders `tool_result` events incrementally; terminal `done`/`error` close the stream. <100ms chunk delivery (NFR). On client disconnect (page close), the broker detects `req.signal` abort and cancels the in-flight adapter call (Edge 8).
|
||
|
||
5. **LLM smoke test — TWO-TRACK (M2 gate item 8)** (backend-engineer) [G-018, G-019]
|
||
- **[G-018] Two-track smoke** (mock-path is the P0 gate; real-path is optional/allow-failure — ensures the gate is reliable regardless of GitHub availability):
|
||
- **Track A — Mock-path smoke (P0 gate, runs ALWAYS, no external dependency):**
|
||
1. CI starts the control plane with `packages/llm-mock` as the BYOM endpoint + a `github-mock` adapter (deterministic canned-repo adapter, distinct from the broker stub — returns `[{"name":"coreci-test-repo-1"},{"name":"coreci-test-repo-2"}]`).
|
||
2. Test sends `POST /v1/chat/completions` with `tools=[github.list_repos definition]` and prompt "List my GitHub repositories."
|
||
3. llm-mock returns `tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}]`.
|
||
4. Broker's translator converts to MCP `tools/call` → broker routes to `github-mock` adapter → canned repo data.
|
||
5. Broker's translator converts the MCP result to an OpenAI tool message.
|
||
6. Test sends a second `POST /v1/chat/completions` with `messages=[original prompt, assistant tool_call, tool message]`.
|
||
7. llm-mock synthesizes a grounded response ("Your repos are: coreci-test-repo-1, coreci-test-repo-2"). Test asserts the response contains the canned repo names.
|
||
- **This track proves the OpenAI→MCP→adapter→result→synthesis integration with zero external dependencies. It is the P0 gate.**
|
||
- **Track B — Real-path smoke (optional, runs when `secrets.GITHUB_SMOKE_PAT` is available, `allow-failure` — does NOT block the gate):**
|
||
1. Same flow as Track A but against the real GitHub adapter (real PAT, test-org-scoped).
|
||
2. Test asserts the response contains real repo names from the CI test org.
|
||
3. **[G-019] Retry policy:** on 429/5xx/timeout, retry up to 3 times with exponential backoff (1s, 2s, 4s); on final failure, skip with a warning (the mock-path smoke is the gate, not this track).
|
||
- **This track proves real-target connectivity. It is the ideal, not the gate.**
|
||
- **P0 — not deferrable:** Track A (mock-path) must pass reliably in CI (no GitHub dependency). Track B (real-path) is optional/allow-failure. This ensures the M2 gate is reliable regardless of GitHub availability.
|
||
|
||
6. **Import guard (R-008)** (backend-engineer)
|
||
- `packages/llm-mock` is a `devDependency` of the CI test package (or `apps/control-plane` devDeps), NOT a `dependency`. `pnpm install --prod` excludes it.
|
||
- Eslint rule `no-restricted-imports` banning `@coreci/llm-mock` in `apps/control-plane/app/**` and `packages/mcp/**` (prod code paths). Allowed only in `tests/**` and `packages/llm-mock/**`.
|
||
- Build-time check: CI step greps the prod build output (`.next/` or `dist/`) for `llm-mock` and fails if found.
|
||
- **Pitfall:** the mock must not be imported transitively by a prod dependency. The broker talks to it over HTTP (as a BYOM endpoint), not via import.
|
||
|
||
### Must-haves
|
||
- [ ] `packages/llm-mock` is a `devDependency`; import-guarded against prod bundle (eslint + build-time grep).
|
||
- [ ] **[G-018]** Two-track LLM smoke: Track A (mock-path, `github-mock` adapter, canned repos) passes reliably in CI — **this is the P0 gate**.
|
||
- [ ] **[G-018]** Track B (real-path, real GitHub) runs when PAT available, `allow-failure` — does NOT block the gate.
|
||
- [ ] **[G-019]** llm-mock pattern matching hardened (regex set, tolerates wording drift); retry policy for real-GitHub track.
|
||
- [ ] **[G-014]** Settings → Adapters UI: closed-tool-set gap documentation in help text (SSH/Proxmox/GitHub/Gitea limitations).
|
||
- [ ] Settings → Adapters UI: adapter type picker (4 types), config forms, validation on submit (REQ-025/026/027), "Test connection" button, multi-target support, help text for introspection gaps (R-002, D-006, R-005).
|
||
- [ ] Test-Call UI: capability picker (9 tools), argument forms (JSON Schema validated), target picker for multi-target (REQ-024), SSE stream consumer, staleness indicator for inventory calls.
|
||
- [ ] SSE stream renders in Test-Call UI within 100ms of events.
|
||
- [ ] Coverage ≥ 80% on `packages/llm-mock` + UI components.
|
||
|
||
---
|
||
|
||
## Final Phase — Review + Audit + Ship (Phase 6)
|
||
|
||
**Goal:** Multi-persona code review + project health audit + milestone ship (`v0.1.6` release, merge to `main`).
|
||
**REQs covered:** all M2 (sign-off).
|
||
**Personas:** lead-developer (review + audit + ship), all personas (review).
|
||
**Patch tag:** `v0.1.6` ← **M2 milestone release**. **Branch:** `phase/06-final-review-ship`.
|
||
|
||
### Tasks
|
||
|
||
1. **Code review** (lead-developer + all personas)
|
||
- Review all changes in `milestone/v0.2` (or the M2 integration branch) since `main`. Auto-apply P0 fixes; flag P1+ for post-hoc. Security-engineer reviews INV-7 at broker, write-method blocklist per adapter, SSH defense-in-depth, fine-grained PAT scope validation, Gitea version-aware scope validation. go-engineer reviews Relay Agent `tool_call` additions + `CheckCommand` contract lock (Wave H only, then removed).
|
||
|
||
2. **Audit** (lead-developer)
|
||
- Reconstruction test: `git log` matches `.ciagent/` files (every decision, research finding, persona assignment traceable to a commit). File/branch/commit discipline verified (all commits on `phase/NN-*` branches with `---ci---` blocks; no direct commits to `main`).
|
||
- M2 gate items 1-15 all pass (spec §6).
|
||
|
||
3. **M2 acceptance gate verification** (lead-developer)
|
||
- Spec §6 — all 13 REQs (015-027) pass with Given/When/Then coverage.
|
||
- Mocks + real GitHub smoke + LLM smoke (P0 gate item 8) + INV-7 verified (per-adapter write-blocklist tests) + CI Postgres RLS verified (Wave 0) + M1 non-regression.
|
||
- Coverage ≥ 80% on new M2 modules; DB coverage ≥ 80% maintained on `packages/db`.
|
||
|
||
4. **MCP conformance verification artifact review (R-001)** (lead-developer + security-engineer)
|
||
- `packages/mcp/PROTOCOL.md` + 6 conformance tests in `tests/mcp-conformance/` reviewed and confirmed (gate item 15).
|
||
|
||
5. **Milestone ship** (lead-developer)
|
||
- Tag `v0.1.6` (final phase patch = M2 milestone release).
|
||
- Merge `phase/06` → `milestone/v0.1` (or `main` per branch hierarchy) → `main`.
|
||
- Create Gitea release (`https://git.cloudinit.dev/coreci/coreci-chat`) with full M2 summary.
|
||
|
||
6. **Complete** (lead-developer)
|
||
- Mark all M2 REQs (015-027) complete in `REQUIREMENTS.md`.
|
||
- Mark M2 complete in `ROADMAP.md`.
|
||
- Clear checkpoint.
|
||
|
||
---
|
||
|
||
## Wave ordering & parallelism
|
||
|
||
```
|
||
Phase 0 (this plan) ──▶ Wave 0 (prerequisites) ──▶ Wave F (gateway core)
|
||
│
|
||
┌───────────────────────────────────┼───────────────────────┐
|
||
│ │ │
|
||
├──▶ Wave G (Proxmox) ├──▶ Wave H (SSH) ├──▶ Wave I (Git adapters)
|
||
│ │ │
|
||
└───────────────────────────────────┴───────────────────────┘
|
||
│
|
||
▼
|
||
Wave J (SSE + LLM smoke + UI)
|
||
│
|
||
▼
|
||
Final (review + audit + ship)
|
||
```
|
||
|
||
- **F must complete first** (all adapters depend on the broker).
|
||
- **G, H, I can run in parallel after F** (different adapter territories; no cross-dependencies). Wave H reactivates the go-engineer persona for Relay Agent integration only.
|
||
- **J depends on F + at least I** (GitHub adapter for the real-target LLM smoke, gate item 8).
|
||
- **Final depends on all.**
|
||
|
||
---
|
||
|
||
## Test strategy
|
||
|
||
- **Unit:** vitest in `packages/mcp/**` + `apps/control-plane`; Go `testing` in `apps/relay-agent`.
|
||
- **Integration:** Postgres 16 container in CI (Wave 0); RLS + `withTenant` + audit tests against real Postgres (replaces PGlite-only verification, R-009). Two CI jobs: `test-pglite` (default) + `test-postgres` (service container + `DB_MODE=pg` + role setup).
|
||
- **Adapter validation:**
|
||
- Proxmox: mock PVE API (no live Proxmox in CI).
|
||
- SSH: mock + cross-layer test (R-003).
|
||
- Gitea: mock + verify against running instance during Wave I (R-005).
|
||
- GitHub: real-target smoke in CI (real PAT, Wave 0 prerequisite — gate item 7).
|
||
- **LLM smoke:** `packages/llm-mock` driving the full OpenAI→MCP→adapter→result→synthesis path against real GitHub (P0 gate item 8 — not deferrable).
|
||
- **MCP conformance:** `PROTOCOL.md` + 6 tests in `tests/mcp-conformance/` (R-001 artifact, gate item 15).
|
||
- **Cross-layer SSH test:** broker (layer 1) + Relay Agent (layer 2) both reject non-whitelisted commands (R-003).
|
||
- **Coverage gate:** ≥ 80% on new M2 modules (gate item 3); DB coverage ≥ 80% maintained (gate item 4).
|
||
- **M1 non-regression:** all M1 tests still pass (gate item 1).
|
||
|
||
---
|
||
|
||
## Decisions logged (to DecisionEngine)
|
||
|
||
- **D-M2-P001:** Wave 0 is a hard prerequisite for Wave F — CI Postgres 16 + role setup (`coreci_app` no BYPASSRLS, `migrator` BYPASSRLS) + real GitHub PAT. RLS assertions gated on `DB_MODE=pg` replace M1's PGlite-only placeholder. Confidence 0.90.
|
||
- **D-M2-P002:** Wave F ships the broker with stub adapters (not real adapters); real adapters land in waves G/H/I. This isolates broker verification (INV-7, rate-limit, SSE, conformance) from adapter upstream concerns. Confidence 0.85.
|
||
- **D-M2-P003:** G/H/I parallel after F; J depends on F + at least I (GitHub for real-target LLM smoke). go-engineer active for Wave H only, removed after. Confidence 0.85.
|
||
- **D-M2-P004:** v0.1.6 (final phase patch) IS the M2 milestone release; merge to `main` at the final phase. Tags run on v0.1.x patch line (M1's previous minor). Confidence 0.90.
|
||
|
||
All above the 0.6 threshold. No escalations. Pipeline proceeds to GRILL.
|
||
|
||
---
|
||
|
||
*End of M2 PLAN. M1 PLAN preserved in git history (commit prior to M2 overwrite).* |