Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset: variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring). Made fully project- and machine-agnostic — no hardcoded paths: - New src/projectPaths.js auto-detects the host .uproject (walk-up), project name, Editor build target, log file, and engine install (EngineAssociation via launcher manifest/registry, else installed-engine scan). All overridable via UE_* env vars. - Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver. - Voxel made optional: Build.cs auto-detects the Voxel plugin (env UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL, so the module builds in projects without Voxel; .uplugin marks Voxel optional. - De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded node_modules/Binaries/Intermediate/__pycache__/secrets from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
108
test/insights.test.js
Normal file
108
test/insights.test.js
Normal file
@ -0,0 +1,108 @@
|
||||
// Structural tests for the Insights MCP layer.
|
||||
// We synthesise minimal Insights-style TSVs in a tmp dir, run the ingester
|
||||
// + analytics handlers directly, and assert the shapes. The full e2e path
|
||||
// (UnrealInsights.exe export) is not exercised here — that needs a real
|
||||
// .utrace and an installed Insights binary.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { dbAvailable, openDb, ingestRawDir, listTables, tableColumns, parseTabular } from "../src/insightsDb.js";
|
||||
|
||||
function mkTmp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "insights-mcp-test-"));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeTsv(dir, name, header, rows) {
|
||||
const text = [header.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
}
|
||||
|
||||
test("parseTabular: TSV with mixed types", async () => {
|
||||
const dir = mkTmp();
|
||||
const raw = path.join(dir, "raw");
|
||||
fs.mkdirSync(raw);
|
||||
writeTsv(raw, "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["UWorld::Tick", "GameThread", "0.0", "0.008"],
|
||||
["BPP_Foo::Tick", "GameThread", "0.008", "0.003"],
|
||||
["FrameTime", "GameThread", "0.0", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.052"], // a hitch
|
||||
]
|
||||
);
|
||||
const parsed = await parseTabular(path.join(raw, "events.tsv"));
|
||||
assert.deepEqual(parsed.headers, ["Name", "ThreadName", "StartTime", "Duration"]);
|
||||
assert.equal(parsed.types[2], "REAL");
|
||||
assert.equal(parsed.rows.length, 4);
|
||||
});
|
||||
|
||||
test("ingestRawDir: creates tables, picks indexes", async (t) => {
|
||||
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet (run npm install)");
|
||||
const dir = mkTmp();
|
||||
const raw = path.join(dir, "raw");
|
||||
fs.mkdirSync(raw);
|
||||
writeTsv(raw, "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["UWorld::Tick", "GameThread", "0.0", "0.008"],
|
||||
["FrameTime", "GameThread", "0.0", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.052"],
|
||||
]
|
||||
);
|
||||
writeTsv(raw, "memtags.tsv",
|
||||
["Name", "Peak", "Current"],
|
||||
[["Niagara", "180000000", "120000000"], ["Audio", "40000000", "30000000"]]
|
||||
);
|
||||
|
||||
const db = openDb(dir);
|
||||
try {
|
||||
const summary = await ingestRawDir(db, raw);
|
||||
assert.ok(summary.find((s) => s.table === "events" && s.rows === 3));
|
||||
assert.ok(summary.find((s) => s.table === "memtags" && s.rows === 2));
|
||||
const tables = listTables(db);
|
||||
assert.ok(tables.includes("events"));
|
||||
assert.ok(tables.includes("memtags"));
|
||||
const cols = tableColumns(db, "events").map((c) => c.name);
|
||||
assert.deepEqual(cols, ["Name", "ThreadName", "StartTime", "Duration"]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
test("handleFrameStats: detects hitches over threshold", async (t) => {
|
||||
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet");
|
||||
const { handleOpenTrace, handleFrameStats } = await import("../src/insights.js");
|
||||
// We bypass open_trace's real-export by manually staging the cache
|
||||
// structure that open_trace would have produced.
|
||||
const projectDir = mkTmp();
|
||||
const traceId = "abcd1234deadbeef";
|
||||
const dir = path.join(projectDir, "Saved/InsightsMCP", traceId);
|
||||
fs.mkdirSync(path.join(dir, "raw"), { recursive: true });
|
||||
writeTsv(path.join(dir, "raw"), "events.tsv",
|
||||
["Name", "ThreadName", "StartTime", "Duration"],
|
||||
[
|
||||
["FrameTime", "GameThread", "0.000", "0.016"],
|
||||
["FrameTime", "GameThread", "0.016", "0.017"],
|
||||
["FrameTime", "GameThread", "0.033", "0.052"], // hitch
|
||||
["FrameTime", "GameThread", "0.085", "0.018"],
|
||||
["FrameTime", "GameThread", "0.103", "0.045"], // hitch
|
||||
]
|
||||
);
|
||||
const db = openDb(dir);
|
||||
try { await ingestRawDir(db, path.join(dir, "raw")); } finally { db.close(); }
|
||||
// Write a minimal trace index entry.
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, "Saved/InsightsMCP/traces.json"),
|
||||
JSON.stringify({ [traceId]: { label: "synthetic", trace_path: "synthetic.utrace" } })
|
||||
);
|
||||
|
||||
const res = await handleFrameStats({ trace_id: traceId, hitch_ms: 33, project_dir: projectDir });
|
||||
assert.equal(res.isError, undefined, res.content?.[0]?.text);
|
||||
const out = JSON.parse(res.content[0].text);
|
||||
assert.equal(out.frames, 5);
|
||||
assert.equal(out.hitch_count, 2);
|
||||
assert.ok(out.worst_hitches[0].ms >= 50);
|
||||
});
|
||||
60
test/mockUE.test.js
Normal file
60
test/mockUE.test.js
Normal file
@ -0,0 +1,60 @@
|
||||
// Spin up a fake Remote Control endpoint and verify the bridge talks to it.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { executePythonInUE, ping } from "../src/ueBridge.js";
|
||||
|
||||
function startMockUE() {
|
||||
const seen = { calls: [] };
|
||||
const srv = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", c => body += c);
|
||||
req.on("end", () => {
|
||||
if (req.url === "/remote/info" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ name: "MockUE", version: "5.7" }));
|
||||
}
|
||||
if (req.url === "/remote/object/call" && req.method === "PUT") {
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(body); } catch {}
|
||||
seen.calls.push(parsed);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ReturnValue: true }));
|
||||
}
|
||||
res.writeHead(404); res.end();
|
||||
});
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
resolve({ srv, seen, baseUrl: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("ping reaches mock UE", async () => {
|
||||
const { srv, baseUrl } = await startMockUE();
|
||||
try {
|
||||
const r = await ping({ baseUrl, timeoutMs: 2000 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.info.name, "MockUE");
|
||||
} finally { srv.close(); }
|
||||
});
|
||||
|
||||
test("executePythonInUE PUTs the right payload to /remote/object/call", async () => {
|
||||
const { srv, seen, baseUrl } = await startMockUE();
|
||||
try {
|
||||
const r = await executePythonInUE("print('hi')", { baseUrl, timeoutMs: 2000 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(seen.calls.length, 1);
|
||||
assert.equal(seen.calls[0].functionName, "ExecutePythonCommand");
|
||||
assert.equal(seen.calls[0].parameters.PythonCommand, "print('hi')");
|
||||
assert.ok(seen.calls[0].objectPath.includes("PythonScriptLibrary"));
|
||||
} finally { srv.close(); }
|
||||
});
|
||||
|
||||
test("executePythonInUE returns ok:false when UE unreachable", async () => {
|
||||
const r = await executePythonInUE("print('x')", { baseUrl: "http://127.0.0.1:1", timeoutMs: 500 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /cannot reach UE/);
|
||||
});
|
||||
70
test/nodeSpec.test.js
Normal file
70
test/nodeSpec.test.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateGraph, NODE_KINDS } from "../src/nodeSpec.js";
|
||||
|
||||
test("validateGraph allows missing bp_path (auto-detect mode)", () => {
|
||||
const errs = validateGraph({ nodes: [] });
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph rejects bp_path not under /Game/ when provided", () => {
|
||||
const errs = validateGraph({ bp_path: "/Engine/Foo", nodes: [] });
|
||||
assert.ok(errs.some(e => e.includes("bp_path")));
|
||||
});
|
||||
|
||||
test("validateGraph accepts empty-string bp_path as auto-detect", () => {
|
||||
const errs = validateGraph({ bp_path: "", nodes: [] });
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph accepts a minimal valid graph", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/Test/BP_Foo",
|
||||
nodes: [{ id: "n1", kind: "branch" }],
|
||||
edges: [],
|
||||
});
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("validateGraph catches duplicate ids", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [
|
||||
{ id: "a", kind: "branch" },
|
||||
{ id: "a", kind: "sequence" },
|
||||
],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("duplicate")));
|
||||
});
|
||||
|
||||
test("validateGraph catches unknown kind", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [{ id: "a", kind: "teleport_to_mars" }],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("teleport_to_mars")));
|
||||
});
|
||||
|
||||
test("validateGraph catches edge to unknown node", () => {
|
||||
const errs = validateGraph({
|
||||
bp_path: "/Game/X",
|
||||
nodes: [{ id: "a", kind: "branch" }],
|
||||
edges: [{ from: ["a", "Then"], to: ["ghost", "Exec"] }],
|
||||
});
|
||||
assert.ok(errs.some(e => e.includes("ghost")));
|
||||
});
|
||||
|
||||
test("NODE_KINDS contains the documented set", () => {
|
||||
for (const k of [
|
||||
"call_function", "event", "custom_event", "component_event",
|
||||
"variable_get", "variable_set", "self",
|
||||
"branch", "sequence",
|
||||
"switch_int", "switch_string", "switch_enum",
|
||||
"macro", "cast",
|
||||
"make_array", "make_struct", "break_struct", "set_fields_in_struct",
|
||||
"spawn_actor", "format_text", "get_subsystem", "load_asset",
|
||||
"bind_delegate", "unbind_delegate", "call_delegate", "assign_delegate",
|
||||
]) {
|
||||
assert.ok(NODE_KINDS.has(k), `missing kind: ${k}`);
|
||||
}
|
||||
});
|
||||
35
test/pythonGen.test.js
Normal file
35
test/pythonGen.test.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApplyCommand, parseResultEnvelope, _internal } from "../src/pythonGen.js";
|
||||
|
||||
test("pyRepr escapes quotes, backslashes and newlines", () => {
|
||||
const r = _internal.pyRepr(`a'b\\c\nd`);
|
||||
assert.equal(r, `'a\\'b\\\\c\\nd'`);
|
||||
});
|
||||
|
||||
test("buildApplyCommand emits importable bootstrap and embeds payload", () => {
|
||||
const graph = { bp_path: "/Game/X", nodes: [{ id: "n1", kind: "branch" }] };
|
||||
const cmd = buildApplyCommand(graph);
|
||||
assert.match(cmd, /import sys, json/);
|
||||
assert.match(cmd, /apply_graph\.entrypoint\(/);
|
||||
assert.match(cmd, /__MCP_RESULT__/);
|
||||
// Payload must survive as a python literal that, when eval'd, equals our JSON.
|
||||
const m = /apply_graph\.entrypoint\(('.*?')\)/s.exec(cmd);
|
||||
assert.ok(m, "no payload found in command");
|
||||
// Roughly verify: drop quotes & unescape single-quote/backslash and compare JSON.
|
||||
const lit = m[1].slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\")
|
||||
.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
||||
assert.deepEqual(JSON.parse(lit), graph);
|
||||
});
|
||||
|
||||
test("parseResultEnvelope extracts JSON between markers", () => {
|
||||
const stdout = "noise\n__MCP_RESULT__" + JSON.stringify({ ok: true, spawned: { a: "K2N_1" } }) + "__MCP_END__\nmore noise";
|
||||
const r = parseResultEnvelope(stdout);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.spawned.a, "K2N_1");
|
||||
});
|
||||
|
||||
test("parseResultEnvelope errors on missing envelope", () => {
|
||||
const r = parseResultEnvelope("no envelope here");
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
110
test/server.test.js
Normal file
110
test/server.test.js
Normal file
@ -0,0 +1,110 @@
|
||||
// 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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user