Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53c6d6cb9 |
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
201
apps/coder/src/services/mcp-server.ts
Normal file
201
apps/coder/src/services/mcp-server.ts
Normal file
@@ -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<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)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
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<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)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
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<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 });
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user