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.

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 its own toolset so an agent can 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, 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, and modifiers[] (channel Implicit/Prefix/Suffix, modifierId, tier, value, description). The itemization analogue of the GAS inspector.

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) — this toolset registers 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.


⚠️ 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 EditDefaultsOnlyset_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 definitionscode 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 PythonWidgetBlueprint.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.6\...\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.iniProvider=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