Files
Agent-System/scripts/mcp/agentctl-mcp.mjs
T
WeiCHEN 821a307feb feat(ops): add Cursor skills/MCP/rules/hooks for agentctl
Wrap the live coordinator in project Cursor capabilities so agents
default to leases, docs apply, and daemon enqueue. Track skills/hooks/mcp
via .gitignore exceptions; guard destructive git with ask permission.
2026-09-23 19:23:40 +08:00

311 lines
10 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 { 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));