feat(mcp): external registry + agent ctx + Streamable HTTP server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,25 @@ export function buildCtxFromEnv(env = process.env) {
|
|||||||
actor: { kind: 'user', id: null }
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
15
lib/mcp/external-registry.js
Normal file
15
lib/mcp/external-registry.js
Normal file
@@ -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);
|
||||||
54
lib/mcp/http.js
Normal file
54
lib/mcp/http.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
52
tests/mcp/external_registry.test.js
Normal file
52
tests/mcp/external_registry.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user