aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
610 lines (568 loc) • 20.8 kB
JavaScript
/**
* AIWG MCP Subsystem Toolsets
*
* Exposes AIWG's CLI subsystems (memory, kb, reflections, provenance,
* research-store, activity-log, index, ralph, mc, ops) as MCP toolsets.
* Each toolset is opt-in via AIWG_MCP_TOOLSETS env var or `aiwg mcp
* serve --toolsets=` flag (#1332 / S18).
*
* Default toolset enabled: `core` (already registered by discovery.mjs +
* command-run.mjs in server.mjs).
*
* @architecture @.aiwg/architecture/sketch-hermes-mcp-parity.md DD-3
* @issues #1322 (memory) #1323 (kb) #1324 (provenance) #1325 (activity-log)
* #1326 (index) #1327 (ralph) #1328 (ops) #1331 (mc) #1332 (dispatch)
*/
import { z } from 'zod';
import { runAiwgCli, mcpError, mcpJson } from '../helpers.mjs';
/**
* Wrap an `aiwg <subsystem> <verb>` CLI call as an MCP tool.
*
* @param {object} cfg
* @param {string} cfg.name MCP tool name
* @param {string} cfg.subsystem CLI subsystem (e.g. "memory")
* @param {string} cfg.verb CLI verb (e.g. "list")
* @param {string} cfg.title MCP tool title
* @param {string} cfg.description Human-readable description
* @param {object} cfg.inputSchema zod schema
* @param {boolean} [cfg.destructive] Mark as destructive
* @param {boolean} [cfg.readOnly] Mark as read-only
* @param {(args: object) => string[]} cfg.buildArgs Convert MCP args to CLI argv tail (excluding subsystem + verb)
* @param {(args: object) => string|undefined} [cfg.buildInput] Optional stdin payload (for put commands)
*/
function registerSubsystemTool(server, cfg) {
const {
name,
subsystem,
verb,
title,
description,
inputSchema,
destructive = false,
readOnly = false,
buildArgs,
buildInput,
} = cfg;
server.registerTool(name, {
title,
description,
inputSchema,
annotations: {
readOnlyHint: readOnly,
destructiveHint: destructive,
},
}, async (args) => {
try {
const cliArgs = [subsystem, verb, ...buildArgs(args)];
const input = buildInput ? buildInput(args) : undefined;
const { stdout, stderr, code } = await runAiwgCli(cliArgs, { input });
if (code !== 0) {
return mcpError(`${name} failed (exit ${code}): ${stderr || stdout}`);
}
// Prefer JSON if the output parses
try {
const parsed = JSON.parse(stdout);
return mcpJson(parsed);
} catch {
return mcpJson({ output: stdout, stderr });
}
} catch (err) {
return mcpError(`${name}: ${err.message}`);
}
});
}
// ============================================================================
// Storage subsystems (memory, kb, reflections, provenance, research-store)
//
// All five share the same CLI surface: path, list, get, put, delete.
// Generated via a single factory below.
// ============================================================================
const STORAGE_SUBSYSTEMS = [
{ name: 'memory', toolset: 'memory', project: true, description: 'Project memory / persistent agent recall' },
{ name: 'reflections', toolset: 'memory', project: true, description: 'Agent reflection memory' },
{ name: 'kb', toolset: 'kb', project: true, description: 'Project knowledge base' },
{ name: 'provenance', toolset: 'research', project: true, description: 'W3C PROV records' },
{ name: 'research-store', toolset: 'research', project: true, description: 'Research corpus' },
];
function registerStorageSubsystem(server, sub) {
const { name, description } = sub;
registerSubsystemTool(server, {
name: `${name}-list`,
subsystem: name,
verb: 'list',
title: `List ${description} entries`,
description: `List entries in the ${name} subsystem. Mirrors \`aiwg ${name} list --json\`.`,
inputSchema: {
prefix: z.string().optional().describe('Filter by path prefix'),
},
readOnly: true,
buildArgs: ({ prefix }) => {
const args = ['--json'];
if (prefix) args.push('--prefix', prefix);
return args;
},
});
registerSubsystemTool(server, {
name: `${name}-get`,
subsystem: name,
verb: 'get',
title: `Get ${description} entry`,
description: `Read an entry from the ${name} subsystem. Mirrors \`aiwg ${name} get <path>\`.`,
inputSchema: {
path: z.string().describe('Entry path/key'),
},
readOnly: true,
buildArgs: ({ path }) => [path],
});
registerSubsystemTool(server, {
name: `${name}-put`,
subsystem: name,
verb: 'put',
title: `Put ${description} entry`,
description: `Write content to an entry in the ${name} subsystem. Mirrors \`aiwg ${name} put <path>\` with stdin.`,
inputSchema: {
path: z.string().describe('Entry path/key'),
content: z.string().describe('Content to write (stdin to CLI)'),
},
destructive: false, // append/upsert semantics
buildArgs: ({ path }) => [path],
buildInput: ({ content }) => content,
});
registerSubsystemTool(server, {
name: `${name}-delete`,
subsystem: name,
verb: 'delete',
title: `Delete ${description} entry`,
description: `Delete an entry. No-op if missing. Mirrors \`aiwg ${name} delete <path>\`.`,
inputSchema: {
path: z.string().describe('Entry path/key'),
},
destructive: true,
buildArgs: ({ path }) => [path],
});
registerSubsystemTool(server, {
name: `${name}-path`,
subsystem: name,
verb: 'path',
title: `${description} resolved path`,
description: `Resolve the underlying physical path (fs backend). Mirrors \`aiwg ${name} path\`.`,
inputSchema: {
subpath: z.string().optional().describe('Optional subpath'),
},
readOnly: true,
buildArgs: ({ subpath }) => {
const args = ['--json'];
if (subpath) args.unshift(subpath);
return args;
},
});
}
// ============================================================================
// activity-log
// ============================================================================
function registerActivityLogToolset(server) {
registerSubsystemTool(server, {
name: 'activity-log-show',
subsystem: 'activity-log',
verb: 'show',
title: 'Show activity log',
description: 'Show the AIWG activity log (chronological event stream). Mirrors `aiwg activity-log show`.',
inputSchema: {
since: z.string().optional().describe('Filter by date YYYY-MM-DD'),
operation: z.string().optional().describe('Filter by operation token (e.g. "create", "deploy")'),
limit: z.number().int().min(1).max(1000).default(100),
},
readOnly: true,
buildArgs: ({ since, operation, limit }) => {
const args = [];
if (since) args.push('--since', since);
if (operation) args.push('--operation', operation);
if (limit) args.push('--limit', String(limit));
return args;
},
});
registerSubsystemTool(server, {
name: 'activity-log-append',
subsystem: 'activity-log',
verb: 'append',
title: 'Append activity log entry',
description: 'Append a one-line event. Mirrors `aiwg activity-log append <op> "<summary>"`.',
inputSchema: {
operation: z.string().describe('Operation token (e.g. "create", "deploy", "lint")'),
summary: z.string().describe('One-line summary (<120 chars)'),
},
buildArgs: ({ operation, summary }) => [operation, summary],
});
registerSubsystemTool(server, {
name: 'activity-log-stats',
subsystem: 'activity-log',
verb: 'stats',
title: 'Activity log stats',
description: 'Summary statistics over the activity log. Mirrors `aiwg activity-log stats`.',
inputSchema: {},
readOnly: true,
buildArgs: () => [],
});
}
// ============================================================================
// index
// ============================================================================
function registerIndexToolset(server) {
registerSubsystemTool(server, {
name: 'index-build',
subsystem: 'index',
verb: 'build',
title: 'Build artifact index',
description: 'Rebuild the artifact index (project / codebase / framework / user graphs). Mirrors `aiwg index build`.',
inputSchema: {
force: z.boolean().default(false).describe('Force full rebuild'),
graph: z.enum(['project', 'codebase', 'framework']).optional().describe('Restrict to one graph'),
},
buildArgs: ({ force, graph }) => {
const args = [];
if (force) args.push('--force');
if (graph) args.push('--graph', graph);
return args;
},
});
registerSubsystemTool(server, {
name: 'index-query',
subsystem: 'index',
verb: 'query',
title: 'Query artifact index',
description: 'Search artifacts by keyword/type/phase/tags. Mirrors `aiwg index query`.',
inputSchema: {
keyword: z.string().optional().describe('Keyword query'),
type: z.string().optional().describe('Artifact type filter'),
limit: z.number().int().min(1).max(500).default(50),
},
readOnly: true,
buildArgs: ({ keyword, type, limit }) => {
const args = ['--json'];
if (keyword) args.push(keyword);
if (type) args.push('--type', type);
if (limit) args.push('--limit', String(limit));
return args;
},
});
registerSubsystemTool(server, {
name: 'index-deps',
subsystem: 'index',
verb: 'deps',
title: 'Artifact dependency graph',
description: 'Show dependency relationships for an artifact. Mirrors `aiwg index deps <path>`.',
inputSchema: {
artifact: z.string().describe('Artifact path or ID'),
direction: z.enum(['upstream', 'downstream', 'both']).default('both'),
},
readOnly: true,
buildArgs: ({ artifact, direction }) => {
const args = [artifact, '--json'];
if (direction) args.push('--direction', direction);
return args;
},
});
registerSubsystemTool(server, {
name: 'index-stats',
subsystem: 'index',
verb: 'stats',
title: 'Index statistics',
description: 'Artifact counts by phase/type, graph metrics, coverage. Mirrors `aiwg index stats`.',
inputSchema: {},
readOnly: true,
buildArgs: () => ['--json'],
});
}
// ============================================================================
// ralph (agent loop) — session-id async pattern
// ============================================================================
function registerRalphToolset(server) {
// ralph start is a command, not a subcommand. We use the top-level `ralph` command.
server.registerTool('ralph-start', {
title: 'Start AIWG Ralph (agent loop)',
description: 'Begin a Ralph iterative agent loop. Returns immediately with a session id; status polled via `ralph-status`. Mirrors `aiwg ralph "<task>" --completion "<criteria>"`.',
inputSchema: {
task: z.string().describe('Task description'),
completion: z.string().describe('Measurable completion criteria (verification command)'),
max_cycles: z.number().int().min(1).max(500).default(20),
confirmed: z.boolean().default(false).describe('Required (Ralph is long-running)'),
},
annotations: { destructiveHint: true },
}, async ({ task, completion, max_cycles, confirmed }) => {
if (!confirmed) {
return mcpError(
`ralph-start is a long-running agent loop. Re-invoke with confirmed=true to proceed.`,
{ requiresConfirmation: true, remediation: 'Surface task/completion/max_cycles to the user before confirming.' }
);
}
try {
// Spawn detached: kick off the loop in background; return immediately
const { stdout, stderr, code } = await runAiwgCli(
['ralph', task, '--completion', completion, '--max-cycles', String(max_cycles), '--background'],
{ timeoutMs: 10_000 }
);
if (code !== 0) return mcpError(`ralph-start failed: ${stderr}`);
return mcpJson({ message: 'ralph started', stdout, stderr });
} catch (err) {
return mcpError(`ralph-start: ${err.message}`);
}
});
registerSubsystemTool(server, {
name: 'ralph-status',
subsystem: 'ralph-status',
verb: '',
title: 'Ralph loop status',
description: 'Show status of running/recent Ralph loops. Mirrors `aiwg ralph-status`.',
inputSchema: {
session_id: z.string().optional().describe('Specific session id (default: all running)'),
all: z.boolean().default(false).describe('Include completed/failed loops'),
},
readOnly: true,
buildArgs: ({ session_id, all }) => {
const args = ['--json'];
if (session_id) args.push('--session', session_id);
if (all) args.push('--all');
return args;
},
});
registerSubsystemTool(server, {
name: 'ralph-abort',
subsystem: 'ralph-abort',
verb: '',
title: 'Abort Ralph loop',
description: 'Abort a running Ralph loop. Mirrors `aiwg ralph-abort`.',
inputSchema: {
session_id: z.string().describe('Session id'),
confirmed: z.boolean().default(false),
},
destructive: true,
buildArgs: ({ session_id, confirmed }) => {
if (!confirmed) throw new Error('ralph-abort requires confirmed=true');
return ['--session', session_id];
},
});
registerSubsystemTool(server, {
name: 'ralph-attach',
subsystem: 'ralph-attach',
verb: '',
title: 'Attach to running Ralph loop',
description: 'Attach to a running external agent loop. Mirrors `aiwg ralph-attach`.',
inputSchema: {
session_id: z.string().describe('Session id'),
},
readOnly: true,
buildArgs: ({ session_id }) => ['--session', session_id, '--json'],
});
}
// ============================================================================
// mc (Mission Control)
// ============================================================================
function registerMcToolset(server) {
registerSubsystemTool(server, {
name: 'mc-start',
subsystem: 'mc',
verb: 'start',
title: 'Start Mission Control session',
description: 'Begin a multi-loop background orchestration session. Mirrors `aiwg mc start`.',
inputSchema: {
name: z.string().optional().describe('Session label'),
},
buildArgs: ({ name }) => {
const args = [];
if (name) args.push('--name', name);
return args;
},
});
registerSubsystemTool(server, {
name: 'mc-dispatch',
subsystem: 'mc',
verb: 'dispatch',
title: 'Dispatch mission to MC session',
description: 'Add a background mission to an MC session. Mirrors `aiwg mc dispatch <id> "<objective>"`.',
inputSchema: {
session_id: z.string().describe('Session id'),
objective: z.string().describe('Mission objective'),
completion: z.string().optional().describe('Completion criteria'),
},
buildArgs: ({ session_id, objective, completion }) => {
const args = [session_id, objective];
if (completion) args.push('--completion', completion);
return args;
},
});
registerSubsystemTool(server, {
name: 'mc-status',
subsystem: 'mc',
verb: 'status',
title: 'MC session status',
description: 'View status of all missions in MC sessions. Mirrors `aiwg mc status`.',
inputSchema: {
session_id: z.string().optional(),
},
readOnly: true,
buildArgs: ({ session_id }) => {
const args = ['--json'];
if (session_id) args.push('--session', session_id);
return args;
},
});
registerSubsystemTool(server, {
name: 'mc-stop',
subsystem: 'mc',
verb: 'stop',
title: 'Stop MC session',
description: 'Shut down a Mission Control session. Mirrors `aiwg mc stop <id>`.',
inputSchema: {
session_id: z.string(),
confirmed: z.boolean().default(false),
},
destructive: true,
buildArgs: ({ session_id, confirmed }) => {
if (!confirmed) throw new Error('mc-stop requires confirmed=true');
return [session_id];
},
});
registerSubsystemTool(server, {
name: 'mc-list',
subsystem: 'mc',
verb: 'list',
title: 'List MC sessions',
description: 'List all active/recent MC sessions. Mirrors `aiwg mc list`.',
inputSchema: {},
readOnly: true,
buildArgs: () => ['--json'],
});
}
// ============================================================================
// ops
// ============================================================================
function registerOpsToolset(server) {
registerSubsystemTool(server, {
name: 'ops-status',
subsystem: 'ops',
verb: 'status',
title: 'Ops workspace status',
description: 'Show ops workspace health. Mirrors `aiwg ops status`.',
inputSchema: {
all: z.boolean().default(false),
},
readOnly: true,
buildArgs: ({ all }) => {
const args = ['--json'];
if (all) args.push('--all');
return args;
},
});
registerSubsystemTool(server, {
name: 'ops-list',
subsystem: 'ops',
verb: 'list',
title: 'List ops workspaces',
description: 'List registered ops workspaces. Mirrors `aiwg ops list`.',
inputSchema: {},
readOnly: true,
buildArgs: () => ['--json'],
});
registerSubsystemTool(server, {
name: 'ops-use',
subsystem: 'ops',
verb: 'use',
title: 'Switch ops workspace',
description: 'Switch active ops workspace. Mirrors `aiwg ops use <workspace>`.',
inputSchema: {
workspace: z.string(),
},
buildArgs: ({ workspace }) => [workspace],
});
registerSubsystemTool(server, {
name: 'ops-push',
subsystem: 'ops',
verb: 'push',
title: 'Push ops workspace repos',
description: 'Push workspace repos to remote. DESTRUCTIVE — affects shared state. Mirrors `aiwg ops push`.',
inputSchema: {
workspace: z.string().optional(),
confirmed: z.boolean().default(false),
},
destructive: true,
buildArgs: ({ workspace, confirmed }) => {
if (!confirmed) throw new Error('ops-push requires confirmed=true');
const args = [];
if (workspace) args.push('--workspace', workspace);
return args;
},
});
}
// ============================================================================
// Toolset registry + dispatch (#1332 / S18)
// ============================================================================
/**
* Known toolsets. Each maps to a registration function. The `core` toolset
* is always registered by server.mjs (discovery + command-run).
*/
const TOOLSET_REGISTRY = {
memory: (server) => {
registerStorageSubsystem(server, STORAGE_SUBSYSTEMS.find(s => s.name === 'memory'));
registerStorageSubsystem(server, STORAGE_SUBSYSTEMS.find(s => s.name === 'reflections'));
},
kb: (server) => {
registerStorageSubsystem(server, STORAGE_SUBSYSTEMS.find(s => s.name === 'kb'));
},
research: (server) => {
registerStorageSubsystem(server, STORAGE_SUBSYSTEMS.find(s => s.name === 'provenance'));
registerStorageSubsystem(server, STORAGE_SUBSYSTEMS.find(s => s.name === 'research-store'));
},
'activity-log': registerActivityLogToolset,
index: registerIndexToolset,
ralph: registerRalphToolset,
mc: registerMcToolset,
ops: registerOpsToolset,
};
/**
* Parse AIWG_MCP_TOOLSETS env or `--toolsets` CLI value into a Set.
*
* Format: comma-separated list (whitespace ignored). `core` is always
* implicit. Unknown names produce a warning but don't abort startup.
*
* Examples:
* AIWG_MCP_TOOLSETS=memory,kb,ralph
* AIWG_MCP_TOOLSETS=core,memory (core is implicit; harmless)
* AIWG_MCP_TOOLSETS=all (all known toolsets)
*/
export function parseToolsets(value) {
if (!value || typeof value !== 'string') return new Set();
const items = value.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
if (items.includes('all')) {
return new Set(Object.keys(TOOLSET_REGISTRY));
}
const out = new Set();
const known = new Set(Object.keys(TOOLSET_REGISTRY));
for (const item of items) {
if (item === 'core') continue; // always on
if (!known.has(item)) {
console.error(`[AIWG MCP] Unknown toolset: "${item}" — known: core, ${[...known].join(', ')}, all`);
continue;
}
out.add(item);
}
return out;
}
/**
* Register all opt-in toolsets requested via env/flag.
*/
export function registerOptInToolsets(server, toolsets) {
const enabled = [];
for (const name of toolsets) {
const fn = TOOLSET_REGISTRY[name];
if (fn) {
fn(server);
enabled.push(name);
}
}
if (enabled.length > 0) {
console.error(`[AIWG MCP] Opt-in toolsets enabled: ${enabled.join(', ')}`);
} else {
console.error('[AIWG MCP] Default toolset only (core). Enable more via AIWG_MCP_TOOLSETS=<csv>.');
}
}
export const KNOWN_TOOLSETS = Object.keys(TOOLSET_REGISTRY);