#!/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 { createInterface } from 'node:readline'; 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 }, }, ]; 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); } default: return errResult(`unknown tool: ${name}`); } } function send(msg) { const body = JSON.stringify(msg); process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`); } 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));