Files
integration/tools/blueprint-mcp/dist/ue-bridge.js

237 lines
8.9 KiB
JavaScript
Raw Normal View History

import * as fs from "node:fs";
import * as path from "node:path";
import { spawn } from "node:child_process";
// --- Constants ---
export const UE_PROJECT_DIR = process.env.UE_PROJECT_DIR || process.cwd();
export const UE_PORT = parseInt(process.env.UE_PORT || "9847", 10);
export const UE_BASE_URL = `http://localhost:${UE_PORT}`;
export const PLUGIN_MODULE_NAME = "BlueprintMCP";
export const PLUGIN_DLL_NAME = `UnrealEditor-${PLUGIN_MODULE_NAME}.dll`;
// --- Mutable state singleton ---
export const state = {
ueProcess: null,
editorMode: false,
startupPromise: null,
};
// --- UE5 Process Manager ---
/**
* Read the EngineAssociation field from the .uproject file.
* Returns a short version string like "5.4" or "5.7", or null.
*/
export function readEngineVersion() {
const uproject = findUProject();
if (!uproject)
return null;
try {
const data = JSON.parse(fs.readFileSync(uproject, "utf-8"));
if (typeof data.EngineAssociation === "string" && data.EngineAssociation) {
return data.EngineAssociation;
}
}
catch { /* ignore parse errors */ }
return null;
}
export function findEditorCmd() {
// Priority 1: explicit env var
if (process.env.UE_EDITOR_CMD && fs.existsSync(process.env.UE_EDITOR_CMD)) {
return process.env.UE_EDITOR_CMD;
}
// Priority 2: auto-detect from .uproject EngineAssociation
const engineVersion = readEngineVersion();
if (engineVersion) {
const versionCandidates = [
`C:\\Program Files\\Epic Games\\UE_${engineVersion}\\Engine\\Binaries\\Win64\\UnrealEditor-Cmd.exe`,
`C:\\Program Files (x86)\\Epic Games\\UE_${engineVersion}\\Engine\\Binaries\\Win64\\UnrealEditor-Cmd.exe`,
];
for (const p of versionCandidates) {
if (fs.existsSync(p)) {
console.error(`[BlueprintMCP] Auto-detected engine ${engineVersion} from .uproject`);
return p;
}
}
}
// Priority 3: scan for any installed UE5 version
const epicGamesDir = "C:\\Program Files\\Epic Games";
try {
const entries = fs.readdirSync(epicGamesDir);
for (const entry of entries.sort().reverse()) {
if (entry.startsWith("UE_")) {
const candidate = path.join(epicGamesDir, entry, "Engine", "Binaries", "Win64", "UnrealEditor-Cmd.exe");
if (fs.existsSync(candidate)) {
const detectedVersion = entry.replace("UE_", "");
console.error(`[BlueprintMCP] Found engine ${detectedVersion} (no match for .uproject version${engineVersion ? ` ${engineVersion}` : ""})`);
return candidate;
}
}
}
}
catch { /* directory may not exist */ }
return null;
}
/** Find the .uproject file in UE_PROJECT_DIR (auto-detect by globbing). */
export function findUProject() {
try {
const entries = fs.readdirSync(UE_PROJECT_DIR);
const uprojectFile = entries.find((e) => e.endsWith(".uproject"));
if (uprojectFile)
return path.join(UE_PROJECT_DIR, uprojectFile);
}
catch { /* ignore */ }
return null;
}
/**
* Ensure the .modules file in Binaries/Win64/ lists the BlueprintMCP module.
* A build of only the Game target will overwrite this file without the editor module,
* causing the commandlet to fail with "module could not be found".
*/
export function ensureModulesFile() {
const modulesPath = path.join(UE_PROJECT_DIR, "Binaries", "Win64", "UnrealEditor.modules");
try {
if (!fs.existsSync(modulesPath))
return; // nothing to fix
const data = JSON.parse(fs.readFileSync(modulesPath, "utf-8"));
if (data.Modules && !data.Modules[PLUGIN_MODULE_NAME]) {
const dllPath = path.join(UE_PROJECT_DIR, "Binaries", "Win64", PLUGIN_DLL_NAME);
if (!fs.existsSync(dllPath)) {
console.error(`[BlueprintMCP] Warning: ${PLUGIN_DLL_NAME} not found — editor module may not be compiled.`);
return;
}
data.Modules[PLUGIN_MODULE_NAME] = PLUGIN_DLL_NAME;
fs.writeFileSync(modulesPath, JSON.stringify(data, null, "\t") + "\n", "utf-8");
console.error(`[BlueprintMCP] Fixed .modules file — added ${PLUGIN_MODULE_NAME} entry.`);
}
}
catch (e) {
console.error("[BlueprintMCP] Warning: could not check/fix .modules file:", e);
}
}
/**
* Ask the UE5 server to shut down gracefully via /api/shutdown, then wait for
* the process to exit. Falls back to kill after a timeout.
*/
export async function gracefulShutdown() {
// Try graceful shutdown via HTTP
try {
await fetch(`${UE_BASE_URL}/api/shutdown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: AbortSignal.timeout(3000),
});
}
catch { /* server may already be gone */ }
// Wait up to 15 seconds for process to exit on its own
if (state.ueProcess) {
const proc = state.ueProcess;
const exited = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), 15000);
proc.on("exit", () => { clearTimeout(timer); resolve(true); });
});
if (!exited && state.ueProcess) {
console.error("[BlueprintMCP] Graceful shutdown timed out, force-killing.");
state.ueProcess.kill();
}
state.ueProcess = null;
}
}
/** Returns the health payload if the server is reachable, or null. */
export async function getUEHealth() {
try {
const resp = await fetch(`${UE_BASE_URL}/api/health`, { signal: AbortSignal.timeout(2000) });
if (!resp.ok)
return null;
return await resp.json();
}
catch {
return null;
}
}
export async function isUEHealthy() {
return (await getUEHealth()) !== null;
}
export async function waitForHealthy(timeoutSeconds = 180) {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
if (await isUEHealthy())
return true;
// If the process died while we were waiting, bail out
if (!state.ueProcess)
return false;
await new Promise((r) => setTimeout(r, 2000));
}
return false;
}
export async function spawnAndWait() {
const editorCmd = findEditorCmd();
if (!editorCmd) {
return "Could not find UnrealEditor-Cmd.exe. Set UE_EDITOR_CMD environment variable.";
}
const uproject = findUProject();
if (!uproject) {
return `No .uproject file found in ${UE_PROJECT_DIR}`;
}
const logPath = path.join(UE_PROJECT_DIR, "Saved", "Logs", "BlueprintMCP_server.log");
console.error("[BlueprintMCP] Spawning UE5 commandlet...");
state.ueProcess = spawn(editorCmd, [
uproject,
"-run=BlueprintMCP",
`-port=${UE_PORT}`,
"-unattended",
"-nopause",
"-nullrhi",
`-LOG=${logPath}`,
], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
state.ueProcess.on("exit", (code) => {
console.error(`[BlueprintMCP] UE5 server exited with code ${code}`);
state.ueProcess = null;
});
state.ueProcess.stdout?.on("data", (data) => {
console.error(`[UE5:out] ${data.toString().trim()}`);
});
state.ueProcess.stderr?.on("data", (data) => {
console.error(`[UE5:err] ${data.toString().trim()}`);
});
console.error("[BlueprintMCP] Waiting for health check (up to 3 min)...");
const ok = await waitForHealthy(180);
if (ok) {
console.error("[BlueprintMCP] UE5 Blueprint server is ready.");
return null; // success
}
// Failed — clean up
if (state.ueProcess) {
state.ueProcess.kill();
state.ueProcess = null;
}
return "UE5 Blueprint server failed to start within 3 minutes. Check Saved/Logs/BlueprintMCP_server.log.";
}
export async function ensureUE() {
const health = await getUEHealth();
if (health) {
state.editorMode = health.mode === "editor";
return null;
}
return "UE5 editor is not running or BlueprintMCP plugin is not loaded. Start the editor first.";
}
// --- HTTP helpers ---
export async function ueGet(endpoint, params = {}) {
const url = new URL(endpoint, UE_BASE_URL);
for (const [k, v] of Object.entries(params)) {
if (v)
url.searchParams.set(k, v);
}
const resp = await fetch(url.toString(), { signal: AbortSignal.timeout(300000) }); // 5 min for search
return resp.json();
}
export async function uePost(endpoint, body) {
const resp = await fetch(`${UE_BASE_URL}${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(300000), // 5 min for compile+save
});
return resp.json();
}
//# sourceMappingURL=ue-bridge.js.map