optional-skills/creative/unreal-mcp/references/pitfalls.md
Read before your first session; return whenever something misbehaves. Ordered by when they bite: setup → calling discipline → editor state → content → delivery.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Form | Example | Loads |
|---|---|---|
| Package | /Game/Foo/Bar | The package (asset-registry queries) |
| Package.Asset | /Game/Foo/Bar.Bar | The primary asset (most load/assign args) |
| Package.Asset_C | /Game/Foo/Bar.Bar_C | A 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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.