Files
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
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---
2026-08-25 06:14:21 +00:00

252 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture
## Overview
CoreCI Chat v0.1 is a multi-tenant SaaS with a TypeScript control plane, a Go Relay Agent distributed to customer Linux hosts, an OpenAI-compatible BYOM routing layer, and (M2) an MCP capability broker gateway with four Day-1 infrastructure adapters. The control plane runs in a single AWS region (us-east-1) and enforces tenant isolation via Postgres Row-Level Security. Every request flows through an API gateway that authenticates the session, resolves the tenant, enforces RBAC, and writes an immutable audit entry. The Relay Agent is an outbound-only WebSocket client that registers as a target and heartbeats to the control plane; in M2 it receives MCP tool calls (SSH) and enforces a fixed command whitelist (defense-in-depth layer 2; the broker is layer 1). All credentials live in AWS Secrets Manager (prod) or a local-encrypted fallback (dev) behind a `SecretProvider` interface — never env vars, config files, or DB columns.
The v0.1 wedge is read-only diagnostic: no write actions, no hosted inference, no remediation. Async durable execution is provided by Trigger.dev (instrumented in M3 for chat workflows; the runtime bootstrap lands in M1 Wave A so M3 plugs in cleanly).
### M2 addition — MCP capability broker
M2 introduces the Model Context Protocol (MCP) capability broker as the structured layer between the LLM/UI and customer infrastructure. The broker enforces INV-7 (read-only by default) as the load-bearing safety boundary: it maintains a closed, enumerated tool registry (9 tools across 4 adapters), a per-adapter write-method blocklist, token-bucket rate limiting, multi-target scope disambiguation, and SSE streaming. The broker implements MCP spec version `2025-06-18`. Conformance is verified against modelcontextprotocol.io (artifact required at M2 gate). M3 consumes the broker's REST+SSE gateway as a stable contract (M2 spec §9).
**MCP transport architecture (D-007):** three layers:
1. **Broker ↔ Proxmox/GitHub/Gitea adapters:** in-process custom MCP transport. JSON-RPC 2.0 messages (`tools/list`, `tools/call`) passed in-process between the broker and the TS adapter modules. MCP `2025-06-18` allows custom transports provided they preserve the JSON-RPC message format and lifecycle requirements. No subprocess spawning for same-process TS modules.
2. **Broker ↔ SSH adapter:** the MCP `tools/call` JSON-RPC layer sits between the broker and the TS SSH adapter module (in-process). The TS SSH adapter module then calls the M1 Relay Agent (Go binary) over WebSocket — this downstream WebSocket transport is inherited from M1 infrastructure and is downstream of the JSON-RPC layer. It does not affect MCP conformance.
3. **Broker ↔ CI/LLM smoke:** stdio transport (JSON-RPC over stdin/stdout) for the CI `packages/llm-mock` smoke test. The mock LLM acts as an MCP host.
**Broker ↔ UI:** REST facade + SSE (NOT MCP Streamable HTTP — a browser-friendly facade with MCP-compliant tool schemas and results inside). `POST /api/mcp/invoke` returns `{correlationId, streamUrl}`; `GET /api/mcp/stream/:correlationId` is the SSE stream.
**OpenAI ↔ MCP translation contract:** `tool_calls[].function.{name, arguments}` (OpenAI) → `params.{name, arguments}` (MCP, parsed JSON object); `result.content[].text` + `isError` (MCP) → OpenAI tool message `{role:"tool", tool_call_id, content}`. Documented as a typed translator module in `packages/mcp/translator.ts`.
### Init-time invariants (preserved by the architecture-drift guard)
- Single git repository at `~/coreci-chat`, branch hierarchy `main → milestone/v0.1-bootstrap → phase/NN-*`.
- `.ciagent/` reference files at repo root (single-project mode).
- Release target: Gitea `coreci/coreci-chat` at `https://git.cloudinit.dev`.
- Autonomy: `full` (no HITL after clarify; decision threshold 0.6).
### Architecture invariants (M1 → v0.1)
- Every HTTP request hits the API gateway first: auth → tenant resolve → RBAC → audit.
- Every DB query runs under `SET app.tenant_id = ?` via a session-scoped transaction; RLS policies enforce scoping. Cross-tenant queries return empty.
- Every credential is resolved via `SecretProvider.get(tenantId, key)`; the provider abstracts AWS Secrets Manager (prod) and local-encrypted (dev/test).
- Every auditable event (prompt, tool call, SSH command, response) is appended to `audit_log` with a `prev_hash → curr_hash` chain; `UPDATE`/`DELETE` are REVOKE'd from the app role; a write failure halts the enclosing operation (REQ-038 Edge 7).
- Every LLM inference call is routed to the tenant's configured BYOM endpoint via an OpenAI-compatible `/v1/chat/completions` contract; unconfigured/unreachable → reject with actionable error (REQ-009).
- The Relay Agent makes only outbound connections (WebSocket to SaaS). No inbound firewall rules on customer hosts.
### Architecture invariants (M2 addition)
- **INV-7 at the broker (load-bearing):** 100% of write-action requests rejected at the MCP broker (HTTP 403) before adapter invocation. The broker is the load-bearing safety boundary, NOT the adapter. Per-adapter write-method blocklist: Proxmox POST/PUT/DELETE; SSH non-whitelist commands; GitHub scopes outside `metadata:read`+`actions:read`; Gitea POST/PUT/DELETE/PATCH on all endpoints. Verified by a test per adapter at the M2 gate.
- **Closed tool registry (REQ-015):** 9-tool starter set locked. Per-tenant policy may disable individual tools but never add new ones. Additions require spec amendment (v1.2+).
- **Defense-in-depth for SSH (REQ-021, REQ-026):** broker validates `command` against the 6-command whitelist subset BEFORE dispatch to Relay Agent (layer 1); M1 Relay Agent `CheckCommand` validates at execution (layer 2). Both layers must pass; either rejecting → HTTP 403 + `adapter.write_rejected` audit event.
- **MCP capability invocations run under `withTenant` + RLS (INV-2):** every capability invocation runs in a tenant-scoped transaction. Adapters are per-tenant; no cross-tenant adapter sharing.
- **Adapter credentials via SecretProvider (INV-3):** all adapter credentials (Proxmox token, GitHub/Gitea PAT, SSH registration token) resolved via `SecretProvider.get`. Credentials never logged; secret identifiers hashed in audit events.
- **Audit completeness for adapter events (INV-4):** new event types appended to M1's `audit_log`: `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected`. All hash-chained, append-only, UPDATE/DELETE REVOKE'd.
- **Rate limiting (REQ-019):** token-bucket per user (60 req/min) and per tenant (300 req/min). In-memory, process-local in M2. Capacity = rate; refill 1/sec (user) / 5/sec (tenant).
- **SSE streaming (REQ-017):** per-call streams with ULID correlation IDs. Client disconnect cancels in-flight adapter call; no audit event for client-side cancellation.
- **M1 non-regression:** all M1 REQs (001-014, 038-040) remain passing. No schema, invariant, or behavioral changes to M1 systems except additive (new tables, new audit event types).
## Components
### apps/control-plane (TypeScript, Next.js App Router API routes + standalone services)
- **Description**: API gateway, tenant resolution, RBAC enforcement, BYOM registry/validation/routing, Relay Agent WebSocket server, audit writer, secret provider bootstrap, dashboard (Next.js). Hosts the admin dashboard in M1; the chat UI lands here in M3.
- **Boundaries**: All inbound HTTP/WebSocket enters here. Owns the Postgres connection and the `SET app.tenant_id` discipline. Owns the Trigger.dev runtime bootstrap.
- **Depends on**: `packages/db`, `packages/auth`, `packages/audit`, `packages/secrets`, `packages/config`, Trigger.dev, WorkOS, AWS Secrets Manager.
### apps/relay-agent (Go)
- **Description**: Single static binary. Outbound WebSocket client to the control plane; registration (tenant/target/hostname/OS/IP/version); heartbeat + exponential-backoff reconnect (max 5 → alert); systemd service; SSH whitelist file format + enforcement hook (the SSH adapter plugs into the hook in M2 — no SSH execution in M1).
- **Boundaries**: No inbound ports. Reads its tenant registration token from a local config written by the install script (token resolves to a secret-manager reference, never the raw secret). All state is in-memory or on the control plane.
- **Depends on**: control-plane WebSocket endpoint; systemd; the fixed SSH whitelist file shipped alongside the binary.
### apps/dashboard (Next.js, part of apps/control-plane in M1)
- **Description**: Tenant admin dashboard surfaced in the same Next.js app. M1 surfaces Relay Agent status (green/yellow/red), target hostname, last 100 log lines, BYOM config + validation, RBAC user/role management. Per-tenant view under RLS.
- **Boundaries**: Server components read via the same API gateway (no DB access bypassing RLS). Client components subscribe to a status WebSocket fan-out.
- **Depends on**: `apps/control-plane` API, `packages/auth`.
### packages/db (TypeScript)
- **Description**: Postgres schema, migrations, RLS policies, the `withTenant(tenantId, fn)` helper that sets `app.tenant_id` and runs `fn` in a transaction. Tables: `tenants`, `users`, `tenant_memberships` (user × tenant × role), `targets`, `byom_endpoints` (URL in DB, key ref to secret manager), `audit_log` (append-only, hash-chain), `invitations`. All tenant-scoped tables carry `tenant_id` and an RLS policy.
- **Boundaries**: The ONLY module that opens a Postgres connection. App role has INSERT/SELECT on `audit_log` only; UPDATE/DELETE REVOKE'd. Schema migrations run via a gated migrator.
- **Depends on**: Postgres 16.
### packages/auth (TypeScript)
- **Description**: WorkOS SSO/SAML integration, session management, tenant resolution middleware, RBAC role → route permission map (Admin/Operator/Viewer). Enforces RBAC at the API gateway from the first endpoint (REQ-005 pattern).
- **Boundaries**: Sessions are httpOnly cookies; tokens never logged. WorkOS is the only IdP in v0.1.
- **Depends on**: WorkOS SDK, `packages/db`.
### packages/audit (TypeScript)
- **Description**: Append-only audit writer. Computes `curr_hash = sha256(prev_hash || canonical_payload)`. Payload includes tenant_id, user_id, target_id (for SSH), timestamp, correlation_id, event_type, event_body. On write failure: throws `AuditWriteHaltError` → enclosing operation halts + admin alert (REQ-038 Edge 7).
- **Boundaries**: Never exposes UPDATE/DELETE. Read path is admin-export only (no query UI in MVP — spec §2.2).
- **Depends on**: `packages/db`.
### packages/secrets (TypeScript)
- **Description**: `SecretProvider` interface with two impls: `AwsSecretsManagerProvider` (prod, us-east-1, KMS-backed) and `LocalEncryptedProvider` (dev/test, AES-256-GCM with a key from an env var only for the master key — the master key is the ONE allowed env var, everything else is provider-resolved). Every tenant credential (BYOM key, Proxmox token, SSH key, Git token) is stored/retrieved via `provider.put(tenantId, name, value)` / `provider.get(tenantId, name)`.
- **Boundaries**: Secrets never logged, never written to DB columns, never in config files. The DB stores only a reference (e.g. `aws-sm:coreci/<tenantId>/byom`).
- **Depends on**: AWS SDK (prod), Node crypto (dev).
### packages/config (TypeScript)
- **Description**: Typed config loading (env-aware: prod/staging/dev/test). Loads non-secret config only. The only env vars consumed here are infra-level (DATABASE_URL, WORKOS_API_KEY, AWS_REGION, SECRET_MASTER_KEY_DEV, TRIGGER_API_KEY). All tenant/customer secrets go through `packages/secrets`.
- **Boundaries**: Never reads tenant secrets directly.
- **Depends on**: nothing.
## Components (M2 addition)
### packages/mcp (TypeScript — NEW in M2)
- **Description**: The MCP capability broker gateway. Implements MCP spec version `2025-06-18`. Contains: (1) the closed tool registry (9 tools, REQ-015); (2) the adapter router (REQ-016) resolving `(tenant_id, adapter_type, target_id)` tuples to adapter instances; (3) the write-method blocklist enforcer (REQ-018, INV-7 — the load-bearing safety boundary); (4) the token-bucket rate limiter (REQ-019, in-memory, capacity = rate); (5) the SSE stream manager (REQ-017, per-call streams, ULID correlation IDs); (6) the OpenAI ↔ MCP translator module (`translator.ts`); (7) the in-process custom MCP transport for broker ↔ adapter JSON-RPC.
- **Boundaries**: The broker NEVER invokes an adapter for a write-capable method. The broker validates SSH `command` against the whitelist subset BEFORE dispatch (defense-in-depth layer 1). All capability invocations run under `withTenant` + RLS.
- **Depends on**: `packages/db` (withTenant, adapter config rows), `packages/audit` (adapter event types), `packages/secrets` (adapter credentials), `packages/auth` (RBAC), `packages/config`.
### packages/mcp/adapters/proxmox (TypeScript — NEW in M2)
- **Description**: Read-only Proxmox VE adapter. PVE API client over HTTPS (cookie-based auth). Capabilities: `proxmox.list_vms` (inventory, `GET /api2/json/nodes/{node}/qemu`), `proxmox.get_vm_status` (live, `GET /api2/json/nodes/{node}/qemu/{vmid}/status/current`), `proxmox.get_node_metrics` (live, `GET /api2/json/nodes/{node}/status`). Validates `PVEAuditor` role at config submit time (REQ-025). Write-method blocklist: POST/PUT/DELETE.
- **Boundaries**: Calls only GET endpoints. Token stored via `SecretProvider.set` (INV-3).
- **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`.
### packages/mcp/adapters/ssh (TypeScript — NEW in M2)
- **Description**: Read-only SSH/Linux adapter via M1 Relay Agent. The MCP `tools/call` JSON-RPC layer is in-process (broker → TS SSH module); the TS module then calls the M1 Relay Agent (Go binary) over WebSocket (M1 infrastructure, downstream of JSON-RPC). Capability: `ssh.run_whitelisted_command` (live). Broker validates `command` against the 6-command subset (`uptime`, `df -h`, `free -m`, `systemctl status <svc>`, `journalctl -n <N>`, `systemctl list-units --type=service`) BEFORE dispatch (layer 1); Relay Agent `CheckCommand` validates at execution (layer 2, M1 G-004 contract).
- **Boundaries**: Never invokes non-whitelisted commands. Two-layer enforcement. Relay registration token stored via `SecretProvider.set` (INV-3).
- **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`, M1 Relay Agent (Go binary, WebSocket).
### packages/mcp/adapters/github (TypeScript — NEW in M2)
- **Description**: Read-only GitHub adapter. REST API client. Capabilities: `github.list_repos` (inventory, `GET /user/repos?per_page=100`), `github.get_recent_ci_runs` (live, `GET /repos/{owner}/{repo}/actions/runs`), `github.get_workflow_run` (live, `GET /repos/{owner}/{repo}/actions/runs/{run_id}`). Validates fine-grained PAT scopes at submit time: `metadata:read` + `actions:read` minimum (D-006). Observes `X-RateLimit-Remaining` header; backs off on 429.
- **Boundaries**: Calls only REST GET endpoints. Fine-grained PAT only (no classic PAT — coarse scopes grant write). Token stored via `SecretProvider.set` (INV-3).
- **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`.
### packages/mcp/adapters/gitea (TypeScript — NEW in M2)
- **Description**: Read-only Gitea adapter. REST API client. Capabilities: `gitea.list_repos` (inventory, `GET /user/repos?limit=50`), `gitea.get_recent_ci_runs` (live, `GET /repos/{owner}/{repo}/actions/runs`). Version-aware scope validation: Gitea ≥1.22 requires `read:repository`; Gitea <1.22 accepts any token with broker-side write-method blocklist (POST/PUT/DELETE/PATCH) as security backstop. Version detected via `GET /api/v1/version` and recorded in adapter config row.
- **Boundaries**: Calls only REST GET endpoints. Write-method blocklist enforced at broker for all versions. Token stored via `SecretProvider.set` (INV-3).
- **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`.
### packages/llm-mock (TypeScript — NEW in M2, devDependency, CI-only)
- **Description**: CI-only mock LLM provider for the M2 gate LLM smoke test. Implements OpenAI-compatible `/v1/chat/completions` that accepts a `tools` parameter (OpenAI tool definitions from the broker's `GET /api/mcp/tools`), returns `tool_calls` referencing one of the provided tools, accepts follow-up `tool` role messages (broker's translated adapter result), and synthesizes a grounded text response.
- **Boundaries**: `devDependency` only (not a production dependency). Import-guarded against prod bundle via build-time check/eslint rule. Consumed only by CI.
- **Depends on**: nothing (mock provider; the broker calls it as a BYOM endpoint).
### apps/control-plane (M2 additions)
- **M2 routes**: `GET /api/mcp/tools` (list tools, MCP `tools/list` facade), `POST /api/mcp/invoke` (invoke capability, returns `{correlationId, streamUrl}`), `GET /api/mcp/stream/:correlationId` (SSE stream), `POST /api/mcp/adapter` (configure adapter), `PATCH/DELETE /api/mcp/adapter/:id` (update/remove adapter). Settings → Adapters UI + Test-Call UI in the dashboard.
- **M2 schema**: `mcp_adapters` table (tenant_id, adapter_type, target_id, config JSON, secret_ref, validated, created_at) — tenant-scoped with RLS. No cache tables (in-memory only).
- **M2 audit events**: `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected` — all appended to M1's `audit_log` with hash-chain.
### M3 consumer contract (M2 spec §9 — frozen at M2 acceptance gate)
| Endpoint | Method | Purpose | M3 consumer |
|:---------|:-------|:--------|:------------|
| `/api/mcp/tools` | GET | List available tools (MCP `tools/list` facade) | M3 chat UI populates the LLM's `tools` parameter |
| `/api/mcp/invoke` | POST | Invoke a capability; returns `{correlationId, streamUrl}` | M3 chat orchestration calls when the LLM emits `tool_calls` |
| `/api/mcp/stream/:correlationId` | GET (SSE) | Stream tool execution output | M3 chat UI streams tool traces to the trace panel |
| `/api/mcp/adapter` | POST | Configure an adapter | M3 does not call (M2 Settings UI only) |
| `/api/mcp/adapter/:id` | PATCH/DELETE | Update/remove adapter config | M3 does not call (M2 Settings UI only) |
**Stability rules:** additive changes (new tools, adapters, SSE event types) permitted; breaking changes require M3 spec amendment + deprecation period.
**Open boundary (M3 design, not M2 build):** M3 needs a separate SSE endpoint for LLM token streaming (`/api/chat/stream` or similar) — distinct from MCP tool output streaming. M2's `/api/mcp/stream/:correlationId` streams tool execution output, not LLM completion tokens.
## Data Flow
### M1 happy path — Relay Agent registration (J2 steps 34)
```
Platform Lead ──install script──▶ customer Linux host
detect-OS → install-binary → write-systemd-unit → register-target (writes tenant reg token to local config)
systemd ──start──▶ relay-agent (Go binary)
relay-agent ──outbound WebSocket (wss, tenant reg token)──▶ control-plane WS server
control-plane: auth token → resolve tenant (RLS) → INSERT target (hostname/OS/IP/version) → audit append
control-plane ──ack + target_id──▶ relay-agent
relay-agent ──heartbeat every Ns──▶ control-plane (updates last_seen → green status)
control-plane ──status fan-out──▶ dashboard (server-sent events / WS)
```
### M1 happy path — BYOM configure + validate (J2 step 2)
```
Admin ──POST /api/byom (url, key)──▶ API gateway
auth → tenant resolve (RLS) → RBAC (Admin only) → audit append (config event)
secrets.put(tenantId, "byom", key) → returns ref
INSERT byom_endpoints (tenant_id, url, secret_ref) — under RLS
BYOM validator: fetch OpenAI-compatible /v1/chat/completions with a trivial test prompt
on success → mark validated=true, audit append (validation ok), return green
on failure → rollback insert, audit append (validation fail), return error details (Edge 11)
```
### M3 (forward reference) — chat diagnostic (J1)
```
Operator ──prompt──▶ API gateway → auth → tenant → RBAC (Operator) → audit append (prompt)
orchestrator (Trigger.dev task) → BYOM routing shim → POST tenant's BYOM /v1/chat/completions
if unconfigured/unreachable → reject (REQ-009, Edge 1), audit append, halt
LLM returns tool selection → MCP gateway → route to adapter (M2) → Relay Agent (SSH, M2)
read-only enforced at gateway (403 on write) and Relay Agent (whitelist)
tool output streams via SSE → UI trace panel → audit append (tool call + SSH command + output)
LLM synthesizes cited answer → stream to UI → audit append (response)
conversation persisted (tenant-scoped) → audit append
```
### M2 happy path — adapter configure + test connection (J1)
```
Sam ──POST /api/mcp/adapter (type=proxmox, host, token)──▶ API gateway
auth → tenant resolve (RLS) → RBAC (Admin only) → audit append (adapter.configured)
broker validates PVEAuditor role on target (REQ-025)
if role missing → HTTP 422, no persist, audit append (validation fail)
secrets.set(tenantId, "proxmox:<targetId>", token) → returns ref
INSERT mcp_adapters (tenant_id, type, target_id, config, secret_ref, validated) — under RLS
UI confirms save
Sam ──clicks "Test connection"──▶ broker invokes test_connection (REQ-016)
broker: write-method blocklist check (GET only for Proxmox) → rate limit check → route to adapter
adapter: GET /api2/json/nodes (PVE API, cookie auth) → 200 OK
broker: audit append (adapter.test_connection.succeeded) → UI green
```
### M2 happy path — capability invocation via Test-Call UI (J2)
```
Sam/Devon ──POST /api/mcp/invoke (tool=github.list_repos, target_id, args={})──▶ API gateway
auth → tenant resolve (RLS) → RBAC → audit append (adapter.capability_invoked, correlation_id=ULID)
broker: write-method blocklist check (GET only) → rate limit check (60/min user, 300/min tenant)
if rate exceeded → HTTP 429 + Retry-After, no adapter call
broker: resolve adapter (tenant_id, github, target_id) → route (REQ-016)
broker → returns {correlationId, streamUrl} to UI
UI ──GET /api/mcp/stream/:correlationId (SSE)──▶ broker
broker: invoke adapter (in-process custom MCP transport, tools/call JSON-RPC)
adapter: secrets.get(tenantId, "github:<targetId>") → PAT
adapter: GET /user/repos?per_page=100 (GitHub REST, fine-grained PAT, metadata:read+actions:read)
if 429 → back off (X-RateLimit-Remaining observed)
if 403 → scope violation, HTTP 403 + audit append (adapter.write_rejected)
adapter: normalize response → return to broker
broker: emit SSE events (id=<ulid>-<seq>, event=tool_result, data={content:[...],isError:false})
broker: terminal event (event=done) → close stream
broker: audit append (adapter.capability_invoked, result=success)
UI: render result (inventory call → "cached Xs ago" if served from 60s TTL cache)
```
### M2 edge — SSH whitelist violation (Edge 7, defense-in-depth)
```
Sam ──POST /api/mcp/invoke (tool=ssh.run_whitelisted_command, args={command:"rm -rf /"})──▶ broker
broker: validate command against 6-command subset (layer 1)
"rm" not in {uptime, df, free, systemctl, journalctl} → REJECT at broker
broker: HTTP 403 + audit append (adapter.write_rejected) → never invokes adapter
(if broker somehow passed it: Relay Agent CheckCommand (layer 2) would also reject — defense-in-depth)
```
### M2 edge — SSE client disconnect (Edge 8)
```
Client ──GET /api/mcp/stream/:correlationId──▶ broker (SSE stream open)
Client disconnects mid-stream
broker: detect EventSource close → cancel in-flight adapter call
broker: clean up correlation context → no orphan adapter calls
broker: NO audit event for client-side cancellation (per spec Edge 8)
```
## Build Order (M1 waves — each a vertical slice, testable + shippable)
1. **Wave A — Foundations.** Monorepo (pnpm): `apps/control-plane`, `apps/relay-agent` (Go module), `apps/dashboard`, `packages/{db,auth,audit,secrets,config}`. Postgres schema + RLS + `withTenant`. `audit_log` hash-chain + REVOKE UPDATE/DELETE. `SecretProvider` interface + AWS SM impl + local-encrypted impl. Trigger.dev bootstrap (runtime wired, no tasks yet). Covers REQ-038, REQ-039, REQ-040.
2. **Wave B — Identity & RBAC.** WorkOS SSO; session; tenant resolution middleware (`SET app.tenant_id`); RBAC at API gateway (Admin/Operator/Viewer → route permission map). First endpoint protected on day one. Covers REQ-001, REQ-002, REQ-003, REQ-004, REQ-005.
3. **Wave C — BYOM.** Tenant-scoped BYOM endpoint registry (URL in DB, key in secret manager); validate-on-save test inference call (OpenAI-compatible); routing shim (no inference in M1 — proxy contract + REQ-009 reject path). Covers REQ-006, REQ-007, REQ-008, REQ-009.
4. **Wave D — Relay Agent.** Modular install script (detect-OS/install-binary/write-systemd-unit/register-target; clean abort on unsupported OS). Go binary: outbound wss + tenant reg token, registration (tenant/target/hostname/OS/IP/version), heartbeat + exp-backoff (max 5 → alert), auto-reconnect, systemd unit w/ auto-restart. SSH whitelist file format + enforcement hook (no adapter yet — M2 plugs in). Covers REQ-010, REQ-011, REQ-012, REQ-013, (REQ-026 whitelist hook).
5. **Wave E — Dashboard surfacing.** WebSocket fan-out of agent status to dashboard; green/yellow/red; target hostname; last 100 log lines; per-tenant view under RLS. M1 gate demo: SSO → BYOM green → install → register → green dashboard. Covers REQ-014.
## Build Order (M2 waves — each a vertical slice, testable + shippable)
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 IS the M2 milestone release.
**Wave 0 — Prerequisites (not a spec REQ; must complete before any M2 adapter work):** CI Postgres 16 container with RLS verification (replaces PGlite-only verification); real GitHub PAT available in CI (ephemeral or test-org-scoped). Retroactively validates M1's RLS claims.
0. **Phase 0 — pre-execution.** SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → MVP/UX. Ships `v0.1.0`.
1. **Wave F — MCP Gateway core.** `packages/mcp` broker: closed tool registry (9 tools), adapter router, write-method blocklist enforcer (INV-7 at broker), token-bucket rate limiter, SSE stream manager, OpenAI ↔ MCP translator, in-process custom transport. `mcp_adapters` table with RLS. Covers REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024.
2. **Wave G — Proxmox adapter.** `packages/mcp/adapters/proxmox`: PVE API client, `PVEAuditor` role validation (REQ-025), 3 capabilities (`list_vms`, `get_vm_status`, `get_node_metrics`). Covers REQ-020, REQ-025.
3. **Wave H — SSH/Linux adapter (Relay Agent).** `packages/mcp/adapters/ssh`: broker SSH whitelist validation (layer 1), downstream WebSocket to M1 Relay Agent, `ssh.run_whitelisted_command` capability. Reactivates go-engineer persona for Relay Agent integration. Covers REQ-021, REQ-026 (full — M1 shipped the hook; M2 plugs the adapter in).
4. **Wave I — Git adapters.** `packages/mcp/adapters/github` + `packages/mcp/adapters/gitea`: REST API clients, fine-grained PAT scope validation (D-006 for GitHub, version-aware for Gitea), 5 capabilities. Covers REQ-022, REQ-023, REQ-027.
5. **Wave J — SSE integration + LLM smoke + adapter UI.** `packages/llm-mock` (CI-only devDependency), LLM smoke test (OpenAI→MCP→adapter→result→synthesis against real GitHub), Settings → Adapters UI + Test-Call UI. Covers REQ-017 integration, M2 gate item 8 (LLM smoke).
6. **Final — Review + Audit + Ship.** Multi-persona review, project health audit, milestone ship v0.1.(N+1) = milestone release, merge to main. Covers all M2 (sign-off).
**Wave ordering & parallelism:** 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). J depends on F + at least one adapter (for the LLM smoke against GitHub). Final depends on all.