Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.1 KiB
TypeScript
233 lines
7.1 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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)'),
|
|
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
|
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
|
},
|
|
async (args) => {
|
|
const [row] = await sql<TaskRow[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
|
|
VALUES (
|
|
${args.project_id},
|
|
${args.input},
|
|
${args.agent ?? null},
|
|
${args.model ?? null},
|
|
${args.mode_id ?? null},
|
|
${args.thinking_option_id ?? null},
|
|
'pending'
|
|
)
|
|
RETURNING id, state
|
|
`;
|
|
return textResult({
|
|
task_id: row!.id,
|
|
state: row!.state,
|
|
mode_id: args.mode_id ?? null,
|
|
thinking_option_id: args.thinking_option_id ?? null,
|
|
});
|
|
},
|
|
);
|
|
|
|
// 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<PendingRow[]>`
|
|
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<PendingRow[]>`
|
|
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<ProjectPathRow[]>`
|
|
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)'),
|
|
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
|
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
|
},
|
|
async (args) => {
|
|
const [row] = await sql<TaskRow[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
|
|
VALUES (
|
|
${args.project_id},
|
|
${args.input},
|
|
${args.agent},
|
|
${args.model ?? null},
|
|
${args.mode_id ?? null},
|
|
${args.thinking_option_id ?? 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,
|
|
mode_id: args.mode_id ?? null,
|
|
thinking_option_id: args.thinking_option_id ?? null,
|
|
});
|
|
},
|
|
);
|
|
|
|
// 6. boocoder.list_worktrees
|
|
server.tool(
|
|
'boocoder.list_worktrees',
|
|
'List active worktrees from running tasks',
|
|
{},
|
|
async () => {
|
|
const rows = await sql<WorktreeRow[]>`
|
|
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<void>((resolve) => {
|
|
process.stdin.on('end', resolve);
|
|
process.stdin.on('close', resolve);
|
|
});
|
|
|
|
await sql.end({ timeout: 5 });
|
|
}
|