ue5-mcp — Field manual for driving Unreal Engine 5 via MCP
Engine-level wisdom an LLM needs to drive UE5 through an MCP server without
faceplanting on UE's silent-fail edges. This skill is server-agnostic:
the gotchas, patterns, and identifiers documented here apply whether you're
connected to Epic's official ModelContextProtocol plugin (UE 5.8+) or any
other MCP server that exposes UE5 functionality.
What this skill isn't: a list of commands for any particular MCP server.
Each server publishes its own tool catalogue — ask the server with
tools/list for what it actually exposes. This skill covers what bites you
after you know the tool names.
1. Session checklist — read before you write
UE5 is a structured-asset editor. The agent that wins is the one that reads state before mutating it.
- Always dump the asset before editing it. Blueprint, material, Niagara
system, widget, level — every MCP server worth its salt exposes a
dump_*/inspect_*/read_*family. Use it. Editing a graph without first knowing what's there creates broken connections, duplicate nodes, and unrecoverable corruption faster than anything else. - Discover before assuming. Call
tools/listonce on connect, cache it, and use it to figure out which tools your server actually exposes. Different servers wrap UE5 differently; recipes from this manual that reference a generic capability (e.g., "dump the Blueprint graph") will map to different concrete tool names on different servers. - Verify after mutating. UE5 has too many silent-fail edges to trust a successful response. Read the property back. Compare to what you asked for. If they differ, re-examine.
- Save explicitly. Most introspection tools serialize from disk. If
your last mutation is in-memory only, the dump returns the pre-mutation
state. Save the asset (or
SaveDirtyAssets) before re-reading.
2. UE5 reflection gotchas
These bite agents regardless of which MCP server sits in front of them. Every one has cost real debugging time.
2.1 PascalCase, not snake_case, for UPROPERTY writes
Setting a UPROPERTY via the Python binding's set_editor_property("auto_possess_ai", ...)
silently no-ops on many builds. UPROPERTY names are PascalCase at the
reflection layer: AutoPossessAI. Python's unreal module accepts
snake_case at the call site, but the underlying lookup is case-sensitive
against the PascalCase name. There is no error returned — the property
simply doesn't change.
Detection pattern: round-trip verify. After any UPROPERTY write, read the
property back and compare. Naive string compare misses normalization
(EFoo::Bar vs Foo::Bar, (X=1,Y=2) vs (X=1.000000,Y=2.000000)). The
robust pattern in native C++:
- Allocate a scratch buffer aligned to
Property->GetMinAlignment()and callProperty->InitializeValue(Scratch). Property->ImportText_Direct(RequestedValue, Scratch, Owner, PPF_None)to canonicalize what was requested.Property->ExportTextItem_Direct(ExpectedText, Scratch, ...)for the canonical form of "what we asked for."- Apply the write, then
Property->ExportTextItem_Direct(ActualText, ...)for "what we got." - Compare
ExpectedTexttoActualText.
If they differ, the write didn't take — usually due to snake_case mismatch, an enum-class qualifier issue, or a struct-text format the property doesn't recognize.
2.2 Blueprint class path needs the _C suffix
LoadObject<UClass>(nullptr, "/Game/Path/BP_Foo") returns nullptr. The
Blueprint's generated class lives under a different name:
/Game/Path/BP_Foo.BP_Foo_C. StaticLoadClass expands this internally;
LoadObject<UClass> does not.
If an MCP tool returns "Class not found: /Game/Path/BP_Foo," that's almost
always the missing suffix. Retry with <path>.<asset>_C.
2.3 Async asset operations don't block
MetaHuman texture downloads, asset compilation, shader compilation, derived-data builds, Niagara compile, package save — all async. An agent that requests "download MetaHuman textures" and immediately reads the character sees the previous texture state, not the new one.
Patterns:
- Poll the relevant
Is*Completepredicate before continuing. - Subscribe to the completion delegate if the subsystem exposes one
(
FAssetCompilingManager::Get().GetPostCompilationDelegate(), etc.). - For MetaHuman: poll
IsTextureSourceRequestComplete(Character)afterRequestTextureSources. - For asset save: don't immediately re-read the package file; let
UPackage::SavePackagecomplete first.
2.4 Save before reading from disk
Many "dump" / "serialize" operations read the asset from its .uasset
package on disk. If the most recent edits are in-memory only, the dump
returns the pre-edit state. Save explicitly between mutate and read, or use
an in-memory-aware introspection path if the server provides one.
2.5 PostEditChangeProperty is required after direct property writes
Property->CopyCompleteValue(Dest, Src) writes the value but doesn't fire
PostEditChangeProperty. Any derived state set up by the object's
PostEditChangeProperty handler — preview meshes, generated thumbnails,
recompiles, dependent properties — won't update. Notify it manually:
FPropertyChangedEvent ChangeEvent(Property, EPropertyChangeType::ValueSet);
Object->PostEditChangeProperty(ChangeEvent);
The Details panel will show the new value either way, but the object's behaviour won't reflect it until the event fires.
2.6 Blueprint graph mutations need three steps, not one
To safely add a node to a UEdGraph:
- Construct the node (
NewObject<UEdGraphNode>(Graph)). Graph->Nodes.Add(NewNode).Graph->NotifyGraphChanged().
Single-call helpers in some bindings do step 1 only and leave the graph in an inconsistent state — the node exists but the editor's pin-resolution + compile pipeline doesn't see it. Symptoms: phantom "missing node" errors at compile, broken connect operations, or nodes that vanish after editor reload.
2.7 Enum-string resolution has three accepted forms
UENUM-defined enums store their entries as fully-qualified FName forms
like EAutoExposureMethod::AEM_Manual. UEnum::GetValueByNameString
matches the fully-qualified form, but bare short names (AEM_Manual) and
the Python-binding casing the unreal module exposes (AEM_MANUAL from
unreal.EAutoExposureMethod.AEM_MANUAL) silently miss — those are the
forms agents most naturally reach for, especially when copying values
out of dump_post_process_settings output or Python docs.
A 3-step resolver covers the common cases:
int64 ResolveEnumValue(UEnum* Enum, const FString& Name)
{
if (!Enum) return INDEX_NONE;
int64 Val = Enum->GetValueByNameString(Name); // EEnumType::ShortName
if (Val != INDEX_NONE) return Val;
Val = Enum->GetValueByName(FName(*Name)); // FName lookup
if (Val != INDEX_NONE) return Val;
const int32 N = Enum->NumEnums(); // case-insensitive
for (int32 i = 0; i < N; ++i) // suffix-after-::
{
FString EntryName = Enum->GetNameStringByIndex(i);
int32 ColonPos = INDEX_NONE;
if (EntryName.FindLastChar(TEXT(':'), ColonPos))
EntryName = EntryName.RightChop(ColonPos + 1);
if (EntryName.Equals(Name, ESearchCase::IgnoreCase))
return Enum->GetValueByIndex(i);
}
return INDEX_NONE;
}
Affects every reflection-driven property setter that accepts enum-typed
JSON strings (FEnumProperty, FByteProperty whose Enum field is
populated, properties resolved via StaticEnum<...>()). The 1-step form
Enum->GetValueByNameString(Name, EGetByNameFlags::CaseSensitive) is
the most fragile — it rejects everything except the fully-qualified
form. The fallback chain trades a tiny scan cost (enums rarely have more
than a few dozen entries) for actually