Add first-run setup wizard + doctor diagnostics

New users hit predictable friction (no npm install, Remote Control / Python
plugin not enabled, C++ not built). This makes onboarding self-service:

- src/doctor.js — shared diagnostics (project & engine detection, Node deps,
  Remote Control reachability, Python plugin, C++ module built, optional Voxel).
  Zero-dependency top-level imports so it runs before `npm install`. Runnable as
  `npm run doctor`, reused by the wizard and the server first-run hook.
- src/setup.js — interactive `npm run setup` wizard: offers to install deps,
  confirms/pins project & engine (and RC URL) overrides, re-checks the editor
  until reachable, then prints the MCP-client config snippet.
- projectPaths.js — persisted config (mcp.config.json, git-ignored) with
  precedence env > config > auto-detect; remoteControlUrl(); first-run marker
  helpers (isFirstRun/markRun under Saved/).
- server.js — `doctor` MCP tool (so a client/assistant can run the checks in a
  session) + a one-time first-run diagnostic printed to stderr pointing at
  `npm run setup`.
- ueBridge.js honors the config-driven Remote Control URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-06-24 01:15:52 +03:00
parent eba71c4ca8
commit 04c1e92a63
8 changed files with 438 additions and 8 deletions

3
.gitignore vendored
View File

@ -91,3 +91,6 @@ agent-gateway/.generated-audio/
# real API keys live in a local, untracked copy # real API keys live in a local, untracked copy
integrations.local.json integrations.local.json
# Local setup-wizard overrides (machine-specific — never commit)
mcp.config.json

View File

@ -40,13 +40,18 @@ It has two halves that ship together:
4. In the editor, enable **Remote Control** and start its web server 4. In the editor, enable **Remote Control** and start its web server
(`WebControl.StartServer`, or set `WebControl.EnableServerOnStartup=true`). (`WebControl.StartServer`, or set `WebControl.EnableServerOnStartup=true`).
5. Install the MCP server deps: 5. **Run the setup wizard** — it installs deps, confirms the auto-detected
project/engine, checks the editor side, and prints the exact client-config
snippet to paste:
```bash ```bash
cd Plugins/UEBlueprintMCP cd Plugins/UEBlueprintMCP
npm install npm run setup
``` ```
(Prefer to do it by hand? `npm install`, then point your client at
`src/server.js` as below.)
6. Point your MCP client at `src/server.js`, e.g.: 6. Point your MCP client at `src/server.js`, e.g.:
```json ```json
@ -62,6 +67,20 @@ It has two halves that ship together:
Call `ping_ue` first to confirm the editor is reachable. Call `ping_ue` first to confirm the editor is reachable.
### First-run check & `doctor`
The first time the server starts it prints a one-time environment check to its
stderr (visible in your client's MCP logs) and points you at `npm run setup` if
anything is off. You can re-run the checks any time:
- `npm run doctor` — print the diagnostics report in a terminal.
- the **`doctor`** MCP tool — same checks from inside a session, so the assistant
can diagnose and guide you.
The wizard pins any overrides (project dir, engine dir, Remote Control URL, ...)
to a git-ignored `mcp.config.json`. Precedence everywhere is **env var > config >
auto-detect**.
--- ---
## Zero-config path resolution ## Zero-config path resolution

View File

@ -9,6 +9,8 @@
}, },
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"setup": "node src/setup.js",
"doctor": "node src/doctor.js",
"test": "node --test \"test/*.test.js\"" "test": "node --test \"test/*.test.js\""
}, },
"dependencies": { "dependencies": {

170
src/doctor.js Normal file
View File

@ -0,0 +1,170 @@
// doctor.js — environment diagnostics for the UE MCP server.
//
// Used three ways:
// 1. `node src/doctor.js` — print a one-shot report (npm run doctor)
// 2. by the setup wizard — drives the interactive fixes
// 3. by server.js on first run — prints a summary so the user knows what to fix
//
// Intentionally imports ONLY zero-dependency modules at the top level so it runs
// before `npm install` (checking for the deps is one of its jobs). Heavy/optional
// deps are probed with dynamic import inside try/catch.
import { ping, executePythonInUE } from "./ueBridge.js";
import * as PP from "./projectPaths.js";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
export const STATUS = { OK: "ok", WARN: "warn", FAIL: "fail", SKIP: "skip" };
// Run every check. `deep` also probes the live editor (Remote Control + C++ module).
export async function runDiagnostics({ deep = true } = {}) {
const checks = [];
// --- project ---
const proj = PP.projectDir();
const uproj = PP.uprojectPath();
checks.push(uproj
? { id: "project", label: "Project", status: STATUS.OK,
detail: `${PP.projectName()} (${uproj})` }
: { id: "project", label: "Project", status: STATUS.FAIL,
detail: `no .uproject found above ${proj}`,
fix: "Place the plugin under <YourProject>/Plugins/, or set UE_PROJECT_DIR / run the setup wizard." });
// --- engine ---
const eng = PP.engineDir();
checks.push(eng
? { id: "engine", label: "Engine", status: STATUS.OK, detail: eng }
: { id: "engine", label: "Engine", status: STATUS.FAIL,
detail: "could not locate an Unreal Engine install",
fix: "Set UE_ENGINE_DIR to the folder containing /Engine, or run the setup wizard." });
// --- node deps ---
const deps = await checkNodeDeps();
checks.push(deps);
// --- remote control ---
let rc = null;
if (deep) {
const url = PP.remoteControlUrl();
const p = await ping();
rc = p.ok
? { id: "remote_control", label: "Remote Control", status: STATUS.OK, detail: url }
: { id: "remote_control", label: "Remote Control", status: STATUS.FAIL,
detail: `unreachable at ${url} (${p.error})`,
fix: "In UE: enable the 'Remote Control API' plugin, then run console command `WebControl.StartServer` " +
"(or set WebControl.EnableServerOnStartup=true). Make sure the editor is open." };
checks.push(rc);
}
// --- python plugin + C++ module (only if RC is up) ---
if (deep) {
if (rc && rc.status === STATUS.OK) {
const probe = await probeEditor();
if (!probe.ok) {
checks.push({ id: "python", label: "Python plugin", status: STATUS.FAIL,
detail: probe.error || "ExecutePythonCommand failed",
fix: "Enable the 'Python Editor Script Plugin' in UE and restart the editor." });
} else {
checks.push({ id: "python", label: "Python plugin", status: STATUS.OK,
detail: probe.engine ? `UE ${probe.engine}` : "ExecutePythonCommand works" });
checks.push(probe.has_mcp
? { id: "cpp", label: "C++ module", status: STATUS.OK, detail: "UMCPGraphLibrary is loaded" }
: { id: "cpp", label: "C++ module", status: STATUS.FAIL,
detail: "unreal.MCPGraphLibrary not found — UEBlueprintMCPEditor isn't built",
fix: "Generate project files and build the editor target (the plugin has a C++ module)." });
if (probe.has_voxel !== undefined) {
checks.push({ id: "voxel", label: "Voxel (optional)", status: probe.has_voxel ? STATUS.OK : STATUS.SKIP,
detail: probe.has_voxel ? "voxel_graph_op available" : "Voxel plugin not installed — voxel_graph_op disabled" });
}
}
} else {
checks.push({ id: "python", label: "Python plugin", status: STATUS.SKIP, detail: "skipped (editor not reachable)" });
checks.push({ id: "cpp", label: "C++ module", status: STATUS.SKIP, detail: "skipped (editor not reachable)" });
}
}
const summary = summarize(checks);
return { checks, summary, ok: summary.fail === 0 };
}
async function checkNodeDeps() {
const missing = [];
for (const m of ["@modelcontextprotocol/sdk/server/index.js", "better-sqlite3"]) {
try { await import(m); } catch { missing.push(m.split("/")[0].replace(/^@/, "@")); }
}
if (!missing.length) {
return { id: "node_deps", label: "Node deps", status: STATUS.OK, detail: "installed" };
}
const sqliteOnly = missing.length === 1 && missing[0] === "better-sqlite3";
return {
id: "node_deps",
label: "Node deps",
status: sqliteOnly ? STATUS.WARN : STATUS.FAIL,
detail: `missing: ${missing.join(", ")}${sqliteOnly ? " (only the insights_* tools need it)" : ""}`,
fix: "Run `npm install` in the plugin folder.",
};
}
// Ask the live editor to write a probe file we can read back (RC doesn't echo
// Python stdout, so we tee to <Project>/Saved/MCP/doctor.json).
async function probeEditor() {
const outFile = path.join(PP.projectDir(), "Saved", "MCP", "doctor.json").replace(/\\/g, "/");
try { fs.rmSync(outFile, { force: true }); } catch { /* ignore */ }
const py = [
"import unreal, json, os",
"d = {'ok': True}",
"try: d['has_mcp'] = hasattr(unreal, 'MCPGraphLibrary')",
"except Exception as e: d['has_mcp'] = False",
"try: d['has_voxel'] = hasattr(unreal, 'MCPVoxelGraphLibrary') and unreal.MCPVoxelGraphLibrary.is_voxel_graph_asset is not None",
"except Exception: d['has_voxel'] = False",
"try: d['engine'] = unreal.SystemLibrary.get_engine_version()",
"except Exception: pass",
"p = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_saved_dir()) + 'MCP/'",
"os.makedirs(p, exist_ok=True)",
"open(p + 'doctor.json', 'w', encoding='utf-8').write(json.dumps(d))",
].join("\n");
const r = await executePythonInUE(py, { timeoutMs: 8000 });
if (!r.ok) return { ok: false, error: r.error };
// Give the editor a tick to flush the file.
for (let i = 0; i < 20; i++) {
try {
const txt = fs.readFileSync(outFile, "utf8");
return { ok: true, ...JSON.parse(txt) };
} catch { await sleep(100); }
}
return { ok: false, error: "probe file was not written (Python ran but produced no output)" };
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
function summarize(checks) {
const s = { ok: 0, warn: 0, fail: 0, skip: 0 };
for (const c of checks) s[c.status] = (s[c.status] || 0) + 1;
return s;
}
const ICON = { ok: "✓", warn: "!", fail: "✗", skip: "·" };
export function formatReport({ checks, summary }) {
const lines = [];
for (const c of checks) {
lines.push(` ${ICON[c.status] || "?"} ${c.label.padEnd(18)} ${c.detail || ""}`);
if (c.fix && (c.status === STATUS.FAIL || c.status === STATUS.WARN)) {
lines.push(`${c.fix}`);
}
}
lines.push("");
lines.push(` ${summary.ok} ok · ${summary.warn} warn · ${summary.fail} fail · ${summary.skip} skipped`);
return lines.join("\n");
}
// Standalone: `node src/doctor.js`
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const res = await runDiagnostics({ deep: true });
console.log("\nUnreal Engine MCP — diagnostics\n");
console.log(formatReport(res));
console.log(res.ok ? "\nAll good. ✓\n" : "\nSome checks failed — run `npm run setup` to fix interactively.\n");
process.exit(res.ok ? 0 : 1);
}

View File

@ -24,6 +24,42 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // <plugin>/src
export const PLUGIN_DIR = path.resolve(HERE, ".."); // <plugin> export const PLUGIN_DIR = path.resolve(HERE, ".."); // <plugin>
const norm = (p) => (p ? p.replace(/\\/g, "/") : p); const norm = (p) => (p ? p.replace(/\\/g, "/") : p);
// ---------------------------------------------------------------------------
// Persisted config (written by the setup wizard) — lets a user pin overrides
// when auto-detection guesses wrong. Precedence everywhere is: env > config >
// auto-detect. Lives next to the plugin so it needs no project dir to find, and
// is git-ignored so it never gets published.
// ---------------------------------------------------------------------------
export function configPath() {
return norm(process.env.UE_MCP_CONFIG || path.join(PLUGIN_DIR, "mcp.config.json"));
}
let _cfg;
export function loadConfig() {
if (_cfg !== undefined) return _cfg;
try {
_cfg = JSON.parse(fs.readFileSync(configPath(), "utf8"));
if (!_cfg || typeof _cfg !== "object") _cfg = {};
} catch { _cfg = {}; }
return _cfg;
}
export function saveConfig(obj) {
const merged = { ...loadConfig(), ...obj };
// Drop empty values so we don't pin blanks.
for (const k of Object.keys(merged)) {
if (merged[k] === "" || merged[k] == null) delete merged[k];
}
fs.writeFileSync(configPath(), JSON.stringify(merged, null, 2) + "\n", "utf8");
_cfg = merged;
// Invalidate caches that depend on config.
_project = null; _engine = null;
return merged;
}
const cfg = (k) => loadConfig()[k];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Project discovery // Project discovery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -33,8 +69,8 @@ let _project = null;
function discoverProject() { function discoverProject() {
if (_project) return _project; if (_project) return _project;
// 1. Explicit override. // 1. Explicit override (env, then persisted config).
const envDir = process.env.UE_PROJECT_DIR; const envDir = process.env.UE_PROJECT_DIR || cfg("projectDir");
if (envDir && fs.existsSync(envDir)) { if (envDir && fs.existsSync(envDir)) {
_project = fromDir(envDir) || { projectDir: norm(envDir), uproject: null, projectName: null }; _project = fromDir(envDir) || { projectDir: norm(envDir), uproject: null, projectName: null };
return _project; return _project;
@ -62,7 +98,7 @@ function fromDir(dir) {
try { entries = fs.readdirSync(dir); } catch { return null; } try { entries = fs.readdirSync(dir); } catch { return null; }
const up = entries.find((f) => f.toLowerCase().endsWith(".uproject")); const up = entries.find((f) => f.toLowerCase().endsWith(".uproject"));
if (!up) return null; if (!up) return null;
const override = process.env.UE_UPROJECT_NAME; const override = process.env.UE_UPROJECT_NAME || cfg("uprojectName");
const uprojectName = override && entries.includes(override) ? override : up; const uprojectName = override && entries.includes(override) ? override : up;
return { return {
projectDir: norm(dir), projectDir: norm(dir),
@ -75,15 +111,17 @@ export function projectDir() { return discoverProject().projectDir; }
export function uprojectPath() { return discoverProject().uproject; } export function uprojectPath() { return discoverProject().uproject; }
export function uprojectName() { export function uprojectName() {
if (process.env.UE_UPROJECT_NAME) return process.env.UE_UPROJECT_NAME; if (process.env.UE_UPROJECT_NAME) return process.env.UE_UPROJECT_NAME;
if (cfg("uprojectName")) return cfg("uprojectName");
const up = discoverProject().uproject; const up = discoverProject().uproject;
return up ? path.basename(up) : null; return up ? path.basename(up) : null;
} }
export function projectName() { return discoverProject().projectName; } export function projectName() { return discoverProject().projectName; }
// The Editor build target. Convention is "<ProjectName>Editor"; a project can // The Editor build target. Convention is "<ProjectName>Editor"; a project can
// override via env or by naming its .Target.cs differently. // override via env, config, or by naming its .Target.cs differently.
export function buildTarget() { export function buildTarget() {
if (process.env.UE_BUILD_TARGET) return process.env.UE_BUILD_TARGET; if (process.env.UE_BUILD_TARGET) return process.env.UE_BUILD_TARGET;
if (cfg("buildTarget")) return cfg("buildTarget");
const name = projectName(); const name = projectName();
return name ? `${name}Editor` : null; return name ? `${name}Editor` : null;
} }
@ -91,10 +129,16 @@ export function buildTarget() {
// Default UE log file name is "<ProjectName>.log" under Saved/Logs. // Default UE log file name is "<ProjectName>.log" under Saved/Logs.
export function logName() { export function logName() {
if (process.env.UE_LOG_NAME) return process.env.UE_LOG_NAME; if (process.env.UE_LOG_NAME) return process.env.UE_LOG_NAME;
if (cfg("logName")) return cfg("logName");
const name = projectName(); const name = projectName();
return name ? `${name}.log` : "UnrealEditor.log"; return name ? `${name}.log` : "UnrealEditor.log";
} }
// Remote Control base URL the Node server talks to.
export function remoteControlUrl() {
return process.env.UE_REMOTE_CONTROL_URL || cfg("remoteControlUrl") || "http://127.0.0.1:30010";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Engine discovery // Engine discovery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -117,10 +161,13 @@ export function engineDir(override) {
} }
function resolveEngine() { function resolveEngine() {
// 1. Explicit override wins. // 1. Explicit override wins (env, then persisted config).
if (process.env.UE_ENGINE_DIR && fs.existsSync(process.env.UE_ENGINE_DIR)) { if (process.env.UE_ENGINE_DIR && fs.existsSync(process.env.UE_ENGINE_DIR)) {
return norm(process.env.UE_ENGINE_DIR); return norm(process.env.UE_ENGINE_DIR);
} }
if (cfg("engineDir") && fs.existsSync(cfg("engineDir"))) {
return norm(cfg("engineDir"));
}
const assoc = engineAssociation(); const assoc = engineAssociation();
@ -221,6 +268,9 @@ export function insightsExe() {
if (process.env.UE_INSIGHTS_EXE && fs.existsSync(process.env.UE_INSIGHTS_EXE)) { if (process.env.UE_INSIGHTS_EXE && fs.existsSync(process.env.UE_INSIGHTS_EXE)) {
return process.env.UE_INSIGHTS_EXE; return process.env.UE_INSIGHTS_EXE;
} }
if (cfg("insightsExe") && fs.existsSync(cfg("insightsExe"))) {
return norm(cfg("insightsExe"));
}
const eng = engineDir(); const eng = engineDir();
if (eng) { if (eng) {
const p = path.join(eng, "Engine/Binaries/Win64/UnrealInsights.exe"); const p = path.join(eng, "Engine/Binaries/Win64/UnrealInsights.exe");
@ -238,3 +288,26 @@ export function insightsExe() {
} }
return null; return null;
} }
// ---------------------------------------------------------------------------
// First-run marker — lets the server show the setup hint exactly once, and lets
// the wizard record that setup completed. Stored under Saved/ (git-ignored by
// UE) so it's per-checkout, not committed.
// ---------------------------------------------------------------------------
export function markerPath() {
return norm(path.join(projectDir(), "Saved", "MCP", ".mcp-setup.json"));
}
export function isFirstRun() {
try { return !fs.existsSync(markerPath()); } catch { return false; }
}
export function markRun(extra = {}) {
try {
const p = markerPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify({ setupAt: new Date().toISOString(), ...extra }, null, 2) + "\n", "utf8");
return p;
} catch { return null; }
}

View File

@ -30,6 +30,7 @@ import {
handleCompareTraces, handleQuery, handleReport, handleRenderCharts, handleCompareTraces, handleQuery, handleReport, handleRenderCharts,
} from "./insights.js"; } from "./insights.js";
import * as PP from "./projectPaths.js"; import * as PP from "./projectPaths.js";
import { runDiagnostics, formatReport } from "./doctor.js";
const UE_SIDE_DIR_FOR_JS = UE_SIDE_DIR.replace(/\\/g, "/"); const UE_SIDE_DIR_FOR_JS = UE_SIDE_DIR.replace(/\\/g, "/");
const DEFAULT_PROJECT_DIR = PP.projectDir(); const DEFAULT_PROJECT_DIR = PP.projectDir();
@ -107,6 +108,16 @@ const TOOLS = [
description: "Check whether UE Remote Control is reachable. Useful as a first call in any session.", description: "Check whether UE Remote Control is reachable. Useful as a first call in any session.",
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
{
name: "doctor",
description: "First-run / setup diagnostics: verifies project & engine detection, Node deps, Remote Control reachability, the Python plugin, and whether the C++ module is built. Returns each check with a suggested fix. Run this when something isn't working, or guide a new user through it. For interactive fixes the user runs `npm run setup` in the plugin folder.",
inputSchema: {
type: "object",
properties: {
deep: { type: "boolean", description: "Also probe the live editor (Remote Control / Python / C++). Default true." },
},
},
},
{ {
name: "get_active_blueprint", name: "get_active_blueprint",
description: "Return the path of the Blueprint currently open in the UE editor (most-recently-focused). Use to verify auto-detect target before calling apply_graph without bp_path.", description: "Return the path of the Blueprint currently open in the UE editor (most-recently-focused). Use to verify auto-detect target before calling apply_graph without bp_path.",
@ -978,6 +989,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
case "compile": return await handleCompile(args); case "compile": return await handleCompile(args);
case "read_graph": return await handleReadGraph(args || {}); case "read_graph": return await handleReadGraph(args || {});
case "ping_ue": return await handlePing(); case "ping_ue": return await handlePing();
case "doctor": return await handleDoctor(args);
case "get_active_blueprint": return await handleGetActiveBp(); case "get_active_blueprint": return await handleGetActiveBp();
case "read_last_result": return await handleReadLastResult(args || {}); case "read_last_result": return await handleReadLastResult(args || {});
case "read_python_log": return await handleReadPythonLog(args || {}); case "read_python_log": return await handleReadPythonLog(args || {});
@ -1189,6 +1201,18 @@ async function handlePing() {
return okResult(r); return okResult(r);
} }
async function handleDoctor(args = {}) {
const deep = args.deep !== false;
const res = await runDiagnostics({ deep });
return okResult({
ok: res.ok,
summary: res.summary,
report: formatReport(res),
checks: res.checks,
setup_hint: res.ok ? undefined : "Run `npm run setup` in the plugin folder for an interactive fix.",
});
}
async function handleGetActiveBp() { async function handleGetActiveBp() {
const py = [ const py = [
"import json, unreal, os", "import json, unreal, os",
@ -1317,4 +1341,20 @@ async function attachInlineImage(result) {
function okResult(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; } function okResult(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; }
function errResult(msg) { return { content: [{ type: "text", text: msg }], isError: true }; } function errResult(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }
// First-run hint: the stdio channel is the MCP protocol, so we can't prompt —
// instead we print a one-time diagnostic to stderr (visible in the client's MCP
// logs) pointing the user at `npm run setup`, then drop a marker so it's quiet
// afterwards. Best-effort: never let setup diagnostics block or break startup.
if (PP.isFirstRun()) {
try {
const res = await runDiagnostics({ deep: true });
process.stderr.write("\n[ue-mcp] First run — environment check:\n\n" + formatReport(res) + "\n");
if (!res.ok) {
process.stderr.write("\n[ue-mcp] Run `npm run setup` in the plugin folder to fix the items above, " +
"or call the `doctor` tool from your client.\n\n");
}
} catch { /* ignore — diagnostics are advisory only */ }
PP.markRun({ via: "server-first-run" });
}
await server.connect(new StdioServerTransport()); await server.connect(new StdioServerTransport());

121
src/setup.js Normal file
View File

@ -0,0 +1,121 @@
// setup.js — interactive first-run wizard for the UE MCP server.
//
// npm run setup (or: node src/setup.js)
//
// Walks the user through: installing Node deps, confirming the auto-detected
// project/engine (pinning overrides to mcp.config.json when wrong), checking the
// editor side (Remote Control + Python plugin + C++ module), and finally printing
// the MCP-client config snippet to paste. Writes a first-run marker so server.js
// stops nagging once setup is done.
//
// Top-level imports are zero-dependency on purpose — the wizard must run BEFORE
// `npm install`.
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { spawn } from "node:child_process";
import path from "node:path";
import * as PP from "./projectPaths.js";
import { runDiagnostics, formatReport, STATUS } from "./doctor.js";
const rl = createInterface({ input, output });
const ask = (q) => rl.question(q);
async function askYN(q, def = true) {
const a = (await ask(`${q} ${def ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
if (!a) return def;
return a === "y" || a === "yes";
}
function hr() { console.log("─".repeat(64)); }
async function npmInstall() {
const cmd = process.platform === "win32" ? "npm.cmd" : "npm";
console.log(`\nRunning \`npm install\` in ${PP.PLUGIN_DIR}\n`);
return new Promise((resolve) => {
const child = spawn(cmd, ["install"], { cwd: PP.PLUGIN_DIR, stdio: "inherit", shell: process.platform === "win32" });
child.on("close", (code) => resolve(code === 0));
child.on("error", () => resolve(false));
});
}
async function main() {
console.log("\n Unreal Engine MCP — setup wizard\n");
hr();
// 1. First diagnostic pass.
let res = await runDiagnostics({ deep: true });
console.log("\nDetected:\n");
console.log(formatReport(res));
hr();
// 2. Node deps.
let deps = res.checks.find((c) => c.id === "node_deps");
if (deps && deps.status !== STATUS.OK) {
if (await askYN("Node dependencies are missing. Install them now?", true)) {
const ok = await npmInstall();
console.log(ok ? "\n✓ npm install finished.\n" : "\n✗ npm install failed — install manually, then re-run setup.\n");
}
}
// 3. Confirm project.
const proj = res.checks.find((c) => c.id === "project");
if (proj && proj.status === STATUS.OK) {
if (!(await askYN(`Use detected project "${PP.projectName()}" at ${PP.projectDir()}?`, true))) {
const dir = (await ask(" Path to the folder containing your .uproject: ")).trim();
if (dir) { PP.saveConfig({ projectDir: dir }); console.log(" ✓ pinned to mcp.config.json"); }
}
} else {
console.log("\nNo .uproject was auto-detected.");
const dir = (await ask(" Path to the folder containing your .uproject (blank to skip): ")).trim();
if (dir) { PP.saveConfig({ projectDir: dir }); console.log(" ✓ pinned to mcp.config.json"); }
}
// 4. Confirm engine.
const eng = res.checks.find((c) => c.id === "engine");
const engDetected = PP.engineDir();
if (eng && eng.status === STATUS.OK) {
if (!(await askYN(`Use detected engine at ${engDetected}?`, true))) {
const dir = (await ask(" Path to the engine root (folder containing /Engine): ")).trim();
if (dir) { PP.saveConfig({ engineDir: dir }); console.log(" ✓ pinned to mcp.config.json"); }
}
} else {
const dir = (await ask(" Engine root not found. Enter it (folder containing /Engine, blank to skip): ")).trim();
if (dir) { PP.saveConfig({ engineDir: dir }); console.log(" ✓ pinned to mcp.config.json"); }
}
// 5. Optional custom Remote Control URL.
if (await askYN(`Remote Control URL is ${PP.remoteControlUrl()}. Change it?`, false)) {
const url = (await ask(" Remote Control URL: ")).trim();
if (url) { PP.saveConfig({ remoteControlUrl: url }); console.log(" ✓ pinned to mcp.config.json"); }
}
// 6. Re-check the editor until reachable (or user gives up).
hr();
for (;;) {
res = await runDiagnostics({ deep: true });
const rc = res.checks.find((c) => c.id === "remote_control");
const cpp = res.checks.find((c) => c.id === "cpp");
console.log("\nRe-check:\n");
console.log(formatReport(res));
const blocked = (rc && rc.status === STATUS.FAIL) || (cpp && cpp.status === STATUS.FAIL);
if (!blocked) break;
if (!(await askYN("\nFix the items above in the editor, then retry?", true))) break;
}
// 7. Finish — write marker and print client config.
PP.markRun({ via: "wizard" });
const serverEntry = path.join(PP.PLUGIN_DIR, "src", "server.js").replace(/\\/g, "/");
hr();
console.log("\n✓ Setup complete. Add this to your MCP client config:\n");
console.log(JSON.stringify({
mcpServers: {
"ue-blueprint": { command: "node", args: [serverEntry] },
},
}, null, 2));
console.log("\nThen call `ping_ue` in a session to confirm the connection.");
if (PP.configPath()) console.log(`Overrides (if any) saved to: ${PP.configPath()}`);
console.log("");
rl.close();
}
main().catch((e) => { console.error("setup wizard error:", e); rl.close(); process.exit(1); });

View File

@ -4,7 +4,9 @@
// Script Plugin"). Then `WebControl.EnableServerOnStartup = true` or run the // Script Plugin"). Then `WebControl.EnableServerOnStartup = true` or run the
// console command `WebControl.StartServer` in the editor. Default port 30010. // console command `WebControl.StartServer` in the editor. Default port 30010.
const DEFAULT_BASE = process.env.UE_REMOTE_CONTROL_URL || "http://127.0.0.1:30010"; import { remoteControlUrl } from "./projectPaths.js";
const DEFAULT_BASE = remoteControlUrl();
/** /**
* Execute an arbitrary Python command inside the UE editor. * Execute an arbitrary Python command inside the UE editor.