4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
#!/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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}` }
|
|
: {};
|
|
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 };
|