feat(mcp): external registry + agent ctx + Streamable HTTP server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 20:08:14 +10:00
parent c955f1eaaf
commit 185a4f3c96
4 changed files with 143 additions and 0 deletions

View File

@@ -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
};
}

View 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
View 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);
}