Files
AgentHub/mcp/server.js
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

297 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/workspaces",
},
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 };