Initial checkin of Blueprint-MCP plugin
This commit is contained in:
237
tools/blueprint-mcp/dist/ue-bridge.js
vendored
Normal file
237
tools/blueprint-mcp/dist/ue-bridge.js
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
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
|
||||
Reference in New Issue
Block a user