Files
AgentHub/mcp/server.js
T
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +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/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 };