From f53c6d6cb9cc8c673753cba7c671866468739360 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 25 May 2026 04:17:28 +0000 Subject: [PATCH] =?UTF-8?q?v2.0.2:=20BooCoder=20MCP=20server=20=E2=80=94?= =?UTF-8?q?=206=20tools=20over=20stdio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of v2.0. BooCoder exposes its task primitives as MCP tools so external agents (Sam's opencode in Termius) can drive the task queue without going through the web UI. 6 MCP tools registered via McpServer + StdioServerTransport: - boocoder.create_task — INSERT pending task - boocoder.list_pending_changes — SELECT pending changes - boocoder.apply — apply a specific pending change to disk - boocoder.reject — reject a pending change - boocoder.dispatch_external_agent — create task with agent for Path B - boocoder.list_worktrees — list active worktrees from running tasks Activated by --mcp CLI flag: `node dist/index.js --mcp` starts the MCP server over stdio instead of the HTTP server. Configure in opencode: {"mcpServers":{"boocoder":{"type":"stdio","command":"docker", "args":["exec","-i","boocoder","node","dist/index.js","--mcp"]}}} Uses McpServer class from @modelcontextprotocol/sdk/server/mcp.js (high-level .tool() registration API). Zod schemas for input validation. Process blocks on stdin close, cleanly shuts down DB. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/coder/package.json | 1 + apps/coder/src/index.ts | 10 ++ apps/coder/src/services/mcp-server.ts | 201 ++++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 215 insertions(+) create mode 100644 apps/coder/src/services/mcp-server.ts diff --git a/apps/coder/package.json b/apps/coder/package.json index ce96fb4..1cceabe 100644 --- a/apps/coder/package.json +++ b/apps/coder/package.json @@ -16,6 +16,7 @@ "@boocode/server": "workspace:*", "@fastify/static": "^7.0.4", "@fastify/websocket": "^10.0.1", + "@modelcontextprotocol/sdk": "^1.29.0", "fastify": "^4.28.1", "postgres": "^3.4.4", "zod": "^3.23.8" diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 78a69de..aaee0c5 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -9,6 +9,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); import { loadConfig } from './config.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js'; +import { startMcpServer } from './services/mcp-server.js'; // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the // inference loop, broker, and tool registry without duplication. import { createInferenceRunner } from '@boocode/server/inference'; @@ -30,6 +31,15 @@ import { createDispatcher } from './services/dispatcher.js'; import { probeAgents } from './services/agent-probe.js'; async function main() { + // MCP mode: stdio transport, no HTTP server + if (process.argv.includes('--mcp')) { + const config = loadConfig(); + const sql = getSql(config); + await applySchema(sql); + await startMcpServer(sql); + return; + } + const config = loadConfig(); const app = Fastify({ diff --git a/apps/coder/src/services/mcp-server.ts b/apps/coder/src/services/mcp-server.ts new file mode 100644 index 0000000..47e0bf2 --- /dev/null +++ b/apps/coder/src/services/mcp-server.ts @@ -0,0 +1,201 @@ +/** + * BooCoder MCP Server — exposes task primitives as MCP tools. + * + * Started when `--mcp` flag is passed to the entry point. Runs stdio transport + * so external tools (opencode in Termius) can drive the task queue. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; +import { applyOne, rejectOne } from './pending_changes.js'; + +// --- Tool handlers ----------------------------------------------------------- + +interface TaskRow { + id: string; + state: string; +} + +interface PendingRow { + id: string; + file_path: string; + operation: string; + diff: string; + session_id: string; +} + +interface WorktreeRow { + id: string; + worktree_path: string; + agent: string; + started_at: string; +} + +interface ProjectPathRow { + path: string; +} + +function textResult(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; +} + +// --- Public entry ------------------------------------------------------------ + +export async function startMcpServer(sql: Sql): Promise { + const server = new McpServer( + { name: 'boocoder', version: '2.0.2' }, + { capabilities: { tools: {} } }, + ); + + // 1. boocoder.create_task + server.tool( + 'boocoder.create_task', + 'Create a new task in the BooCoder task queue', + { + project_id: z.string().describe('Project UUID'), + input: z.string().describe('Task description / prompt for the agent'), + agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'), + model: z.string().optional().describe('Model override (optional)'), + }, + async (args) => { + const [row] = await sql` + INSERT INTO tasks (project_id, input, agent, model, state) + VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending') + RETURNING id, state + `; + return textResult({ task_id: row!.id, state: row!.state }); + }, + ); + + // 2. boocoder.list_pending_changes + server.tool( + 'boocoder.list_pending_changes', + 'List pending changes awaiting review', + { + session_id: z.string().optional().describe('Optional session filter'), + }, + async (args) => { + let rows: PendingRow[]; + if (args.session_id) { + rows = await sql` + SELECT id, file_path, operation, diff, session_id + FROM pending_changes + WHERE status = 'pending' AND session_id = ${args.session_id} + ORDER BY created_at ASC + `; + } else { + rows = await sql` + SELECT id, file_path, operation, diff, session_id + FROM pending_changes + WHERE status = 'pending' + ORDER BY created_at ASC + `; + } + const items = rows.map((r) => ({ + id: r.id, + file_path: r.file_path, + operation: r.operation, + diff_preview: r.diff.slice(0, 200), + })); + return textResult(items); + }, + ); + + // 3. boocoder.apply + server.tool( + 'boocoder.apply', + 'Apply a pending change (write to disk)', + { + change_id: z.string().describe('Pending change UUID'), + }, + async (args) => { + // Resolve projectRoot from the change's session → project path + const [proj] = await sql` + SELECT p.path FROM pending_changes pc + JOIN sessions s ON pc.session_id = s.id + JOIN projects p ON s.project_id = p.id + WHERE pc.id = ${args.change_id} + `; + if (!proj) { + return textResult({ success: false, file_path: '', error: 'change not found or project path unresolved' }); + } + const result = await applyOne(sql, args.change_id, proj.path); + return textResult({ success: result.success, file_path: result.file_path, error: result.error }); + }, + ); + + // 4. boocoder.reject + server.tool( + 'boocoder.reject', + 'Reject a pending change (mark as rejected, no disk write)', + { + change_id: z.string().describe('Pending change UUID'), + }, + async (args) => { + await rejectOne(sql, args.change_id); + return textResult({ success: true }); + }, + ); + + // 5. boocoder.dispatch_external_agent + server.tool( + 'boocoder.dispatch_external_agent', + 'Create a task targeting a specific external agent (ACP or PTY dispatch)', + { + project_id: z.string().describe('Project UUID'), + input: z.string().describe('Task prompt'), + agent: z.string().describe('Agent name (must match available_agents registry)'), + model: z.string().optional().describe('Model override (optional)'), + }, + async (args) => { + const [row] = await sql` + INSERT INTO tasks (project_id, input, agent, model, state) + VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? null}, 'pending') + RETURNING id, state + `; + + // Determine execution path from available_agents + const [agentRow] = await sql<{ supports_acp: boolean }[]>` + SELECT supports_acp FROM available_agents WHERE name = ${args.agent} + `; + const executionPath = agentRow?.supports_acp ? 'acp' : 'pty'; + + return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath }); + }, + ); + + // 6. boocoder.list_worktrees + server.tool( + 'boocoder.list_worktrees', + 'List active worktrees from running tasks', + {}, + async () => { + const rows = await sql` + SELECT id, worktree_path, agent, started_at + FROM tasks + WHERE worktree_path IS NOT NULL AND state = 'running' + ORDER BY started_at DESC + `; + const items = rows.map((r) => ({ + task_id: r.id, + worktree_path: r.worktree_path, + agent: r.agent, + started_at: r.started_at, + })); + return textResult(items); + }, + ); + + // Connect via stdio + const transport = new StdioServerTransport(); + await server.connect(transport); + + // Block until stdin closes (transport handles lifecycle) + await new Promise((resolve) => { + process.stdin.on('end', resolve); + process.stdin.on('close', resolve); + }); + + await sql.end({ timeout: 5 }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 229dc24..20eebe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@fastify/websocket': specifier: ^10.0.1 version: 10.0.1 + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) fastify: specifier: ^4.28.1 version: 4.29.1