Unreal MCP + Python Automation Guide¶
Summary: Practical playbook for driving the Unreal editor from Claude via MCP and the
pyconsole command. Covers UE 5.8 + Epic's native ModelContextProtocol plugin. The in-repoMcpAutomationBridgeplugin 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)
- Calling Convention & Toolsets
- The Python Console Pattern
- Always Read the Editor Log
- Inspecting & Driving GAS in PIE
- Failure-Mode Taxonomy
- Asset Playbooks
- Editor Lifecycle & Builds
- Crash / Hang Recovery
- Reliability Cheat Sheet
- Related
- Recent Changes
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):
list_toolsets— one-line description per registered toolset.describe_toolset{toolset_name}— full tool list + input schemas for one toolset.call_tool{toolset_name, tool_name, arguments}— executes a toolset tool.tool_nameis the SHORT name (get_current_level), not the fully-qualifiededitor_toolset.toolsets.scene.SceneTools.get_current_level— the long form returns "Unknown tool". The args field isarguments(an object), nottool_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) andGetEquipment(equipped, tagged with slot name); each item carriesitemId,itemLevel,slotName,modifiers[](channel Implicit/Prefix/Suffix, modifierId, tier, value, description), andusagesLeft/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 theEternal.Net.DumpConnectionStatsconsole command). The headless stand-in forstat 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(wrapsEternal.Net.MeasureBandwidth [Map] [NumClients] [Seconds] [Listen|Dedicated]) PIEs the map with a client topology, samples every connection once a second, and writesSaved/NetProfile/NetProfile_<ts>.csv+.md(avg/peak per connection). - Montage notifies, editor closed:
Eternal.Anim.ListMontageNotifies <MontagePath>andEternal.Anim.AddHyperArmorNotify <MontagePath> <StartSec> <EndSec> [Track](saves) are the console twins of the authoring toolset'sListMontageNotifies/AddHyperArmorNotify, runnable throughTools/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 theFHitStopLayerof oneEffectConfigurationsrow (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
McpAutomationBridgetool 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
codecapped at 1 MB; thefilepath is validated to resolve inside the project dir (symlink-escape blocked) — ourArtSource/is in-project, so fine.codeandfileare 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_pythonis 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
pycalls in one editor session (e.g. a_handlefrom 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 withexecute_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 underContent/.Content/Pythonis reserved for editor-managed Python (init_unreal.pyauto-runs there); scratch dumped there silently accumulates — a real cleanup once had to weed ~19 stray one-shot scripts out ofContent/Python. Delete a probe as soon as you've read its output; only promote it toTools/Pythonif 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 AbilitySystemoften 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:Globthe.uasset(filesystem is authoritative and unaffected). Treat a freshdoes_asset_exist=falseas "registry not rescanned yet", not "create failed".Screenshots are async (bridge ≥ v0.5.30). The screenshot handler returns
async: truewith anexpectedDelay— 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_infoand 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_actorsit so theirF(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 viapython). - Import via a Slate post-tick callback, not directly — a direct Interchange import from a
pyconsole call hits a taskgraph re-entrancy crash. Registerunreal.register_slate_post_tick_callback(fn), do theAssetImportTaskinside, 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 →
RecursionErrorspam. Even brief spam poisons the session (stale shared-ptrs) and the next heavy op crashes withAssertion failed: IsValid()(SharedPointer.h:1082). Usedef _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 MCPmanage_texture set_texture_groupinstead (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_assetthen failsForceDeleteObjects("package is now potentially corrupt") and the recreate returnsNone. 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](andMaterialEditorStats: Error) afterrecompile_material— a*_DONElog marker only means the script ran, not that the shader compiled. Common errors: aComponentMaskwith no input connected; aTextureSamplewhose sampler type doesn't match the texture (sRGB-off texture needs the Linear Color sampler — setsampler_type = SAMPLERTYPE_LINEAR_COLOR; data/mask textures should be sRGB-off + Masks/LinearColor). - Reliable path:
unreal.MaterialEditingLibraryin one synchronous script. Create the asset withAssetTools.create_asset(name, pkg, unreal.Material, unreal.MaterialFactoryNew()), setmaterial_domain/blend_mode, add expressions,connect_material_expressions,connect_material_propertyfor outputs (MP_EMISSIVE_COLOR,MP_OPACITY, …), thenrecompile_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; addunreal.CustomInput()with onlyinput_name(do not setinput_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 —
coderuns as a function body;float foo() {...}errors withfunction definition is not allowed here. Inline all helpers (loops/conditionals/locals fine). - NEVER probe pin names by looping
connect_material_expressionsover candidates on a live graph. Each successful connection silently overwrites the target node's existing input wire — no warning. Probing pin names against a realRuntimeVirtualTextureOutputonce overwrote the liveLandscapeLayerBlendwires 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, callconnectonce 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 MCPget_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_ADDITIVEfor glows. Note the materialTimenode in a UMG material does not match worldGetGameTimeInSeconds— do not build one-shot envelopes that compare an externally-setStartTimeagainst materialTime; 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(PythonMaterialInstanceConstantFactoryNewsilently fails in 5.6). Set params with PythonMaterialEditingLibrary.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; checkbp.generated_class()instead, thoughget_super_class()is also absent.) - Set CDO defaults: MCP
manage_blueprint set_defaultworks (e.g. assign an object property). From Python, reach the CDO with the module formcdo = unreal.get_default_object(bp.generated_class())— that returns the Actor/object CDO, socdo.set_editor_property(...)+EditorAssetLibrary.save_loaded_asset(bp, False)works. The method formbp.generated_class().get_default_object()returns the class, andset_editor_propertyon it fails ("not found on BlueprintGeneratedClass"). - Never edit or
compile_blueprinta 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). Callcontrol_editor stop_piefirst — 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_eventreturns 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_blueprintdoes NOT save. Follow with an explicitEditorAssetLibrary.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_tagare unbound in 5.6, but every exposed struct hasimport_text— construct tags in pure Python:t = unreal.GameplayTag(); t.import_text('(TagName="Desire.Break.Poise")')(thenGameplayTagLibrary.make_gameplay_tag_container_from_tag(t)for containers). Works for any ExportText-able struct value on data assets (set +save_assetdirectly). For Blueprint CDOs still prefer theEternal.BP.SetCDOPropertyconsole seam — it handles the modify/compile/save dance.- Reading a tag back:
str(tag)prints an opaque struct — useGameplayTagLibrary.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 viaEternal.BP.SetCDOProperty(which joins trailing args, so spaces are fine there).
UMG Widgets¶
- Widget tree is not editable from Python —
WidgetBlueprint.widget_treeis not an exposed property in 5.6. - MCP
add_image/set_*are flaky: names may not stick (an added image came back namedImage), and setting a material as an image brush threw a handled ensure. - If you do add slots via
manage_widget_authoring, name them withslotName— NOTname. Theadd_*handlers readslotName; anamefield 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_widgetexist in the bridge source but are not reachable via the live tool (UNKNOWN_ACTION), so a mis-named add is permanent — onlyset_visibility Collapsedcan neutralize a stray. Get the name right on the first try. add_*returnsENGINE_ERROR: Handled ensurebut 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 withget_widget_infothat the slot actually appeared. C++BindWidget/BindWidgetOptionalresolves by tree name at runtime regardless of the ensure, so a correctly-named slot binds fine — preferBindWidgetOptionalso a missing slot never blocks compile.set_visibilityand otherset_*also target viaslotNameand work cleanly; nest children withparentSlot=<container name>.- There is no add-user-widget-instance action — you cannot instance an existing
UserWidget(e.g. aW_StatEntryrow) 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_authoringwith the caveats above). Generate the content they consume (materials, textures, sounds) via automation; wire the widget yourself.
Level actors & Sequencer¶
control_actor spawn(byclassPath, e.g./Script/Engine.CameraActoror a BP path) +add_tagare reliable; find at runtime by tag.manage_sequence createmakes 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) Totalappears 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¶
- Confirm state: is
UnrealEditor.exerunning? Tail the log forCritical error/Assertion. - Hung (modal) editor: force-kill —
taskkill //F //IM UnrealEditor.exe(git-bash) — then relaunch. You cannot dismiss a modal dialog headless. delete_assetcorruption: deleting an asset that is referenced (e.g. a material used by a widget image) failsForceDeleteObjectsand flags the package corrupt. Don'tdelete_assetreferenced assets headless. Instead build the replacement at a fresh path and re-point the reference, or edit in place.- Saved assets persist across crashes — re-verify what landed (
does_asset_exist) before redoing work. - Bulk move/delete modal-hangs when Source Control is on. MCP
fixup_redirectorsandEditorAssetLibrary.delete_directoryhang the game thread when the SC provider isGit 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: editSaved/Config/WindowsEditor/SourceControlSettings.ini→Provider=None, relaunch, run the destructive ops (they now run instantly, no modal), then restoreProvider=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.
Related¶
- 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 structuredGetInventory/GetEquipmentreaders.
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 |