From 59f7f0d7200e86fa0e62c34d0179f3daafe972d7 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Sat, 21 Mar 2026 19:42:58 +0700 Subject: [PATCH] feat(mcp): add Mission Control MCP server for agent-native tool access Zero-dependency MCP server (stdio transport, JSON-RPC 2.0) that wraps the Mission Control REST API as 35 MCP tools. Agents can add it with: claude mcp add mission-control -- node scripts/mc-mcp-server.cjs Tools cover the full agent workflow: - Agent lifecycle: list, get, heartbeat, wake, diagnostics, attribution - Memory & Soul: read/write/clear working memory, read/write SOUL, list/retrieve SOUL templates - Tasks: CRUD, queue polling, broadcast, comments (list/add) - Sessions: list, control, continue, transcript - Connections: list, register - Tokens & Costs: stats, agent-costs, costs-by-agent - Skills: list, read content - Cron: list jobs - Status: health, dashboard, overview Auth uses the same profile system as the CLI (~/.mission-control/profiles/) or MC_URL/MC_API_KEY/MC_COOKIE environment variables. Also adds `mc` and `mc:mcp` package.json scripts. --- package.json | 2 + scripts/mc-mcp-server.cjs | 637 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 639 insertions(+) create mode 100755 scripts/mc-mcp-server.cjs diff --git a/package.json b/package.json index 202a113..30c510b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "verify:node": "node scripts/check-node-version.mjs", "api:parity": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore", "api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json", + "mc": "node scripts/mc-cli.cjs", + "mc:mcp": "node scripts/mc-mcp-server.cjs", "dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}", "build": "pnpm run verify:node && next build", "start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}", diff --git a/scripts/mc-mcp-server.cjs b/scripts/mc-mcp-server.cjs new file mode 100755 index 0000000..f8ba2bc --- /dev/null +++ b/scripts/mc-mcp-server.cjs @@ -0,0 +1,637 @@ +#!/usr/bin/env node +/* + Mission Control MCP Server (stdio transport) + - Zero dependencies (Node.js built-ins only) + - JSON-RPC 2.0 over stdin/stdout + - Wraps Mission Control REST API as MCP tools + - Add with: claude mcp add mission-control -- node /path/to/mc-mcp-server.cjs + + Environment: + MC_URL Base URL (default: http://127.0.0.1:3000) + MC_API_KEY API key for auth + MC_COOKIE Session cookie (alternative auth) +*/ + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +function loadConfig() { + // Try profile first, then env vars + const profilePath = path.join(os.homedir(), '.mission-control', 'profiles', 'default.json'); + let profile = {}; + try { + profile = JSON.parse(fs.readFileSync(profilePath, 'utf8')); + } catch { /* no profile */ } + + return { + baseUrl: (process.env.MC_URL || profile.url || 'http://127.0.0.1:3000').replace(/\/+$/, ''), + apiKey: process.env.MC_API_KEY || profile.apiKey || '', + cookie: process.env.MC_COOKIE || profile.cookie || '', + }; +} + +// --------------------------------------------------------------------------- +// HTTP client (same pattern as mc-cli.cjs) +// --------------------------------------------------------------------------- + +async function api(method, route, body) { + const config = loadConfig(); + const headers = { 'Accept': 'application/json' }; + if (config.apiKey) headers['x-api-key'] = config.apiKey; + if (config.cookie) headers['Cookie'] = config.cookie; + + let payload; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } + + const url = `${config.baseUrl}${route.startsWith('/') ? route : `/${route}`}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + + try { + const res = await fetch(url, { method, headers, body: payload, signal: controller.signal }); + clearTimeout(timer); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = { raw: text }; } + if (!res.ok) throw new Error(data.error || data.message || `HTTP ${res.status}: ${text.slice(0, 200)}`); + return data; + } catch (err) { + clearTimeout(timer); + if (err?.name === 'AbortError') throw new Error('Request timeout (30s)'); + throw err; + } +} + +// --------------------------------------------------------------------------- +// Tool definitions +// --------------------------------------------------------------------------- + +const TOOLS = [ + // --- Agents --- + { + name: 'mc_list_agents', + description: 'List all agents registered in Mission Control', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/agents'), + }, + { + name: 'mc_get_agent', + description: 'Get details of a specific agent by ID', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/agents/${id}`), + }, + { + name: 'mc_heartbeat', + description: 'Send a heartbeat for an agent to indicate it is alive', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('POST', `/api/agents/${id}/heartbeat`), + }, + { + name: 'mc_wake_agent', + description: 'Wake a sleeping agent', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('POST', `/api/agents/${id}/wake`), + }, + { + name: 'mc_agent_diagnostics', + description: 'Get diagnostics info for an agent (health, config, recent activity)', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/agents/${id}/diagnostics`), + }, + { + name: 'mc_agent_attribution', + description: 'Get cost attribution, audit trail, and mutation history for an agent', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Agent ID' }, + hours: { type: 'number', description: 'Lookback window in hours (default 24)' }, + section: { type: 'string', description: 'Comma-separated sections: identity,audit,mutations,cost' }, + }, + required: ['id'], + }, + handler: async ({ id, hours, section }) => { + let qs = `?hours=${hours || 24}`; + if (section) qs += `§ion=${encodeURIComponent(section)}`; + return api('GET', `/api/agents/${id}/attribution${qs}`); + }, + }, + + // --- Agent Memory --- + { + name: 'mc_read_memory', + description: 'Read an agent\'s working memory', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/agents/${id}/memory`), + }, + { + name: 'mc_write_memory', + description: 'Write or append to an agent\'s working memory', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Agent ID' }, + working_memory: { type: 'string', description: 'Memory content to write' }, + append: { type: 'boolean', description: 'Append to existing memory instead of replacing (default false)' }, + }, + required: ['id', 'working_memory'], + }, + handler: async ({ id, working_memory, append }) => + api('PUT', `/api/agents/${id}/memory`, { working_memory, append: append || false }), + }, + { + name: 'mc_clear_memory', + description: 'Clear an agent\'s working memory', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('DELETE', `/api/agents/${id}/memory`), + }, + + // --- Agent Soul --- + { + name: 'mc_read_soul', + description: 'Read an agent\'s SOUL (System of Unified Logic) content — the agent\'s identity and behavioral directives', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Agent ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/agents/${id}/soul`), + }, + { + name: 'mc_write_soul', + description: 'Write an agent\'s SOUL content, or apply a named template', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Agent ID' }, + soul_content: { type: 'string', description: 'SOUL content to write (omit if using template_name)' }, + template_name: { type: 'string', description: 'Name of a SOUL template to apply (omit if providing soul_content)' }, + }, + required: ['id'], + }, + handler: async ({ id, soul_content, template_name }) => { + const body = {}; + if (template_name) body.template_name = template_name; + else if (soul_content) body.soul_content = soul_content; + return api('PUT', `/api/agents/${id}/soul`, body); + }, + }, + { + name: 'mc_list_soul_templates', + description: 'List available SOUL templates, or retrieve a specific template\'s content', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Agent ID' }, + template: { type: 'string', description: 'Template name to retrieve (omit to list all)' }, + }, + required: ['id'], + }, + handler: async ({ id, template }) => { + const qs = template ? `?template=${encodeURIComponent(template)}` : ''; + return api('PATCH', `/api/agents/${id}/soul${qs}`); + }, + }, + + // --- Tasks --- + { + name: 'mc_list_tasks', + description: 'List all tasks in Mission Control', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/tasks'), + }, + { + name: 'mc_get_task', + description: 'Get a specific task by ID', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Task ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/tasks/${id}`), + }, + { + name: 'mc_create_task', + description: 'Create a new task', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Task title' }, + description: { type: 'string', description: 'Task description' }, + priority: { type: 'string', description: 'Priority: low, medium, high, critical' }, + assigned_to: { type: 'string', description: 'Agent name to assign to' }, + }, + required: ['title'], + }, + handler: async (args) => api('POST', '/api/tasks', args), + }, + { + name: 'mc_update_task', + description: 'Update an existing task (status, priority, assigned_to, title, description, etc.)', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Task ID' }, + status: { type: 'string', description: 'New status' }, + priority: { type: 'string', description: 'New priority' }, + assigned_to: { type: 'string', description: 'New assignee agent name' }, + title: { type: 'string', description: 'New title' }, + description: { type: 'string', description: 'New description' }, + }, + required: ['id'], + }, + handler: async ({ id, ...fields }) => api('PUT', `/api/tasks/${id}`, fields), + }, + { + name: 'mc_poll_task_queue', + description: 'Poll the task queue for an agent — returns the next available task(s) to work on', + inputSchema: { + type: 'object', + properties: { + agent: { type: 'string', description: 'Agent name to poll for' }, + max_capacity: { type: 'number', description: 'Max tasks to return (default 1)' }, + }, + required: ['agent'], + }, + handler: async ({ agent, max_capacity }) => { + let qs = `?agent=${encodeURIComponent(agent)}`; + if (max_capacity) qs += `&max_capacity=${max_capacity}`; + return api('GET', `/api/tasks/queue${qs}`); + }, + }, + { + name: 'mc_broadcast_task', + description: 'Broadcast a message to all subscribers of a task', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Task ID' }, + message: { type: 'string', description: 'Message to broadcast' }, + }, + required: ['id', 'message'], + }, + handler: async ({ id, message }) => api('POST', `/api/tasks/${id}/broadcast`, { message }), + }, + + // --- Task Comments --- + { + name: 'mc_list_comments', + description: 'List comments on a task', + inputSchema: { + type: 'object', + properties: { id: { type: ['string', 'number'], description: 'Task ID' } }, + required: ['id'], + }, + handler: async ({ id }) => api('GET', `/api/tasks/${id}/comments`), + }, + { + name: 'mc_add_comment', + description: 'Add a comment to a task', + inputSchema: { + type: 'object', + properties: { + id: { type: ['string', 'number'], description: 'Task ID' }, + content: { type: 'string', description: 'Comment text (supports @mentions)' }, + parent_id: { type: 'number', description: 'Parent comment ID for threaded replies' }, + }, + required: ['id', 'content'], + }, + handler: async ({ id, content, parent_id }) => { + const body = { content }; + if (parent_id) body.parent_id = parent_id; + return api('POST', `/api/tasks/${id}/comments`, body); + }, + }, + + // --- Sessions --- + { + name: 'mc_list_sessions', + description: 'List all active sessions', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/sessions'), + }, + { + name: 'mc_control_session', + description: 'Control a session (monitor, pause, or terminate)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Session ID' }, + action: { type: 'string', description: 'Action: monitor, pause, or terminate' }, + }, + required: ['id', 'action'], + }, + handler: async ({ id, action }) => api('POST', `/api/sessions/${id}/control`, { action }), + }, + { + name: 'mc_continue_session', + description: 'Send a follow-up prompt to an existing session', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string', description: 'Session kind: claude-code, codex-cli, hermes' }, + id: { type: 'string', description: 'Session ID' }, + prompt: { type: 'string', description: 'Follow-up prompt to send' }, + }, + required: ['kind', 'id', 'prompt'], + }, + handler: async ({ kind, id, prompt }) => + api('POST', '/api/sessions/continue', { kind, id, prompt }), + }, + { + name: 'mc_session_transcript', + description: 'Get the transcript of a session (messages, tool calls, reasoning)', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string', description: 'Session kind: claude-code, codex-cli, hermes' }, + id: { type: 'string', description: 'Session ID' }, + limit: { type: 'number', description: 'Max messages to return (default 40, max 200)' }, + }, + required: ['kind', 'id'], + }, + handler: async ({ kind, id, limit }) => { + let qs = `?kind=${encodeURIComponent(kind)}&id=${encodeURIComponent(id)}`; + if (limit) qs += `&limit=${limit}`; + return api('GET', `/api/sessions/transcript${qs}`); + }, + }, + + // --- Connections --- + { + name: 'mc_list_connections', + description: 'List active agent connections (tool registrations)', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/connect'), + }, + { + name: 'mc_register_connection', + description: 'Register a tool connection for an agent', + inputSchema: { + type: 'object', + properties: { + tool_name: { type: 'string', description: 'Tool name to register' }, + agent_name: { type: 'string', description: 'Agent name to connect' }, + }, + required: ['tool_name', 'agent_name'], + }, + handler: async (args) => api('POST', '/api/connect', args), + }, + + // --- Tokens & Costs --- + { + name: 'mc_token_stats', + description: 'Get aggregate token usage statistics (total tokens, cost, request count, per-model breakdown)', + inputSchema: { + type: 'object', + properties: { + timeframe: { type: 'string', description: 'Timeframe: hour, day, week, month, all (default: all)' }, + }, + required: [], + }, + handler: async ({ timeframe }) => { + let qs = '?action=stats'; + if (timeframe) qs += `&timeframe=${encodeURIComponent(timeframe)}`; + return api('GET', `/api/tokens${qs}`); + }, + }, + { + name: 'mc_agent_costs', + description: 'Get per-agent cost breakdown with timeline and model details', + inputSchema: { + type: 'object', + properties: { + timeframe: { type: 'string', description: 'Timeframe: hour, day, week, month, all' }, + }, + required: [], + }, + handler: async ({ timeframe }) => { + let qs = '?action=agent-costs'; + if (timeframe) qs += `&timeframe=${encodeURIComponent(timeframe)}`; + return api('GET', `/api/tokens${qs}`); + }, + }, + { + name: 'mc_costs_by_agent', + description: 'Get per-agent cost summary over a number of days', + inputSchema: { + type: 'object', + properties: { + days: { type: 'number', description: 'Lookback in days (default 30, max 365)' }, + }, + required: [], + }, + handler: async ({ days }) => + api('GET', `/api/tokens/by-agent?days=${days || 30}`), + }, + + // --- Skills --- + { + name: 'mc_list_skills', + description: 'List all skills available in the system', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/skills'), + }, + { + name: 'mc_read_skill', + description: 'Read the content of a specific skill', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string', description: 'Skill source (e.g. workspace, system)' }, + name: { type: 'string', description: 'Skill name' }, + }, + required: ['source', 'name'], + }, + handler: async ({ source, name }) => + api('GET', `/api/skills?mode=content&source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`), + }, + + // --- Cron --- + { + name: 'mc_list_cron', + description: 'List all cron jobs', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/cron'), + }, + + // --- Status --- + { + name: 'mc_health', + description: 'Check Mission Control health status (no auth required)', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/status?action=health'), + }, + { + name: 'mc_dashboard', + description: 'Get a dashboard summary of the entire Mission Control system (agents, tasks, sessions, costs)', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/status?action=dashboard'), + }, + { + name: 'mc_status', + description: 'Get system status overview (uptime, memory, disk, sessions, processes)', + inputSchema: { type: 'object', properties: {}, required: [] }, + handler: async () => api('GET', '/api/status?action=overview'), + }, +]; + +// Build lookup map +const toolMap = new Map(); +for (const tool of TOOLS) { + toolMap.set(tool.name, tool); +} + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 / MCP protocol handler +// --------------------------------------------------------------------------- + +const SERVER_INFO = { + name: 'mission-control', + version: '2.0.1', +}; + +const CAPABILITIES = { + tools: {}, +}; + +function makeResponse(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function makeError(id, code, message, data) { + return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } }; +} + +async function handleMessage(msg) { + const { id, method, params } = msg; + + // Notifications (no id) — just acknowledge + if (id === undefined) { + if (method === 'notifications/initialized') return null; // no response needed + return null; + } + + switch (method) { + case 'initialize': + return makeResponse(id, { + protocolVersion: '2024-11-05', + serverInfo: SERVER_INFO, + capabilities: CAPABILITIES, + }); + + case 'tools/list': + return makeResponse(id, { + tools: TOOLS.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }); + + case 'tools/call': { + const toolName = params?.name; + const args = params?.arguments || {}; + const tool = toolMap.get(toolName); + + if (!tool) { + return makeResponse(id, { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }); + } + + try { + const result = await tool.handler(args); + return makeResponse(id, { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }); + } catch (err) { + return makeResponse(id, { + content: [{ type: 'text', text: `Error: ${err?.message || String(err)}` }], + isError: true, + }); + } + } + + case 'ping': + return makeResponse(id, {}); + + default: + return makeError(id, -32601, `Method not found: ${method}`); + } +} + +// --------------------------------------------------------------------------- +// Stdio transport +// --------------------------------------------------------------------------- + +function send(msg) { + if (!msg) return; + const json = JSON.stringify(msg); + process.stdout.write(json + '\n'); +} + +async function main() { + // Disable stdout buffering for interactive use + if (process.stdout._handle && process.stdout._handle.setBlocking) { + process.stdout._handle.setBlocking(true); + } + + const readline = require('node:readline'); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', async (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + + try { + const msg = JSON.parse(trimmed); + const response = await handleMessage(msg); + send(response); + } catch (err) { + send(makeError(null, -32700, `Parse error: ${err?.message || 'invalid JSON'}`)); + } + }); + + rl.on('close', () => { + process.exit(0); + }); + + // Keep process alive + process.stdin.resume(); +} + +main();