Files
Agent-System/scripts/mcp/agentctl-mcp.mjs
T
weichen f9f055bee1 feat(agentctl): add governance console P1 serve and action APIs
Implement loopback HTTP console with claim new, integrate prepare, and pi packet sharing the same helpers as CLI; ship SPA and MCP brief/claim_archive wrappers.
2026-09-24 14:13:27 +00:00

533 lines
18 KiB
JavaScript
Executable File

#!/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));