Back to Hermes Agent

Unreal MCP — Pitfalls & Lessons

optional-skills/creative/unreal-mcp/references/pitfalls.md

2026.7.2017.2 KB
Original Source

Unreal MCP — Pitfalls & Lessons

Read before your first session; return whenever something misbehaves. Ordered by when they bite: setup → calling discipline → editor state → content → delivery.

Setup & Connection

1. Start order: editor first, Hermes second

Hermes probes MCP servers at session start. If the editor (and its server) isn't up yet, no mcp_unreal_engine_* tools exist in the session. Fix: launch the editor, confirm the server bound (Output Log shows LogModelContextProtocol with the address), then open a NEW Hermes session. Tools don't hot-appear mid-session.

2. Server enabled but no tools advertised

The Unreal MCP plugin ships the SERVER, not the tools. If list_toolsets returns nothing/near-nothing, the toolset provider plugin (AllToolsets) or Toolset Registry isn't enabled in this project. Fix in Edit > Plugins, restart the editor, restart the Hermes session.

3. macOS: full Xcode is required, not just Command Line Tools

On a Mac, the editor needs Xcode to compile shaders for Metal. Without it, first launch dies with a modal "Xcode Not Found" dialog and the editor exits as soon as it's dismissed (verified UE 5.8 behavior — the log shows RequestExit right after the dialog). Fix: install full Xcode from the App Store, open it once to accept the license / install components, and if it lives anywhere other than /Applications/Xcode.app, point the toolchain at it: sudo xcode-select -s /path/to/Xcode.app. Verify with xcode-select -p (should print an Xcode path, not the bare CLT path). Expect the first successful editor launch after that to spend a long time compiling shaders.

4. Port 8000 conflicts

Common collisions: local dev servers, Jupyter, other MCP hosts. Symptom: the server fails to bind (Output Log) or Hermes' probe times out. Fix: change Server Port Number in Editor Preferences > Model Context Protocol AND the url in ~/.hermes/config.yaml (mcp_servers.unreal-engine), then restart both sides. Verify: curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/mcp (non-000 means something is listening; whether it's Unreal is a different question — check the Output Log).

4b. "Connection refused" mid-session

The editor was closed, crashed, or the server was stopped (ModelContextProtocol.StopServer). Don't retry the tool in a loop — tell the user, have them relaunch/restart the server, then reconnect (new session if tools were lost).

5. GenerateClientConfig is not for Hermes

ModelContextProtocol.GenerateClientConfig writes config files for Claude Code/Cursor/VSCode/Gemini/Codex into the project root. Hermes' connection lives in ~/.hermes/config.yaml via hermes mcp install unreal-engine. Running GenerateClientConfig neither helps nor harms Hermes — just don't mistake it for the Hermes setup step.

Calling Discipline

6. One call at a time — never batch MCP calls

The server executes tool calls serially on the game thread and Epic explicitly warns against overlapping calls. Hermes executes same-turn tool calls concurrently — so batching two mcp_unreal_engine_* calls in one turn IS issuing overlapping calls. Strictly sequential: call, await, then next. This deliberately overrides the general "batch independent calls" guidance.

7. The editor freezes during every call — keep calls small

Game-thread execution means the editor UI hitches for the duration of each tool call. A 30-second operation is 30 seconds of frozen editor. Split big asks (e.g. "spawn 200 trees") into chunks so the user's editor stays responsive and any failure loses only one chunk.

8. Modal dialogs deadlock the loop

Anything that pops a modal (some deletes, import options, save prompts, experimental-plugin warnings) blocks the game thread — and your call — until a human clicks. If a call hangs far beyond its normal duration, tell the user to look at the editor for a dialog. Prefer tool paths/parameters that avoid interactive prompts; save proactively so "unsaved changes" prompts don't appear at bad times.

9. Timeouts: Hermes gives up before Unreal does

Hermes' default per-call timeout is 120 s. Asset imports, first-shader compiles, big saves, and renders can exceed it — the call "fails" while the editor happily finishes the work. Symptoms: timeout error, then the next scene query shows the operation actually completed. Fixes: raise mcp_servers.unreal-engine.timeout in config for heavy sessions; after any timeout, RE-QUERY state before retrying, or you'll do the work twice (duplicate actors are the classic case).

10. Stale schemas after editor-side changes

Toolsets are cached: after enabling a plugin, authoring a toolset, or Live Coding, run ModelContextProtocol.RefreshTools in the editor console, then re-run list_toolsets/describe_toolset. New C++ UFUNCTIONs need a full editor restart regardless. If a call fails with "unknown tool" that describe_toolset just showed, refresh + reconnect.

10b. Ref-object vs plain-string params are inconsistent — the error schema is the tiebreaker

Most tools take object references as {"refPath": ...} objects, but some take plain string paths — live-verified: add_to_scene_from_class takes actor_type as a refPath OBJECT, while add_to_scene_from_asset takes asset_path as a STRING. Don't pattern-match across tools. When a call fails on params, the error text contains the complete input schema for that exact function — read it and fix; it's faster and more authoritative than re-describing the toolset.

10c. Blueprint DSL node IDs must come from find_node_types

DSL docs examples and intuition both produce wrong node IDs (live-verified: (event Tick) fails — it's EventTick; MakeRotator fails — it's Math|Rotator|MakeRotator; doc-style Utilities|Transformation|AddActorLocalRotation fails — the registry says Transformation|AddActorLocalRotation; there is no (self) node — omit :self for the owning actor). Resolve EVERY node ID with find_node_types against the target graph before writing DSL. Errors are progressive (one failing node at a time, named exactly) — fix and rerun.

11. Experimental means drift

Tool names, parameters, and result shapes may change across engine versions. The live schema from describe_toolset is the only contract. If this skill's examples and the live schema disagree, the schema wins — and patch this skill afterward.

Editor & Scene State

12. Never assume a fresh level — and NEVER double-spawn environment actors

Query the scene before the first edit. Template levels (Open World, etc.) ALREADY contain a DirectionalLight, SkyAtmosphere, SkyLight, ExponentialHeightFog, and often VolumetricCloud. Spawning your own creates duplicates that compound (double fog = whiteout, double sky = wrong exposure) and are invisible in a screenshot until things look inexplicably wrong. Live-verified failure: spawning a "golden hour kit" into the default Open World template produced two of everything and a 254/255-luminance whiteout. Rule: find_actors for each environment class FIRST; configure what exists; spawn only what's missing.

12b. Read the existing sun before imposing physical light values

Scene-craft tables give physical values (golden hour ≈ 10k lux), but a template's world is calibrated as a SYSTEM — the default Open World sun is intensity: 10 (lux-ish units under default exposure), not 100,000. Setting 12,000 lux into that world blows the frame to pure white regardless of small exposure tweaks. Live-verified rule: get_properties the existing sun's intensity first. If the scene is calibrated low (single-digit sun), work RELATIVE to it (e.g. golden hour ≈ 0.5–1× the template's noon value, warm temperature, low pitch) and let auto-exposure adapt, or rebuild the whole exposure chain deliberately (manual EV100 + physical values everywhere). Mixing the two conventions is the #1 whiteout cause. ObjectTools.reset_properties is the escape hatch — it restores per-project defaults when you've painted into a corner.

12c. Verify exposure objectively, not just by eye

A capture can look "bright" in vision judgment while being unrecoverable. Cheap objective check on any capture (editor-host filesystem): ffprobe -f lavfi -i "movie=<png>,signalstats" -show_entries frame_tags=lavfi.signalstats.YAVG -of json — YAVG > 250 means blown white, < 5 means black. Use it whenever a lighting change should have moved the histogram; it distinguishes "fog whiteout" from "exposure whiteout" faster than iterating blind.

13. In-memory edits are lost on crash — save per milestone

Everything you do lives in unsaved packages until a save happens. The editor is an application that can crash, especially mid-experimental-feature. Save the level + dirty packages after every milestone (AssetTools.save_assets, SceneTools.save_actor). Caveat: an UNTITLED level (/Temp/Untitled_*, the state after File > New or launching without a map argument) may route a save through the Save-As dialog — a modal that deadlocks the MCP loop (pitfall 8). Prefer starting from a saved level (pass the map path on the editor command line, or SceneTools.load_level a real /Game/... map) before doing hours of work.

14. Label ≠ Name ≠ path — use the full path as the stable identifier

Outliner shows actor LABELS (settable, duplicable, human-friendly). Internal NAMES are unique per level but auto-generated (StaticMeshActor_3). The only identifier that survives renames and disambiguates duplicates is the full object path: /Game/Maps/Level.Level:PersistentLevel.BP_Character_C_0. Tools may accept label, name, or path — read the schema. When you create an actor, immediately set a meaningful label, and record whatever handle the tool returns for later operations.

14b. Asset path forms mean different things

FormExampleLoads
Package/Game/Foo/BarThe package (asset-registry queries)
Package.Asset/Game/Foo/Bar.BarThe primary asset (most load/assign args)
Package.Asset_C/Game/Foo/Bar.Bar_CA Blueprint's generated class

"Class not found: /Game/Path/BP_Foo" almost always means the missing _C suffix — spawning by Blueprint class needs the generated-class form.

14c. Property writes can silently no-op — round-trip verify

UPROPERTY names are PascalCase at the reflection layer; snake_case lookups through some write paths silently change nothing and return no error. After any property write that matters, READ THE VALUE BACK and compare (allowing formatting normalization like 1 vs 1.000000). If it didn't take, retry with the exact PascalCase name shown by the property dump/schema.

15. Play In Editor changes the world (literally)

If the user hits Play, queries/edits may target the transient PIE world, and edits to it evaporate when play stops. If results look inexplicably transient or actor lists suddenly differ, ask whether PIE is running; do edit work outside PIE.

16. Undo exists, but don't lean on it

Editor transactions power Ctrl+Z; tool-driven changes may or may not create clean transaction boundaries depending on the tool's implementation. Treat undo as the user's manual escape hatch, not your rollback mechanism — your rollback is: query state, compute the inverse edit, apply it.

Content & Assets

17. Long package names, not file paths

Assets are addressed as /Game/Folder/Asset.Asset (project content), /Engine/... (engine content), /Script/Module.Class (native classes). Windows-style or absolute filesystem paths are wrong everywhere except import/export file arguments and screenshot output paths.

18. Filesystem results land on the EDITOR host

Screenshots, renders, and exports write to the machine running Unreal (e.g. <Project>/Saved/Screenshots/...). If Hermes runs elsewhere (SSH backend, container), read_file on that path reads the wrong filesystem. Same-machine setups (the default here) can read captures directly.

19. Referenced ≠ loaded

Engine basics (/Engine/BasicShapes/...) are always available, but project assets may need loading before use, and a typo'd asset path often fails soft (empty mesh, default material) rather than loud. After assigning meshes/materials, re-query the actor to confirm the reference stuck.

20. Material edits: instances, not parents

Editing a parent Material recompiles shaders (slow, global blast radius). Create a Material Instance (Dynamic or Constant per the tool surface), set scalar/vector/texture parameters on it, assign to the mesh. Parameter names must match the parent's exposed parameters exactly — query/describe before setting; a misnamed parameter usually no-ops silently.

20b. Shader/asset compilation is async — don't judge or proceed early

Material creation/edits kick off shader compilation that can run seconds to minutes; Niagara compiles, DDC builds, and package saves are async too. A screenshot taken mid-compile shows the old (or default-checkerboard) state. After material work, wait for compilation before judging visuals (poll a compile/errors predicate if the tool surface has one; otherwise screenshot after a delay and re-check if it looks wrong). Same discipline after saves: don't chain a disk-read straight after a write.

20c. Emissive needs intensity > 1 to bloom

Emissive at ≤1.0 looks self-lit but never blooms. 3–10 gives visible glow — and the Post Process Volume must have Bloom enabled (default on).

20d. Crash patterns to avoid outright

Engine-level, any server: (a) deleting or transforming an ASSET while level actors still reference it → RegisteredElementType assertion, editor down, unsaved work gone — walk references first, swap actors to a replacement, then delete; (b) spawn→delete→spawn the same actor in rapid succession can corrupt the actor registry — don't tight-loop create/destroy cycles; (c) Niagara/MetaSound assertion during PIE reverts to last on-disk save — save BEFORE entering PIE when those subsystems are involved.

Delivery

21. Screenshot judgment is part of the job

Don't declare a lighting/composition milestone done from numbers alone — capture the viewport, vision_analyze it, and art-direct (silhouette, exposure, horizon placement, scale against human height). The user is non-technical; you are the one with eyes on both the brief and the frame.

21b. Editor sprite icons appear in captures — hide them at the source

Viewport captures include per-component editor sprites (light bulbs, speaker icons, and the plain grey/blue billboard every empty Actor gets). They are editor overlay, NOT scene content — and for video/hero work they photobomb every frame. Do NOT try to remove them in post; a sprite overlapping scene geometry defeats 2D cleanup (live-verified: three inpainting strategies all either smeared letter edges or left outline pixels).

Fix at the source, before capturing: sprites are real components on the actor. ActorTools.get_components → find Billboard/Sprite/Arrow components → ObjectTools.set_properties with {"bVisible": false} on each (round-trip verify). remove_component fails on construction-time default subobjects ("Could not find subobject handle") — hide, don't remove. Sweep the whole scene in one ProgrammaticToolset script: iterate find_actors, hide every matching component (verified: 148 actors scanned, 13 sprites hidden, one round-trip). An editor capture is still not proof a Niagara effect is emitting — verify effects via actor state or a PIE capture.

21c. The viewport axis gizmo survives bShowUI=false — plan a post-crop

CaptureViewport with bShowUI: false hides menus and toolbars but the bottom-left XYZ axis gizmo is still burned into every frame (live-verified on 5.8; at 2027x1534 it occupies roughly the region below y≈1350, x<300). For video/hero deliverables, compose with spare margin and crop it out in post (e.g. a 16:9 punch-in via ffmpeg crop=2027:1140:0:180,scale=1920:1080) — deterministic across every frame of a sequence, no per-frame edits. Unlike the gizmo, actor sprite icons CAN be removed at the source — hide their components before the shoot (see 21b); do that instead of post-cleanup.

21d. Frame sequences: one client session, serial captures, resumable loop

For multi-frame virtual-camera moves (orbits, cranes) drive CaptureViewport in a loop from ONE MCP session, strictly serial (the game thread renders each), and write frames idempotently (skip-if-exists) so a killed run resumes where it stopped. Budget realistically: ~15-20 s/frame at editor resolution on a laptop — a 240-frame 10 s @ 24 fps shot is roughly an hour. Smoothstep-ease camera parameters; constant angular velocity reads mechanical. Remove the VolumetricCloud actor if its low-res gather smears blocky artifacts across sky frames.

22. Report package paths + file paths

The user needs: what actors/assets now exist (labels + /Game/... paths), where the level was saved, and absolute filesystem paths of any captures/renders (delivered as MEDIA: where appropriate).

Sources

Grounded in Epic's UE 5.8 Unreal MCP documentation, Epic's own agent-facing skill pack for this server (unreal-engine-skills-for-claude-code), and engine-level field reports from the UE-via-MCP community (ue5-mcp field manual). Engine behaviors (crash patterns, Lumen mobility, async compiles, reflection casing) are server-agnostic; tool names and schemas remain whatever the live describe_toolset says.