Skip to content

Unreal MCP + Python Automation Guide

Summary: Practical playbook for driving the Unreal editor from Claude via MCP and the py console command. Covers UE 5.8 + Epic's native ModelContextProtocol plugin. The in-repo McpAutomationBridge plugin and its Node TS server were removed 2026-07-14 — sections below the legacy banner still describe bridge-era tool names and need re-validation against the native toolsets before you lean on them.

Table of Contents


Setup (Native MCP)

UE 5.8 ships Epic's own MCP server: the ModelContextProtocol engine plugin (Experimental) plus a toolset ecosystem (Engine/Plugins/Experimental/Toolsets/*). No third-party plugin, no Node process, no version-locking — the server lives inside the editor and speaks streamable HTTP.

The project wiring (all committed, nothing to install per-machine):

Piece Where What it does
ModelContextProtocol + AllToolsets plugins ProjectEternal.uproject (Editor targets only) MCP server + all ~21 Epic toolsets (GAS, UMG, Sequencer, PCG, SlateInspector, AutomationTest, …)
bAutoStartServer=True Config/DefaultEditorPerProjectUserSettings.ini HTTP server starts with the editor — no manual start, no reconnect dance
unreal-engine server entry .mcp.json (project scope) Claude Code connects to http://127.0.0.1:8000/mcp whenever the editor is running

Endpoint: http://127.0.0.1:8000/mcp (port/path in Editor Preferences → Model Context Protocol, backed by the same ini). Since transport is plain HTTP, Claude reconnects per request — restarting the editor does not require restarting Claude Code, and there is no heartbeat/reconnect-storm failure mode like the old bridge had. Editor closed = tool calls fail cleanly until it's back.

Settings live on UModelContextProtocolSettings (config=EditorPerProjectUserSettings): ServerUrlPath (/mcp), ServerPortNumber (8000), bAutoStartServer, bEnableToolSearch (on: tools/list stays tiny and toolset tools are discovered on demand).


Calling Convention & Toolsets

With tool search on, the server exposes exactly three MCP tools (verified live 2026-07-14):

  1. list_toolsets — one-line description per registered toolset.
  2. describe_toolset {toolset_name} — full tool list + input schemas for one toolset.
  3. call_tool {toolset_name, tool_name, arguments} — executes a toolset tool. tool_name is the SHORT name (get_current_level), not the fully-qualified editor_toolset.toolsets.scene.SceneTools.get_current_level — the long form returns "Unknown tool". The args field is arguments (an object), not tool_args — a wrong field name is silently dropped and the server errors with "input params Json is empty" (verified live 2026-07-17).

Toolset highlights (from the live registry): editor_toolset.toolsets.* (actor/asset/blueprint/material/ data-table/object/scene/skeletal-mesh/texture + a sandboxed Python ProgrammaticToolset for batching), GASToolsets.* (AttributeSet discovery, ASC runtime inspection, gameplay cues), AutomationTestToolset (discover/run/monitor the same tests as Session Frontend), SlateInspectorToolset (Playwright-style editor UI automation with refs + screenshots), UMGToolSet (widget-tree authoring — follow its list_properties → get/set_properties workflow), NiagaraToolsets.*, PCGToolset, Sequencer suites, GameplayTagsToolset, ConfigSettingsToolset, SemanticSearchToolset.

Niagara authoring is a first-class MCP path on 5.8 (re-verified live 2026-08-08): NiagaraToolset_System alone exposes ~45 tools — CreateNiagaraSystem, AddEmitter/AddModule/AddRenderer (+ their removes), SetStackInputData, Add/GetUserVariables, GetSystemSummary/GetEmitterTopology, GetSystemCompileState, GetStackIssues + ApplyStackIssueFix — with NiagaraToolset_Component for runtime/user-variable overrides and NiagaraToolset_Assets for module discovery. Author systems through these, not Python. Arg names differ per tool (moduleToRemove, stackInputRef, …) and the error response echoes the expected schema.

Raw HTTP debugging (when MCP client plumbing is in doubt): POST JSON-RPC to the endpoint with Accept: application/json, text/event-stream; capture the Mcp-Session-Id response header from initialize and send it on every later call; tools/call responses arrive as SSE (data: lines).

Project toolset — ProjectEternalEditor.EternalCheatToolset

Beyond Epic's toolsets, the project registers two toolsets of its own (both in Source/ProjectEternalEditor/Private/MCP/, registered together in ProjectEternalEditor.cpp). The first lets an agent fire the dev-console cheats over MCP — the native plugin has no console-exec tool, so this is the only agent path to them. Source: Source/ProjectEternalEditor/Private/MCP/EternalCheatToolset.{h,cpp}, registered via UToolsetRegistry. It wraps UEternalCheatManager (resolving the first PIE world that has a local player controller) and returns each cheat's own LogTemp lines in Log — read Log, don't grep the editor log. The tools, by group (count the AICallable UFUNCTIONs in EternalCheatToolset.h before quoting a number anywhere — this listing goes stale first):

  • Combat: SpawnEnemy, ClearEnemies, KillTarget, ActivateAbility, SetGodMode (idempotent bool, not a toggle), AddAttribute, ApplyStatus, TriggerCombatEffect.
  • Itemization: GiveItem (world pickup — needs a walk-and-interact no agent can drive), GiveItemToBag (rolls straight into the bag; the agent path), EquipItem, CraftModifier, ClearItemModifiers, ListItemModifiers.
  • Remnants / Greed: GiveSealedRemnant, GiveAwakenedRemnant, DumpRemnant, ListRemnantPoolIDs, ConsumeGreed.
  • Structured loadout reads (return FEternalItemInfo[] as DATA, not log — assert on these): GetInventory (bag) and GetEquipment (equipped, tagged with slot name); each item carries itemId, itemLevel, slotName, modifiers[] (channel Implicit/Prefix/Suffix, modifierId, tier, value, description), and usagesLeft / maxUsages (consumable charges as THIS instance sees them; -1 when the item has no consumable fragment). The itemization analogue of the GAS inspector.
  • Networking: DumpNetStats — in/out bytes and packets per second for every net connection in every PIE world (wraps the Eternal.Net.DumpConnectionStats console command). The headless stand-in for stat net, which only draws on the viewport. Sample before/after a change and diff the per-second figures. For an unattended run with the editor closed, Tools/net_measure.ps1 (wraps Eternal.Net.MeasureBandwidth [Map] [NumClients] [Seconds] [Listen|Dedicated]) PIEs the map with a client topology, samples every connection once a second, and writes Saved/NetProfile/NetProfile_<ts>.csv + .md (avg/peak per connection).
  • Montage notifies, editor closed: Eternal.Anim.ListMontageNotifies <MontagePath> and Eternal.Anim.AddHyperArmorNotify <MontagePath> <StartSec> <EndSec> [Track] (saves) are the console twins of the authoring toolset's ListMontageNotifies / AddHyperArmorNotify, runnable through Tools/run_editor_console.py.

Dedicated-server PIE (Play-As-Client) works with these tools. Set PlayNetMode=PIE_Client + bLaunchSeparateServer=true on /Script/UnrealEd.Default__LevelEditorPlaySettings via ObjectTools, PIE on a gameplay map (L_TestArena; a menu-flow map travel would disconnect the client). The cheat manager is force-enabled on the client PC by the toolset (the engine only spawns one where a GameMode exists), and the acting cheats forward to the server through Server_ExecuteCheat — their Log then only shows the forward line; read the server's outcome from the editor log (grep "ActivateAbility:\|GiveItemToBag:") and the client's view of state through GetInventory / GetEquipment. This is the route for verifying OnRep-driven UI state on a real client (e.g. consumable charge counts after a server-only decrement).

Not every cheat is exposed over MCP. TeleportToRoom <query> and ListDungeonRooms (dungeon navigation — see Itemization Tooling) exist only as console execs on UEternalCheatManager; reaching them from an agent means a console line, not a toolset call.

Notes: arg names are the C++ param names, PascalCase (SlotName, ModifierID, ItemID, bEnabled). No PIE / no reachable player raises a script error (isError:true) — "start PIE first" is explicit, not a silent no-op. The DLL loads on editor launch, so a fresh build needs an editor relaunch before the tools appear. Any C++ toolset registration must defer past GEditor creation (the registry subsystem does not exist at module StartupModule) — both project toolsets register on FCoreDelegates::GetOnPostEngineInit(). The ToolsetRegistry plugin is listed explicitly in ProjectEternal.uproject (Editor only); ProjectEternalEditor hard-depends its module. Full PIE workflow and the reusable raw-HTTP caller live in the pie-test skill.

Project toolset — ProjectEternalEditor.EternalAuthoringToolset

The second project toolset is content-authoring surgery that neither Python nor the generic object tools can reach — FAnimNotifyEvent exposes zero script properties, so montage notify placement has to run in C++. Source: Source/ProjectEternalEditor/Private/MCP/EternalAuthoringToolset.{h,cpp}. Unlike the cheat toolset it needs no PIE: it works on editor assets. Every mutator marks the touched package dirty but does not save — batch saves go through the asset toolset so a review pass can still discard. Paths are full object paths (/Game/Folder/Asset.Asset; a bare package path is expanded). Failures raise a script error with the reason. Tools (verify against the header's AICallable UFUNCTIONs before quoting):

  • Montage notifies: ListMontageNotifies(MontagePath) — read first so new timings line up with existing hit windows; AddWeaponTrailNotify(MontagePath, StartTime, EndTime, NiagaraSystemPath, SpawnSocketName, StartSocketName="Start", EndSocketName="End", bUseLeftWeapon=false, TrackName="Trail"); AddPlaySoundNotify(MontagePath, Time, SoundPath, VolumeMultiplier=1, TrackName="Audio").
  • Combat effects: SetHitStopLayer(TagName, Duration, TimeScale) — edits the FHitStopLayer of one EffectConfigurations row (e.g. Effects.Parry) in the combat effects configuration asset; the row must already carry a hit-stop layer.
  • WidgetForge (the agent route to the console commands of the same name; the report JSON rides in Log): WidgetForgeApply(SpecJsonFilePath, bForce=false), WidgetForgeExport(AssetPath, OutJsonFilePath), WidgetForgeRender(AssetPath, Width, Height, OutPngFilePath). See WidgetForge.

⚠️ Legacy content below — bridge-era (pre-2026-07-14)

Everything below this banner was written against the removed McpAutomationBridge tool surface (system_control, manage_*, control_*, inspect). The tool names no longer exist; the lessons (log-reading discipline, deferred FBX import, crash taxonomy, editor lifecycle) largely transfer. Re-validate each pattern against the native toolsets on first use and migrate the section up above the banner once verified.

The Python Console Pattern

Bridge ≥ v0.5.30 — prefer system_control execute_python. It runs Python directly (no console hop), takes either inline code or a file path, and returns captured stdout/stderr + exec time in the MCP result — so you no longer have to round-trip through the log just to read output. Write longer scripts to ArtSource/ (never Content/) and pass the path:

system_control  →  execute_python  →  { "file": "C:/Projects/ProjectEternal/ArtSource/<feature>/script.py" }
system_control  →  execute_python  →  { "code": "import unreal; unreal.log('hi')" }   # inline, ≤ 1 MB
  • Constraints (new in 0.5.30): inline code capped at 1 MB; the file path is validated to resolve inside the project dir (symlink-escape blocked) — our ArtSource/ is in-project, so fine. code and file are mutually exclusive.
  • Still editor-world context — cheat-manager Execs are not reachable this way (see GAS section); that limitation is unchanged.

Legacy fallback — console_command "py ..." (works on any version, use if execute_python is absent):

control_editor / system_control  →  console_command:
  py exec(open(r'C:\Projects\ProjectEternal\ArtSource\<feature>\script.py').read())
  • One-liners work too (;-separated), but multi-statement logic (loops, try/except) needs a file + exec.
  • The console call returns when the statement finishes and reports almost nothing — confirm via the log marker (below). execute_python is strictly better here since it returns stdout directly.
  • Long ops (recompile, import) can exceed the 30 s MCP timeout — a timeout is not failure; the Python may still be running. Verify via the log. (Screenshots are now genuinely async — see Asset Playbooks.)
  • Globals persist across py calls in one editor session (e.g. a _handle from a prior exec is still bound).
  • unreal.log("MARKER ...") / log_error(...) at the end of every script — that string confirms success from the log. Even with execute_python's stdout capture, keep tailing the log for engine-level signals ([SM6] shader errors, Handled ensure, crashes) that never reach Python stdout.

Probe Instead of Guessing

When inspect returns only a Package wrapper for an asset path (no parent material, no params, no displacement/other internals) — the sign the MCP surface can't reach what you need — don't assume; write a one-shot probe. A small script that EditorAssetLibrary.load_assetes the asset and dumps it via reflection (get_editor_property, MaterialEditingLibrary param listings, etc.) produces ground truth that inspect couldn't. Two thin inspect calls on the same path is the trigger to switch to a probe.

  • Probe scripts live in Tools/Python (or a temp path), NEVER under Content/. Content/Python is reserved for editor-managed Python (init_unreal.py auto-runs there); scratch dumped there silently accumulates — a real cleanup once had to weed ~19 stray one-shot scripts out of Content/Python. Delete a probe as soon as you've read its output; only promote it to Tools/Python if it's a genuinely reusable tool.

Always Read the Editor Log

After every py/MCP mutation, tail Saved/Logs/ProjectEternal.log (Grep). The MCP response says "success" even when the engine logged errors — the bridge only warns. Look for:

Signal Meaning
your MARKER_DONE string actually succeeded
LogPython: Error: / Traceback script threw (your try/except should log *_FAILED)
Handled ensure: non-fatal engine assert — often a half-applied op
=== Critical error: === / Assertion failed crash; process is dying
ForceDeleteObject failed ... potentially corrupt delete_asset on a referenced asset failed

The log rotates per launch (fresh ProjectEternal.log each start; previous goes to a backup), so grepping the live file is current-session only — good for "did my last op work".


Inspecting & Driving GAS in PIE

Reading/poking the player's attributes while Play-In-Editor runs. MCP inspect / find_by_class see the editor world, not the PIE world — they return 0 actors during PIE. Go through Python's PIE world instead.

Where things live (this project): the ASC + UEternalAttributeSet are on the PlayerState (AEternalPlayerState), not the pawn (EternalPlayerCharacter) — see System Ownership Matrix. The pawn's IAbilitySystemInterface just forwards to it.

Reusable read probe (write to Saved/, exec, then grep the log for the marker):

import unreal
w  = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_game_world()   # PIE world (None if not playing)
ps = unreal.GameplayStatics.get_player_state(w, 0)
asc = next(c for c in ps.get_components_by_class(unreal.ActorComponent)
           if "AbilitySystemComponent" in c.get_class().get_name())
attrs = {str(a.get_editor_property("attribute_name")): a for a in asc.get_all_attributes()}
def val(n):                                   # get_gameplay_attribute_value returns (float, bFound)
    v = asc.get_gameplay_attribute_value(attrs[n]); return v[0] if isinstance(v, tuple) else v
unreal.log("PROBE Ferocity=%s MaxHealth=%s" % (val("Ferocity"), val("MaxHealth")))
Need How
PIE world UnrealEditorSubsystem.get_game_world() (NOT EditorLevelLibrary/editor world)
Player pawn / state / controller GameplayStatics.get_player_pawn/get_player_state/get_player_controller(world, 0)
The ASC scan PlayerState components for class-name "AbilitySystemComponent" (engine AbilitySystemBlueprintLibrary is not Python-bound; pawn.get_player_state() is also absent — use GameplayStatics)
Current attribute value asc.get_gameplay_attribute_value(attr) → tuple (value, bFound), take [0]; handles from asc.get_all_attributes() keyed by get_editor_property("attribute_name")
Base vs current attrset = ps.get_editor_property("attribute_set"); attrset.get_editor_property("MaxHealth") → FGameplayAttributeData with .base_value / .current_value

You cannot build/apply a UGameplayEffect from Python. Its duration_policy, and a modifier's attribute, are EditDefaultsOnly → set_editor_property throws "cannot be edited on instances". To change an attribute through the real GAS pipeline (aggregator → live re-aggregation of dependents), use a C++ dev cheat.

Cheat-manager Exec functions are NOT reachable via MCP console_command. That dispatches at the engine/GEngine level — it runs stat, ShowDebug, py, CVars fine, but a UCheatManager Exec only logs a bare Cmd: echo and never executes (the PIE player's cheat manager isn't the exec target). Invoke it on the cheat-manager object via Python instead:

pc = unreal.GameplayStatics.get_player_controller(w, 0)
pc.get_editor_property("cheat_manager").call_method("AddAttribute", ("Stats.Attributes.Hard.Ferocity", 100.0))
  • AddAttribute <Tag> <Amount> (UEternalCheatManager, dev-only, requires authority) flat-adds to any attribute by tag through an infinite Additive GE → the canonical way to test derived stats / live re-aggregation in PIE. Note runtime GE changes do not persist across PIE restarts (the GE is on the runtime ASC) — re-apply each session.
  • ShowDebug AbilitySystem often renders nothing in this top-down custom-HUD game — don't rely on a screenshot; use the Python readout above.

On a dedicated PIE session

Under bLaunchSeparateServer the reachable player controller is the client's, so cheat_manager above is the client's cheat manager — not the server's. Authority-requiring cheats still work, because AEternalPlayer self-forwards them to the server over Server_ExecuteCheat. The guards on that relay are worth knowing, because they define what is and is not reachable from a client:

Guard Effect
Requires a server-side CheatManager Cheats must be enabled on the server (always in PIE; -EnableCheats otherwise). That existence check is the authorization gate
Dispatches via ProcessConsoleExec on the cheat manager Only declared cheat functions are reachable — a client on a cheats-enabled server cannot run arbitrary engine console commands
256-character command cap Bounds reliable-channel abuse
Compiled out of Shipping The relay does not exist in a shipping build

GiveSimulatedItem remains the exception: it takes a rolled manifest struct, which is not console-parseable, so it still requires a server console (a listen host).


Failure-Mode Taxonomy

Distinguish these — they need different responses:

Symptom (MCP result) Likely cause Response
timed out after 30000ms long op still running (recompile/import) wait + poll the log for the marker; don't re-run
Connection lost mid-op editor crashed during the op check process; relaunch; avoid the op that crashed
ENGINE_ERROR ... Handled ensure op partially applied (e.g. bad widget add) inspect actual state; clean up the half-result
process alive but all MCP calls time out game thread blocked — usually a modal dialog force-kill + relaunch (can't dismiss a modal headless)
editor exits, launch task "exit 3" crash earlier in the session relaunch clean

Asset Playbooks

Newly-created assets may not show up in queries immediately (bridge ≥ v0.5.30). 0.5.30 dropped the synchronous asset-registry scan from query/workflow handlers (it was blocking the GameThread), so a just-created asset can be absent from does_asset_exist / asset queries until the editor rescans — even though it landed. Verify on disk instead: Glob the .uasset (filesystem is authoritative and unaffected). Treat a fresh does_asset_exist=false as "registry not rescanned yet", not "create failed".

Screenshots are async (bridge ≥ v0.5.30). The screenshot handler returns async: true with an expectedDelay — the image isn't ready when the call returns. Poll/wait per the returned timing instead of assuming the file exists immediately. (Still ASK before any camera move for a screenshot — don't reposition the viewport; let the user F-focus.)

Don't touch the user's editor state mid-task. The user is actively navigating the editor, so scripts must never mutate the viewport camera (set_level_viewport_camera_info and similar), change the actor selection of arbitrary actors, open/close tabs, switch levels, or change the ViewMode. To help the user find a spawned test actor, set_selected_level_actors it so their F (focus selected) works — but do not move the camera yourself. If a script genuinely needs a camera move (e.g. an automated screenshot), ASK first.

Textures

  • Generate sources with PIL/numpy → ArtSource/Textures/<feature>/ (PIL + numpy are available via python).
  • Import via a Slate post-tick callback, not directly — a direct Interchange import from a py console call hits a taskgraph re-entrancy crash. Register unreal.register_slate_post_tick_callback(fn), do the AssetImportTask inside, then unregister.
  • CRITICAL: unregister the callback as the FIRST line inside it, before any work — not at the end. The tick can queue the callback more than once; unregistering only after the import lets a queued second call re-enter → RecursionError spam. Even brief spam poisons the session (stale shared-ptrs) and the next heavy op crashes with Assertion failed: IsValid() (SharedPointer.h:1082). Use def _do(d): unregister(_h); <import>.
  • After an import session (especially any that spammed), restart the editor before the next heavy op (material build, etc.). A clean session runs asset/material ops fine; a post-import/post-spam session tends to crash on the next big operation. Observed repeatedly.
  • texture.set_editor_property("lod_group", unreal.TextureGroup.TEXTUREGROUP_UI) raises a type-conversion error in 5.6 — set the texture group via MCP manage_texture set_texture_group instead (or skip; default group is fine for UI).

Materials (base)

  • To ITERATE a material, build at a NEW unique name — never delete_asset + recreate. Once a material has been opened in the editor or referenced by anything (e.g. a widget image), it's loaded; delete_asset then fails ForceDeleteObjects ("package is now potentially corrupt") and the recreate returns None. This bit the chant flourish repeatedly. Bump the asset name (..._Altar, ..._v2) and re-point references, or restart the editor to unload before deleting. Either way: don't delete a loaded/referenced asset headless.
  • ALWAYS grep [SM6] (and MaterialEditorStats: Error) after recompile_material — a *_DONE log marker only means the script ran, not that the shader compiled. Common errors: a ComponentMask with no input connected; a TextureSample whose sampler type doesn't match the texture (sRGB-off texture needs the Linear Color sampler — set sampler_type = SAMPLERTYPE_LINEAR_COLOR; data/mask textures should be sRGB-off + Masks/LinearColor).
  • Reliable path: unreal.MaterialEditingLibrary in one synchronous script. Create the asset with AssetTools.create_asset(name, pkg, unreal.Material, unreal.MaterialFactoryNew()), set material_domain/ blend_mode, add expressions, connect_material_expressions, connect_material_property for outputs (MP_EMISSIVE_COLOR, MP_OPACITY, …), then recompile_material + save_asset. This does not crash (unlike asset import) and is far more reliable than MCP node-by-node.
  • Custom HLSL node: set code + output_type; add unreal.CustomInput() with only input_name (do not set input_type — black material in 5.6). Texture inputs auto-name a <InputName>Sampler. Custom nodes have been implicated in editor hangs during graph build — prefer standard math nodes (Subtract/Divide/Clamp/Multiply) when feasible.
  • Custom HLSL forbids nested function definitions — code runs as a function body; float foo() {...} errors with function definition is not allowed here. Inline all helpers (loops/conditionals/locals fine).
  • NEVER probe pin names by looping connect_material_expressions over candidates on a live graph. Each successful connection silently overwrites the target node's existing input wire — no warning. Probing pin names against a real RuntimeVirtualTextureOutput once overwrote the live LandscapeLayerBlend wires with a zero-constant, so RVT pages stored zeros and rocks went dark (~30 min to trace, because the symptom looked like a shader bug, not a self-inflicted rewire). Probe on a throwaway material (or a disposable target instance), keep a known-good pin-name cheat-sheet (reference_ue56_python_material_quirks), and if you must probe live, call connect once with the best guess and check the return rather than iterating candidates.
  • Graph inspection: mat.get_editor_property("expressions") throws "protected and cannot be read" in 5.6. Use MCP get_material_info — returns full node list, parameters, and connections (sourceNodeId/sourceOutputIndex/targetNodeId/targetInput); the only reliable way to see wiring.
  • MI parent ref breaks on parent delete+recreate. If a build script deletes and recreates a parent material, every existing MI's parent reference goes invalid (MI loads but shows no params / wrong defaults). Re-link at the end of the script: load each known MI, set_editor_property("parent", parent_mat), save. (Better: don't delete+recreate — see the iterate-at-a-new-name rule above.)
  • UI materials: MaterialDomain.MD_UI, BLEND_ADDITIVE for glows. Note the material Time node in a UMG material does not match world GetGameTimeInSeconds — do not build one-shot envelopes that compare an externally-set StartTime against material Time; they never line up. Drive UI one-shots by animating a material scalar param (or the Image's RenderOpacity) from a widget animation instead, and keep the material "always on" (gated only by that param).

Material Instances

  • Create with MCP create_material_instance (Python MaterialInstanceConstantFactoryNew silently fails in 5.6). Set params with Python MaterialEditingLibrary.set_material_instance_*_parameter_value.

Blueprints — class vs graph

  • Reparent works via Python: BlueprintEditorLibrary.reparent_blueprint(bp, unreal.NewParentClass) + compile_blueprint + save_asset. (bp.get_editor_property('parent_class') is not a valid property — don't read it back that way; check bp.generated_class() instead, though get_super_class() is also absent.)
  • Set CDO defaults: MCP manage_blueprint set_default works (e.g. assign an object property). From Python, reach the CDO with the module form cdo = unreal.get_default_object(bp.generated_class()) — that returns the Actor/object CDO, so cdo.set_editor_property(...) + EditorAssetLibrary.save_loaded_asset(bp, False) works. The method form bp.generated_class().get_default_object() returns the class, and set_editor_property on it fails ("not found on BlueprintGeneratedClass").
  • Never edit or compile_blueprint a Blueprint the user is currently playing in PIE. PIE holds live instances of the class; recompiling it underneath them stalls the editor (the user has to interrupt, and PIE gets stuck). Call control_editor stop_pie first — or ask the user to exit PIE — before any py that touches a played BP (component defaults, SCS edits, compile_blueprint). Asset-only edits (materials, MICs) are safer but still prefer PIE stopped.
  • Graph editing (events, delegate binds, node wiring) via MCP is unreliable: add_event returns GUID node names, and binding a multicast delegate (OnX) to a custom event headless is error-prone. Do graph work in-editor by hand, or move the logic to C++.
  • BlueprintEditorLibrary.reparent_blueprint + compile_blueprint does NOT save. Follow with an explicit EditorAssetLibrary.save_asset(..., only_if_is_dirty=False) or the reparent silently reverts on editor close (cost a session a template's parent class before the test suite caught it).

Gameplay tags & struct values from Python

  • request_gameplay_tag/make_literal_gameplay_tag are unbound in 5.6, but every exposed struct has import_text — construct tags in pure Python: t = unreal.GameplayTag(); t.import_text('(TagName="Desire.Break.Poise")') (then GameplayTagLibrary.make_gameplay_tag_container_from_tag(t) for containers). Works for any ExportText-able struct value on data assets (set + save_asset directly). For Blueprint CDOs still prefer the Eternal.BP.SetCDOProperty console seam — it handles the modify/compile/save dance.
  • Reading a tag back: str(tag) prints an opaque struct — use GameplayTagLibrary.get_tag_name(tag).
  • The stamp console command (Eternal.Ability.Stamp) tokenizes on whitespace and does not honor quotes — AbilityName="Echo Strike" aborts. Stamp with no spaced values, then set display fields via Eternal.BP.SetCDOProperty (which joins trailing args, so spaces are fine there).

UMG Widgets

  • Widget tree is not editable from Python — WidgetBlueprint.widget_tree is not an exposed property in 5.6.
  • MCP add_image/set_* are flaky: names may not stick (an added image came back named Image), and setting a material as an image brush threw a handled ensure.
  • If you do add slots via manage_widget_authoring, name them with slotName — NOT name. The add_* handlers read slotName; a name field is ignored, so the widget silently takes the default class name (TextBlock/VerticalBox). This is the #1 trap, and it's unrecoverable: remove_widget/rename_widget exist in the bridge source but are not reachable via the live tool (UNKNOWN_ACTION), so a mis-named add is permanent — only set_visibility Collapsed can neutralize a stray. Get the name right on the first try.
  • add_* returns ENGINE_ERROR: Handled ensure but still creates the widget (the ensure is the compiler's "added but did not get a GUID" — non-fatal). Don't trust the error response; confirm with get_widget_info that the slot actually appeared. C++ BindWidget/BindWidgetOptional resolves by tree name at runtime regardless of the ensure, so a correctly-named slot binds fine — prefer BindWidgetOptional so a missing slot never blocks compile. set_visibility and other set_* also target via slotName and work cleanly; nest children with parentSlot=<container name>.
  • There is no add-user-widget-instance action — you cannot instance an existing UserWidget (e.g. a W_StatEntry row) as a child via MCP; those must be authored in-editor by hand.
  • Conclusion: build/lay out widgets and author widget animations in-editor by hand (or add plain BindWidget slots via manage_widget_authoring with the caveats above). Generate the content they consume (materials, textures, sounds) via automation; wire the widget yourself.

Level actors & Sequencer

  • control_actor spawn (by classPath, e.g. /Script/Engine.CameraActor or a BP path) + add_tag are reliable; find at runtime by tag. manage_sequence create makes a Level Sequence; deep track/binding authoring is best done in-editor.

Editor Lifecycle & Builds

Change type How to apply Editor open?
.cpp only (no header/UPROPERTY/module change) Live Coding: console LiveCoding.Compile yes — no restart
Header change, new class, new module dependency (Build.cs) Full UBT build no — close first
  • Build (Home env): dotnet "Q:\Unreal\UE_5.8\...\UnrealBuildTool.dll" ProjectEternalEditor Win64 Development "-Project=...\ProjectEternal.uproject" -WaitMutex -FromMSBuild. Always confirm Home vs Work env first.
  • Full build requires the editor closed (DLL lock). Guarded pattern: wait for the process to exit, then build, in one backgrounded command.
  • Launch + wait-for-ready: relaunch the editor in the background, then poll the log until (Engine Initialization) Total appears and the process is up (avoids racing the MCP bridge).
  • Close cleanly with console QUIT_EDITOR (saves + exits). The bridge reconnects automatically on relaunch.

Crash / Hang Recovery

  1. Confirm state: is UnrealEditor.exe running? Tail the log for Critical error / Assertion.
  2. Hung (modal) editor: force-kill — taskkill //F //IM UnrealEditor.exe (git-bash) — then relaunch. You cannot dismiss a modal dialog headless.
  3. delete_asset corruption: deleting an asset that is referenced (e.g. a material used by a widget image) fails ForceDeleteObjects and flags the package corrupt. Don't delete_asset referenced assets headless. Instead build the replacement at a fresh path and re-point the reference, or edit in place.
  4. Saved assets persist across crashes — re-verify what landed (does_asset_exist) before redoing work.
  5. Bulk move/delete modal-hangs when Source Control is on. MCP fixup_redirectors and EditorAssetLibrary.delete_directory hang the game thread when the SC provider is Git LFS 2 — the checkout / mark-for-delete prompt is a modal that can't be dismissed headless, so every MCP call then times out (log frozen, CPU idle). Fix: edit Saved/Config/WindowsEditor/SourceControlSettings.ini → Provider=None, relaunch, run the destructive ops (they now run instantly, no modal), then restore Provider=Git LFS 2 (effective next launch).

Reliability Cheat Sheet

✅ Reliable headless ⚠️ Flaky / avoid headless
Material graphs via MaterialEditingLibrary UMG widget tree / widget animations
MI creation via MCP create_material_instance BP graph node wiring / delegate binds
Texture import via Slate-tick-deferred AssetImportTask Texture import direct from console py
reparent_blueprint, set_default, compile_blueprint delete_asset on referenced assets
control_actor spawn/tag/transform; manage_level save Custom HLSL nodes during graph build (hang risk)
Live Coding for .cpp-only changes Reading bp.parent_class / class.get_super_class()

Rule of thumb: automation generates assets; humans (or C++) wire logic and UI. When an op crashes or hangs twice, stop iterating headless and hand that step to the editor.


  • Skeletal Mesh Rig Transfer — Blender→UE pipeline, also uses MCP/Python import.
  • Gameplay Cue Visuals — data-driven materials/auras.
  • Source/ProjectEternal/Public/AbilitySystem/EternalAttributeSet.h — attribute names used by the PIE probe.
  • Source/ProjectEternal/Public/Debug/EternalCheatManager.h — the runtime dev cheats (combat, itemization, remnants, greed) usable from the in-game console.
  • Source/ProjectEternalEditor/Private/MCP/EternalCheatToolset.{h,cpp} — the MCP toolset wrapping those cheats (the agent path; see "Project toolset" above), incl. the structured GetInventory/GetEquipment readers.

Recent Changes

Date Change Impact
2026-05-30 Initial guide from the glyph-flourish session Captures import-via-slate-tick, material-via-Python, UMG/BP-graph headless limits, delete-on-referenced corruption, UMG material-time vs game-time mismatch
2026-06-17 Added "Inspecting & Driving GAS in PIE" PIE-world (not editor-world) access, ASC-on-PlayerState discovery, attribute read pattern (tuple return; base vs current), GE-not-constructible-in-Python, cheat-manager execs need call_method not MCP console, AddAttribute cheat for re-aggregation tests
2026-07-03 Merged orphaned .claude/notes/UnrealMCP_Guide.md Custom-HLSL no-nested-functions rule, get_material_info for protected expressions list, MI-parent-invalidation on parent delete+recreate
2026-06-18 MCP bridge 0.5.21 → v0.5.30 Prefer system_control execute_python (file/inline, stdout/stderr captured; 1 MB + in-project-path limits) over console_command "py exec()"; fresh does_asset_exist may lag the registry rescan (verify on disk); screenshots now async (expectedDelay)
2026-07-03 Promoted 7 session-memory notes New Setup & Versioning section (version-locked server+plugin, 4-step upgrade); probe-when-inspect-thin rule + probes in Tools/Python not Content/; destructive pin-name probe warning; widget slotName-not-name authoring rules; CDO write via get_default_object(bp.generated_class()); no BP edit/compile during PIE; Git-LFS-2 modal-hang on fixup_redirectors/delete_directory (flip Provider=None); broadened viewport rule (no selection/tab/ViewMode changes mid-task)
2026-07-15 Documented ProjectEternalEditor.EternalCheatToolset (project's own MCP toolset) 18 tools (combat + itemization + remnants/greed + structured GetInventory/GetEquipment loadout reads); the only agent path to the project's console cheats; C++ toolset registration must defer to GetOnPostEngineInit() (registry subsystem absent at module StartupModule); ToolsetRegistry now an explicit uproject plugin; live-verified end-to-end over MCP
2026-08-06 Corrected the cheat-toolset listing and documented dedicated-session cheat reach The toolset had grown past the stated count (ApplyStatus, TriggerCombatEffect were missing) — count the AICallable UFUNCTIONs rather than trusting a number. Player resolution is now "first PIE world with a local PC", so on a dedicated session the reachable cheat manager is the client's; authority cheats self-forward over Server_ExecuteCheat, gated on a server-side cheat manager, ProcessConsoleExec dispatch and a 256-char cap. TeleportToRoom / ListDungeonRooms are console-only, not toolset tools
2026-08-20 Documented the second project toolset, ProjectEternalEditor.EternalAuthoringToolset Montage notify placement (ListMontageNotifies, AddWeaponTrailNotify, AddPlaySoundNotify), SetHitStopLayer, and the WidgetForge Apply/Export/Render agent route were live in code but absent from the guide; mutators dirty-but-don't-save by design