#!/usr/bin/env node /** * @file AgentHub's MCP server — a small, READ-ONLY Model Context Protocol server * over stdio (newline-delimited JSON-RPC 2.0: `initialize`, `ping`, * `tools/list`, `tools/call`). Every tool is one GET against the running * dashboard's HTTP API, so the numbers are the dashboard's own; nothing here * opens the database or writes anything. The base URL is `AGENTHUB_URL`, else * the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when * set, is sent as a bearer token. Run it as `agenthub mcp` or * `node mcp/server.js`; see docs/MCP.md. * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const readline = require("node:readline"); const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; const VERSION = (() => { try { return require(path.join(__dirname, "..", "package.json")).version; } catch { return "0.0.0"; } })(); function baseUrl() { if (process.env.AGENTHUB_URL) return process.env.AGENTHUB_URL.replace(/\/+$/, ""); return require(path.join(__dirname, "..", "server", "lib", "dashboard-url.js")).dashboardUrl(); } // ── Tool table ─────────────────────────────────────────────────────────────── // Each tool: description, JSON-schema properties, and `path(args)` → the API // path to GET. Only the listed query keys are ever forwarded. const str = (description) => ({ type: "string", description }); const int = (description) => ({ type: "integer", description }); const TZ = int( "Minutes from UTC as JavaScript's getTimezoneOffset() returns them (e.g. -420 for UTC+7). Defaults to this machine's." ); const TOOL = str( "AI CLI to scope to: claude, codex, antigravity, … (ignored by dashboards without the filter)" ); const SESSION_ID = str("Session id"); const LANE = int("Workspace (lane) id, from list_lanes"); /** `?a=1&b=2` from the given keys of `args`, skipping empty ones. */ function query(args, keys) { const q = new URLSearchParams(); for (const k of keys) { const v = args[k]; if (v !== undefined && v !== null && v !== "") q.set(k, String(v)); } const s = q.toString(); return s ? `?${s}` : ""; } const withTz = (args) => ({ tz_offset: new Date().getTimezoneOffset(), ...args }); const id = (v) => encodeURIComponent(String(v)); const TOOLS = { get_health: { description: "Is the AgentHub dashboard up, and which version.", properties: {}, path: () => "/api/health", }, get_info: { description: "Dashboard system info: version, database size and row counts, server memory, hook status.", properties: {}, path: () => "/api/settings/info", }, get_stats: { description: "Overview totals: sessions and agents by status, today's events, active sessions.", properties: { tz_offset: TZ, tool: TOOL }, path: (a) => `/api/stats${query(withTz(a), ["tz_offset", "tool"])}`, }, get_total_cost: { description: "Estimated USD cost across all sessions, with per-model breakdown and daily costs (daily_costs[].date in the given time zone).", properties: { tz_offset: TZ, lane: LANE, tool: TOOL }, path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane", "tool"])}`, }, get_session_cost: { description: "Estimated USD cost of one session, per model.", properties: { session_id: SESSION_ID, tz_offset: TZ }, required: ["session_id"], path: (a) => `/api/pricing/cost/${id(a.session_id)}${query(withTz(a), ["tz_offset"])}`, }, get_lane_cost: { description: "Estimated USD cost of the sessions one workspace (lane) owns.", properties: { lane: LANE, tz_offset: TZ }, required: ["lane"], path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane"])}`, }, list_sessions: { description: "List sessions, newest first, with cost and token totals.", properties: { q: str("Substring of the session id, name or cwd"), status: { type: "string", enum: ["active", "completed", "error", "abandoned"] }, tool: TOOL, lane: LANE, limit: int("Max rows (default 50)"), }, path: (a) => `/api/sessions${query(a, ["q", "status", "tool", "lane", "limit"])}`, }, get_session: { description: "One session: its row, agents (with each agent's own cost) and Workflow-tool runs. Events are left out unless include_events is true (they can be huge).", properties: { session_id: SESSION_ID, include_events: { type: "boolean", description: "Also return every hook event" }, }, required: ["session_id"], path: (a) => `/api/sessions/${id(a.session_id)}${a.include_events ? "" : "?events=0"}`, }, get_session_stats: { description: "Aggregated counts for one session: tools used, agents, errors, durations.", properties: { session_id: SESSION_ID }, required: ["session_id"], path: (a) => `/api/sessions/${id(a.session_id)}/stats`, }, get_analytics: { description: "Token totals, tool usage, daily events/sessions, agent types — the Analytics page's data.", properties: { tz_offset: TZ, tool: TOOL }, path: (a) => `/api/analytics${query(withTz(a), ["tz_offset", "tool"])}`, }, get_workflows: { description: "Workflow intelligence: orchestration, tool flow, subagent effectiveness, patterns.", properties: { status: { type: "string", enum: ["active", "completed"] }, tool: TOOL }, path: (a) => `/api/workflows${query(a, ["status", "tool"])}`, }, list_alerts: { description: "Fired alerts, newest first.", properties: { unacked: { type: "boolean", description: "Only unacknowledged ones" }, limit: int("Max rows (default 50, max 200)"), }, path: (a) => `/api/alerts${query({ ...a, unacked: a.unacked ? "true" : undefined }, ["unacked", "limit"])}`, }, list_lanes: { description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.", properties: {}, path: () => "/api/lanes", }, list_pricing: { description: "Model pricing rules (USD per million tokens) the cost estimates use.", properties: {}, path: () => "/api/pricing", }, get_budget_status: { description: "Budgets: spend this period, percent of limit, projected end-of-period spend.", properties: {}, path: () => "/api/budgets/status", }, }; // ── HTTP ───────────────────────────────────────────────────────────────────── /** GET a dashboard path; resolves to an MCP tool result, never rejects. */ async function callApi(pathname) { const url = `${baseUrl()}${pathname}`; const headers = process.env.DASHBOARD_TOKEN ? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` } : {}; // Server mode: inside a dashboard pane, act as the workspace's owner. if (process.env.AGENTHUB_PANE_TOKEN) headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN; let res; try { res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }); } catch (err) { return fail( `AgentHub dashboard not reachable at ${baseUrl()} (${err?.cause?.code || err?.name || err}). Start it with \`agenthub start\`, or set AGENTHUB_URL.` ); } const text = await res.text(); let data; try { data = JSON.parse(text); } catch { data = undefined; } // The API answers in JSON, its own 404s included. A non-JSON body is // Express's "Cannot GET" 404, or — on a production dashboard — the SPA page // served with 200 for any unknown path: either way, no such route. if (data === undefined && (res.ok || res.status === 404)) { return fail( `Not available on this dashboard: GET ${pathname} does not exist (an older AgentHub?).` ); } if (!res.ok) { return fail( `GET ${pathname} → HTTP ${res.status}: ${data?.error?.message || text.slice(0, 300)}` ); } return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; } const fail = (text) => ({ content: [{ type: "text", text }], isError: true }); // ── JSON-RPC ──────────────────────────────────────────────────────────────── function toolList() { return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: { type: "object", properties: t.properties, ...(t.required ? { required: t.required } : {}), additionalProperties: false, }, annotations: { readOnlyHint: true, openWorldHint: false }, })); } async function callTool(params) { const tool = TOOLS[params?.name]; if (!tool) return fail(`Unknown tool: ${params?.name}`); const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {}; const missing = (tool.required || []).filter((k) => args[k] === undefined || args[k] === ""); if (missing.length) return fail(`Missing required argument(s): ${missing.join(", ")}`); return callApi(tool.path(args)); } /** Answer one parsed message; resolves to the response, or null for a notification. */ async function handle(msg) { const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result }); const error = (code, message) => ({ jsonrpc: "2.0", id: msg.id ?? null, error: { code, message }, }); if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") { return error(-32600, "Invalid Request"); } const isNotification = msg.id === undefined; let out; switch (msg.method) { case "initialize": { const asked = msg.params?.protocolVersion; out = reply({ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0], capabilities: { tools: {} }, serverInfo: { name: "agenthub", version: VERSION }, instructions: "Read-only access to the local AgentHub dashboard: sessions, costs, analytics, workspaces, alerts.", }); break; } case "ping": out = reply({}); break; case "tools/list": out = reply({ tools: toolList() }); break; case "tools/call": out = reply(await callTool(msg.params)); break; default: out = error(-32601, `Method not found: ${msg.method}`); } return isNotification ? null : out; } function main() { const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n"); const rl = readline.createInterface({ input: process.stdin }); rl.on("line", async (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { return send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); } try { const out = await handle(msg); if (out) send(out); } catch (err) { if (msg?.id !== undefined) { send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: String(err?.message || err) }, }); } } }); } if (require.main === module) main(); module.exports = { main, handle, TOOLS };