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:
Bonchellon
2026-06-24 01:07:24 +03:00
parent d46e03e53e
commit eba71c4ca8
76 changed files with 23838 additions and 1 deletions

108
test/insights.test.js Normal file
View 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
View 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
View 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
View 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
View 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(); }
});