agentsclimarketplace

Ue5 mcp

Skill ibrews/ue5-mcp

Cowork/Claude Code skill for Unreal Engine 5 development via MCP, pixel streaming, and Python editor scripting

Install
npx -y skills add ibrews/ue5-mcp

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.

What its author says it does

Copied from the file, not written here

Field manual for driving Unreal Engine 5.7 / 5.8 through MCP. Engine-level gotchas, silent-fail edges, crash patterns, and the call sequences that actually work — applicable regardless of which MCP server you're using (Epic's official ModelContextProtocol plugin, custom servers, or anything else). Auto-trigger when Unreal Engine MCP tools are detected in a session, or when the user mentions Unreal Engine, UE5, Blueprints, Niagara, MetaSound, materials, or any UE editor automation workflow. This skill contains hard-won knowledge from real debugging sessions — most entries trace back to an actual editor crash or hours-long faceplant. Ignoring it when UE5 MCP tools are present will lead to wasted time hitting known dead ends.

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

44.1 KB, ~10.5k tokens by cl100k_base, as published. Nobody here has run it

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.

  1. 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.
  2. Discover before assuming. Call tools/list once 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.
  3. 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.
  4. 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++:

  1. Allocate a scratch buffer aligned to Property->GetMinAlignment() and call Property->InitializeValue(Scratch).
  2. Property->ImportText_Direct(RequestedValue, Scratch, Owner, PPF_None) to canonicalize what was requested.
  3. Property->ExportTextItem_Direct(ExpectedText, Scratch, ...) for the canonical form of "what we asked for."
  4. Apply the write, then Property->ExportTextItem_Direct(ActualText, ...) for "what we got."
  5. Compare ExpectedText to ActualText.

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*Complete predicate before continuing.
  • Subscribe to the completion delegate if the subsystem exposes one (FAssetCompilingManager::Get().GetPostCompilationDelegate(), etc.).
  • For MetaHuman: poll IsTextureSourceRequestComplete(Character) after RequestTextureSources.
  • For asset save: don't immediately re-read the package file; let UPackage::SavePackage complete 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:

  1. Construct the node (NewObject<UEdGraphNode>(Graph)).
  2. Graph->Nodes.Add(NewNode).
  3. 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 accepting the strings callers pass in.

The same pattern applies on the agent side: when calling an MCP tool that takes an enum-named string, prefer the C++ short form (AEM_Manual) — it's accepted by every resolver that follows even minimal best practice; the Python-binding uppercase form may not be.

When resolution misses, list the valid values in the error. Returning "unsupported type or value coercion failed" and nothing else forces the caller to grep engine source for the enum's entries. The same NumEnums() / GetNameStringByIndex() iteration that backs the case-insensitive fallback also gives you the discovery surface — trim each entry to its short name (after the last ::), skip the auto-generated _MAX terminator, and join the rest into the error string:

TArray<FString> ValidNames;
const int32 N = Enum->NumEnums();
for (int32 i = 0; i < N; ++i)
{
    FString EntryName = Enum->GetNameStringByIndex(i);
    int32 ColonPos = INDEX_NONE;
    if (EntryName.FindLastChar(TEXT(':'), ColonPos))
        EntryName = EntryName.RightChop(ColonPos + 1);
    if (EntryName.EndsWith(TEXT("_MAX")))
        continue;
    ValidNames.Add(EntryName);
}
// "Could not apply 'X' (enum EAutoExposureMethod). Valid values:
//  AEM_Histogram, AEM_Basic, AEM_Manual. (Case-insensitive; C++ short
//  name, not Python display name.)"

The error message becomes self-documenting: any agent that calls the tool with an invalid string immediately sees the valid set in the response. No round-trip through engine source. This pairs naturally with the resolver above — same iteration, same _MAX filter, used for discovery instead of resolution.

2.8 Actor "properties" may live on the RootComponent, not the AActor

A reflection-driven property setter that only walks Actor->GetClass() silently misses the properties that look actor-level in the editor but are actually stored on the RootComponent (a SceneComponent). The member-of-component set includes Mobility, bHidden, bVisible, RelativeLocation, RelativeRotation, RelativeScale3D, AreaClass, and the other SceneComponent transform/visibility fields.

The failure mode is hostile: the setter returns success-shaped (the property name is real, and the JSON value coerced cleanly), the call log shows "property_name": "Mobility", "applied": true, but a follow-up read returns the old value. There's no error, no deprecation warning, no typo suggestion — just a write that went into the void because the writer aimed at the wrong UObject.

Fix: when the property isn't found on Actor->GetClass() and the caller didn't pin a specific component, fall back to the RootComponent's class:

UClass* TargetClass = Actor->GetClass();
void*   TargetPtr   = Actor;

FProperty* Prop = TargetClass->FindPropertyByName(*PropertyName);
if (!Prop && Actor->GetRootComponent())
{
    USceneComponent* Root = Actor->GetRootComponent();
    if (FProperty* RootProp = Root->GetClass()->FindPropertyByName(*PropertyName))
    {
        Prop        = RootProp;
        TargetClass = Root->GetClass();
        TargetPtr   = Root;
    }
}

Surface which container actually received the write in the response (target_object: "Actor" | "<ComponentName>"). Without that hint, a caller debugging "why didn't the mobility change?" has no clue whether the fallback fired or whether the original Actor-level write succeeded on a same-named property. The silent-magic failure mode is worse than the original wart — the call now appears to work but you can't tell where the change landed.

The same pattern applies to other SceneComponent-resident sets — light intensity / color on light components, mesh on StaticMeshComponent, etc. Those usually have explicit component-targeting parameters in MCP surfaces, so the silent-miss mode there is rarer, but the fallback is the right default for any property setter accepting an actor name without an explicit component scope.


3. UE5 stability — actions that crash the editor

These are crashes that hit any agent driving the editor, regardless of MCP server. Worth knowing before you do them.

3.1 Don't delete or modify assets that other actors reference

Deleting (or transforming) a mesh asset while level actors reference it triggers a RegisteredElementType assertion crash. The editor goes down and any unsaved work in other windows is lost.

Safe pattern: before deleting, walk the asset dependency graph. Most MCP servers expose this (get_asset_references or similar). If anything depends on the asset, create a new replacement asset, swap actor references to it, then delete the original.

3.2 Don't spawn-then-immediately-delete actors in quick succession

Same RegisteredElementType assertion. Spawn → delete → focus in rapid succession (sub-frame timing) corrupts the actor registry. Add a small delay or interleave with other operations.

3.3 Niagara assertions and MetaSound crashes wipe unsaved changes

When Niagara or MetaSound asserts during PIE, the editor reverts to the last on-disk save. Custom nodes, in-memory tweaks, and uncompiled edits are gone. Save before every PIE test for these subsystems. Pattern after a crash: restart editor, dump the asset, recreate the lost nodes from the dump.

3.4 MetaSound: scalar literal on an Audio-type pin

Setting a float (or other scalar) literal directly on a pin typed as Audio crashes the editor at runtime, not at edit time. The edit succeeds silently; the crash fires when PIE starts and the graph evaluates. The stack signature is:

bExpectsNone [MetasoundDataFactory.h:395]

Rule: Audio-type pins expect audio buffer connections, not scalar values. Don't pipe a Multiply (Audio) directly from a Constant; route it through an Oscillator or noise source that produces an audio-rate buffer.

After this crash, all custom MetaSound nodes are wiped on next editor launch — only OnPlay, OnFinished, and Output survive.

3.5 Editor sprite icons are not particles

Editor viewport screenshots include sprite icons for each component (NiagaraComponent, AudioComponent, etc.). They look like particles but are the editor's UI overlay, not the actual VFX. Editor screenshots are not reliable verification for live Niagara behavior. Verify by:

  1. Reading is_active: true off the Niagara actor after spawn.
  2. Entering PIE and screenshotting the running game viewport.
  3. Or using pixel streaming for real-time visual confirmation.

4. Identifier and path conventions

4.1 Actor labels are not stable identifiers

a.get_actor_label() returns the display string shown in the Outliner. Two actors can share a label. The label is user-editable.

Use the actor's full path as the stable identifier:

/Game/Maps/Level.Level:PersistentLevel.BP_Character_C_0

Most MCP servers accept either, but the path is the only form that survives renames and disambiguates duplicates.

4.2 Asset path forms

UE5 accepts three forms for an asset, and they mean different things:

FormExampleWhat it loads
Package name/Game/Foo/BarThe package (used by the asset registry)
Package.Asset/Game/Foo/Bar.BarThe primary asset within the package (LoadObject<UObject>)
Package.Asset_C/Game/Foo/Bar.Bar_CThe generated class of a Blueprint (LoadObject<UClass>)

If a tool returns a path-not-found error, check that the form matches what the tool expects.

4.3 Widget paths vs widget Blueprint paths

UMG-related tools usually take one of two parameters with similar names:

  • widget_blueprint_path — path to the WidgetBlueprint asset on disk (/Game/UI/WBP_HUD)
  • widget_path — the identifier of a widget within a tree, addressing a node inside the WidgetBlueprint's hierarchy

A "compile this widget" tool wants the Blueprint path. A "remove this widget from its parent" tool wants the tree-internal path. Servers vary on which they expose where — check input schemas before assuming.


5. UE5 subsystem gotchas

Engine-level facts about specific subsystems. These apply regardless of MCP server — the underlying UE5 behavior is the same.

5.1 Lumen lighting — Movable mobility is mandatory

Lumen Global Illumination only considers lights with Movable mobility. Static and Stationary lights contribute nothing to Lumen GI. Agents that spawn a DirectionalLight default to Stationary and then complain that GI isn't working — the fix is to set Mobility to Movable explicitly.

5.2 Blueprint instance override staleness

Level-placed Blueprint instances retain editor-modified component property overrides even after the parent Blueprint changes. If you edit BP_Character to change Speed from 600 to 800, instances of BP_Character placed in the level keep their old override (whatever the designer or a prior agent set on that specific instance).

Pattern after any parent BP property change: walk affected level instances and either revert overrides to defaults or re-apply the new value explicitly per instance. The "Reset to Defaults" right-click in the Details panel does this for humans; for agents, set the property directly on each instance.

5.3 Niagara: created-from-empty systems don't emit

Programmatically constructing a UNiagaraSystem from scratch produces a system that compiles clean but never emits. The empty-system default state isn't valid for emission (missing system spawn script wiring, missing emitter mode, etc.).

Working pattern: start from a working template. UE ships /Niagara/DefaultAssets/DefaultSystem which has a valid sprite emitter. Most MCP servers expose an "asset duplicate" tool; use it to duplicate a working system and then mutate the copy. Don't try to build emitters from nothing.

5.4 Niagara: script_usage is part of the module identity

The same module name can appear in multiple script-usage stages:

  • system_spawn, system_update — once per system frame
  • emitter_spawn, emitter_update — once per emitter per frame
  • particle_spawn, particle_update — once per particle

A module named SpawnRate typically lives in emitter_update. A module named Initialize Particle lives in particle_spawn. When setting module inputs, always specify script_usage — the same module name in different stages is a different module instance, and the wrong stage silently no-ops.

5.5 Niagara: user-facing inputs vs script pins

The Niagara stack panel shows "user-facing inputs" — the named tweakable parameters per module. These are NOT the same as the underlying script's function-call pins. An agent that reads script pins and assumes they're the inputs will fail to set values.

Discovery pattern: ask for the module's input list before setting anything. Servers usually expose this as list_module_inputs or equivalent. The returned names are what set_*_module_input expects.

5.6 Niagara: dynamic input setting is broken in many versions

set_niagara_dynamic_input (or equivalent) typically fails with:

Failed to load random range script

This is a UE5 Niagara API gap, not an MCP-server bug. Workarounds: bake the dynamic value to a constant before assignment, or compute the value in Python and set the static input.

5.7 MetaSound: exact pin names matter

MetaSound pin names are case-sensitive and exact. SuperOscillator uses Frequency, Voices, Detune — not Base Frequency or Freq. Always dump the MetaSound's nodes (dump_metasound_graph or equivalent) before setting pins to learn the exact names.

5.8 Materials: emissive bloom threshold

Emissive intensity must exceed 1.0 to trigger bloom in the Post Process pipeline. Values of 3–10 produce visibly bloomed emissive surfaces. An emissive material with intensity 0.8 looks dim and self-lit but won't bloom.

Additional requirement: Post Process Volume must have Bloom enabled in its Effects settings. Default volumes have it on, but an agent that explicitly disabled bloom for performance won't get emissive bloom either.

5.9 Materials: translucent particle materials need Unlit shading

Lit translucent materials require normal vectors. Niagara sprite particles don't reliably provide normals (the orientation comes from the renderer, not the geometry). Pattern for particle materials:

  • blend_mode = Translucent
  • shading_model = Unlit
  • Emissive output, no base color routing

A lit translucent particle material renders as a black/featureless sprite because the lighting calc has nothing to work with.

5.10 Materials: compilation lag

Creating or modifying a material kicks off shader compilation. Depending on how many permutations the material has (number of materials in the project using it, light counts, etc.), compilation takes seconds to minutes. Visual output doesn't update until compilation completes.

Pattern: after a material edit, poll for compile completion before judging visuals. Most servers expose a "get material errors" or "is asset compiled" predicate; if not, screenshot after a fixed delay (10–30s for moderately complex materials).

5.11 UMG widgets: CreateWidget needs an owning player context

A widget created without specifying an owning player context renders blank at runtime. The widget Blueprint compiles, the instance spawns, and AddToViewport accepts it — but nothing draws because the widget has no World context to anchor in.

Fix: pass GetPlayerController(0) as the Owner argument to CreateWidget (the Owner pin in the Blueprint node, or the OwningPlayer parameter in the Python unreal.CreateWidget call).

5.12 In-world widget components need a WidgetClass

Adding a WidgetComponent to a Blueprint doesn't automatically associate it with a Widget Blueprint. Set the WidgetClass property explicitly — it's a TSubclassOf<UUserWidget> (an FClassProperty), so the value is the generated class path (/Game/UI/WBP_HUD.WBP_HUD_C) or the asset path (/Game/UI/WBP_HUD, which auto-expands).

Default draw mode is screen-space; for a 3D billboard, set the component's Space to World.

5.13 AudioComponent type name

When adding an audio component to a Blueprint via reflection, the type name is Audio, not AudioComponent. Some servers' helpers handle either; raw reflection lookups don't. Sound asset assignment goes through the Sound UPROPERTY (a FObjectProperty); pass the sound asset's path as the value and the property handler resolves the load.

5.14 UltraDynamicSky: override booleans first

The UltraDynamicSky / UltraDynamicWeather plugin uses a "manual override" pattern. Each weather parameter (rain, snow, wind, etc.) has a companion bool flag named <Param> - Manual Override. The manual override bool must be set to true before setting the parameter value — otherwise the plugin's auto-weather logic overrides whatever you set on the next tick.

Other UltraDynamicSky notes:

  • Spawn Ultra_Dynamic_Sky and Ultra_Dynamic_Weather at origin together.
  • First spawn triggers 150+ shader compiles; visual output isn't reliable until they finish.
  • Conflicts with existing DirectionalLights — remove or hide them.
  • Rain/snow particles render most visibly in PIE; editor viewport often shows the sky but not the particle layer.

5.15 Sequencer: extending the playback range doesn't extend track sections

UMovieScene::SetPlaybackRange(...) updates the scene's logical playback range, but per-track sections (created by AddNewCameraCut, AddSection on a TransformTrack, FloatTrack, etc.) keep their original lengths. The camera cut track section in particular bounds what Movie Render Queue actually renders — MRQ stops at the end of the active camera cut section regardless of the playback range. Symptom: a sequence whose GetPlaybackRange() reports 12 s renders only the first 5 s; the returned image count matches the section length, not the playback range.

After extending the playback range, iterate every track on every binding and call SetRange on every section:

const TRange<FFrameNumber> NewRange = MovieScene->GetPlaybackRange();
for (UMovieSceneTrack* Track : MovieScene->GetTracks())                       // master tracks
    for (UMovieSceneSection* Sec : Track->GetAllSections()) Sec->SetRange(NewRange);

if (UMovieSceneTrack* CutTrack = MovieScene->GetCameraCutTrack())             // camera cut
    for (UMovieSceneSection* Sec : CutTrack->GetAllSections()) Sec->SetRange(NewRange);

for (const FMovieSceneBinding& B : MovieScene->GetBindings())                 // per-binding tracks
    for (UMovieSceneTrack* T : B.GetTracks())
        for (UMovieSceneSection* Sec : T->GetAllSections()) Sec->SetRange(NewRange);

Symmetrically: shrinking the playback range while leaving sections long is also a no-op for MRQ (sections still play to their end). If you want "playback range and sections always match," apply the same loop on every range mutation. Real-world failure mode: a sequence's playback range and its camera cut section's range drift over a series of edits and the next render silently uses whichever happens to be shorter.

5.16 Sequencer: channel keys at the same time stack instead of replacing

FMovieSceneDoubleChannel::AddLinearKey(FrameNumber, Value) and the equivalent on FMovieSceneFloatChannel append a key to the channel's time-sorted array even if a key already exists at FrameNumber. The MovieScene tracks both keys as separate entries; on interpolation, the first-found one can shadow the just-added one — silently producing the wrong animated value. Re-keying the same time looks like a successful no-op.

Detect and update in place via the channel's TMovieSceneChannelData wrapper:

TMovieSceneChannelData<FMovieSceneDoubleValue> Data = Channel->GetData();
const int32 ExistingIdx = Data.FindKey(FrameNumber);     // exact-frame match
if (ExistingIdx != INDEX_NONE)
{
    TArrayView<FMovieSceneDoubleValue> Values = Data.GetValues();
    FMovieSceneDoubleValue Updated = Values[ExistingIdx];
    Updated.Value = NewValue;                            // preserves tangent / interp
    Values[ExistingIdx] = Updated;
}
else
{
    Channel->AddLinearKey(FrameNumber, NewValue);
}

FindKey has an optional InTolerance parameter (FFrameNumber(0) by default) for inexact matches. The same pattern applies to FMovieSceneFloatChannel / FMovieSceneFloatValue, FMovieSceneIntegerChannel, and FMovieSceneBoolChannel — each Channel->GetData() returns the matching TMovieSceneChannelData<T> wrapper.

For 3D transform sections specifically, channel-proxy index order is [0-2] Translation X,Y,Z, [3-5] Rotation X,Y,Z (where X=Roll, Y=Pitch, Z=Yaw), [6-8] Scale X,Y,Z. All nine channels need the same set-or-replace treatment when re-keying a transform — there's no top-level helper that does all nine at once.

5.17 Sequencer: save the sequence package before MRQ re-reads it

Movie Render Queue's PIE executor re-loads the level sequence package from disk when it spawns PIE. In-memory edits to the MovieScene (playback range changes, transform keys, camera bindings, sub-sequences, etc.) that haven't been flushed to disk via UPackage::SavePackage are lost — the render uses the stale on-disk version even though LoadObject<ULevelSequence>(SeqPath) returns the in-memory copy. The result is a successful-looking render that doesn't reflect the most recent edits.

This is a sequence-asset specialisation of the general "save before reading from disk" rule (see §2.4) — MRQ counts as a from-disk reader because the PIE world it spawns reloads the sequence asset fresh. Save the sequence's outermost package before kicking the render:

UPackage* SeqPkg = Seq->GetOutermost();
if (SeqPkg && SeqPkg->IsDirty())
{
    const FString Filename = FPackageName::LongPackageNameToFilename(
        SeqPkg->GetName(), FPackageName::GetAssetPackageExtension());
    FSavePackageArgs Args;
    Args.TopLevelFlags = RF_Public | RF_Standalone;
    Args.SaveFlags = SAVE_NoError;
    UPackage::SavePackage(SeqPkg, Seq, *Filename, Args);
}

Same hazard applies to any authoring → MRQ chain where the on-disk state diverges from the in-memory state — camera cuts, sub-sequence bindings, shot tracks, etc. Saving the level alone (FEditorFileUtils::SaveCurrentLevel) does not cover this; /Game/... sequence assets live in their own packages.


6. MCP transport requirements

6.1 Accept header on Streamable HTTP

Servers implementing MCP 2025-03-26 Streamable HTTP generally require both content types in the Accept header:

Accept: application/json, text/event-stream

Omit it and the server returns 406 Not Acceptable. Most MCP clients (Claude Desktop, Cursor, VS Code's MCP UI, Epic's EDA panel) set this automatically. Raw curl does not — pass -H 'Accept: application/json, text/event-stream' when testing manually.

6.2 Image content blocks

MCP supports an image content block alongside text:

{
  "content": [{
    "type": "image",
    "data": "<base64-encoded PNG>",
    "mimeType": "image/png"
  }]
}

The decoded bytes start with \x89PNG\r\n\x1a\n. Most MCP clients render images inline in the chat UI. UE5 servers commonly use this for editor viewport / PIE / depth captures.

6.3 Cancellation

MCP 2025-03-26 defines notifications/cancelled with a requestId parameter. Whether it actually aborts an in-flight tool call is server-dependent — synchronous servers can't interrupt themselves mid-execution. Don't depend on cancellation working unless the server documents support.

6.4 Sessions

Servers that implement the Mcp-Session-Id header expect clients to echo it back on subsequent requests in the same session. The server mints a fresh ID if the request arrives without one. Sessions usually expire on idle timeout — re-send initialize if the server returns "unknown session."


7. Python ↔ MCP data channel

UE's Python interpreter is reachable from most MCP servers via a console command (py <code>) or a dedicated execute_script tool. Python is the right hammer for:

  • Bulk asset operations where the MCP surface lacks a vectorised tool
  • Niagara parameter manipulation when the dynamic-input path is broken
  • Sequencer batch edits
  • Anywhere the agent needs N+1 operations that depend on each other and a round-trip per dependency would be expensive

Critical limitation: Python's print() and stdout go to the UE log, not back to the MCP client. The agent that ran the script can't see what it produced.

Workaround — Actor Tags as a data channel:

import unreal
result = "...whatever the script produced..."
note = unreal.EditorLevelLibrary.spawn_actor_from_class(
    unreal.Note, unreal.Vector(0, 0, 9999)
)
# Keep tags short — UE truncates very long tag strings on save.
note.tags = [result[:200]]

Read back via MCP: query the Note actor's properties and inspect tags. Delete the Note actor after. Spawn a fresh actor for each result — stale actors return stale tags.

For larger payloads, write to a known file under Saved/Logs/ or Saved/AgentScratch/ and read it back via the MCP server's file-read tool (if available).

Server-side Python execution alternative: some MCP servers expose execute_script (or similar) that captures the return value of a run() function directly into the response. If your server has this, prefer it — the data-channel workaround becomes unnecessary.


8. Patterns for MCP server authors targeting UE5

If you're building an MCP server against the UE5 editor, the following patterns have proven valuable in practice. None are required by the MCP spec — they're hard-won pragmatic recommendations.

8.1 Schema-in-error self-correction

When a tool call fails input validation, return the tool's full input JSON Schema inline in the error text. The LLM caller reads the schema, fixes the argument, and retries. Saves 3–5 round-trips per error compared to a bare "Invalid argument" message.

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Validation failed: 'body_type' must be one of [Skinny, Athletic, Heavyset]. Full schema:\n\n{...inputSchema JSON...}"
  }]
}

8.2 Output schema as authority

Ship an outputSchema for every introspection / read-only tool. Fields in the schema are guaranteed to appear; additional metadata fields may also appear but aren't promised. Clients can parse responses against the schema instead of guessing field names.

8.3 Continuation tokens for large responses

UE5 asset dumps can run hundreds of KB. MCP clients vary on how they handle large text content. Pattern: cap response text at N bytes (64KB is a reasonable default), stash the remainder under an opaque token, return token + first chunk + _remaining_chars so the client knows there's more. Expose a continue_response(token, max_bytes) tool to fetch the next chunk.

8.4 FScopedTransaction for mutating tools

Wrap every UPROPERTY write, asset edit, level mutation, or Blueprint graph change in FScopedTransaction. Ctrl+Z in the editor then reverts the agent's change — critical for user trust. Losing the undo path is the fastest way to make agents feel scary to work with.

8.5 Lazy tool registration for large surfaces

If your server exposes 200+ tools, the default tools/list response can overwhelm small-context-window clients. Pattern: split the surface into categories, default tools/list to a small set of meta-tools (list_categories, describe_category, load_category) plus a few always-on essentials, and load full categories on demand. Use notifications/tools/list_changed to signal subscribers when the visible surface changes.

8.6 Game-thread discipline

Slate operations, viewport rendering, asset operations, and Python execution must all run on the game thread. MCP requests typically arrive on a worker thread; dispatch the actual work back to the game thread via AsyncTask(ENamedThreads::GameThread, ...) and signal completion via a TPromise or similar. check(IsInGameThread()) defensively before any Slate / viewport call.

8.7 Recursion-cap the dumpers

Asset structures can contain cycles (Blueprint references, Material Functions, Sound Cues with self-referential branches). Always cap recursion depth (1024 is a safe default for graph walks). Always cap output size before returning. Always opportunistically GC any per-tool caches.


9. Patterns for MCP clients (agents)

9.1 Cache tools/list once per session

Tool surfaces are stable within a session. Cache the response on connect. Polling mid-session wastes round-trips and can confuse cancellation logic.

9.2 Parse against outputSchema when present

If the server publishes output schemas, validate against them instead of guessing field names. Saves debugging cycles when an agent assumes a field exists that doesn't, or doesn't notice a new field appearing in a later version.

9.3 Always verify writes

UE5 has too many silent-fail edges to trust a successful response. The full pattern:

  1. Issue the write.
  2. Issue a read of the same state.
  3. Compare to what you asked for.
  4. If they differ, re-examine (snake_case? wrong enum form? async-deferred?).

Verify-after-mutate is the difference between an agent that works 80% of the time and one that works 99%.

9.4 Treat isError: true as load-bearing

The MCP spec specifies tool errors return isError: true with the error text in content[0].text. Servers may also return JSON-RPC transport-level errors with the error field at the top level. Both need handling; both contain debugging info worth reading.

9.5 Don't chain a read after a write without saving

If a tool dumps an asset from disk, the dump reflects the on-disk state. If you just mutated the asset in memory and haven't saved, the dump shows the pre-mutation state. Save explicitly between mutate and dump if you need the current state.


10. UE 5.7 vs 5.8 — engine-level differences

These affect any agent driving UE5 across engine versions, regardless of MCP layer.

10.1 MovieRenderGraph is 5.8-only

UMovieGraphConfig, UMovieGraphPipeline, and graph-based rendering composition land in 5.8. UE 5.7 has MovieRenderQueue (UMoviePipelineQueueEngineSubsystem) but no graph composition layer.

10.2 FJsonObject::Values key type change

FJsonObject::Values is TMap<FString, ...> on 5.7 and TMap<UE::FSharedString, ...> on 5.8. Affects native C++ iteration; doesn't affect Python or JSON-over-the-wire usage.

10.3 PathTracer setting surface

Several PathTracer settings migrated from CVar-only to UPROPERTY on UPostProcessVolume in 5.8. An agent that sets them via console commands works on both engines; one that sets them via reflection on the post-process volume needs 5.8.

10.4 Epic's official ModelContextProtocol plugin is 5.8-only

Epic's MCP plugin ships in UE 5.8 (experimental). UE 5.7 users running an agent against the editor need a third-party MCP server.

10.5 Niagara editor module split

UE 5.8 reorganised some Niagara editor APIs into a NiagaraStackEditor module. Tools that introspect Niagara stack issues need to depend on the right module on each engine version.


11. Asset structure quick reference

What gets serialized when common UE5 assets are dumped to JSON. Useful for parsing responses regardless of which server produced them. This isn't an exhaustive schema — see Epic's UE docs for the authoritative structure.

Blueprint

  • UbergraphPages — event graphs plus auto-generated wrappers
  • FunctionGraphs — user-defined functions
  • DelegateSignatureGraphs — dispatcher signatures
  • MacroGraphs — user macros (if any)
  • Variables — name, type, default value, CategoryName, RepNotifyFunc
  • Components — SimpleConstructionScript root + AddedComponents
  • ParentClass, implemented interfaces, compile state

Niagara System

  • SystemSpawnScript / SystemUpdateScript — system-level VM scripts
  • EmitterHandles — per-emitter wrapper (Enabled, LocalSpace, EmitterMode)
  • UserParameters — parameters exposed to the component for runtime tuning
  • Per-emitter: SpawnScript / UpdateScript / RenderScript stacks, modules per stack, renderer settings

Material

  • ExpressionsUMaterialExpression* nodes (one per node in the graph)
  • Connections — input pin → output pin pairs
  • ScalarParameterValues, VectorParameterValues, TextureParameterValues
  • ShadingModel, BlendMode, MaterialDomain

Level

  • Actors — array of actor entries with name, class, transform, components
  • WorldSettings — game mode, level scripts, navmesh settings
  • Streaming — sublevels and their state (loaded / visible / unloaded)

Sequencer (ULevelSequence)

  • MovieScene — root scene
  • Tracks — per binding: TransformTrack, FloatTrack, EventTrack, etc.
  • Sections per track — start/end frame, evaluation type, easing

Widget Blueprint

  • WidgetTree — hierarchy of widgets (root, panels, leaf widgets)
  • Per-widget: Slot properties (depend on parent panel type), variable-or-not flag, accessibility text
  • BindingClass — MVVM viewmodel class (if any)

12. Anti-patterns

12.1 Don't pass actor labels as identifiers

Two actors can share a label. The label is user-editable. Use the full path.

12.2 Don't poll tools/list mid-session

Tool surfaces are stable per session. Cache once on connect.

12.3 Don't assume tool surface parity across servers

Two MCP servers against UE5 will have different tool catalogues, different parameter names for similar concepts, and different output shapes. Always read tools/list to discover what's actually exposed before assuming a tool exists.

12.4 Don't ignore isError: true

The error message is usually load-bearing. Schema-in-error servers put the input schema right in there.

12.5 Don't chain reads after writes without a save (or explicit in-memory mode)

Most introspection tools serialize from disk. Mutation without save is invisible to those tools.

12.6 Don't assume async operations completed

Texture compile, Niagara compile, shader compile, asset save, package compile — all async. Poll completion or wait on the relevant delegate before continuing.

12.7 Don't delete a referenced asset

Check get_asset_references (or equivalent) first. Deleting referenced meshes/materials crashes the editor with RegisteredElementType.

12.8 Don't connect scalar nodes to Audio-typed pins in MetaSound

Runtime crash. Use audio buffer sources (Oscillators, Noise) into Audio pins. Scalar math (Multiply, Add) on the audio path goes through Audio variants of those nodes, not the float variants.

12.9 Don't destructively rewrite committed config to toggle sim vs device

A script that flips .ini settings in place to switch a UE5 visionOS/AVP project between the Simulator and a real device makes the repo stateful — easy to cook the wrong target, dirties tracked files, and invalidates caches for a bigger recompile than necessary. Carry the delta in the build command instead: build arch (-clientarchitecture=iossimulator for sim, arm64 for device), code-signing team, install method (simctl vs devicectl), and a METAL_SIM shader cook flag (-ini:...:bEnableSimulatorSupport=True, sim only) as -ini: overrides passed to BuildCookRun. The committed render config never changes. Reference implementation (ue-avp-build.sh sim|device) + full rationale: AgileLens internal KB, intelligence/techniques/ue-visionos-sim-device-build-flow.md (ask the user for a copy if you don't have KB access).


13. Reading list

For server-specific tool catalogues, query the server with tools/list.


Version history

VersionDateNotes
3.2.02026-07-03Adds §12.9 (don't destructively rewrite committed config to toggle sim vs device — carry the delta in the build command instead).
3.1.22026-05-26Adds §2.8 (Actor "properties" may live on the RootComponent, not the AActor). Reflection-driven property setters that only walk Actor->GetClass() silently miss Mobility / bHidden / bVisible / Relative* and other SceneComponent-resident fields. Documents the fallback pattern + the response-shape requirement (surface target_object so the write isn't silent magic).
3.1.12026-05-26Extends §2.7 with the paired discovery path: when enum-string resolution misses, list the valid short names in the error message using the same NumEnums() / GetNameStringByIndex() iteration (trim to short-name, skip _MAX). Self-documenting error replaces the opaque "unsupported coercion failed" failure mode.
3.1.02026-05-26Adds §2.7 (enum-string resolution — three accepted forms), §5.15 (Sequencer playback-range vs section-range divergence), §5.16 (MovieScene channel keys at same time stack instead of replacing), §5.17 (sequence package save before MRQ re-loads it). All four trace back to multi-hour debugging sessions on the showcase render pipeline.
3.0.02026-05-21Server-agnostic rewrite. No plugin dependency; works against any MCP server exposing UE5. Engine-level wisdom only.
2.x2026-05-19 and earlierTied to a specific MCP plugin; deprecated.

Gives 0 of the 12 instructions most automation workflows skills give in ~10.5k tokens

Counted across 745 of the 1,008 authors here whose files we hold, read 2026-08-06

  • write conventional commit messagesin 36 of 745, across 35 files
  • delete branches after mergein 30 of 745, across 21 files
  • make atomic commitsin 25 of 745, across 15 files
  • write minimal code to pass testsin 22 of 745, across 10 files
  • run tests before committingin 21 of 745, across 13 files
  • re-snapshot after navigation or DOM changesin 21 of 745, across 13 files
  • use try-catch for error handlingin 20 of 745, across 6 files
  • write tests before implementationin 20 of 745, across 8 files
  • configure branch protection rulesin 19 of 745, across 5 files
  • explain the why in commit messagesin 19 of 745, across 9 files
  • refactor code while tests remain greenin 19 of 745, across 6 files
  • Interact with elements using refsin 19 of 745, across 11 files

Said here and by no other author read

  • dump assets before editing
  • discover available tools after connecting
  • verify asset state after mutating
  • save assets explicitly before rereading
  • use PascalCase for property writes
  • append the _C suffix to blueprint class paths

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.