Files
Void-Homelab/docs/superpowers/plans/2026-06-04-mcp-http-transport.md
2026-06-04 20:06:32 +10:00

627 lines
26 KiB
Markdown
Raw 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.
# 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: '<TARGET_SPACE_UUID>' }
}, { 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: <id>' -H 'CF-Access-Client-Secret: <secret>' \
-H "Authorization: Bearer <AGENT_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 14. Deploy + hostname + service token + provision → Task 6. Version bump → Task 5. All covered.
**Placeholder scan:** Only intentional infra placeholders in Task 6 (`<TARGET_SPACE_UUID>`, `<id>/<secret>`, 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.