diff --git a/lib/mcp/context.js b/lib/mcp/context.js index f48df51..321a984 100644 --- a/lib/mcp/context.js +++ b/lib/mcp/context.js @@ -16,3 +16,25 @@ export function buildCtxFromEnv(env = process.env) { actor: { kind: 'user', id: null } }; } + +/** + * 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 + }; +} diff --git a/lib/mcp/external-registry.js b/lib/mcp/external-registry.js new file mode 100644 index 0000000..10fa1ae --- /dev/null +++ b/lib/mcp/external-registry.js @@ -0,0 +1,15 @@ +// 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); diff --git a/lib/mcp/http.js b/lib/mcp/http.js new file mode 100644 index 0000000..43fd77a --- /dev/null +++ b/lib/mcp/http.js @@ -0,0 +1,54 @@ +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); +} diff --git a/tests/mcp/external_registry.test.js b/tests/mcp/external_registry.test.js new file mode 100644 index 0000000..761e681 --- /dev/null +++ b/tests/mcp/external_registry.test.js @@ -0,0 +1,52 @@ +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); + }); +});