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