feat(mcp): stdio MCP server exposing the four companion tools

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 21:45:42 +10:00
parent c7a94f26d1
commit 1c03d6c277
5 changed files with 499 additions and 0 deletions

110
lib/mcp/companion-stdio.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* companion-stdio.js
*
* Exposes the four Void companion tools through the MCP stdio protocol.
* The `claude` CLI spawns this file as a subprocess and communicates
* over stdin/stdout using JSON-RPC.
*
* Usage (via claude MCP config):
* VOID_AGENT_JSON='...' VOID_SPACE_ID='...' node lib/mcp/companion-stdio.js
*
* Exported helpers (transport-free — safe to import in tests):
* listMcpTools() → array of {name, description, input_schema}
* callMcpTool(name, args, ctx) → raw handler result object
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import { companionRegistry } from '../ai/agent/tools/index.js';
import { buildCtxFromEnv } from './context.js';
// ---------------------------------------------------------------------------
// Transport-free helpers (exported for testing)
// ---------------------------------------------------------------------------
/**
* Returns the list of MCP tool descriptors (no handler) from the companion registry.
* @returns {{ name: string, description: string, input_schema: object }[]}
*/
export function listMcpTools() {
return companionRegistry.listTools().map(({ name, description, input_schema }) => ({
name,
description,
input_schema
}));
}
/**
* Calls a tool handler directly (same code path as the MCP server), bypassing transport.
* @param {string} name
* @param {object} args
* @param {object} ctx tool context {agent, space_id, view, actor}
* @returns {Promise<object>} raw handler result
*/
export async function callMcpTool(name, args, ctx) {
const tool = companionRegistry.getTool(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
return tool.handler(args, ctx);
}
// ---------------------------------------------------------------------------
// MCP server factory (used when running as a script)
// ---------------------------------------------------------------------------
function createServer() {
const server = new Server(
{ name: 'void-companion', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// tools/list — return all companion tools with their raw JSON schemas
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: listMcpTools().map(({ name, description, input_schema }) => ({
name,
description,
inputSchema: input_schema // MCP protocol field name is inputSchema
}))
}));
// tools/call — delegate to the registry handler, wrap result as text content
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
const ctx = buildCtxFromEnv();
try {
const result = await callMcpTool(name, args, ctx);
const text = JSON.stringify(result);
return {
content: [{ type: 'text', text }],
structuredContent: result
};
} catch (err) {
return {
content: [{ type: 'text', text: err.message ?? String(err) }],
isError: true
};
}
});
return server;
}
// ---------------------------------------------------------------------------
// Entry point — only connect stdio transport when run as a script
// ---------------------------------------------------------------------------
const isMain =
typeof process !== 'undefined' &&
process.argv[1] &&
import.meta.url === new URL(process.argv[1], 'file://').href;
if (isMain) {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
}

18
lib/mcp/context.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* Builds the tool ctx object from environment variables.
* Used by companion-stdio.js when a tool call arrives, so each call reads
* fresh env values (useful if the process restarts or env is injected at launch).
*
* Environment variables:
* VOID_AGENT_JSON JSON-serialised agent actor object (required for most tools)
* VOID_SPACE_ID UUID of the active space
* VOID_VIEW_JSON JSON-serialised view object (optional)
*/
export function buildCtxFromEnv(env = process.env) {
return {
agent: env.VOID_AGENT_JSON ? JSON.parse(env.VOID_AGENT_JSON) : null,
space_id: env.VOID_SPACE_ID || null,
view: env.VOID_VIEW_JSON ? JSON.parse(env.VOID_VIEW_JSON) : null,
actor: { kind: 'user', id: null }
};
}