// 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`); }); });