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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -91,3 +91,6 @@ agent-gateway/.generated-audio/
|
||||
# real API keys live in a local, untracked copy
|
||||
integrations.local.json
|
||||
|
||||
# Local setup-wizard overrides (machine-specific — never commit)
|
||||
mcp.config.json
|
||||
|
||||
|
||||
23
README.md
23
README.md
@ -40,13 +40,18 @@ It has two halves that ship together:
|
||||
4. In the editor, enable **Remote Control** and start its web server
|
||||
(`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
|
||||
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.:
|
||||
|
||||
```json
|
||||
@ -62,6 +67,20 @@ It has two halves that ship together:
|
||||
|
||||
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
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"setup": "node src/setup.js",
|
||||
"doctor": "node src/doctor.js",
|
||||
"test": "node --test \"test/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
170
src/doctor.js
Normal file
170
src/doctor.js
Normal 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);
|
||||
}
|
||||
@ -24,6 +24,42 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // <plugin>/src
|
||||
export const PLUGIN_DIR = path.resolve(HERE, ".."); // <plugin>
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -33,8 +69,8 @@ let _project = null;
|
||||
function discoverProject() {
|
||||
if (_project) return _project;
|
||||
|
||||
// 1. Explicit override.
|
||||
const envDir = process.env.UE_PROJECT_DIR;
|
||||
// 1. Explicit override (env, then persisted config).
|
||||
const envDir = process.env.UE_PROJECT_DIR || cfg("projectDir");
|
||||
if (envDir && fs.existsSync(envDir)) {
|
||||
_project = fromDir(envDir) || { projectDir: norm(envDir), uproject: null, projectName: null };
|
||||
return _project;
|
||||
@ -62,7 +98,7 @@ function fromDir(dir) {
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const up = entries.find((f) => f.toLowerCase().endsWith(".uproject"));
|
||||
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;
|
||||
return {
|
||||
projectDir: norm(dir),
|
||||
@ -75,15 +111,17 @@ export function projectDir() { return discoverProject().projectDir; }
|
||||
export function uprojectPath() { return discoverProject().uproject; }
|
||||
export function uprojectName() {
|
||||
if (process.env.UE_UPROJECT_NAME) return process.env.UE_UPROJECT_NAME;
|
||||
if (cfg("uprojectName")) return cfg("uprojectName");
|
||||
const up = discoverProject().uproject;
|
||||
return up ? path.basename(up) : null;
|
||||
}
|
||||
export function projectName() { return discoverProject().projectName; }
|
||||
|
||||
// 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() {
|
||||
if (process.env.UE_BUILD_TARGET) return process.env.UE_BUILD_TARGET;
|
||||
if (cfg("buildTarget")) return cfg("buildTarget");
|
||||
const name = projectName();
|
||||
return name ? `${name}Editor` : null;
|
||||
}
|
||||
@ -91,10 +129,16 @@ export function buildTarget() {
|
||||
// Default UE log file name is "<ProjectName>.log" under Saved/Logs.
|
||||
export function logName() {
|
||||
if (process.env.UE_LOG_NAME) return process.env.UE_LOG_NAME;
|
||||
if (cfg("logName")) return cfg("logName");
|
||||
const name = projectName();
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -117,10 +161,13 @@ export function engineDir(override) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
return norm(process.env.UE_ENGINE_DIR);
|
||||
}
|
||||
if (cfg("engineDir") && fs.existsSync(cfg("engineDir"))) {
|
||||
return norm(cfg("engineDir"));
|
||||
}
|
||||
|
||||
const assoc = engineAssociation();
|
||||
|
||||
@ -221,6 +268,9 @@ export function insightsExe() {
|
||||
if (process.env.UE_INSIGHTS_EXE && fs.existsSync(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();
|
||||
if (eng) {
|
||||
const p = path.join(eng, "Engine/Binaries/Win64/UnrealInsights.exe");
|
||||
@ -238,3 +288,26 @@ export function insightsExe() {
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
handleCompareTraces, handleQuery, handleReport, handleRenderCharts,
|
||||
} from "./insights.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 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.",
|
||||
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",
|
||||
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 "read_graph": return await handleReadGraph(args || {});
|
||||
case "ping_ue": return await handlePing();
|
||||
case "doctor": return await handleDoctor(args);
|
||||
case "get_active_blueprint": return await handleGetActiveBp();
|
||||
case "read_last_result": return await handleReadLastResult(args || {});
|
||||
case "read_python_log": return await handleReadPythonLog(args || {});
|
||||
@ -1189,6 +1201,18 @@ async function handlePing() {
|
||||
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() {
|
||||
const py = [
|
||||
"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 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());
|
||||
|
||||
121
src/setup.js
Normal file
121
src/setup.js
Normal 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); });
|
||||
@ -4,7 +4,9 @@
|
||||
// Script Plugin"). Then `WebControl.EnableServerOnStartup = true` or run the
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user