diff --git a/docs/superpowers/plans/2026-06-04-mcp-http-transport.md b/docs/superpowers/plans/2026-06-04-mcp-http-transport.md new file mode 100644 index 0000000..bb582e6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-mcp-http-transport.md @@ -0,0 +1,626 @@ +# MCP HTTP/SSE Transport Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose Void's tool registry to external agents over MCP Streamable HTTP — authenticated, read + suggest-only, hard-scoped to one Space. + +**Architecture:** A new in-app endpoint `POST/GET /mcp` runs a stateless `StreamableHTTPServerTransport` serving a dedicated `externalRegistry` (search/read/context/propose_change). A `mcpAuth` middleware requires a Void agent bearer (owner/CF-only rejected) bound to a Space via `scopes.space_id`. Reads are filtered to that Space; `propose_change` already routes to the `pending_changes` inbox. + +**Tech Stack:** Node 22 ESM, Express 5, `@modelcontextprotocol/sdk` 1.x, Postgres, vitest + supertest (serial). + +**Spec:** `docs/superpowers/specs/2026-06-04-mcp-http-transport-design.md` + +--- + +## File Structure + +- `lib/ai/agent/tools/read.js` — **modify**: space-scope enforcement. +- `lib/mcp/context.js` — **modify**: add `buildCtxFromAgent(agent)`. +- `lib/mcp/external-registry.js` — **create**: curated registry (4 tools). +- `lib/mcp/http.js` — **create**: transport-free helpers + `createExternalMcpServer` + `handleMcp`. +- `lib/api/middleware/mcp_auth.js` — **create**: bearer→agent→space-scope gate + rate limit. +- `server.js` — **modify**: mount `/mcp`; bump VERSION. +- `package.json` — **modify**: bump version. +- Tests under `tests/mcp/` and `tests/api/`. + +--- + +### Task 1: Space-scope the `read` tool + +**Files:** +- Modify: `lib/ai/agent/tools/read.js` +- Test: `tests/ai/agent/tools/read_scope.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/ai/agent/tools/read_scope.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/db/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { readTool } from '../../../../lib/ai/agent/tools/read.js'; + +let spaceA, spaceB, pageInA, convoId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceA }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id`)); + ({ rows: [{ id: spaceB }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id`)); + ({ rows: [{ id: pageInA }] } = await pool.query( + `INSERT INTO pages(space_id,slug,title,body_md) VALUES($1,'p','P','body') RETURNING id`, [spaceA])); + ({ rows: [{ id: convoId }] } = await pool.query( + `INSERT INTO conversations(title) VALUES('c') RETURNING id`)); +}); + +describe('read tool space scoping', () => { + it('reads an in-space page', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, { space_id: spaceA }); + expect(out.title).toBe('P'); + }); + it('blocks a cross-space page (scoped caller)', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, { space_id: spaceB }); + expect(out.error).toMatch(/not found/i); + }); + it('blocks unprovable kinds (conversation) for spaceScoped callers', async () => { + const out = await readTool.handler({ kind: 'conversation', id: convoId }, { space_id: spaceA, spaceScoped: true }); + expect(out.error).toMatch(/not found/i); + }); + it('owner (no space bound) reads anything', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, {}); + expect(out.title).toBe('P'); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `npx vitest run tests/ai/agent/tools/read_scope.test.js` +Expected: FAIL — cross-space + conversation cases return the row instead of not-found. + +- [ ] **Step 3: Implement the scoping** + +Replace the `handler` in `lib/ai/agent/tools/read.js` (keep imports + `TABLE` + descriptor): + +```js + async handler({ kind, id }, ctx = {}) { + const table = TABLE[kind]; + if (!table) return { error: `unknown kind "${kind}"` }; + const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]); + if (!row) return { error: `${kind} ${id} not found` }; + // Space scoping. When a Space is bound (external scoped agents — and Dross, + // which operates within one Space), an entity that carries space_id must + // match it. Kinds without a space_id column (conversations) can't be proven + // in-scope, so they're denied to spaceScoped callers (external agents) only. + // Owner/Dross with no bound Space (ctx.space_id == null) → unrestricted. + if (ctx.space_id != null) { + if (row.space_id !== undefined) { + if (row.space_id !== ctx.space_id) return { error: `${kind} ${id} not found` }; + } else if (ctx.spaceScoped) { + return { error: `${kind} ${id} not found` }; + } + } + return row; + } +``` + +- [ ] **Step 4: Run tests (new + existing read/search), verify pass** + +Run: `npx vitest run tests/ai/agent/tools/read_scope.test.js tests/ai/agent/tools/read_search.test.js` +Expected: PASS (existing `read_search` still green — it passes a matching `space_id`). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/tools/read.js tests/ai/agent/tools/read_scope.test.js +git commit -m "feat(mcp): space-scope the read tool for bound callers" +``` + +--- + +### Task 2: External registry, ctx builder, transport-free helpers + +**Files:** +- Create: `lib/mcp/external-registry.js` +- Modify: `lib/mcp/context.js` +- Create: `lib/mcp/http.js` +- Test: `tests/mcp/external_registry.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/mcp/external_registry.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../lib/db/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { buildCtxFromAgent } from '../../lib/mcp/context.js'; +import { listExternalTools, callExternalTool } from '../../lib/mcp/http.js'; + +let spaceId, otherSpace, pageInOther, agent; +const owner = { kind: 'user', id: null }; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: otherSpace }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('o','O') RETURNING id`)); + ({ rows: [{ id: pageInOther }] } = await pool.query( + `INSERT INTO pages(space_id,slug,title,body_md) VALUES($1,'sec','Secret','hidden') RETURNING id`, [otherSpace])); + agent = await agentsRepo.create({ + slug: `ext-${Date.now()}`, name: 'Ext', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: { space_id: spaceId } + }, owner); +}); + +describe('external registry', () => { + it('exposes exactly the four read+suggest tools', () => { + const names = listExternalTools().map(t => t.name).sort(); + expect(names).toEqual(['context', 'propose_change', 'read', 'search']); + }); + it('buildCtxFromAgent forces the agent bound space + spaceScoped', () => { + const ctx = buildCtxFromAgent(agent); + expect(ctx.space_id).toBe(spaceId); + expect(ctx.spaceScoped).toBe(true); + expect(ctx.agent.id).toBe(agent.id); + }); + it('read cannot reach another space', async () => { + const ctx = buildCtxFromAgent(agent); + const out = await callExternalTool('read', { kind: 'page', id: pageInOther }, ctx); + expect(out.error).toMatch(/not found/i); + }); + it('propose_change lands in pending_changes with agent + space, applied:false', async () => { + const ctx = buildCtxFromAgent(agent); + const out = await callExternalTool('propose_change', + { entity_type: 'page', action: 'create', payload: { slug: 'np', title: 'New', body_md: 'b' }, reason: 'r' }, ctx); + expect(out.applied).toBe(false); + expect(out.pending_change_id).toBeTruthy(); + const { rows: [pc] } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [out.pending_change_id]); + expect(pc.agent_id).toBe(agent.id); + expect(pc.payload.space_id).toBe(spaceId); + }); + it('unknown tool throws', async () => { + await expect(callExternalTool('nope', {}, buildCtxFromAgent(agent))).rejects.toThrow(/unknown tool/i); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `npx vitest run tests/mcp/external_registry.test.js` +Expected: FAIL — `lib/mcp/http.js` and `buildCtxFromAgent` don't exist yet. + +- [ ] **Step 3: Create the external registry** + +```js +// lib/mcp/external-registry.js +// Curated registry exposed to EXTERNAL agents over MCP HTTP. Deliberately +// separate from companionRegistry (Dross) so new Dross tools never auto-leak +// to the internet. Read + suggest-only: search/read/context + propose_change +// (which always routes to the pending_changes inbox). +import { createRegistry } from '../ai/agent/registry.js'; +import { searchTool } from '../ai/agent/tools/search.js'; +import { readTool } from '../ai/agent/tools/read.js'; +import { contextTool } from '../ai/agent/tools/context.js'; +import { proposeChangeTool } from '../ai/agent/tools/propose_change.js'; + +export const externalRegistry = createRegistry(); +externalRegistry.registerTool(searchTool); +externalRegistry.registerTool(readTool); +externalRegistry.registerTool(contextTool); +externalRegistry.registerTool(proposeChangeTool); +``` + +- [ ] **Step 4: Add `buildCtxFromAgent` to `lib/mcp/context.js`** + +Append to `lib/mcp/context.js`: + +```js +/** + * Builds the tool ctx for an authenticated EXTERNAL agent. The Space is taken + * from the agent's own scope (never client-supplied) and `spaceScoped` is set + * so read() denies entities it can't prove are in-Space. + * @param {{id:string, capabilities?:object, scopes?:object}} agent + */ +export function buildCtxFromAgent(agent) { + const actor = { + kind: 'agent', + id: agent.id, + capabilities: agent.capabilities || {}, + scopes: agent.scopes || {} + }; + return { + agent: actor, + space_id: (agent.scopes && agent.scopes.space_id) || null, + view: null, + spaceScoped: true, + actor + }; +} +``` + +- [ ] **Step 5: Create `lib/mcp/http.js` (helpers + server factory; transport handler added in Task 4-step)** + +```js +// lib/mcp/http.js +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { externalRegistry } from './external-registry.js'; +import { buildCtxFromAgent } from './context.js'; +import { recordAudit } from '../db/repos/audit_stub.js'; + +// --- transport-free helpers (exported for tests) --- +export function listExternalTools() { + return externalRegistry.listTools().map(({ name, description, input_schema }) => + ({ name, description, input_schema })); +} +export async function callExternalTool(name, args, ctx) { + const tool = externalRegistry.getTool(name); + if (!tool) throw new Error(`Unknown tool: ${name}`); + return tool.handler(args, ctx); +} + +// --- MCP server factory (one per request in stateless mode) --- +export function createExternalMcpServer(ctx) { + const server = new Server( + { name: 'void-external', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: listExternalTools().map(({ name, description, input_schema }) => + ({ name, description, inputSchema: input_schema })) + })); + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + try { + const result = await callExternalTool(name, args, ctx); + recordAudit(ctx.actor, 'mcp_tool_call', 'agent', ctx.agent.id, null, + { tool: name, space_id: ctx.space_id }).catch(() => {}); + return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }; + } catch (err) { + return { content: [{ type: 'text', text: err.message ?? String(err) }], isError: true }; + } + }); + return server; +} + +// --- Express handler: stateless Streamable HTTP. Requires req.mcpAgent (mcpAuth). --- +export async function handleMcp(req, res) { + const ctx = buildCtxFromAgent(req.mcpAgent); + const server = createExternalMcpServer(ctx); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + enableJsonResponse: true + }); + res.on('close', () => { try { transport.close(); } catch {} try { server.close(); } catch {} }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +} +``` + +- [ ] **Step 6: Run tests, verify pass** + +Run: `npx vitest run tests/mcp/external_registry.test.js` +Expected: PASS (5/5). + +- [ ] **Step 7: Commit** + +```bash +git add lib/mcp/external-registry.js lib/mcp/context.js lib/mcp/http.js tests/mcp/external_registry.test.js +git commit -m "feat(mcp): external registry + agent ctx + Streamable HTTP server" +``` + +--- + +### Task 3: `mcpAuth` middleware (bearer → agent → space scope) + +**Files:** +- Create: `lib/api/middleware/mcp_auth.js` +- Test: `tests/mcp/mcp_auth.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/mcp/mcp_auth.test.js +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { mcpAuth, _resetMcpRate } from '../../lib/api/middleware/mcp_auth.js'; + +const owner = { kind: 'user', id: null }; +function mockRes() { + return { statusCode: 200, body: null, + status(c) { this.statusCode = c; return this; }, + json(b) { this.body = b; return this; } }; +} +async function run(headers) { + const req = { headers }; + const res = mockRes(); + const next = vi.fn(); + await mcpAuth(req, res, next); + return { req, res, next }; +} + +let scopedToken, unscopedToken; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + const { rows: [{ id: spaceId }] } = await (await import('../../lib/db/pool.js')).pool + .query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`); + const scoped = await agentsRepo.create({ slug: `s-${Date.now()}`, name: 'S', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: { space_id: spaceId } }, owner); + ({ token: scopedToken } = await agentsRepo.createToken(scoped.id, 'mcp')); + const unscoped = await agentsRepo.create({ slug: `u-${Date.now()}`, name: 'U', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: {} }, owner); + ({ token: unscopedToken } = await agentsRepo.createToken(unscoped.id, 'mcp')); +}); + +describe('mcpAuth', () => { + it('missing bearer → 401', async () => { + const { res, next } = await run({}); expect(res.statusCode).toBe(401); expect(next).not.toHaveBeenCalled(); + }); + it('owner token → 401 agent_required', async () => { + const { res } = await run({ authorization: 'Bearer test-token' }); + expect(res.statusCode).toBe(401); expect(res.body.error.code).toBe('agent_required'); + }); + it('invalid token → 401', async () => { + const { res } = await run({ authorization: 'Bearer vk_nope.nope' }); expect(res.statusCode).toBe(401); + }); + it('agent without space scope → 403 no_space_scope', async () => { + const { res } = await run({ authorization: `Bearer ${unscopedToken}` }); + expect(res.statusCode).toBe(403); expect(res.body.error.code).toBe('no_space_scope'); + }); + it('valid scoped agent → next() + req.mcpAgent', async () => { + const { req, next } = await run({ authorization: `Bearer ${scopedToken}` }); + expect(next).toHaveBeenCalled(); expect(req.mcpAgent.scopes.space_id).toBeTruthy(); + }); + it('rate limit → 429 past the cap', async () => { + _resetMcpRate(); process.env.MCP_RATE_LIMIT = '2'; + await run({ authorization: `Bearer ${scopedToken}` }); + await run({ authorization: `Bearer ${scopedToken}` }); + const { res } = await run({ authorization: `Bearer ${scopedToken}` }); + expect(res.statusCode).toBe(429); + delete process.env.MCP_RATE_LIMIT; + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `npx vitest run tests/mcp/mcp_auth.test.js` +Expected: FAIL — module doesn't exist. + +- [ ] **Step 3: Implement the middleware** + +```js +// lib/api/middleware/mcp_auth.js +// Auth gate for /mcp. External agents must present a Void agent bearer token +// bound to a Space (scopes.space_id). Owner / CF-Access identities are NOT +// accepted here — external agents never inherit owner powers. CF Access service +// tokens are enforced at the edge (Cloudflare policy on mcp.void.hynesy.com). +import * as agents from '../../db/repos/agents.js'; + +// Minimal fixed-window in-memory rate limit per token (defense-in-depth; the +// real gate is CF Access + bearer). Window is 60s. +const WINDOW_MS = 60_000; +const hits = new Map(); // token -> { count, resetAt } +export function _resetMcpRate() { hits.clear(); } +function rateLimited(token) { + const limit = Number(process.env.MCP_RATE_LIMIT || 120); + const now = Date.now(); + let h = hits.get(token); + if (!h || now > h.resetAt) { h = { count: 0, resetAt: now + WINDOW_MS }; hits.set(token, h); } + h.count += 1; + return h.count > limit; +} + +export async function mcpAuth(req, res, next) { + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'missing bearer token' } }); + } + if (process.env.OWNER_TOKEN && token === process.env.OWNER_TOKEN) { + return res.status(401).json({ error: { code: 'agent_required', message: 'owner token not valid for MCP' } }); + } + if (rateLimited(token)) { + return res.status(429).json({ error: { code: 'rate_limited', message: 'too many requests' } }); + } + let agent; + try { agent = await agents.verifyToken(token); } + catch (e) { return next(e); } + if (!agent) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } }); + } + if (!(agent.scopes && agent.scopes.space_id)) { + return res.status(403).json({ error: { code: 'no_space_scope', message: 'agent has no space scope' } }); + } + req.mcpAgent = agent; + next(); +} +``` + +- [ ] **Step 4: Run tests, verify pass** + +Run: `npx vitest run tests/mcp/mcp_auth.test.js` +Expected: PASS (6/6). + +- [ ] **Step 5: Commit** + +```bash +git add lib/api/middleware/mcp_auth.js tests/mcp/mcp_auth.test.js +git commit -m "feat(mcp): mcpAuth middleware — agent bearer + space scope + rate limit" +``` + +--- + +### Task 4: Mount `/mcp` + integration tests + +**Files:** +- Modify: `server.js` +- Test: `tests/mcp/mcp_http.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/mcp/mcp_http.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { setup } from '../api/helpers.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { pool } from '../../lib/db/pool.js'; + +let app, ownerHeaders, scopedToken; +const owner = { kind: 'user', id: null }; +const ACCEPT = 'application/json, text/event-stream'; +const init = { + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '1' } } +}; + +beforeAll(async () => { + ({ app, ownerHeaders } = await setup()); + const { rows: [{ id: spaceId }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`); + const agent = await agentsRepo.create({ slug: `m-${Date.now()}`, name: 'M', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: { space_id: spaceId } }, owner); + ({ token: scopedToken } = await agentsRepo.createToken(agent.id, 'mcp')); +}); + +describe('POST /mcp', () => { + it('no bearer → 401', async () => { + const res = await request(app).post('/mcp').set('Accept', ACCEPT).send(init); + expect(res.status).toBe(401); + }); + it('owner token → 401 agent_required', async () => { + const res = await request(app).post('/mcp').set(ownerHeaders).set('Accept', ACCEPT).send(init); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('agent_required'); + }); + it('scoped agent initialize → 200 with serverInfo', async () => { + const res = await request(app).post('/mcp') + .set('Authorization', `Bearer ${scopedToken}`).set('Accept', ACCEPT).send(init); + expect(res.status).toBe(200); + expect(res.body.result.serverInfo.name).toBe('void-external'); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `npx vitest run tests/mcp/mcp_http.test.js` +Expected: FAIL — `/mcp` returns 404 (not mounted). + +- [ ] **Step 3: Mount the route in `server.js`** + +Add imports near the other imports in `server.js`: + +```js +import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; +import { handleMcp } from './lib/mcp/http.js'; +``` + +Inside `createApp()`, after `mountApi(app);` and **before** the 404 handler (`app.use((_req, res) => res.status(404)...)`), add: + +```js + // MCP Streamable HTTP for external agents (read + suggest-only, space-scoped). + app.all('/mcp', mcpAuth, handleMcp); +``` + +- [ ] **Step 4: Run tests, verify pass** + +Run: `npx vitest run tests/mcp/mcp_http.test.js` +Expected: PASS (3/3). If `initialize` returns a 406, confirm the test sends `Accept: application/json, text/event-stream` (the transport requires it). + +- [ ] **Step 5: Commit** + +```bash +git add server.js tests/mcp/mcp_http.test.js +git commit -m "feat(mcp): mount /mcp Streamable HTTP endpoint" +``` + +--- + +### Task 5: Version bump + changelog + +**Files:** +- Modify: `package.json`, `server.js`, `CHANGELOG.md` + +- [ ] **Step 1: Bump version** + +In `package.json` set `"version": "2.0.0-alpha.14"`. In `server.js` set `const VERSION = '2.0.0-alpha.14';`. + +- [ ] **Step 2: Add a CHANGELOG entry** + +Prepend under the changelog's top section: + +```markdown +## 2.0.0-alpha.14 + +- **MCP HTTP/SSE transport** — external agents can connect over MCP Streamable HTTP at `/mcp`, authenticated by a Space-scoped Void agent bearer (owner/CF-only rejected). Read + suggest-only: search/read/context + propose_change (routes to the pending-changes inbox). Reads are hard-scoped to the agent's bound Space; the `read` tool now enforces space membership. +``` + +- [ ] **Step 3: Run the full suite** + +Run: `npx vitest run` +Expected: all green (serial; `fileParallelism:false`). + +- [ ] **Step 4: Commit** + +```bash +git add package.json server.js CHANGELOG.md +git commit -m "chore: release 2.0.0-alpha.14 (MCP HTTP transport)" +``` + +--- + +### Task 6: Deploy + Cloudflare infra + provision the first external agent + +**Files:** none in-repo (infra + DB). Run from the workspace. + +- [ ] **Step 1: Deploy via the hardened pipeline** + +Run: `bash deploy/push.sh` (snapshots prev, rsync, `npm install --omit=dev`, `npm run migrate`, restart, `/health` version gate for `2.0.0-alpha.14`, auto-rollback on failure). +Expected: `/health` reports `version: 2.0.0-alpha.14`, `db_ok: true`. + +- [ ] **Step 2: Add the tunnel ingress hostname** `mcp.void.hynesy.com` → the void-server origin (same origin as the existing void hostname, path `/mcp` reachable). Use the existing void tunnel config (CF API creds in memory `reference_cloudflare_api`). Verify DNS/route resolves. + +- [ ] **Step 3: Create a CF Access application** for `mcp.void.hynesy.com` with a **Service Auth** policy (service token), no interactive IdP. Create a service token; record its Client-Id/Secret in the secrets store (Vaultwarden followup / env). This is the edge gate. + +- [ ] **Step 4: Provision the first external agent (DB)** + +On the app host (CT 311), in a `node` REPL or a one-off script using `lib/db/repos/agents.js`: + +```js +import * as agents from './lib/db/repos/agents.js'; +const a = await agents.create({ + slug: 'external-research', name: 'External Research', kind: 'external', model: 'n/a', + capabilities: { read: true, suggest: true }, // propose-only (no write/apply) + scopes: { space_id: '' } +}, { kind: 'user', id: null }); +const { token } = await agents.createToken(a.id, 'mcp-external'); +console.log('AGENT BEARER:', token); // store in the secrets store; give to the external client +``` + +- [ ] **Step 5: Smoke-test end-to-end** + +```bash +# initialize through the edge (service token + agent bearer) +curl -sS https://mcp.void.hynesy.com/mcp \ + -H 'CF-Access-Client-Id: ' -H 'CF-Access-Client-Secret: ' \ + -H "Authorization: Bearer " \ + -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` +Expected: JSON-RPC result with `serverInfo.name = "void-external"`. Confirm a no-bearer request → 401, and a request without the CF service token → blocked at the edge (CF Access page). + +- [ ] **Step 6: Record outcome** in memory (`project_void_v2_roadmap` / a new `reference` note): hostname, CF Access app id, service-token id, the external-agent slug + bound space. Redact secrets. + +--- + +## Self-Review + +**Spec coverage:** Transport (Streamable HTTP, stateless) → Task 2/4. Auth CF+bearer, owner rejected → Task 3 + Task 6 edge. Space scoping (read.js the crux) → Task 1, enforced in ctx Task 2. Dedicated external registry → Task 2. propose-only via canAct + pending_changes → Task 2 test. Error handling (401/403/429, isError) → Tasks 3/4 + http.js. Audit → http.js CallTool. Testing split (transport-free unit + HTTP integration) → Tasks 1–4. Deploy + hostname + service token + provision → Task 6. Version bump → Task 5. All covered. + +**Placeholder scan:** Only intentional infra placeholders in Task 6 (``, `/`, CF app creation) — these are runtime/secret values, not code gaps. + +**Type consistency:** `buildCtxFromAgent` returns `{agent, space_id, view, spaceScoped, actor}`; `read.js` reads `ctx.space_id`/`ctx.spaceScoped`; `propose_change` reads `ctx.agent.id`/`ctx.agent` (actor-shaped with kind/capabilities/scopes) — consistent. `listExternalTools`/`callExternalTool`/`createExternalMcpServer`/`handleMcp` names match across http.js + tests. `mcpAuth` sets `req.mcpAgent`; `handleMcp` reads `req.mcpAgent` — consistent.