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

20 KiB

M2-VERIFY-P01 — Wave F (Phase 1) Verification

Phase: 1 — Wave F — MCP Gateway core Milestone: v0.2 (M2: MCP Layer & Day 1 Adapters) Branch: phase/01-mcp-gateway Verifier: ci-verifier (glm-5.2) Date: 2026-08-25 Spec: .ciagent/steer-m2-spec.md v1.0 (locked) Plan: .ciagent/PLAN.md — Wave F section Verdict: gaps_found — Wave F is ready to ship; 1 P1 lesson carried to Wave G/H/I.


Executive Summary

Wave F ships the MCP capability broker gateway: closed 9-tool registry, adapter router, write-method blocklist, token-bucket rate limiter, SSE stream manager, OpenAI↔MCP translator, in-process + stdio transports, synthetic lifecycle handshake, mcp_adapters table with RLS, 5 API routes, audit type widening, and the MCP conformance artifact (7 tests + PROTOCOL.md). All 4 verification layers pass with one P1 gap: the 403 + adapter.write_rejected audit path is not wired end-to-end in the invoke flow (the broker hardcodes GET, which never triggers the blocklist; stub adapters don't construct HTTP methods). This is defensible under the G-015 framing (registry is the primary INV-7 boundary; blocklist is a backstop for adapter bugs, and stubs have no bugs) and the spec defers the per-adapter write-rejection test to the M2 gate (Phase 6, with real adapters). The mechanism itself is sound and unit-tested (8 tests). The lesson is carried to Wave G/H/I where real adapters will construct real HTTP methods and the 403+audit path must be wired + tested.

Test totals: 224 unit/integration tests + 32 conformance tests = 256 tests, all green. Coverage: 88.7% on packages/mcp (gate ≥ 80%).


Layer 1: Structural Verification — PASS

Item Status Evidence
mcp_adapters table — columns, UNIQUE, RLS, FORCE packages/db/migrations/0003_mcp_adapters.sql: id, tenant_id, adapter_type CHECK IN (proxmox,ssh,github,gitea), target_id, config jsonb, secret_ref, validated, created_at, updated_at; UNIQUE (tenant_id, adapter_type, target_id); ENABLE + FORCE ROW LEVEL SECURITY; policy tenant_isolation_mcp_adapters with USING + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid). Migration runs clean (pnpm migrate ✓ 3 files).
9 tools in registry (REQ-015 frozen set) packages/mcp/src/registry.ts: all 9 names match REQ-015 exactly — proxmox.list_vms, proxmox.get_vm_status, proxmox.get_node_metrics, ssh.run_whitelisted_command, github.list_repos, github.get_recent_ci_runs, github.get_workflow_run, gitea.list_repos, gitea.get_recent_ci_runs. Each has {name, description, inputSchema} (JSON Schema, type:"object", additionalProperties:false). REGISTRY_SIZE=9 asserted at module load (drift throws). isInventory metadata on list_* tools (cache authority).
MCP_PROTOCOL_VERSION = "2025-06-18" packages/mcp/src/types.ts:19; exported from index.ts; asserted in tools-list.test.ts.
5 API routes apps/control-plane/app/api/mcp/{tools,invoke,stream/[correlationId],adapter,adapter/[id]}/route.ts — all 5 present (GET tools, POST invoke, GET stream SSE, POST adapter, PATCH/DELETE adapter/[id]).
McpAdapter interface (G-020) packages/mcp/src/types.ts:75-90: type: AdapterType, listTools(): Promise<Tool[]>, callTool(name, args): Promise<McpResult>. Stubs implement it (adapters/stubs.ts).
PROTOCOL.md (G-015, G-016) packages/mcp/PROTOCOL.md: spec version pin + links, 4 transports (in-process custom / stdio / REST facade + SSE — NOT Streamable HTTP), JSON-RPC 2.0 shapes, OpenAI↔MCP translation contract, synthetic lifecycle handshake, INV-7 framing (registry primary, blocklist backstop), two enforcement models (method blocklist vs scope-via-403), future risks (GraphQL mutations, PVE GET-with-side-effects), enforcement order, conformance suite inventory, M2→M3 contract freeze.
AuditEventType union (G-012) packages/db/src/audit.ts:24-39: union widened with all 5 new types — adapter.configured, adapter.test_connection.succeeded, adapter.test_connection.failed, adapter.capability_invoked, adapter.write_rejected. Comment documents this is an M1-file type widening (not a DB schema change — audit_log.event_type is TEXT, no CHECK). M1 tests still pass (backward-compatible widening).

Layer 1 result: 7/7 must-haves present.


Layer 2: Behavioral Verification — PASS

Command Status Result
pnpm typecheck All 8 workspace projects + control-plane pass (no TS errors).
pnpm lint All 8 projects pass with --max-warnings 0.
pnpm test 224 tests pass: db 12 + auth 52 + runtime 6 + mcp 94 + byom 18 + control-plane 42. M1 non-regression confirmed (all M1 packages green).
pnpm test:conformance 7 files, 32 tests pass (tools-list, tools-call-happy, tools-call-error, tools-call-invalid-args, translator, lifecycle, stdio-interop G-017). stdio-interop spawns the broker stdio server as a child process and does a real tools/list + tools/call round-trip over stdin/stdout.
pnpm migrate Runs clean: 0001_init, 0002_sessions, 0003_mcp_adapters.
Coverage ≥ 80% on packages/mcp 88.7% statements, 81.09% branch, 89.23% funcs, 88.7% lines. Per-file: write-blocklist 100%, types 100%, stubs 100%, router 100%, stream-manager 98.59%, registry 96.41%, rate-limiter 94.54%, translator 92.72%, broker 82.89%, in-process 73.87%, stdio 0% (excluded — exercised by stdio-interop child process).
M1 non-regression All M1 tests pass (db, auth, runtime, byom, control-plane M1 suites). No M1 behavioral changes except the additive audit type widening.

Layer 2 result: 7/7 green.


Layer 3: Security Verification — ⚠️ PASS with 1 P1 gap

Item Status Evidence
Write-blocklist: registry is primary INV-7 gate proxmox.shutdown_vm (not in registry) → InvokeError(400, "unknown_tool") in broker.ts:108-110; verified in broker.test.ts:73-83 (rejects before rate-limit / context). The closed registry is the load-bearing boundary — a non-tool cannot be routed.
Write-blocklist: checkMethodBlocklist function write-blocklist.ts: METHOD_BLOCKLIST for proxmox (POST/PUT/DELETE) + gitea (POST/PUT/DELETE/PATCH); GitHub intentionally absent (scope-via-403 model); SSH via command-allowlist. 8 unit tests pass (write-blocklist.test.ts): rejects write methods, allows GET, case-insensitive, two-model separation.
Write-blocklist docstring (G-015) Module docstring + PROTOCOL.md both state: "registry is PRIMARY; blocklist is BACKSTOP for adapter bugs." Framing corrected per G-015.
Two enforcement models (G-016) usesMethodBlocklist (proxmox/gitea), usesScopeVia403 (github), usesCommandAllowlist (ssh) — three distinct helpers. Method blocklist is REST-specific; scope-via-403 is runtime (R-004); command-allowlist is SSH (Wave H). Future risks (GraphQL, PVE GET) documented in PROTOCOL.md.
403 + adapter.write_rejected audit in invoke flow ⚠️ GAP The broker's invokeCapability calls checkMethodBlocklist(entry.adapterType, "GET") with hardcoded GET (broker.ts:140) — GET is never in any blocklist, so this never rejects. The WriteBlockedError class is exported but never thrown in the actual invoke path. The invoke route (invoke/route.ts) only audits adapter.capability_invoked on success; it never emits adapter.write_rejected. There is no "test per adapter type with stubs" exercising the 403+audit path at the broker (write-blocklist tests are function-level only). The blocklist backstop is effectively dead code in Wave F because stub adapters don't construct HTTP methods. Defensible per G-015 (registry is primary; blocklist fires only on adapter bugs; stubs have no bugs), and the spec defers the per-adapter write-rejection test to the M2 gate (Phase 6, with real adapters). But the PLAN.md Wave F must-have literally says "test per adapter type with stubs" — that test does not exist. P1 lesson for Wave G/H/I: wire the 403+audit path when real adapters construct real HTTP methods, and add the per-adapter write-rejection test.
Rate limiter: 60/min user + 300/min tenant rate-limiter.ts: USER_CAPACITY=60, USER_REFILL=1/sec, TENANT_CAPACITY=300, TENANT_REFILL=5/sec, AND logic, refund-on-tenant-fail (D-M2-R007). 8 tests pass. 429 returns retryAfterSec; broker maps to InvokeError(429) + route sets Retry-After header. No audit on 429 (broker + route confirm).
SSE client disconnect (Edge 8) — no audit stream-manager.ts:handleDisconnect aborts + deletes + returns client_disconnected; closed flag guards double-close. SSE route (stream/[correlationId]/route.ts) wires req.signal abort + cancel() to handleDisconnect. 3 tests pass (disconnect aborts, guard for already-closed, unknown id). No audit event for client-side cancellation (the route does not audit on the abort path).
All DB queries under withTenant + RLS router.ts:resolveAdapter + listAdaptersByType use withTenant; adapter/route.ts POST/GET use withTenant; adapter/[id]/route.ts PATCH/DELETE use withTenant; invoke/route.ts audit uses withTenant. Migration has WITH CHECK + FORCE ROW LEVEL SECURITY.
All secrets via SecretProvider (INV-3) adapter/route.ts:secrets.put(tenantId, "mcp:<type>:<target>", secret); DB stores only secret_ref. DELETE removes the secret via secrets.delete. No env/config/DB fallback for tenant secrets. No plaintext credentials in DB.

Layer 3 result: 7/8 pass; 1 P1 gap (403+audit not wired in invoke — deferred to Wave G/H/I with real adapters).


Layer 4: Quality Verification — PASS

Item Status Evidence
M1 patterns (withTenant, appendAudit, requireAuth, SecretProvider) All routes use withTenant + appendAudit + requireAuth. Adapter routes check auth.user.role !== "admin" inline (equivalent to requireAdmin). SecretProvider used for credentials.
No secrets logged, no plaintext in DB DB stores secret_ref only; secrets.put stores the raw secret; no console.log of secrets; audit payloads carry adapterType/targetId/correlationId (not secrets).
Commit messages have ---ci--- blocks All 12 P01 commits have ---ci--- blocks (phase: 1, milestone: v0.2, wave: F, task: NN, status: execute). Verified via git log --grep="P01".
No TODO/FIXME in shipped code Grep of packages/mcp/src for TODO/FIXME/XXX/HACK: no matches.

Layer 4 result: 4/4 pass.


Must-Haves (PLAN.md Wave F) Scorecard

# Must-Have Status Notes
1 Closed tool registry: 9 tools with name, description, inputSchema (JSON Schema) All 9 present, REGISTRY_SIZE asserted.
2 Write-method blocklist: 100% write attempts rejected at broker with 403 + audit, adapter never invoked (test per adapter with stubs) ⚠️ PARTIAL Registry gate (primary) verified end-to-end (400 unknown_tool). Blocklist function unit-tested (8 tests). 403 + adapter.write_rejected audit path NOT wired in invoke flow (broker hardcodes GET; stubs don't construct methods; no per-adapter stub test for 403). Deferred to Wave G/H/I + M2 gate (Phase 6).
3 Rate limiter: 60/min user + 300/min tenant, 429 + Retry-After, refund-on-tenant-fail, no audit on 429 All confirmed.
4 SSE stream: per-call, ULID, terminal done/error, 30s stream-not-opened (R-006), <100ms chunk delivery ULID correlation IDs, 30s not-opened + 60s max-lifetime timeouts, done/error terminals. <100ms not measured but encoding is synchronous.
5 Client disconnect (Edge 8): in-flight cancelled, no audit for client-side cancellation handleDisconnect + req.signal abort + cancel() wired; 3 tests.
6 Multi-target: ≥2 same-type without target_id → 400 "target required" with available targets router.ts + broker.test.ts + mcp-routes.test.ts confirm.
7 MCP conformance artifact: PROTOCOL.md + 6 tests passing (gate item 15) 7 tests (G-017 stdio added), 32 sub-tests, all pass.
8 Synthetic initialize/initialized handshake for in-process adapters (R-001) in-process.ts:register performs handshake; lifecycle.test.ts (10 tests) asserts envelope.
9 OpenAI↔MCP translator: tool_calls → tools/call (JSON.parse(arguments)); result.content + isError → tool message translator.ts; 13 tests; conformance translator test. isError → "ERROR: " prefix.
10 mcp_adapters table with RLS (tenant-scoped, verified against real Postgres 16 in CI) (table+RLS) / ⚠️ (CI) Table + RLS + FORCE present. Real Postgres 16 CI verification is Wave 0 (G-011/G-022), not Wave F — PGlite is the Wave F test DB. Wave 0 CI pipeline is a separate prerequisite.
11 Audit events: adapter.configured, test_connection.{succeeded,failed}, capability_invoked, write_rejected — hash-chained via appendAudit (4/5 wired) / ⚠️ (1/5) Union widened (G-012). adapter.configured wired in adapter routes. adapter.capability_invoked wired in invoke route. adapter.write_rejected is in the union but NOT emitted anywhere (see must-have #2 gap). adapter.test_connection.{succeeded,failed} not wired in Wave F (the test_connection capability is Wave G/H/I — Wave F ships the event types).
12 Coverage ≥ 80% on packages/mcp 88.7%.
13 M1 non-regression: all M1 tests still pass All M1 suites green.
14 Security-engineer sign-off on write-method blocklist + INV-7 at broker (documented) / ⚠️ (formal sign-off) The framing + two models + future risks are documented (G-015, G-016) for security review. No formal sign-off recorded in Wave F (automated verification only; verification.automated_only: true in config). The 403+audit gap (must-have #2) is the open item for security review.

Must-have score: 11/14 fully pass, 3 partial (all defensible / deferred).


Requirement Coverage (REQ-015..019, 024)

REQ Wave F coverage Status Evidence
REQ-015 Closed tool registry, JSON Schema, per-tenant disable, arg validation COVERED registry.ts + 17 tests + conformance tools-list + invalid-args.
REQ-016 Route to tenant-specific adapter, SSE response, 404 routing errors COVERED router.ts + 8 tests + broker.test.ts (404 adapter_not_found, 404 target_not_found).
REQ-017 SSE stream, ULID, terminal events, Edge 8 client disconnect, 30s timeout COVERED stream-manager.ts + 15 tests + SSE route + conformance happy/error.
REQ-018 Read-only at broker, write-method blocklist, 403 + write_rejected audit, adapter never invoked ⚠️ PARTIAL Registry gate (primary) verified. Blocklist function unit-tested. 403 + write_rejected audit integration not wired (see Layer 3 gap). Spec defers per-adapter test to M2 gate (Phase 6).
REQ-019 Token-bucket 60/min user + 300/min tenant, 429 + Retry-After, no adapter call on 429 COVERED rate-limiter.ts + 8 tests + broker.test.ts (429 + no context).
REQ-024 Multi-target scope, 400 "target required" with available targets COVERED router.ts + broker.test.ts + mcp-routes.test.ts (Edge 3).

REQ coverage: 5/6 covered, 1 partial (REQ-018 — 403+audit integration deferred to Wave G/H/I).


Import Resolves? Notes
@coreci/mcp exports (index.ts) All 13 task modules exported; control-plane + conformance tests import successfully (typecheck + test green).
@coreci/db (withTenant, appendAudit, setDbClient, createDb, DbClient, ScopedClient) Used by router, broker test, routes.
@coreci/secrets (LocalEncryptedProvider) Used by mcp-routes test + mcp.ts runtime.
@coreci/auth (provisionTenant, createSession) Used by mcp-routes test.
ulid npm dep In packages/mcp dependencies; stream-manager imports ulid().
next/server (NextRequest, NextResponse) All 5 route handlers import; control-plane typecheck green.

Integration: 6/6 resolve.


G-012..G-022 Binding Fixes Applied

ID Applied? Evidence
G-012 (audit type widening as M1-file edit) audit.ts:24-39 union extended; comment labels it "M1 file edit: type widening".
G-015 (INV-7 framing: registry primary, blocklist backstop) registry.ts docstring + write-blocklist.ts docstring + PROTOCOL.md §INV-7.
G-016 (two enforcement models + future risks) write-blocklist.ts (method blocklist vs scope-via-403 vs command-allowlist) + PROTOCOL.md §Two models + §Future risks (GraphQL, PVE GET).
G-017 (7th conformance test over stdio) stdio-interop.test.ts (4 tests) spawns the broker stdio server, real tools/list + tools/call round-trip.
G-020 (McpAdapter interface shipped in F) types.ts:75-90; stubs implement it; index re-exports it.
G-011/G-022 (CI pipeline + Postgres 16 RLS) ⚠️ Wave 0 Not Wave F scope — Wave 0 prerequisite. PGlite is Wave F's test DB.

Binding fixes: 5/5 Wave-F fixes applied; 2 Wave-0 fixes out of Wave F scope.


Lessons

  1. [P1, carried to Wave G/H/I] Wire the 403 + adapter.write_rejected audit path in the invoke flow when real adapters construct real HTTP methods. Wave F's broker calls checkMethodBlocklist(adapterType, "GET") with hardcoded GET (never rejects), and stub adapters don't construct HTTP methods — so the blocklist backstop is dead code in Wave F. The registry gate (400 unknown_tool) IS the load-bearing INV-7 boundary and is verified. The blocklist MECHANISM is unit-tested (8 tests). But the full invoke→403→adapter.write_rejected audit→adapter-never-invoked path is not integration-tested with stubs. Wave G (Proxmox) must: (a) have the adapter call checkWriteBlocklist(adapterType, method) before constructing a request, (b) on rejection, emit HTTP 403 + adapter.write_rejected audit, (c) add a per-adapter test with a stub that simulates an adapter bug (constructs a POST) and asserts 403 + audit + adapter-never-invoked. This closes REQ-018's "verified by a test per adapter at the M2 gate."

  2. [P1, informational] adapter.write_rejected and adapter.test_connection.{succeeded,failed} audit event types are in the union but not yet emitted in Wave F. This is correct for Wave F (the test_connection capability is Wave G/H/I; the write-rejection 403 path is Wave G/H/I). The event types are pre-declared so G/H/I emit them without a type change. Wave F emits adapter.configured (adapter routes) and adapter.capability_invoked (invoke route) — both hash-chained via appendAudit.

  3. [informational] Real Postgres 16 RLS verification (G-011/G-022) is Wave 0, not Wave F. Wave F's tests run on PGlite (which does not enforce RLS on SELECT). The mcp_adapters migration has the correct RLS policy + FORCE ROW LEVEL SECURITY, but it is not exercised against a real RLS-enforcing DB in Wave F. Wave 0's CI pipeline (Postgres 16 service container, DB_MODE=pg) is the prerequisite that verifies it.


Conclusion

Wave F is ready to ship (v0.1.1). The 4-layer verification passes: structural 7/7, behavioral 7/7, security 7/8 (1 P1 gap, defensible + deferred), quality 4/4. 256 tests green (224 unit/integration + 32 conformance), 88.7% coverage. The single P1 gap (403 + adapter.write_rejected audit not wired in the invoke flow) is consistent with the G-015 framing (registry is primary; blocklist is a backstop for adapter bugs; stubs have no bugs) and the spec's deferral of the per-adapter write-rejection test to the M2 gate (Phase 6, with real adapters). The lesson is carried to Wave G/H/I. M1 non-regression holds.


End of M2-VERIFY-P01 — Wave F (Phase 1) Verification.