feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
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.
This commit is contained in:
+293
@@ -0,0 +1,293 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user