// End-to-end smoke: spawn the MCP server as a subprocess, speak the wire // protocol over stdio, verify tools/list and a dry_run apply_graph. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SERVER = path.resolve(HERE, "..", "src", "server.js"); function startServer() { const proc = spawn(process.execPath, [SERVER], { stdio: ["pipe", "pipe", "pipe"] }); let buf = ""; const pending = new Map(); let nextId = 1; proc.stdout.on("data", chunk => { buf += chunk.toString(); let idx; while ((idx = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, idx); buf = buf.slice(idx + 1); if (!line.trim()) continue; try { const msg = JSON.parse(line); if (msg.id && pending.has(msg.id)) { const { resolve } = pending.get(msg.id); pending.delete(msg.id); resolve(msg); } } catch { /* ignore non-JSON banner */ } } }); proc.stderr.on("data", () => { /* swallow */ }); function send(method, params) { const id = nextId++; const msg = { jsonrpc: "2.0", id, method, params }; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); proc.stdin.write(JSON.stringify(msg) + "\n"); setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error(`timeout: ${method}`)); } }, 5000); }); } function close() { try { proc.kill(); } catch {} } return { send, close }; } async function initialize(send) { return send("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test", version: "0" }, }); } test("MCP server lists tools over stdio", async () => { const { send, close } = startServer(); try { await initialize(send); const r = await send("tools/list", {}); const names = r.result.tools.map(t => t.name); assert.ok(names.includes("apply_graph")); assert.ok(names.includes("introspect")); assert.ok(names.includes("compile")); assert.ok(names.includes("read_graph")); assert.ok(names.includes("ping_ue")); assert.ok(names.includes("selection_op")); assert.ok(names.includes("voxel_graph_op")); assert.ok(names.includes("material_op")); } finally { close(); } }); test("apply_graph dry_run returns python without calling UE", async () => { const { send, close } = startServer(); try { await initialize(send); const r = await send("tools/call", { name: "apply_graph", arguments: { bp_path: "/Game/Test/BP_Foo", nodes: [ { id: "ev", kind: "event", target: "BeginPlay", position: [0, 0] }, { id: "br", kind: "branch", position: [400, 0] }, ], edges: [{ from: ["ev", "Then"], to: ["br", "Exec"] }], dry_run: true, }, }); assert.equal(r.result.isError ?? false, false); const payload = JSON.parse(r.result.content[0].text); assert.equal(payload.dry_run, true); assert.match(payload.python, /apply_graph\.entrypoint/); assert.match(payload.python, /BeginPlay/); } finally { close(); } }); test("apply_graph rejects invalid graphs", async () => { const { send, close } = startServer(); try { await initialize(send); const r = await send("tools/call", { name: "apply_graph", arguments: { bp_path: "not-a-game-path", nodes: [] }, }); assert.equal(r.result.isError, true); assert.match(r.result.content[0].text, /bp_path/); } finally { close(); } });