/** * @file Tests for the read-only MCP server (`mcp/server.js`): spawns it as a * child over stdio against a fake in-test dashboard API and drives the real * JSON-RPC exchange — initialize, tools/list, tools/call — plus the error * paths: a route the dashboard lacks (404, or the production SPA page), an API * error, an unreachable dashboard, unknown tools/methods and bad JSON. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const http = require("node:http"); const path = require("node:path"); const readline = require("node:readline"); const { spawn } = require("node:child_process"); const SERVER = path.resolve(__dirname, "..", "..", "mcp", "server.js"); let api; let apiUrl; const seen = []; /** Start the MCP server against `url`; returns `rpc(msg)` and `close()`. */ function startMcp(url) { const child = spawn(process.execPath, [SERVER], { env: { ...process.env, AGENTHUB_URL: url, DASHBOARD_TOKEN: "tok" }, stdio: ["pipe", "pipe", "inherit"], }); const waiting = new Map(); const unsolicited = []; readline.createInterface({ input: child.stdout }).on("line", (line) => { const msg = JSON.parse(line); const w = waiting.get(msg.id); if (w) { waiting.delete(msg.id); w(msg); } else unsolicited.push(msg); }); let next = 1; return { unsolicited, rpc(method, params) { const id = next++; child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); return new Promise((resolve) => waiting.set(id, resolve)); }, raw(line) { child.stdin.write(line + "\n"); }, close() { child.stdin.end(); return new Promise((resolve) => child.on("exit", resolve)); }, }; } const text = (res) => res.result.content[0].text; describe("mcp/server.js", () => { let mcp; before(async () => { api = http.createServer((req, res) => { seen.push({ url: req.url, auth: req.headers.authorization }); const send = (status, body, type = "application/json") => { res.writeHead(status, { "Content-Type": type }); res.end(typeof body === "string" ? body : JSON.stringify(body)); }; const p = req.url.split("?")[0]; if (p === "/api/stats") return send(200, { total_sessions: 3 }); if (p === "/api/sessions/s%2F1") return send(200, { session: { id: "s/1" } }); if (p === "/api/sessions/gone") { return send(404, { error: { code: "NOT_FOUND", message: "Session not found" } }); } if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] }); if (p === "/api/analytics") return send(500, { error: { message: "boom" } }); if (p === "/api/lanes") return send(200, "", "text/html"); return send(404, "Cannot GET " + p, "text/html"); }); await new Promise((r) => api.listen(0, "127.0.0.1", r)); apiUrl = `http://127.0.0.1:${api.address().port}`; mcp = startMcp(apiUrl); }); after(async () => { await mcp.close(); api.close(); }); it("initializes, echoing a supported protocol version", async () => { const res = await mcp.rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "0" }, }); assert.equal(res.result.protocolVersion, "2025-03-26"); assert.deepEqual(res.result.capabilities, { tools: {} }); assert.equal(res.result.serverInfo.name, "agenthub"); // A notification gets no answer. mcp.raw(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })); const unknownVersion = await mcp.rpc("initialize", { protocolVersion: "1999-01-01" }); assert.match(unknownVersion.result.protocolVersion, /^\d{4}-\d{2}-\d{2}$/); assert.notEqual(unknownVersion.result.protocolVersion, "1999-01-01"); }); it("lists ~15 read-only tools with object schemas", async () => { const res = await mcp.rpc("tools/list", {}); const names = res.result.tools.map((t) => t.name); for (const n of [ "get_stats", "get_total_cost", "get_session_cost", "list_sessions", "get_session", "get_session_stats", "get_analytics", "get_workflows", "list_alerts", "list_lanes", "get_lane_cost", "list_pricing", "get_health", "get_info", "get_budget_status", ]) { assert.ok(names.includes(n), n); } for (const t of res.result.tools) { assert.equal(t.inputSchema.type, "object"); assert.equal(t.annotations.readOnlyHint, true); } }); it("calls a tool: pretty JSON text, bearer token, query passed through", async () => { seen.length = 0; const res = await mcp.rpc("tools/call", { name: "get_total_cost", arguments: { tz_offset: -420, lane: 7, tool: "codex" }, }); assert.equal(res.result.isError, undefined); assert.deepEqual(JSON.parse(text(res)), { total_cost: 1.5, daily_costs: [] }); assert.equal(seen[0].url, "/api/pricing/cost?tz_offset=-420&lane=7&tool=codex"); assert.equal(seen[0].auth, "Bearer tok"); }); it("defaults tz_offset to this machine's and encodes path ids", async () => { seen.length = 0; await mcp.rpc("tools/call", { name: "get_stats", arguments: {} }); assert.equal(seen[0].url, `/api/stats?tz_offset=${new Date().getTimezoneOffset()}`); const res = await mcp.rpc("tools/call", { name: "get_session", arguments: { session_id: "s/1" }, }); assert.equal(JSON.parse(text(res)).session.id, "s/1"); assert.equal(seen[1].url, "/api/sessions/s%2F1?events=0"); }); it("reports a missing route as not available (404 and SPA page)", async () => { const budgets = await mcp.rpc("tools/call", { name: "get_budget_status", arguments: {} }); assert.equal(budgets.result.isError, true); assert.match(text(budgets), /Not available on this dashboard/); const spa = await mcp.rpc("tools/call", { name: "list_lanes", arguments: {} }); assert.match(text(spa), /Not available on this dashboard/); }); it("returns API errors and bad calls as isError results", async () => { const notFound = await mcp.rpc("tools/call", { name: "get_session_stats", arguments: { session_id: "gone" }, }); // JSON 404 from a real route is the API's own answer, not a missing route. const gone = await mcp.rpc("tools/call", { name: "get_session", arguments: { session_id: "gone" }, }); assert.match(text(gone), /HTTP 404: Session not found/); assert.equal(notFound.result.isError, true); const boom = await mcp.rpc("tools/call", { name: "get_analytics", arguments: {} }); assert.equal(boom.result.isError, true); assert.match(text(boom), /HTTP 500: boom/); const missing = await mcp.rpc("tools/call", { name: "get_session_cost", arguments: {} }); assert.match(text(missing), /Missing required argument\(s\): session_id/); const unknown = await mcp.rpc("tools/call", { name: "drop_tables", arguments: {} }); assert.match(text(unknown), /Unknown tool/); }); it("answers unknown methods and bad JSON with JSON-RPC errors", async () => { const res = await mcp.rpc("resources/list", {}); assert.equal(res.error.code, -32601); const before = mcp.unsolicited.length; mcp.raw("{not json"); await mcp.rpc("ping", {}); // ordering barrier assert.equal(mcp.unsolicited[before].error.code, -32700); }); it("says so when the dashboard is down", async () => { const dead = http.createServer(); await new Promise((r) => dead.listen(0, "127.0.0.1", r)); const port = dead.address().port; await new Promise((r) => dead.close(r)); const down = startMcp(`http://127.0.0.1:${port}`); const res = await down.rpc("tools/call", { name: "get_health", arguments: {} }); assert.equal(res.result.isError, true); assert.match(text(res), /not reachable/); await down.close(); }); });