/** * 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 }); }