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

26 KiB
Raw Permalink Blame History

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.jsmodify: space-scope enforcement.
  • lib/mcp/context.jsmodify: add buildCtxFromAgent(agent).
  • lib/mcp/external-registry.jscreate: curated registry (4 tools).
  • lib/mcp/http.jscreate: transport-free helpers + createExternalMcpServer + handleMcp.
  • lib/api/middleware/mcp_auth.jscreate: bearer→agent→space-scope gate + rate limit.
  • server.jsmodify: mount /mcp; bump VERSION.
  • package.jsonmodify: 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

// 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):

  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
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

// 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
// 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:

/**
 * 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)
// 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
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

// 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
// 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
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

// 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:

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:

  // 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
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:

## 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
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:

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
# 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.