#!/usr/bin/env node /** * agentctl MCP (stdio, zero deps). Thin argv wrappers around scripts/agentctl.py. * lease_run passes command as argv after `--` — no extra shell. */ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO = process.env.AGENTCTL_REPO ? path.resolve(process.env.AGENTCTL_REPO) : path.resolve(__dirname, '../..'); const AGENTCTL = path.join(REPO, 'scripts', 'agentctl.py'); function runAgentctl(args, { cwd = REPO, timeout = 120000 } = {}) { if (!fs.existsSync(AGENTCTL)) { return { ok: false, code: 127, stdout: '', stderr: `agentctl missing: ${AGENTCTL}` }; } const r = spawnSync('python3', ['-B', AGENTCTL, ...args], { cwd, encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024, env: process.env, }); return { ok: r.status === 0, code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error ? String(r.error) : undefined, }; } function textResult(payload) { const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); return { content: [{ type: 'text', text: body }] }; } function errResult(msg) { return { content: [{ type: 'text', text: msg }], isError: true }; } const TOOLS = [ { name: 'status', description: 'agentctl status + daemon status for session start checks', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { name: 'task_register', description: 'Register a task with scopes and optional capabilities', inputSchema: { type: 'object', properties: { id: { type: 'string' }, owner: { type: 'string' }, scopes: { type: 'array', items: { type: 'string' } }, capabilities: { type: 'array', items: { type: 'string' } }, }, required: ['id', 'owner', 'scopes'], additionalProperties: false, }, }, { name: 'task_close', description: 'Close a task as done or cancelled', inputSchema: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string', enum: ['done', 'cancelled'] }, }, required: ['id'], additionalProperties: false, }, }, { name: 'lease_run', description: 'Run argv under agentctl with lease (resources). Command is argv array, no shell.', inputSchema: { type: 'object', properties: { task: { type: 'string' }, resources: { type: 'array', items: { type: 'string' } }, command: { type: 'array', items: { type: 'string' }, minItems: 1 }, cwd: { type: 'string', description: 'optional worktree absolute path' }, }, required: ['task', 'resources', 'command'], additionalProperties: false, }, }, { name: 'docs_snapshot', description: 'Snapshot a shared doc row for CAS apply', inputSchema: { type: 'object', properties: { file: { type: 'string' }, record: { type: 'string' }, }, required: ['file', 'record'], additionalProperties: false, }, }, { name: 'docs_apply', description: 'Apply a docs request JSON file under a task', inputSchema: { type: 'object', properties: { task: { type: 'string' }, request: { type: 'string', description: 'path to request JSON' }, }, required: ['task', 'request'], additionalProperties: false, }, }, { name: 'validate', description: 'Validate candidate HEAD with an explicit command argv', inputSchema: { type: 'object', properties: { task: { type: 'string' }, command: { type: 'array', items: { type: 'string' }, minItems: 1 }, cwd: { type: 'string' }, }, required: ['task', 'command'], additionalProperties: false, }, }, { name: 'queue_enqueue', description: 'Enqueue a validated candidate for daemon ff-only integrate', inputSchema: { type: 'object', properties: { task: { type: 'string' }, expected_dev: { type: 'string' }, head: { type: 'string' }, priority: { type: 'number' }, }, required: ['task', 'expected_dev'], additionalProperties: false, }, }, { name: 'quarantine_status', description: 'List quarantine / suspect runs', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { name: 'catalog_requirement_add', description: 'Add a catalog requirement (R-ID)', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, statement: { type: 'string' }, owner: { type: 'string' }, feature: { type: 'string' }, source_doc: { type: 'string' }, source_task: { type: 'string' }, }, required: ['id', 'title', 'statement', 'owner'], additionalProperties: false, }, }, { name: 'catalog_overlap_probe', description: 'Probe hard/soft requirement overlaps for a task', inputSchema: { type: 'object', properties: { task: { type: 'string' }, statement: { type: 'string' }, requirement: { type: 'string' }, feature: { type: 'string' }, mutex: { type: 'string' }, top: { type: 'number' }, }, required: ['task', 'statement'], additionalProperties: false, }, }, { name: 'catalog_overlap_ack', description: 'Acknowledge a soft overlap with an explicit decision', inputSchema: { type: 'object', properties: { task: { type: 'string' }, overlap: { type: 'string' }, decision: { type: 'string', enum: ['proceed', 'merge_into', 'supersede', 'defer', 'reject'] }, reason: { type: 'string' }, decided_by: { type: 'string' }, authorization_ref: { type: 'string' }, }, required: ['task', 'overlap', 'decision', 'reason'], additionalProperties: false, }, }, { name: 'catalog_search', description: 'FTS5 CJK search over catalog requirements', inputSchema: { type: 'object', properties: { query: { type: 'string' }, top: { type: 'number' }, }, required: ['query'], additionalProperties: false, }, }, { name: 'catalog_status', description: 'Catalog table counts and gate/vector config summary', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { name: 'queue_list', description: 'List integrate queue items (pending/claimed by default)', inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'include terminal queue items' }, format: { type: 'string', enum: ['json', 'table'] }, }, additionalProperties: false, }, }, { name: 'claim_status', description: 'List held claims (or all with all=true)', inputSchema: { type: 'object', properties: { all: { type: 'boolean' }, format: { type: 'string', enum: ['json', 'table'] }, }, additionalProperties: false, }, }, { name: 'cleanup_report', description: 'Batch cleanup readiness for .worktrees (never deletes)', inputSchema: { type: 'object', properties: { format: { type: 'string', enum: ['json', 'table'] }, }, additionalProperties: false, }, }, { name: 'catalog_drift', description: 'Refresh catalog features from MATRIX; report done→non-done regressions', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { name: 'brief', description: 'One-page governance briefing (markdown or json)', inputSchema: { type: 'object', properties: { format: { type: 'string', enum: ['markdown', 'json'] }, }, additionalProperties: false, }, }, { name: 'claim_archive', description: 'Archive done CURRENT claim rows (default dry-run; set apply=true to write)', inputSchema: { type: 'object', properties: { task: { type: 'string' }, retain_days: { type: 'number' }, include_undated: { type: 'boolean' }, apply: { type: 'boolean' }, cwd: { type: 'string', description: 'task worktree absolute path' }, }, required: ['task'], additionalProperties: false, }, }, ]; function callTool(name, args = {}) { switch (name) { case 'status': { const a = runAgentctl(['status']); const b = runAgentctl(['daemon', 'status']); return textResult({ repo: REPO, status: { ok: a.ok, code: a.code, stdout: a.stdout.trim(), stderr: a.stderr.trim() }, daemon: { ok: b.ok, code: b.code, stdout: b.stdout.trim(), stderr: b.stderr.trim() }, }); } case 'task_register': { const argv = ['task', 'register', '--id', args.id, '--owner', args.owner]; for (const s of args.scopes || []) argv.push('--scope', s); for (const c of args.capabilities || []) argv.push('--capability', c); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'task_close': { const argv = ['task', 'close', '--id', args.id, '--status', args.status || 'done']; const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'lease_run': { if (!Array.isArray(args.command) || args.command.length < 1) { return errResult('lease_run.command must be a non-empty argv array'); } const argv = ['with', '--task', args.task]; for (const res of args.resources || []) argv.push('--resource', res); argv.push('--', ...args.command); const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 }); const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim(); return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`); } case 'docs_snapshot': { const r = runAgentctl(['docs', 'snapshot', '--file', args.file, '--record', args.record]); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'docs_apply': { const r = runAgentctl(['docs', 'apply', '--task', args.task, '--request', args.request]); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'validate': { if (!Array.isArray(args.command) || args.command.length < 1) { return errResult('validate.command must be a non-empty argv array'); } const argv = ['validate', '--task', args.task, '--', ...args.command]; const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 }); const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim(); return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`); } case 'queue_enqueue': { const argv = ['queue', 'enqueue', '--task', args.task, '--expected-dev', args.expected_dev]; if (args.head) argv.push('--head', args.head); if (args.priority != null) argv.push('--priority', String(args.priority)); const r = runAgentctl(argv, { cwd: args.cwd || REPO }); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'quarantine_status': { const r = runAgentctl(['quarantine', 'list']); return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error); } case 'catalog_requirement_add': { const argv = [ 'catalog', 'add', '--id', args.id, '--title', args.title, '--statement', args.statement, '--owner', args.owner, ]; if (args.feature) argv.push('--feature', args.feature); if (args.source_doc) argv.push('--source-doc', args.source_doc); if (args.source_task) argv.push('--source-task', args.source_task); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'catalog_overlap_probe': { const argv = ['catalog', 'overlap', '--task', args.task, '--statement', args.statement]; if (args.requirement) argv.push('--requirement', args.requirement); if (args.feature) argv.push('--feature', args.feature); if (args.mutex) argv.push('--mutex', args.mutex); if (args.top != null) argv.push('--top', String(args.top)); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'catalog_overlap_ack': { const argv = [ 'catalog', 'ack', '--task', args.task, '--overlap', args.overlap, '--decision', args.decision, '--reason', args.reason, ]; if (args.decided_by) argv.push('--decided-by', args.decided_by); if (args.authorization_ref) argv.push('--authorization-ref', args.authorization_ref); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'catalog_search': { const argv = ['catalog', 'search', '--query', args.query]; if (args.top != null) argv.push('--top', String(args.top)); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'catalog_status': { const r = runAgentctl(['catalog', 'status']); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'queue_list': { const argv = ['queue', 'list']; if (args.all) argv.push('--all'); if (args.format) argv.push('--format', args.format); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error); } case 'claim_status': { const argv = ['claim', 'list']; if (args.all) argv.push('--all'); if (args.format) argv.push('--format', args.format); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim() || '{"claims":[]}') : errResult(r.stderr || r.stdout || r.error); } case 'cleanup_report': { const argv = ['cleanup', 'report']; if (args.format) argv.push('--format', args.format); const r = runAgentctl(argv, { timeout: 180000 }); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'catalog_drift': { const r = runAgentctl(['catalog', 'drift']); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'brief': { const argv = ['brief']; if (args.format) argv.push('--format', args.format); const r = runAgentctl(argv); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } case 'claim_archive': { const argv = ['claim', 'archive', '--task', args.task]; if (args.retain_days != null) argv.push('--retain-days', String(args.retain_days)); if (args.include_undated) argv.push('--include-undated'); if (args.apply) argv.push('--apply'); const r = runAgentctl(argv, { cwd: args.cwd || REPO }); return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); } default: return errResult(`unknown tool: ${name}`); } } function send(msg) { // Cursor IDE/CLI stdio MCP expects newline-delimited JSON-RPC (same as pi-subagents). // Still accept Content-Length framed *requests* on stdin for compatibility. const body = JSON.stringify(msg); process.stdout.write(body + '\n'); } function handle(msg) { if (!msg || typeof msg !== 'object') return; if (msg.method && msg.id === undefined) { // notification — ignore return; } const { id, method, params } = msg; try { if (method === 'initialize') { send({ jsonrpc: '2.0', id, result: { protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentctl', version: '1.0.0' }, }, }); return; } if (method === 'tools/list') { send({ jsonrpc: '2.0', id, result: { tools: TOOLS } }); return; } if (method === 'tools/call') { const name = params?.name; const args = params?.arguments || {}; const result = callTool(name, args); send({ jsonrpc: '2.0', id, result }); return; } if (method === 'ping') { send({ jsonrpc: '2.0', id, result: {} }); return; } send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); } catch (e) { send({ jsonrpc: '2.0', id, error: { code: -32000, message: String(e?.stack || e) } }); } } // Support both Content-Length framed MCP and newline-delimited JSON (for tests). let buf = Buffer.alloc(0); process.stdin.on('data', (chunk) => { buf = Buffer.concat([buf, chunk]); while (true) { const headerEnd = buf.indexOf('\r\n\r\n'); if (headerEnd !== -1) { const header = buf.slice(0, headerEnd).toString('utf8'); const m = /Content-Length:\s*(\d+)/i.exec(header); if (!m) { buf = buf.slice(headerEnd + 4); continue; } const len = Number(m[1]); const start = headerEnd + 4; if (buf.length < start + len) return; const body = buf.slice(start, start + len).toString('utf8'); buf = buf.slice(start + len); try { handle(JSON.parse(body)); } catch (e) { process.stderr.write(`parse error: ${e}\n`); } continue; } // newline-delimited fallback for harness const nl = buf.indexOf('\n'); if (nl === -1) return; const line = buf.slice(0, nl).toString('utf8').trim(); buf = buf.slice(nl + 1); if (!line || line.startsWith('Content-Length')) continue; try { handle(JSON.parse(line)); } catch (e) { process.stderr.write(`ndjson parse error: ${e}\n`); } } }); process.stdin.on('end', () => process.exit(0));