Files
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:07:24 +03:00

902 lines
37 KiB
JavaScript

// In-engine agent gateway.
//
// What this is:
// A tiny local HTTP server that (1) serves the chat UI loaded by the
// UE WebBrowser widget and (2) runs a Claude agent via the Claude Agent SDK,
// wired to the project's ue-blueprint MCP server so the in-engine chat has
// the exact same UE tools Claude Code uses (edit BPs, spawn nodes, build
// C++, screenshot UMG, ...).
//
// Transport to the browser: POST /api/chat with a JSON body, response is an
// SSE stream of agent events (text deltas, tool start/end, done). The browser
// keeps a sessionId returned on the first turn and replays it to continue the
// conversation (SDK `resume`).
//
// Auth: uses whatever the Agent SDK finds locally (the same subscription
// login Claude Code uses). No API key is read or required here.
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { executePythonInUE } from "../src/ueBridge.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(__dirname, "public");
const INTEGRATIONS_FILE = path.join(__dirname, "integrations.json");
const GENERATED_AUDIO_DIR = path.join(__dirname, ".generated-audio");
const AUDIO_CACHE = new Map();
// Optional local auth file (gitignored). Lets you keep the agent's credentials
// out of the global environment. One KEY=VALUE per line. Supported keys:
// CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token` — subscription auth)
// ANTHROPIC_API_KEY (pay-as-you-go API key)
function loadAuthEnv() {
const file = path.join(__dirname, "auth.env");
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!m) continue;
const val = m[2].replace(/^["']|["']$/g, "");
if (val && !process.env[m[1]]) process.env[m[1]] = val;
}
}
loadAuthEnv();
const HAS_AUTH = !!(process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY);
const PORT = Number(process.env.MCP_AGENT_PORT || 8765);
const HOST = process.env.MCP_AGENT_HOST || "127.0.0.1";
const PROJECT_DIR = (process.env.UE_PROJECT_DIR || path.resolve(__dirname, "..", "..", "..")).replace(/\\/g, "/");
const MCP_SERVER_ENTRY = path.resolve(__dirname, "..", "src", "server.js").replace(/\\/g, "/");
const MODEL = process.env.MCP_AGENT_MODEL || "claude-opus-4-8";
function defaultIntegrations() {
return {
elevenlabs: {
apiKey: "",
importPath: "/Game/Audio/Generated",
variationCount: 3,
loop: false,
durationSeconds: null,
promptInfluence: 0.3,
modelId: "eleven_text_to_sound_v2",
},
textTools: {
improvePrompt: "Fix spelling, grammar, punctuation, and clarity while preserving the original meaning, tone, formatting, and language. Return only the improved text.",
translateLanguages: ["English", "Russian", "German", "French", "Spanish", "Japanese", "Chinese"],
},
};
}
function loadIntegrations() {
try {
const stored = JSON.parse(readFileSync(INTEGRATIONS_FILE, "utf8"));
return {
elevenlabs: {
...defaultIntegrations().elevenlabs,
...(stored.elevenlabs || {}),
},
textTools: {
...defaultIntegrations().textTools,
...(stored.textTools || {}),
},
};
} catch {
return defaultIntegrations();
}
}
let INTEGRATIONS = loadIntegrations();
async function saveIntegrations() {
await writeFile(INTEGRATIONS_FILE, JSON.stringify(INTEGRATIONS, null, 2) + "\n", "utf8");
}
function codexAuthStatus() {
return {
connected: existsSync(path.join(os.homedir(), ".codex", "auth.json")),
method: "OAuth",
};
}
function publicSettings() {
const elevenlabs = INTEGRATIONS.elevenlabs;
return {
elevenlabs: {
configured: !!elevenlabs.apiKey,
apiKeyMasked: elevenlabs.apiKey ? `${elevenlabs.apiKey.slice(0, 5)}...${elevenlabs.apiKey.slice(-4)}` : "",
importPath: elevenlabs.importPath,
variationCount: elevenlabs.variationCount,
loop: elevenlabs.loop,
durationSeconds: elevenlabs.durationSeconds,
promptInfluence: elevenlabs.promptInfluence,
modelId: elevenlabs.modelId,
},
codex: codexAuthStatus(),
textTools: {
improvePrompt: INTEGRATIONS.textTools.improvePrompt,
translateLanguages: INTEGRATIONS.textTools.translateLanguages,
},
};
}
function json(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
}
function safeAudioName(prompt, index) {
const base = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 42) || "generated_sound";
return `${base}_${String(index + 1).padStart(2, "0")}`;
}
function pcm16MonoToWav(pcm, sampleRate = 48000) {
const wav = Buffer.alloc(44 + pcm.length);
wav.write("RIFF", 0);
wav.writeUInt32LE(36 + pcm.length, 4);
wav.write("WAVE", 8);
wav.write("fmt ", 12);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20); // PCM
wav.writeUInt16LE(1, 22); // mono
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(sampleRate * 2, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write("data", 36);
wav.writeUInt32LE(pcm.length, 40);
pcm.copy(wav, 44);
return wav;
}
async function generateSoundEffect(prompt, index, options) {
const id = randomUUID();
const name = safeAudioName(prompt, index);
const url = "https://api.elevenlabs.io/v1/sound-generation?output_format=pcm_48000";
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"xi-api-key": INTEGRATIONS.elevenlabs.apiKey,
},
body: JSON.stringify({
text: prompt,
model_id: options.modelId,
loop: options.loop,
duration_seconds: options.durationSeconds,
prompt_influence: options.promptInfluence,
}),
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 500);
throw new Error(`ElevenLabs HTTP ${response.status}: ${detail}`);
}
const bytes = pcm16MonoToWav(Buffer.from(await response.arrayBuffer()));
await mkdir(GENERATED_AUDIO_DIR, { recursive: true });
const filePath = path.join(GENERATED_AUDIO_DIR, `${id}.wav`);
await writeFile(filePath, bytes);
const item = { id, name, prompt, filePath, createdAt: Date.now(), bytes: bytes.length };
AUDIO_CACHE.set(id, item);
return { id, name, prompt, bytes: bytes.length, audioUrl: `/api/elevenlabs/audio/${id}` };
}
async function importGeneratedSound(id) {
const item = AUDIO_CACHE.get(id);
if (!item || !existsSync(item.filePath)) throw new Error("Generated sound has expired. Generate it again.");
const destination = INTEGRATIONS.elevenlabs.importPath;
if (!destination.startsWith("/Game/")) throw new Error("Import path must start with /Game/");
const resultFile = path.join(PROJECT_DIR, "Saved", "MCP", `forge_audio_import_${id}.json`).replace(/\\/g, "/");
const sourceFile = item.filePath.replace(/\\/g, "/");
const assetName = item.name.replace(/[^A-Za-z0-9_]/g, "_");
const py = [
"import unreal, json, os",
`task=unreal.AssetImportTask()`,
`task.set_editor_property('filename', ${JSON.stringify(sourceFile)})`,
`task.set_editor_property('destination_path', ${JSON.stringify(destination)})`,
`task.set_editor_property('destination_name', ${JSON.stringify(assetName)})`,
"task.set_editor_property('automated', True)",
"task.set_editor_property('save', True)",
"task.set_editor_property('replace_existing', False)",
"unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])",
"paths=[str(x) for x in task.get_editor_property('imported_object_paths')]",
`out={'ok':bool(paths),'paths':paths,'error':None if paths else 'UE did not import the WAV file'}`,
`os.makedirs(os.path.dirname(r'${resultFile}'), exist_ok=True)`,
`open(r'${resultFile}','w',encoding='utf-8').write(json.dumps(out))`,
].join("\n");
const sent = await executePythonInUE(py, { timeoutMs: 45000 });
if (!sent.ok) throw new Error(sent.error || "Cannot reach Unreal Editor");
const result = JSON.parse(await readFile(resultFile, "utf8"));
if (!result.ok) throw new Error(result.error || "Import failed");
return result;
}
async function runTextTool(instruction, text) {
if (!HAS_AUTH) throw new Error("Forge agent auth is not configured.");
const prompt = [
"Transform the text exactly as requested.",
"Do not answer the text, discuss it, add notes, or wrap the result in quotes or markdown fences.",
"Return only the transformed text.",
"",
`Instruction: ${instruction}`,
"",
"Text:",
text,
].join("\n");
let result = "";
const options = {
...agentOptions(MODEL),
maxTurns: 1,
allowedTools: [],
mcpServers: {},
systemPrompt: "You are a precise text editing utility. Return only the transformed text.",
};
for await (const message of query({ prompt, options })) {
if (message.type === "result") result = typeof message.result === "string" ? message.result.trim() : result;
}
if (!result) throw new Error("Text tool returned an empty result.");
return result;
}
const SYSTEM_PROMPT = `You are the in-engine assistant for the current Unreal Engine project.
You are embedded inside the Unreal editor via a WebBrowser widget. The developer talks to you here while the editor is open.
You have full access to the project's ue-blueprint MCP tools (prefixed mcp__ue-blueprint__) to inspect and modify the project live: edit Blueprint graphs, spawn/connect nodes, compile, build C++ via live coding, author UMG and screenshot it, run PCG/Niagara/material ops, run console commands, analyze Insights traces, and more. Prefer these tools over guessing.
Conventions:
- Always confirm UE is reachable with mcp__ue-blueprint__ping_ue before tool-heavy work in a fresh session.
- For any visual/UMG work, follow the project's UI workflow and use the screenshot vision loop.
- Reference assets with their full /Game/... or /Script/... path, and Blueprint nodes by name — the chat UI turns those into clickable chips, so be precise.
- Be concise. The chat panel is narrow. Lead with the answer, then detail.
The project lives at ${PROJECT_DIR}.`;
// ---------------------------------------------------------------------------
// SSE helpers
// ---------------------------------------------------------------------------
function sseHead(res) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
}
function sseSend(res, event, data) {
if (event !== "text") console.log(`[sse] -> ${event}`);
const ok = res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (!ok) console.log(`[sse] backpressure on ${event}`);
}
// ---------------------------------------------------------------------------
// Sessions: one long-lived Agent SDK query per chat, fed via streaming input.
// The conversation lives in memory (no `resume` / disk reload). This avoids the
// "tool_use ids must be unique" 400 that resume+tools produced when the saved
// transcript got a duplicated tool_use block.
// ---------------------------------------------------------------------------
// Models offered in the chat's model switcher. id must be a valid Anthropic id.
const MODELS = [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", tag: "самый умный" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", tag: "баланс" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", tag: "быстрый" },
];
const MODEL_IDS = new Set(MODELS.map((m) => m.id));
function modelsForClient() {
const models = [...MODELS];
if (codexAuthStatus().connected) {
models.push({ id: "codex-oauth", name: "Codex", tag: "OAuth connected · runtime unavailable", disabled: true });
}
return models;
}
function agentOptions(model) {
return {
model: MODEL_IDS.has(model) ? model : MODEL,
cwd: PROJECT_DIR,
permissionMode: "bypassPermissions", // no interactive terminal here; full-access agent per project choice
allowDangerouslySkipPermissions: true, // required alongside bypassPermissions
includePartialMessages: true, // live token streaming (safe on SDK 0.3+)
settingSources: ["project"], // load the repo CLAUDE.md so the agent knows the conventions
mcpServers: {
"ue-blueprint": {
type: "stdio",
command: "node",
args: [MCP_SERVER_ENTRY],
env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR },
},
},
systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_PROMPT },
stderr: (data) => console.error("[sdk-stderr]", data),
};
}
// Tools available to the agent — captured from the first init we ever see, so
// the sidebar can show a count before the user's first message.
let CACHED_TOOLS = [];
// Image attachments: uploaded out-of-band (POST /api/upload), then referenced by
// id in the EventSource GET (which can't carry a base64 body).
const ATTACH = new Map();
function putAttach(dataUrl) {
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl || "");
if (!m) return null;
const id = randomUUID().slice(0, 8);
ATTACH.set(id, { media_type: m[1], data: m[2], t: Date.now() });
return id;
}
function takeAttach(ids) {
const out = [];
for (const id of ids) {
const a = ATTACH.get(id);
if (a) { out.push({ media_type: a.media_type, data: a.data }); ATTACH.delete(id); }
}
return out;
}
setInterval(() => { const now = Date.now(); for (const [k, v] of ATTACH) if (now - v.t > 300000) ATTACH.delete(k); }, 60000).unref?.();
const sessions = new Map();
class Session {
constructor(model) {
this.id = randomUUID();
this.model = MODEL_IDS.has(model) ? model : MODEL;
this.pending = []; // queued { text, images? }
this.wake = null; // resolver to wake the input generator
this.res = null; // SSE response for the in-flight turn
this.busy = false; // a turn is running in the SDK (independent of the socket)
this.closed = false;
this.resetTurn();
this.consume();
}
resetTurn() {
this.sawText = false;
this.textLen = 0;
this.assistantText = "";
}
// Streaming input: yields each queued user message, then parks until the next.
async *input() {
while (!this.closed) {
if (this.pending.length === 0) {
await new Promise((r) => { this.wake = r; });
if (this.closed) break;
}
const item = this.pending.shift();
let content;
if (item.images && item.images.length) {
content = [{ type: "text", text: item.text || "" }];
for (const img of item.images) content.push({ type: "image", source: { type: "base64", media_type: img.media_type, data: img.data } });
} else {
content = item.text;
}
yield { type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: this.id };
}
}
push(item) {
this.pending.push(item);
if (this.wake) { const w = this.wake; this.wake = null; w(); }
}
beginTurn(res, message, images) {
this.res = res;
this.busy = true;
this.resetTurn();
sseSend(res, "init", { sessionId: this.id, model: this.model, tools: CACHED_TOOLS });
this.push({ text: message, images });
}
endTurn() {
this.busy = false;
if (this.res) { try { this.res.end(); } catch { /* ignore */ } this.res = null; }
}
async consume() {
try {
for await (const msg of query({ prompt: this.input(), options: agentOptions(this.model) })) {
if (msg.type === "system" && msg.subtype === "init" && Array.isArray(msg.tools) && msg.tools.length) CACHED_TOOLS = msg.tools;
this.route(msg);
}
} catch (err) {
console.error("[session]", this.id.slice(0, 8), "error:", err?.message || err);
if (this.res) sseSend(this.res, "error", { message: String(err?.message || err) });
this.endTurn();
}
}
route(msg) {
const res = this.res;
switch (msg.type) {
case "stream_event": {
const ev = msg.event;
if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta") {
this.sawText = true;
this.textLen += (ev.delta.text || "").length;
if (res) sseSend(res, "text", { delta: ev.delta.text });
} else if (ev?.type === "content_block_delta" && ev.delta?.type === "thinking_delta") {
if (res) sseSend(res, "thinking", { delta: ev.delta.thinking });
}
break;
}
case "assistant": {
for (const b of (msg.message?.content || [])) {
if (b.type === "tool_use") {
if (res) sseSend(res, "tool_start", { id: b.id, name: b.name, input: b.input });
} else if (b.type === "text" && typeof b.text === "string") {
this.assistantText += b.text;
}
}
break;
}
case "user": {
for (const b of (msg.message?.content || [])) {
if (b.type === "tool_result") {
if (res) sseSend(res, "tool_end", {
id: b.tool_use_id,
isError: !!b.is_error,
content: normalizeToolResult(b.content),
});
}
}
break;
}
case "result": {
const resultText = (typeof msg.result === "string" ? msg.result : "") || this.assistantText;
console.log(`[session ${this.id.slice(0, 8)}] result: sawText=${this.sawText} streamed=${this.textLen} resultChars=${resultText.length}`);
if (res) {
if (msg.is_error || msg.subtype === "error_during_execution") {
sseSend(res, "error", { message: resultText || msg.subtype || "agent error" });
} else if (!this.sawText && resultText) {
// No live deltas (tool-only turn) — emit the full final text.
sseSend(res, "text", { delta: resultText });
}
sseSend(res, "done", {
sessionId: this.id,
subtype: msg.subtype,
isError: !!msg.is_error,
durationMs: msg.duration_ms,
costUsd: msg.total_cost_usd,
});
}
this.endTurn();
break;
}
}
}
}
// Bind an SSE response to a (new or existing) session and run one turn.
function handleTurn(res, { message, sessionId, model, images }) {
let session = sessionId ? sessions.get(sessionId) : null;
if (!session) {
session = new Session(model);
sessions.set(session.id, session);
}
sseHead(res);
if (session.busy) {
// A turn is still running in the SDK (client should serialize; this also
// guards against a dropped socket whose turn hasn't finished yet).
sseSend(res, "init", { sessionId: session.id, model: session.model, tools: CACHED_TOOLS });
sseSend(res, "error", { message: "агент ещё занят предыдущим запросом — подожди" });
sseSend(res, "done", { sessionId: session.id, subtype: "busy" });
res.end();
return;
}
res.on("close", () => { if (session.res === res) session.res = null; });
session.beginTurn(res, message, images);
}
// Exact ue-blueprint tool count. The SDK's init reports the MCP server as
// "pending" (tools connect after init), so we ask the MCP server directly over
// its stdio JSON-RPC: initialize → tools/list. Cheap, runs once at startup.
let UE_TOOL_COUNT = 0;
function countMcpTools() {
return new Promise((resolve) => {
let done = false;
const child = spawn("node", [MCP_SERVER_ENTRY], { env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR } });
const finish = (n) => { if (done) return; done = true; try { child.kill(); } catch { /* ignore */ } resolve(n); };
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch { /* ignore */ } };
let buf = "";
child.stdout.on("data", (d) => {
buf += d.toString();
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
if (!line.trim()) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id === 1 && msg.result) {
send({ jsonrpc: "2.0", method: "notifications/initialized" });
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
}
if (msg.id === 2 && msg.result && Array.isArray(msg.result.tools)) finish(msg.result.tools.length);
}
});
child.on("error", () => finish(0));
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mcp-agent-gateway", version: "1" } } });
setTimeout(() => finish(0), 8000);
});
}
// Grab the agent's tool list once at startup (init arrives before any model
// call), so the sidebar count is populated before the first chat.
async function primeTools() {
if (!HAS_AUTH || CACHED_TOOLS.length) return;
try {
const q = query({ prompt: "ready", options: agentOptions() });
for await (const m of q) {
if (m.type === "system" && m.subtype === "init") { CACHED_TOOLS = m.tools || []; break; }
}
if (typeof q.interrupt === "function") { try { await q.interrupt(); } catch { /* ignore */ } }
console.log(`[primeTools] cached ${CACHED_TOOLS.length} tools`);
} catch (e) { console.error("[primeTools]", e?.message || e); }
}
// ---------------------------------------------------------------------------
// Asset list (for @mention autocomplete). Cached; refreshed on demand.
// Dumps the AssetRegistry + open editors to a JSON file UE can write and we read.
// ---------------------------------------------------------------------------
let ASSET_CACHE = { at: 0, list: [] };
const ASSET_FILE = path.join(PROJECT_DIR, "Saved", "MCP", "agent_assets.json").replace(/\\/g, "/");
async function refreshAssets() {
const py = [
"import unreal, json, os",
"out=[]; seen=set(); open_paths=set()",
"try:",
" sub=unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)",
" for a in sub.get_all_edited_assets():",
" open_paths.add(a.get_path_name().split('.')[0])",
"except Exception: pass",
"ar=unreal.AssetRegistryHelpers.get_asset_registry()",
"f=unreal.ARFilter(package_paths=['/Game'], recursive_paths=True)",
"for ad in ar.get_assets(f):",
" p=str(ad.package_name)",
" if p in seen: continue",
" seen.add(p)",
" try: cls=str(ad.asset_class_path.asset_name)",
" except Exception: cls=str(getattr(ad,'asset_class',''))",
" out.append({'name':str(ad.asset_name),'path':p,'class':cls,'open':p in open_paths})",
`os.makedirs(os.path.dirname(r'${ASSET_FILE}'), exist_ok=True)`,
`open(r'${ASSET_FILE}','w',encoding='utf-8').write(json.dumps(out))`,
].join("\n");
await executePythonInUE(py, { timeoutMs: 25000 });
const raw = await readFile(ASSET_FILE, "utf8");
ASSET_CACHE = { at: Date.now(), list: JSON.parse(raw) };
}
function readBody(req) {
return new Promise((resolve) => {
let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { resolve(JSON.parse(b || "{}")); } catch { resolve({}); } });
});
}
function normalizeToolResult(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c) => (c?.type === "text" ? c.text : c?.type ? `[${c.type}]` : String(c)))
.join("\n");
}
return content == null ? "" : String(content);
}
// ---------------------------------------------------------------------------
// Static file serving
// ---------------------------------------------------------------------------
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
async function serveStatic(req, res) {
let rel = decodeURIComponent(req.url.split("?")[0]);
if (rel === "/") rel = "/index.html";
const filePath = path.join(PUBLIC_DIR, path.normalize(rel));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403).end("forbidden");
return;
}
try {
const data = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(data);
} catch {
res.writeHead(404).end("not found");
}
}
// ---------------------------------------------------------------------------
// HTTP server
// ---------------------------------------------------------------------------
const server = createServer(async (req, res) => {
// CORS — WebBrowser widget origins can be quirky; allow all for localhost dev.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") {
res.writeHead(204).end();
return;
}
if (req.method === "POST" && req.url === "/api/chat") {
const payload = await readBody(req);
const message = (payload.message || "").trim();
const images = [];
if (Array.isArray(payload.images)) for (const d of payload.images) { const m = /^data:([^;]+);base64,(.+)$/s.exec(d || ""); if (m) images.push({ media_type: m[1], data: m[2] }); }
if (!message && !images.length) { res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}'); return; }
handleTurn(res, { message, sessionId: payload.sessionId || null, model: payload.model || null, images });
return;
}
// Upload an image attachment (base64 data URL) → returns a short id the
// EventSource GET references via &attach=, since GET can't carry the body.
if (req.method === "POST" && req.url === "/api/upload") {
const payload = await readBody(req);
const id = putAttach(payload.dataUrl);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(id ? { ok: true, id } : { ok: false, error: "bad data url" }));
return;
}
// EventSource transport (GET). UE's CEF WebBrowser does not consume a streamed
// fetch() body, but it reads EventSource reliably. Message rides in the query.
if (req.method === "GET" && req.url.startsWith("/api/chat-stream")) {
const u = new URL(req.url, `http://${req.headers.host}`);
const message = (u.searchParams.get("message") || "").trim();
const sessionId = u.searchParams.get("sessionId") || null;
const model = u.searchParams.get("model") || null;
const images = takeAttach((u.searchParams.get("attach") || "").split(",").filter(Boolean));
if (!message && !images.length) {
res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}');
return;
}
handleTurn(res, { message, sessionId, model, images });
return;
}
// Asset list for @mention autocomplete (cached ~30s).
if (req.method === "GET" && req.url.startsWith("/api/assets")) {
const u = new URL(req.url, `http://${req.headers.host}`);
const qq = (u.searchParams.get("q") || "").toLowerCase();
try { if (Date.now() - ASSET_CACHE.at > 30000) await refreshAssets(); }
catch (e) { console.error("[assets]", e?.message || e); }
let list = ASSET_CACHE.list || [];
if (qq) list = list.filter((a) => a.name.toLowerCase().includes(qq) || a.path.toLowerCase().includes(qq));
list = list.slice().sort((a, b) => (b.open ? 1 : 0) - (a.open ? 1 : 0)).slice(0, 40);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ assets: list, total: (ASSET_CACHE.list || []).length }));
return;
}
if (req.method === "POST" && req.url === "/api/focus") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", async () => {
let payload = {};
try { payload = JSON.parse(body || "{}"); } catch { /* ignore */ }
const assetPath = (payload.path || "").trim();
res.writeHead(200, { "Content-Type": "application/json" });
if (!assetPath.startsWith("/Game/")) {
res.end(JSON.stringify({ ok: false, error: "not a /Game asset path" }));
return;
}
const py = `import unreal\nunreal.EditorAssetLibrary.sync_browser_to_objects([${JSON.stringify(assetPath)}])`;
const out = await executePythonInUE(py).catch((e) => ({ ok: false, error: String(e) }));
res.end(JSON.stringify(out));
});
return;
}
if (req.method === "GET" && req.url === "/api/settings") {
json(res, 200, publicSettings());
return;
}
if (req.method === "POST" && req.url === "/api/settings") {
const payload = await readBody(req);
const current = INTEGRATIONS.elevenlabs;
const currentTextTools = INTEGRATIONS.textTools;
const importPath = String(payload.elevenlabs?.importPath || current.importPath).trim().replace(/\/+$/, "");
const variationCount = Number(payload.elevenlabs?.variationCount || current.variationCount);
if (!importPath.startsWith("/Game/")) {
json(res, 400, { ok: false, error: "ElevenLabs import path must start with /Game/" });
return;
}
if (!Number.isInteger(variationCount) || variationCount < 1 || variationCount > 4) {
json(res, 400, { ok: false, error: "Variation count must be between 1 and 4" });
return;
}
const nextElevenlabs = {
apiKey: payload.elevenlabs?.apiKey === undefined ? current.apiKey : String(payload.elevenlabs.apiKey).trim(),
importPath,
variationCount,
loop: current.loop,
durationSeconds: payload.elevenlabs?.durationSeconds === null || payload.elevenlabs?.durationSeconds === ""
? null
: Number(payload.elevenlabs?.durationSeconds ?? current.durationSeconds),
promptInfluence: Number(payload.elevenlabs?.promptInfluence ?? current.promptInfluence),
modelId: String(payload.elevenlabs?.modelId || current.modelId),
};
if (nextElevenlabs.durationSeconds !== null && (!Number.isFinite(nextElevenlabs.durationSeconds) || nextElevenlabs.durationSeconds < 0.5 || nextElevenlabs.durationSeconds > 30)) {
json(res, 400, { ok: false, error: "Default ElevenLabs duration must be Auto or between 0.5 and 30 seconds." });
return;
}
if (!Number.isFinite(nextElevenlabs.promptInfluence) || nextElevenlabs.promptInfluence < 0 || nextElevenlabs.promptInfluence > 1) {
json(res, 400, { ok: false, error: "Default ElevenLabs prompt influence must be between 0 and 1." });
return;
}
if (nextElevenlabs.modelId !== "eleven_text_to_sound_v2") {
json(res, 400, { ok: false, error: "Unsupported ElevenLabs Sound Effects model." });
return;
}
INTEGRATIONS.elevenlabs = nextElevenlabs;
const improvePrompt = String(payload.textTools?.improvePrompt || currentTextTools.improvePrompt).trim();
const translateLanguages = Array.isArray(payload.textTools?.translateLanguages)
? payload.textTools.translateLanguages.map((x) => String(x).trim()).filter(Boolean).slice(0, 20)
: currentTextTools.translateLanguages;
if (!improvePrompt || improvePrompt.length > 2000) {
json(res, 400, { ok: false, error: "Text Fix prompt must contain 1 to 2000 characters." });
return;
}
if (!translateLanguages.length) {
json(res, 400, { ok: false, error: "Add at least one translation language." });
return;
}
INTEGRATIONS.textTools = { improvePrompt, translateLanguages };
await saveIntegrations();
json(res, 200, { ok: true, settings: publicSettings() });
return;
}
if (req.method === "POST" && req.url === "/api/text-tools/improve") {
const payload = await readBody(req);
const text = String(payload.text || "").trim();
if (!text || text.length > 12000) {
json(res, 400, { ok: false, error: "Text Fix expects 1 to 12000 characters." });
return;
}
try {
json(res, 200, { ok: true, text: await runTextTool(INTEGRATIONS.textTools.improvePrompt, text) });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/text-tools/translate") {
const payload = await readBody(req);
const text = String(payload.text || "").trim();
const language = String(payload.language || "").trim();
if (!text || text.length > 12000) {
json(res, 400, { ok: false, error: "Translate expects 1 to 12000 characters." });
return;
}
if (!INTEGRATIONS.textTools.translateLanguages.includes(language)) {
json(res, 400, { ok: false, error: "Choose a configured translation language." });
return;
}
try {
const instruction = `Translate the text into ${language}. Preserve meaning, tone, paragraph structure, line breaks, asset paths, code, and Unreal identifiers.`;
json(res, 200, { ok: true, text: await runTextTool(instruction, text), language });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/integrations/codex/login") {
try {
const child = spawn("cmd.exe", ["/c", "start", "", "codex", "login"], {
detached: true,
stdio: "ignore",
windowsHide: false,
});
child.unref();
json(res, 200, { ok: true, message: "Codex OAuth login opened in a separate terminal." });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/elevenlabs/generate") {
const payload = await readBody(req);
const prompt = String(payload.prompt || "").trim();
if (!INTEGRATIONS.elevenlabs.apiKey) {
json(res, 400, { ok: false, error: "Add an ElevenLabs API key in Settings first." });
return;
}
if (!prompt || prompt.length > 450) {
json(res, 400, { ok: false, error: "Sound prompt must contain 1 to 450 characters." });
return;
}
try {
const defaults = INTEGRATIONS.elevenlabs;
const count = Number(payload.variationCount ?? defaults.variationCount);
const loop = payload.loop === undefined ? defaults.loop : !!payload.loop;
const durationSeconds = payload.durationSeconds === null || payload.durationSeconds === undefined || payload.durationSeconds === ""
? null
: Number(payload.durationSeconds);
const promptInfluence = Number(payload.promptInfluence ?? defaults.promptInfluence);
const modelId = String(payload.modelId || defaults.modelId);
if (!Number.isInteger(count) || count < 1 || count > 4) throw new Error("Variation count must be between 1 and 4.");
if (durationSeconds !== null && (!Number.isFinite(durationSeconds) || durationSeconds < 0.5 || durationSeconds > 30)) throw new Error("Duration must be Auto or between 0.5 and 30 seconds.");
if (!Number.isFinite(promptInfluence) || promptInfluence < 0 || promptInfluence > 1) throw new Error("Prompt influence must be between 0 and 1.");
if (modelId !== "eleven_text_to_sound_v2") throw new Error("Unsupported ElevenLabs Sound Effects model.");
const options = { loop, durationSeconds, promptInfluence, modelId };
const sounds = await Promise.all(Array.from({ length: count }, (_, index) => generateSoundEffect(prompt, index, options)));
json(res, 200, { ok: true, prompt, options, sounds });
} catch (e) {
json(res, 502, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "GET" && req.url.startsWith("/api/elevenlabs/audio/")) {
const id = decodeURIComponent(req.url.split("/").pop().split("?")[0]);
const item = AUDIO_CACHE.get(id);
if (!item || !existsSync(item.filePath)) {
res.writeHead(404).end("audio not found");
return;
}
const bytes = await readFile(item.filePath);
res.writeHead(200, {
"Content-Type": "audio/wav",
"Content-Length": bytes.length,
"Cache-Control": "private, max-age=3600",
});
res.end(bytes);
return;
}
if (req.method === "POST" && req.url === "/api/elevenlabs/import") {
const payload = await readBody(req);
try {
const result = await importGeneratedSound(String(payload.id || ""));
json(res, 200, { ok: true, ...result });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "GET" && req.url === "/api/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, model: MODEL, models: modelsForClient(), projectDir: PROJECT_DIR, mcp: MCP_SERVER_ENTRY, hasAuth: HAS_AUTH, tools: CACHED_TOOLS, ueToolCount: UE_TOOL_COUNT, integrations: publicSettings() }));
return;
}
if (req.method === "GET") {
await serveStatic(req, res);
return;
}
res.writeHead(405).end("method not allowed");
});
server.listen(PORT, HOST, () => {
console.log(`[mcp-agent-gateway] http://${HOST}:${PORT}`);
console.log(`[mcp-agent-gateway] model=${MODEL}`);
console.log(`[mcp-agent-gateway] project=${PROJECT_DIR}`);
console.log(`[mcp-agent-gateway] mcp=${MCP_SERVER_ENTRY}`);
if (!HAS_AUTH) {
console.warn("[mcp-agent-gateway] WARNING: no CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY found.");
console.warn("[mcp-agent-gateway] Run `claude setup-token` and put it in agent-gateway/auth.env — see README.md.");
}
primeTools(); // fire-and-forget: populate the total tool list
countMcpTools().then((n) => { UE_TOOL_COUNT = n; console.log(`[mcp tools] ue-blueprint exposes ${n} tools`); });
});