# MCP Tools Reference

**105 tools total.** 72 are JSON-RPC 2.0 methods served on `127.0.0.1:18888` directly by the plugin's C++ handlers. The remaining 33 — `wait_for_events`, `get_camera_transform`, `set_camera_transform`, `screenshot_actor`, `compile_mod_pak`, `compile_mod_pak_direct`, `bulk_delete_assets`, `bulk_move_assets`, `bulk_rename_assets`, `bulk_duplicate_assets`, `bulk_inspect_assets`, `inspect_data_asset`, `inspect_sound_class`, `inspect_sound_submix`, `inspect_audio_bus`, `inspect_material_function`, `inspect_metasound`, `find_unused_assets`, `get_reference_chain`, `bulk_compile_blueprints`, `audit_blueprint_compile_status`, `find_actors_by_class`, `bulk_focus_actors`, `bulk_screenshot_actors`, `bulk_set_actor_property`, `compare_assets`, `bulk_set_console_variables`, `inspect_dependency_graph`, `bulk_fix_redirectors`, `marketplace_search`, `marketplace_import`, `convert_hdri_to_cubemap`, `sequencer_add_transform_keyframe` — are bridge-side **synthetic tools** that are intercepted by `bridge/unreal_ai_connection_bridge.py` and served by composing existing handlers (or running Python via `execute_unreal_python`, or — for `compile_mod_pak` — shelling out to `RunUAT.bat` entirely outside the UE process). They are visible to MCP clients exactly like the C++ tools but cannot be reached by sending raw JSON-RPC to the TCP socket — only via the MCP bridge or by replicating their composition manually. The "Implementation" header on each entry below indicates whether a tool is C++ or bridge-side.

Each tool's params and result are documented with a working example.

To run any of the **C++** tools manually from the shell, use Python:

```python
import socket, json
s = socket.socket(); s.connect(('127.0.0.1', 18888))
s.send(json.dumps({"jsonrpc":"2.0","id":1,"method":"<METHOD>","params":{...}}).encode())
print(s.recv(65536).decode())
```

Synthetic tools (all 33: `wait_for_events`, `get_camera_transform`, `set_camera_transform`, `screenshot_actor`, `compile_mod_pak`, `compile_mod_pak_direct`, `bulk_delete_assets`, `bulk_move_assets`, `bulk_rename_assets`, `bulk_duplicate_assets`, `bulk_inspect_assets`, `inspect_data_asset`, `inspect_sound_class`, `inspect_sound_submix`, `inspect_audio_bus`, `inspect_material_function`, `inspect_metasound`, `find_unused_assets`, `get_reference_chain`, `bulk_compile_blueprints`, `audit_blueprint_compile_status`, `find_actors_by_class`, `bulk_focus_actors`, `bulk_screenshot_actors`, `bulk_set_actor_property`, `compare_assets`, `bulk_set_console_variables`, `inspect_dependency_graph`, `bulk_fix_redirectors`, `marketplace_search`, `marketplace_import`, `convert_hdri_to_cubemap`, `sequencer_add_transform_keyframe`) must be reached through the MCP bridge.

---

## execute_unreal_python

Universal escape hatch. Runs an arbitrary block of Python in the editor's embedded interpreter via `IPythonScriptPlugin`. Multi-line scripts are supported — the handler writes the source to a temp `.py` file under `Intermediate/UnrealClaudeMCPPython/` and asks UE to execute it (this avoids `ExecPythonCommandEx`'s file-vs-source heuristic getting confused by multi-line input).

**Params**
- `code` (string, required) — Python source code

**Result**
- `ok` (bool) — true if the script ran without raising
- `output` (string) — what the interpreter printed
- `temp_script` (string) — path to the temp `.py` file (debugging aid)

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"execute_unreal_python","params":{
  "code":"import unreal\nunreal.log('hello from MCP')"
}}
```

---

## get_project_summary

Top-level snapshot of the open project.

**Params** — none

**Result**
- `project_name`, `project_id`, `project_version`, `company_name`, `engine_version` (strings)
- `plugins` (array) — `{name, version, category, enabled_by_default}`. `enabled_by_default` is a string: `"enabled"` / `"disabled"` / `"unspecified"`.
- `asset_count` (int) — assets under `/Game` and any plugin-content roots

---

## inspect_blueprint

Read parent class, declared variables, function/event graph names, and compile status of a Blueprint asset.

**Params**
- `path` (string, required) — e.g. `/Game/Blueprints/BP_MyActor.BP_MyActor`

**Result**
- `path`, `parent_class`, `blueprint_class` (strings)
- `blueprint_status` (string) — one of `UpToDate`, `UpToDateWithWarnings`, `Dirty`, `Error`, `BeingCreated`, `Unknown`. Mirrors `inspect_widget_blueprint`'s field of the same name. Pairs with `audit_blueprint_compile_status`, which buckets BPs by this value.
- `variables` (array) — `{name, type_category, type_subcategory, default}`
- `function_graphs`, `event_graphs` (arrays of strings)

---

## inspect_widget_tree

Read the widget hierarchy of a `UWidgetBlueprint` or `UEditorUtilityWidgetBlueprint`. **This is the headline feature of the plugin** — UE Python can't reach `WidgetTree` because of `UPROPERTY()` reflection limits, but this handler accesses it via direct C++ and exposes it as JSON.

**Params**
- `path` (string, required)

**Result**
- `path`, `blueprint_class`, `root` (root widget name), `root_class` (strings)
- `widgets` (array) — `{name, class, parent}`
- `widget_count` (int)

---

## edit_widget_tree

Mutate the widget tree of a `UWidgetBlueprint` / EUW. Three ops.

**Params**
- `path` (string, required)
- `op` (string, required) — `"set_root"` | `"add_child"` | `"set_property"`
- `class` (string) — for set_root and add_child; one of `VerticalBox|HorizontalBox|CanvasPanel|TextBlock|Button|Border|Image|Spacer|EditableTextBox`, or a fully-qualified `UClass` path
- `name` (string) — name to assign to the new widget
- `parent` (string) — for add_child; name of the parent panel widget
- `widget` (string) — for set_property; target widget name
- `property` (string) — for set_property; UProperty name
- `value` (string) — for set_property; coerced to str/float/int/bool by property type
- `compile` (bool, optional, default false) — call `FKismetEditorUtilities::CompileBlueprint` after the edit. Skip for batch operations; compile once explicitly at the end (per-edit compile crashed UE during testing under high-frequency mutation).

**Result**
- `op` — the op that ran
- `created` (string) — for set_root and add_child, the widget name
- `set` (string) — for set_property, `"WidgetName.PropertyName"`

**Important persistence note**: every call marks the widget tree dirty + the BP structurally modified, then saves the asset to disk. The asset is recompiled automatically when next loaded by the editor, or you can explicitly compile via the `compile: true` parameter on the last edit in a batch.

**UE 5.7 note for python callers creating Widget Blueprints in-process**: `WidgetBlueprintFactory.parent_class` was removed between UE 5.x and 5.7. Setting it on the factory raises `AttributeError`. Create the WBP with the factory's default parent (UserWidget) and, if you need a different parent class, set it on the resulting `UWidgetBlueprint` via `unreal.WidgetBlueprint.set_editor_property("ParentClass", ...)` after creation. Surfaced during the PR #174 100-tool validation run (Stage 10).

---

## get_viewport_screenshot

Capture the active editor viewport as a PNG, return base64 inline.

**Params** — none

**Result**
- `width`, `height`, `png_bytes` (numbers)
- `png_base64` (string) — the PNG bytes encoded as base64

Beware: a 1920x1080 PNG can be 1-3 MB of base64. If you're proxying through Claude, the response may exceed Claude's context — for big captures use `take_high_res_screenshot` instead, which writes to disk.

---

## render_camera_to_png

Force a **synchronous** render and write it to disk as a PNG. The handler runs on the game thread and drives the exact draw chain itself (`Invalidate` → `RedrawLevelEditingViewports(true)` → `Viewport->Draw(false)` → `FlushRenderingCommands` → `ReadPixels` → encode), so it succeeds in headless / backgrounded sessions where every *deferred* screenshot path (`HighResShot`, `FScreenshotRequest`, automation, Python `SceneCapture`) returns blank pixels or never resolves.

Two render modes, selected automatically:

- **Viewport mode** (default) — synchronously redraws the active level-editor viewport and reads its pixels. Used when neither `width`/`height` nor `camera_label` is supplied.
- **Off-screen mode** — spawns a transient `ASceneCapture2D` into the editor world, points it at the requested transform, and captures into a `UTextureRenderTarget2D` at the requested resolution. Used when `width` and `height` are both > 0, or when `camera_label` is set.

**Params**
- `out_path` (string, required) — absolute filesystem path for the `.png` to write. Parent directory must exist and be writable.
- `width` (int, optional) — output width in pixels. Supplying both `width` and `height` switches to off-screen mode (default 1920 if omitted in off-screen mode; clamped 1..8192).
- `height` (int, optional) — output height in pixels (default 1080 in off-screen mode; clamped 1..8192).
- `camera_label` (string, optional) — render from this level actor's world transform instead of the current viewport camera. Forces off-screen mode.
- `fov` (number, optional) — horizontal field of view in degrees; overrides the viewport / capture FOV for this render only (the original FOV is restored afterward in viewport mode).

**Result**
- `ok` (bool)
- `path` (string) — the absolute path that was written (echo of `out_path`)
- `width`, `height` (int) — pixel dimensions of the written PNG

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"render_camera_to_png","params":{
  "out_path": "C:/Captures/shot.png",
  "width": 3840,
  "height": 2160,
  "camera_label": "CineCamera_Hero",
  "fov": 50
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "path": "C:/Captures/shot.png",
  "width": 3840,
  "height": 2160
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `render_camera_to_png: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `no_editor` | `GEditor` is null (editor build only). |
| `bad_param` | `out_path` was missing or an empty string. |
| `no_level_editor` | LevelEditor module unavailable (viewport mode). |
| `no_viewport` | No active level viewport / could not obtain `FViewport*` (viewport mode). |
| `no_world` | Editor world is null (off-screen mode). |
| `actor_not_found` | `camera_label` did not match any actor's label. |
| `rt_alloc_failed` | Could not create the `UTextureRenderTarget2D` (off-screen mode). |
| `spawn_failed` | Could not spawn the transient `ASceneCapture2D` (off-screen mode). |
| `read_failed` | `ReadPixels` returned false or an empty bitmap. |
| `encode_failed` | PNG compression produced empty output. |
| `write_failed` | Could not write the PNG to `out_path` (path/permissions). |

---

## list_tools

Return the names of every registered MCP method.

**Params** — none

**Result**
- `tools` (array of strings, sorted)
- `count` (int)

---

## get_actors_in_level

Return name / class / transform of every actor in the active editor world.

**Params**
- `name_contains` (string, optional) — case-insensitive substring filter on actor label

**Result**
- `world` (string) — world name
- `total_actors` (int) — actors in the world before filtering
- `returned` (int) — actors after filtering
- `actors` (array) — `{name, label, class, loc_x, loc_y, loc_z, yaw, pitch, roll}`

---

## focus_actor

Select an actor by label or unique name and frame the editor viewport on it.

**Params**
- `name` (string, required) — actor label OR unique name

**Result**
- `focused` (string) — actor label
- `name` (string) — unique name
- `loc_x`, `loc_y`, `loc_z` (numbers)

---

## load_level_by_path

Load a UE level by package path.

**Params**
- `path` (string, required) — e.g. `/Game/Maps/MyLevel`

**Result**
- `path`, `loaded` (bool)

---

## take_high_res_screenshot

Trigger UE's `HighResShot` console command. Output goes to `<Project>/Saved/Screenshots/<Platform>Editor/` — `WindowsEditor` on Windows, `MacEditor` on Mac, `LinuxEditor` on Linux. The exact path is returned in `output_dir_hint` of the response.

**Params**
- `multiplier` (number, optional, default 1, capped at 8) — viewport-pixel scaling factor

**Result**
- `command` (string) — the command that was issued
- `multiplier` (number)
- `dispatched` (bool)
- `output_dir_hint` (string)

---

## import_texture

Import an image file (PNG/JPG/EXR/TGA/BMP/HDR) from disk into the project as a `UTexture2D` asset, using UE's canonical `UAssetImportTask` + `IAssetTools::ImportAssetTasks` path. The factory's settings inference, source-file metadata, and reimport hooks all behave exactly as if you had drag-dropped the file into the Content Browser manually.

**Params**
- `source_path` (string, required) — absolute filesystem path to the source image.
- `dest_path` (string, required) — UE package path; must start with `/Game/` (e.g. `/Game/Textures/Environment`).
- `dest_name` (string, optional) — asset-name override; defaults to the source filename stem.
- `replace_existing` (bool, optional, default `false`) — overwrite the asset if one already exists at `dest_path/dest_name`.
- `automated` (bool, optional, default `true`) — suppress modal dialogs; required for non-interactive use.
- `save` (bool, optional, default `true`) — persist the `.uasset` to disk after import.

**Result**
- `ok` (bool)
- `asset_path` (string) — UE package path of the new asset
- `asset_name` (string)
- `source_path` (string) — echo of the source argument
- `width`, `height` (int) — pixel dimensions
- `format` (string) — `EPixelFormat` name (e.g. `PF_B8G8R8A8`)
- `message` (string) — human-readable status

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"import_texture","params":{
  "source_path": "C:/Art/stone_diffuse.png",
  "dest_path": "/Game/Textures/Environment",
  "dest_name": "T_Stone_D",
  "replace_existing": false,
  "save": true
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "asset_path": "/Game/Textures/Environment/T_Stone_D",
  "asset_name": "T_Stone_D",
  "source_path": "C:/Art/stone_diffuse.png",
  "width": 2048,
  "height": 2048,
  "format": "PF_B8G8R8A8",
  "message": "Imported PNG (2048x2048) as UTexture2D."
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `import_texture: <error_code>: <human-readable detail>`. Clients can split on `: ` (twice) to extract the stable error code. Stable codes:

| Code | Trigger |
|---|---|
| `missing_params` | Request had no `params` object. |
| `missing_required_field` | `source_path` or `dest_path` was missing or empty. |
| `invalid_dest_path` | `dest_path` does not start with `/Game/`. |
| `source_not_found` | `source_path` does not exist on disk. |
| `unsupported_extension` | File extension not in `{png, jpg, jpeg, exr, tga, bmp, hdr}`. |
| `import_factory_failed` | UE's `IAssetTools::ImportAssetTasks` produced no imported object (factory rejected the input). |
| `imported_not_a_texture` | Defensive — the factory returned an object that is not a `UTexture2D`. Should never fire for the texture factory. |

---

## configure_texture

Adjust the four most common texture settings — `SRGB`, `CompressionSettings`, `LODGroup`, `Filter` — on an existing `UTexture` asset and persist the change. The handler wraps mutations in the required `PreEditChange` / `Modify` / set / `PostEditChangeProperty` / `UpdateResource` / `SaveLoadedAsset` sequence so that the in-editor preview and the on-disk `.uasset` stay consistent.

The `applied` object in the result contains *only* the fields the caller actually provided in the request. Fields that were not specified in the call do not appear in `applied`, which makes it safe to drive this tool in partial-update loops without accidentally inferring what was left unchanged.

**Params**
- `path` (string, required) — UE package path of an existing texture asset, e.g. `/Game/Textures/Environment/T_Stone_D`.
- `srgb` (bool, optional) — sets `UTexture::SRGB`.
- `compression` (string enum, optional) — maps to `TextureCompressionSettings`. Accepted values: `Default`, `Normalmap`, `Masks`, `Grayscale`, `Displacementmap`, `VectorDisplacementmap`, `HDR`, `UserInterface2D`, `BC7`, `HalfFloat`, `SingleFloat`, `Alpha`, `DistanceFieldFont`, `HDR_Compressed`, `HDR_F32`.
- `lod_group` (string enum, optional) — maps to `TextureGroup`. Common values: `World`, `WorldNormalMap`, `WorldSpecular`, `Character`, `CharacterNormalMap`, `CharacterSpecular`, `Weapon`, `WeaponNormalMap`, `WeaponSpecular`, `Vehicle`, `VehicleNormalMap`, `VehicleSpecular`, `Cinematic`, `Effects`, `EffectsNotFiltered`, `Skybox`, `UI`, `Lightmap`, `Shadowmap`, `RenderTarget`, `MobileFlattened`, `Pixels2D`, `HierarchicalLOD`. (Exhaustive list validated against `Engine/Source/Runtime/Engine/Classes/Engine/TextureDefines.h` in UE 5.7.)
- `filter` (string enum, optional) — maps to `TextureFilter`. Accepted values: `Nearest`, `Bilinear`, `Trilinear`, `Default`.
- `compress` (bool, optional, default `true`) — whether to call `UpdateResource()` after mutation; set to `false` for batched edits and trigger a rebuild separately.

**Result**
- `ok` (bool)
- `path` (string) — UE package path of the modified asset
- `applied` (object) — only the fields that were present in the request, with their applied values
- `message` (string) — human-readable status, e.g. `"Applied 3 changes; resource rebuilt and saved."`

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"configure_texture","params":{
  "path": "/Game/Textures/Environment/T_Stone_D",
  "srgb": false,
  "compression": "Normalmap",
  "lod_group": "WorldNormalMap"
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "path": "/Game/Textures/Environment/T_Stone_D",
  "applied": {
    "srgb": false,
    "compression": "Normalmap",
    "lod_group": "WorldNormalMap"
  },
  "message": "Applied 3 changes; resource rebuilt and saved."
}}
```

**Note:** the canonical list of accepted enum values is the parser map in `Handler_ConfigureTexture.cpp`. This document mirrors it but the source is authoritative — UE versions can add or remove enum entries.

**Errors:** Returned as JSON-RPC `error.message` strings in the format `configure_texture: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_params` | Request had no `params` object. |
| `missing_required_field` | `path` was missing or empty. |
| `no_changes_specified` | None of `srgb` / `compression` / `lod_group` / `filter` were provided. The handler is a mutation tool — calling it with zero changes is treated as caller error, not a successful no-op. |
| `asset_not_found` | `LoadObject<UTexture>(path)` returned null (asset doesn't exist or isn't a `UTexture` subclass). |
| `unknown_enum_value` | A string value for `compression` / `lod_group` / `filter` did not match any valid enum. The error message lists the offending field and value. |
| `save_failed` | `UEditorAssetLibrary::SaveLoadedAsset` returned `false` after mutations were applied in-memory. |

---

## find_assets

Query the UE asset registry by class, path, and optional name substring. This is the discovery tool for level-building workflows: call it first to learn which Static Meshes, Blueprints, or other assets are available before spawning them into the level.

**Params**
- `class_path` (string, required) — UE class path, e.g. `/Script/Engine.StaticMesh`, `/Script/Engine.Blueprint`, `/Script/Engine.Texture2D`.
- `path_under` (string, optional, default `/Game/`) — recursive path filter; must start with `/Game/` or `/Engine/`.
- `name_contains` (string, optional) — case-insensitive substring filter on asset name.
- `limit` (int, optional, default `100`) — cap result count; max `500` (silently capped).

**Result**
- `ok` (bool)
- `matched` (int) — total assets matching the filter before `limit` is applied
- `returned` (int) — actual count in the response
- `assets` (array) — `{name, package_path, class}` per asset, sorted by name

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"find_assets","params":{
  "class_path": "/Script/Engine.StaticMesh",
  "path_under": "/Engine/BasicShapes/",
  "limit": 10
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "matched": 5,
  "returned": 5,
  "assets": [
    {"name": "Cone",   "package_path": "/Engine/BasicShapes/Cone",   "class": "StaticMesh"},
    {"name": "Cube",   "package_path": "/Engine/BasicShapes/Cube",   "class": "StaticMesh"},
    {"name": "Cylinder","package_path": "/Engine/BasicShapes/Cylinder","class": "StaticMesh"},
    {"name": "Plane",  "package_path": "/Engine/BasicShapes/Plane",  "class": "StaticMesh"},
    {"name": "Sphere", "package_path": "/Engine/BasicShapes/Sphere", "class": "StaticMesh"}
  ]
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `find_assets: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `class_path` was missing or empty. |
| `invalid_class_path` | `class_path` does not resolve to a known UClass. |
| `invalid_path_filter` | `path_under` does not start with `/Game/` or `/Engine/`. |
| `invalid_tag_value` | A `tags` map entry's value is neither a string nor null (v0.7.0). |

### v0.7.0 additions

Two optional parameters extend the v0.4.0 contract without breaking existing callers:

- `tags` (object) — map of tag-name → required-value (string) or `null` (any value). Multiple entries are AND-combined by the asset registry.
- `include_tags` (bool, default `false`) — when `true`, each returned asset includes a `tags` map of all its registry tags.

Tag values are stringified via UE's `FAssetTagValueRef::AsString()`, so numeric or object-path tags appear as their string representation. Tag names are FName-compared (case-insensitive); tag values are FString-compared (case-sensitive).

**Example — Texture2D assets in `/Game/Textures/` with `LODGroup` tag set to `TEXTUREGROUP_World`, returning the full tag map for each:**

```json
{"jsonrpc":"2.0","id":1,"method":"find_assets","params":{
  "class_path": "/Script/Engine.Texture2D",
  "path_under": "/Game/Textures/",
  "tags": {"LODGroup": "TEXTUREGROUP_World"},
  "include_tags": true
}}
```

---

## spawn_actor

Create an actor in the current editor world at a given location, with optional rotation, label override, and initial properties. The `class_path` accepts both built-in classes (e.g. `/Script/Engine.StaticMeshActor`) and Blueprint-generated classes (note the `_C` suffix for Blueprint classes: `/Game/Blueprints/BP_MyActor.BP_MyActor_C`). Initial properties are applied via `PropertyCoercion` immediately after spawn; if any property fails, the actor is still placed and the error message identifies which property caused the failure.

**Params**
- `class_path` (string, required) — actor class path.
- `location` (object, optional, default `{x:0, y:0, z:0}`) — world-space `{x, y, z}` floats.
- `rotation` (object, optional, default `{pitch:0, yaw:0, roll:0}`) — Euler angles in degrees.
- `label` (string, optional) — visible name in World Outliner; defaults to UE's auto-name.
- `properties` (object, optional) — map of `{"PropertyName": value}` applied after spawn.

**Result**
- `ok` (bool)
- `name` (string) — unique FName assigned by UE (e.g. `BP_MyActor_C_4`)
- `label` (string) — World Outliner label
- `class` (string) — class name
- `location` (object) — `{x, y, z}`
- `rotation` (object) — `{pitch, yaw, roll}`

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"spawn_actor","params":{
  "class_path": "/Script/Engine.StaticMeshActor",
  "location": {"x": 0, "y": 0, "z": 0},
  "label": "SmokeCube1"
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "StaticMeshActor_3",
  "label": "SmokeCube1",
  "class": "StaticMeshActor",
  "location": {"x": 0.0, "y": 0.0, "z": 0.0},
  "rotation": {"pitch": 0.0, "yaw": 0.0, "roll": 0.0}
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `spawn_actor: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `class_path` was missing or empty. |
| `invalid_class_path` | Class not found in the asset registry. |
| `class_not_spawnable` | Class is abstract, does not derive from `AActor`, or has `bEditorOnly = true`. |
| `spawn_failed` | `UWorld::SpawnActor` returned null (UE refused for an internal reason). |
| `property_application_failed` | One of the `properties` entries failed to apply. The actor is still spawned; the message identifies which property failed. Caller can use `delete_actor` for atomic-like cleanup. |

---

## set_actor_transform

Move, rotate, or scale an existing actor by label or FName. Uses the hybrid label-or-FName identification scheme: label match is tried first; if multiple actors share the label, an `ambiguous_actor` error is returned with all matching FNames listed so the caller can retry with the specific FName. Only the fields provided are changed; omitted fields retain their current values.

**Params**
- `name` (string, required) — actor label or FName.
- `location` (object, optional) — `{x, y, z}` world-space position; unchanged if omitted.
- `rotation` (object, optional) — `{pitch, yaw, roll}` in degrees; unchanged if omitted.
- `scale` (object, optional) — `{x, y, z}` scale multiplier; unchanged if omitted.
- `relative` (bool, optional, default `false`) — when `true`, deltas are added to the current values instead of replacing them.

**Result**
- `ok` (bool)
- `name` (string) — actor FName
- `applied` (object) — only the fields that were actually changed, with their new values

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"set_actor_transform","params":{
  "name": "SmokeCube2",
  "location": {"x": 200, "y": 200, "z": 50},
  "rotation": {"pitch": 0, "yaw": 45, "roll": 0}
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "StaticMeshActor_4",
  "applied": {
    "location": {"x": 200.0, "y": 200.0, "z": 50.0},
    "rotation": {"pitch": 0.0, "yaw": 45.0, "roll": 0.0}
  }
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `set_actor_transform: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `name` was missing or empty. |
| `actor_not_found` | No actor matches the given name. |
| `ambiguous_actor` | Label matches multiple actors. Error message lists candidate FNames. |
| `no_changes_specified` | None of `location` / `rotation` / `scale` were provided. |

---

## delete_actor

Remove an actor from the current editor world by label or FName. Uses the same hybrid label-or-FName identification scheme as the other write handlers. The optional `force` flag controls whether to proceed when the actor has attached children: without it the call is refused with a `has_children` error, giving the caller a chance to decide what to do with dependents first.

**Params**
- `name` (string, required) — actor label or FName.
- `force` (bool, optional, default `false`) — when `false`, refuses deletion if the actor has attached children (`has_children` error). When `true`, deletes anyway; children become detached, mirroring UE's native delete-with-children behavior.

**Result**
- `ok` (bool)
- `name` (string) — actor FName
- `deleted` (bool)

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"delete_actor","params":{
  "name": "SmokeCube1",
  "force": true
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "StaticMeshActor_3",
  "deleted": true
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `delete_actor: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `name` was missing or empty. |
| `actor_not_found` | No actor matches the given name. |
| `ambiguous_actor` | Label matches multiple actors. Error message lists candidate FNames. |
| `has_children` | `force=false` and the actor has attached children. |

---

## set_actor_property

Mutate any `UPROPERTY` on an actor by label or FName. Uses the hybrid label-or-FName identification scheme. The property name must match the C++ field name exactly (case-sensitive). The result includes both the `old_value` and `new_value` encoded in the same JSON shape as the input, so changes can be verified or undone.

**Params**
- `name` (string, required) — actor label or FName.
- `property` (string, required) — `UPROPERTY` field name (case-sensitive, matches C++ exactly).
- `value` (any, required) — JSON value coerced to the property's native type.

**Supported types in v0.4.0**

| FProperty C++ type | JSON value shape |
|---|---|
| `bool` | JSON bool |
| `int8`, `int16`, `int32`, `int64` | JSON number (range-checked) |
| `uint8`, `uint16`, `uint32`, `uint64` | JSON number (range-checked) |
| `float`, `double` | JSON number |
| `FString` | JSON string |
| `FText` | JSON string |
| `FName` | JSON string |
| `FVector` | `{x, y, z}` |
| `FVector2D` | `{x, y}` |
| `FRotator` | `{pitch, yaw, roll}` |
| `FLinearColor` | `{r, g, b, a}` (0–1 floats) |
| `FColor` | `{r, g, b, a}` (0–255 ints) |
| Enum (`UEnum`-decorated) | JSON string (enum value name) |
| `TSoftObjectPtr<T>` | JSON string (asset path) |
| **USTRUCT** (any reflected struct, recursive) | JSON object — fields coerced individually |
| **TArray<T>** | JSON array (element-wise) |
| **TMap<FString \| FName, V>** | JSON object (string keys only in v0.4.0) |
| **TSet<T>** | JSON array (deduplicated server-side) |
| **FObjectProperty** (hard UObject*) | JSON string (asset path) — `LoadObject`'d + class-checked |

FInstancedStruct is deferred to v0.4.x. The error message for unsupported types includes the FProperty class name (e.g. `"StructProperty(MyCustomStruct)"`) so the caller can fall back to `execute_unreal_python`.

**Property-name path traversal**

The `property` param accepts dotted paths like `"RootComponent.RelativeLocation"` to access nested properties via recursive traversal. Each segment either traverses an `FStructProperty` (accessing the struct's memory directly) or dereferences an `FObjectProperty` pointer. Errors include the path: `path_traversal_null at .RootComponent: cannot continue through null UObject`. Recursion depth is capped at 8 levels.

**Result**
- `ok` (bool)
- `name` (string) — actor FName
- `property` (string) — property name as given
- `old_value` (any) — previous value in JSON form
- `new_value` (any) — applied value in JSON form

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"set_actor_property","params":{
  "name": "PointLight_2",
  "property": "Intensity",
  "value": 12000.0
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "PointLight_2",
  "property": "Intensity",
  "old_value": 5000.0,
  "new_value": 12000.0
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `set_actor_property: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `name`, `property`, or `value` was missing. |
| `actor_not_found` | No actor matches the given name. |
| `ambiguous_actor` | Label matches multiple actors. Error message lists candidate FNames. |
| `field_not_found` | During path traversal, a segment does not exist on the current struct. |
| `wrong_object_class` | During path traversal, an `FObjectProperty` points to an object of an incompatible class. |
| `recursion_depth_exceeded` | Path traversal exceeded the 8-level depth limit. |
| `path_traversal_null` | During path traversal, encountered a null `FObjectProperty` pointer. Error message includes the path segment. |
| `path_traversal_invalid_type` | Path segment refers to a property that is neither `FStructProperty` nor `FObjectProperty`, blocking further traversal. |
| `property_not_found` | No `UPROPERTY` with that name exists on the actor's class (when property is not a dotted path). |
| `unsupported_property_type` | Property exists but its FProperty class is not supported (e.g., FInstancedStruct). The error message includes the FProperty class name. |
| `value_coercion_failed` | Value could not be coerced to the property type (e.g. string given for an int, range overflow). |

---

## add_component

Attach a new component to an existing actor at runtime by label or FName. Uses the hybrid label-or-FName identification scheme. The `relative_transform` parameter uses the same `{location, rotation, scale}` shape as `set_actor_transform`, expressed relative to the parent component. For `USceneComponent` subclasses the component is attached to the root component by default, or to a named component (and optional socket) via `attach_to` and `socket`.

**Params**
- `actor_name` (string, required) — label or FName of the host actor.
- `class_path` (string, required) — component class path, e.g. `/Script/Engine.StaticMeshComponent`, `/Script/Engine.PointLightComponent`.
- `component_name` (string, optional) — FName for the new component; defaults to UE's auto-name.
- `attach_to` (string, optional) — name of an existing component on the actor to attach to; defaults to the root component.
- `socket` (string, optional) — socket name on the parent component.
- `relative_transform` (object, optional, default identity) — `{location: {x,y,z}, rotation: {pitch,yaw,roll}, scale: {x,y,z}}` relative to the parent.

**Result**
- `ok` (bool)
- `actor` (string) — actor FName
- `component` (string) — new component FName
- `class` (string) — component class name
- `attached_to` (string) — parent component name

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"add_component","params":{
  "actor_name": "SmokeCube1",
  "class_path": "/Script/Engine.PointLightComponent",
  "relative_transform": {"location": {"x": 0, "y": 0, "z": 100}}
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "actor": "StaticMeshActor_3",
  "component": "PointLightComponent_1",
  "class": "PointLightComponent",
  "attached_to": "DefaultSceneRoot"
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `add_component: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `actor_name` or `class_path` was missing or empty. |
| `actor_not_found` | No actor matches the given name. |
| `ambiguous_actor` | Label matches multiple actors. Error message lists candidate FNames. |
| `invalid_component_class` | Class not found, abstract, or not a `USceneComponent` / `UActorComponent` subclass. |
| `attach_target_not_found` | `attach_to` was specified but no component with that name exists on the actor. |
| `socket_not_found` | `socket` was specified but the parent component does not have that socket. |

---

## get_log_lines

Read recent UE Output Log entries from the in-process ring buffer maintained by `FUCMCPLogCapture`. The buffer holds the last 1000 log lines across all categories. Useful for inspecting errors, warnings, and debug output without opening the UE Output Log panel.

**Params**
- `count` (int, optional, default `100`) — maximum number of lines to return. Capped at 1000.
- `category_filter` (string, optional) — case-insensitive substring filter on the log category (e.g. `"LogTemp"` returns all categories containing "LogTemp").
- `min_verbosity` (string, optional, default `"Log"`) — minimum severity to include. One of `Fatal`, `Error`, `Warning`, `Display`, `Log`, `Verbose`, `VeryVerbose`. Lines at or above this level are included (Fatal is highest severity; VeryVerbose is lowest).

**Result**
- `ok` (bool)
- `returned` (int) — number of lines in the response array
- `lines` (array) — `{timestamp, category, verbosity, message}` per line, in chronological order (oldest first within the filtered set)

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"get_log_lines","params":{
  "count": 10,
  "min_verbosity": "Warning"
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "returned": 2,
  "lines": [
    {"timestamp": "2026.05.08-13.30.45", "category": "LogTemp", "verbosity": "Warning", "message": "something unexpected"},
    {"timestamp": "2026.05.08-13.31.02", "category": "LogUCMCP", "verbosity": "Error", "message": "handler error"}
  ]
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `get_log_lines: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `invalid_verbosity` | `min_verbosity` is not one of the seven accepted values. |

---

## execute_console_command

Execute a UE console command in the editor world context and optionally capture its output. Suitable for stat commands (`stat fps`, `stat unit`), CVar mutations (`r.ScreenPercentage 50`), and any other console command the running editor supports. Output is captured via a `FStringOutputDevice` for the duration of the call; it does not permanently redirect the console.

**Params**
- `command` (string, required) — the console command to execute (e.g. `"stat fps"`, `"r.ScreenPercentage 50"`).
- `capture_output` (bool, optional, default `true`) — when `true`, captures and returns the command output string. When `false`, output flows to the normal Output Log and the result's `output` field is empty.

**Result**
- `ok` (bool)
- `command` (string) — echo of the command that was executed
- `output` (string) — captured console output, or `""` if `capture_output=false`

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"execute_console_command","params":{
  "command": "stat fps"
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "command": "stat fps",
  "output": "Frame: 16.6ms (60 FPS)\n..."
}}
```

**Errors:** Returned as JSON-RPC `error.message` strings in the format `execute_console_command: <error_code>: <human-readable detail>`. Stable codes:

| Code | Trigger |
|---|---|
| `missing_required_field` | `command` was missing or empty. |
| `command_execution_failed` | `GEngine` or `GEditor` is null (not running in an editor context). |

---

## inspect_asset

Read every fact the asset registry knows about a single asset.

**Params**
- `path` (string, required) — asset path or package path. Both forms accepted: `/Game/Textures/T_Stone` and `/Game/Textures/T_Stone.T_Stone`.

**Result**
- `name` — leaf name (no folder, no `.Name` suffix)
- `package_path` — `/Game/...` form without `.Name`
- `asset_path` — same path with `.Name` suffix
- `class` — leaf class name (e.g. `Texture2D`)
- `class_path` — full class path (e.g. `/Script/Engine.Texture2D`)
- `tags` (object) — all registry tags coerced to strings via `FAssetTagValueRef::AsString()`
- `dependencies` (array of string) — package paths this asset hard-references
- `referencers` (array of string) — package paths that hard-reference this asset
- `package_size_bytes` — integer (on-disk size in bytes) or `null` (transient/in-memory asset)

**Errors:** `missing_required_field`, `asset_not_found`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_asset","params":{
  "path": "/Engine/BasicShapes/Cube"
}}
```

---

## move_asset

Move an asset to a different folder. The leaf name does not change.

**Params**
- `path` (string, required) — source asset path. Both forms accepted (with or without `.Name` suffix).
- `dest_folder` (string, required) — destination folder under `/Game/` or `/Engine/`.

**Result**
- `ok` (bool)
- `old_path` (string) — the source asset path before the move
- `new_path` (string) — the destination asset path after the move

**Errors:** `missing_required_field`, `asset_not_found`, `invalid_dest_folder`, `dest_exists`, `rename_failed`.

**Behavior note:** UE creates a redirector at the source path so existing references in other assets continue to resolve. Redirectors persist until you run *Fix Up Redirectors* in the Content Browser.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"move_asset","params":{
  "path": "/Game/Textures/T_Stone",
  "dest_folder": "/Game/Textures/Environment"
}}
```

---

## rename_asset

Rename an asset's leaf name. The containing folder does not change.

**Params**
- `path` (string, required) — source asset path. Both forms accepted (with or without `.Name` suffix).
- `new_name` (string, required) — new leaf name. No `/` or `.` characters allowed.

**Result**
- `ok` (bool)
- `old_path` (string) — the source asset path before the rename
- `new_path` (string) — the destination asset path after the rename

**Errors:** `missing_required_field`, `asset_not_found`, `invalid_asset_name`, `dest_exists`, `rename_failed`.

**Behavior note:** Same redirector behavior as `move_asset`. The old name continues to resolve via the redirector until *Fix Up Redirectors* is run.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"rename_asset","params":{
  "path": "/Game/Textures/T_Stone",
  "new_name": "T_StoneAlbedo"
}}
```

---

## duplicate_asset

Copy an asset to a new path. The source asset is preserved at its original location; the duplicate is created at `dest_path`.

**Params**
- `path` (string, required) — source asset path. Both forms accepted (with or without `.Name` suffix).
- `dest_path` (string, required) — destination asset path. Must not already exist.

**Result**
- `ok` (bool)
- `src_path` (string) — the source asset path (normalized)
- `dest_path` (string) — the new asset path (normalized)

**Errors:** `missing_required_field`, `asset_not_found`, `dest_exists`, `duplicate_failed`.

**Behavior note:** Unlike `move_asset` and `rename_asset`, no redirector is created — duplication is a copy, not a relocation, so existing references continue to point at the source. Callers that want to switch references to the duplicate must update them explicitly. Like `save_asset`, a `duplicate_failed` error is most often a Source Control checkout failure or a destination folder that's read-only.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"duplicate_asset","params":{
  "path": "/Game/Textures/T_Stone",
  "dest_path": "/Game/Textures/Variants/T_Stone_Mossy"
}}
```

---

## delete_asset

Delete an asset from the project. By default, refuses if any other package references the asset.

**Params**
- `path` (string, required) — asset path to delete. Both forms accepted.
- `force` (bool, optional, default `false`) — when `true`, delete even if referenced.

**Result**
- `ok` (bool)
- `deleted_path` (string) — the asset path that was deleted

**Errors:** `missing_required_field`, `asset_not_found`, `has_referencers`, `delete_failed`.

**Safety**

UE's `EditorAssetLibrary::DeleteAsset` is documented as a force-delete that does **not** check referencers — it will happily delete a texture being used by 50 actors and cascade into broken references. The handler runs `IAssetRegistry::GetReferencers` first and refuses unless `force=true`. The `has_referencers` error message lists up to 5 referencer package names so the caller can see what would break.

`force=true` cannot be recovered via Undo. Make sure your source-control checkout has the asset before deleting.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"delete_asset","params":{
  "path": "/Game/Textures/T_Stone_OldVariant",
  "force": false
}}
```

If `T_Stone_OldVariant` is referenced by `M_Stone` and `M_StoneWet`, the response is:
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"delete_asset: has_referencers: '/Game/Textures/T_Stone_OldVariant' is referenced by 2 package(s): /Game/Materials/M_Stone, /Game/Materials/M_StoneWet. Set force=true to delete anyway."}}
```

---

## inspect_sequence

Read the structure of a Level Sequence asset.

**Params**
- `path` (string, required) — Level Sequence asset path. Both forms accepted (with or without `.Name` suffix).

**Result**
- `name`, `package_path` — asset identity
- `tick_resolution` (int) — internal tick rate (typically 24000 for 24fps display rate × 1000 sub-frame divisor)
- `display_rate_fps` (number) — sequence's display frame rate (e.g. 24.0, 30.0)
- `playback_range` — `{start_frames, end_frames}` in **tick units** (divide by `tick_resolution / display_rate_fps` for display frames)
- `bindings` — array of `{guid, name, type, bound_actor_label?}` entries; `type` is `"possessable"` or `"spawnable"`. `bound_actor_label` is only present for possessables and equals the binding name (which itself was set to the actor's label by `bind_actor_to_sequence`).
- `tracks` — array of `{name, class, section_count, binding_guid}` entries. Master tracks have `binding_guid: ""`; binding-attached tracks carry their owning binding's GUID.

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_sequence`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sequence","params":{
  "path": "/Game/Cinematics/MainCinematic"
}}
```

---

## create_sequence

Create a new empty Level Sequence asset.

**Params**
- `path` (string, required) — destination folder under `/Game/`.
- `name` (string, required) — leaf asset name. No `/` or `.` characters allowed.
- `display_rate_fps` (number, optional, default `30.0`) — sequence display frame rate.
- `playback_end_frames` (int, optional, default `240`) — end of playback range in **display** frames (not ticks).

**Result**
- `ok`, `asset_path`, `package_path`
- `display_rate_fps` (number) — final value applied to the MovieScene (round-tripped through `FFrameRate(N, 1000)`)
- `playback_range` — `{start_frames, end_frames}` in **tick units** (the conversion uses UE's `FFrameRate::TransformTime`)

**Errors:** `missing_required_field`, `invalid_path`, `invalid_asset_name`, `dest_exists`, `create_failed`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"create_sequence","params":{
  "path": "/Game/Cinematics",
  "name": "MyNewSequence",
  "display_rate_fps": 24.0,
  "playback_end_frames": 144
}}
```

---

## bind_actor_to_sequence

Add a level actor as a possessable binding to a Level Sequence.

**Params**
- `sequence_path` (string, required) — Level Sequence asset path. Both forms accepted.
- `actor_name` (string, required) — actor label or FName in the current editor world. Hybrid identification: ambiguous labels return `ambiguous_actor` listing all candidates' FNames.

**Result**
- `ok` (bool)
- `sequence_path` (string) — normalized asset path
- `binding_guid` (string) — GUID of the created possessable binding (canonical hyphenated form)
- `actor_label` (string) — the actor's `GetActorLabel()` value, also stored as the binding's name
- `binding_type` (string) — always `"possessable"` in v0.8.0

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_sequence`, `actor_not_found`, `ambiguous_actor`, `bind_failed`.

**Behavior note:** v0.8.0 only supports possessables (existing actors). Spawnables — per-sequence-instance actors instantiated from a template — are deferred to v0.8.x. Use `execute_unreal_python` for spawnable workflows in the meantime.

The binding name is set to the actor's `GetActorLabel()`, which means `inspect_sequence` reports it as `bound_actor_label` for round-trip consistency.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"bind_actor_to_sequence","params":{
  "sequence_path": "/Game/Cinematics/MainCinematic",
  "actor_name": "MyHero_Actor"
}}
```

---

## create_material_instance

Create a `UMaterialInstanceConstant` asset and set its parent to an existing `UMaterial` or `UMaterialInstance`.

**Params**
- `parent_path` (string, required) — path of the parent material or material instance.
- `path` (string, required) — destination folder under `/Game/`.
- `name` (string, required) — leaf asset name. No `/` or `.` allowed.

**Result**
- `ok`, `asset_path`, `package_path`
- `parent_path` (string) — full path of the parent (echoes back what was set)

**Errors:** `missing_required_field`, `invalid_path`, `invalid_asset_name`, `parent_not_found`, `parent_not_a_material`, `dest_exists`, `create_failed`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"create_material_instance","params":{
  "parent_path": "/Game/Materials/M_Stone",
  "path": "/Game/Materials/Instances",
  "name": "MI_Stone_Wet"
}}
```

The new instance starts with no parameter overrides; use `set_mi_parameter` to customize it.

---

## set_mi_parameter

Override a scalar/vector/texture parameter on a `UMaterialInstanceConstant`. Single handler with a `type` discriminator — the JSON `value` shape varies by type.

**Params**
- `path` (string, required) — material instance asset path.
- `parameter` (string, required) — parameter name as declared on the parent material.
- `type` (string, required) — one of `"scalar"`, `"vector"`, `"texture"`.
- `value` (varies by type, required):
  - `"scalar"` → number, e.g. `0.75`
  - `"vector"` → object `{r, g, b, a}` (each in `[0, 1]`; `a` defaults to `1.0` if omitted)
  - `"texture"` → string asset path of a `UTexture`

**Result**
- `ok`, `path`, `parameter`, `type` — echo back what was set
- `applied_value` — same shape as input `value` (textures normalized to canonical asset path)

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_material_instance`, `invalid_parameter_type`, `invalid_value_shape`, `texture_not_found`, `parameter_not_applied`.

`parameter_not_applied` fires when UE's setter returns false — typically because the parameter name isn't declared on the parent material. Use `inspect_material` on the parent first to learn what parameters are available.

**Examples**

Scalar:
```json
{"jsonrpc":"2.0","id":1,"method":"set_mi_parameter","params":{
  "path": "/Game/Materials/MI_Stone_Wet",
  "parameter": "Roughness",
  "type": "scalar",
  "value": 0.85
}}
```

Vector:
```json
{"jsonrpc":"2.0","id":1,"method":"set_mi_parameter","params":{
  "path": "/Game/Materials/MI_Stone_Wet",
  "parameter": "BaseColorTint",
  "type": "vector",
  "value": {"r": 0.6, "g": 0.6, "b": 0.7}
}}
```

Texture:
```json
{"jsonrpc":"2.0","id":1,"method":"set_mi_parameter","params":{
  "path": "/Game/Materials/MI_Stone_Wet",
  "parameter": "BaseColorMap",
  "type": "texture",
  "value": "/Game/Textures/T_Stone_Wet_D"
}}
```

---

## inspect_material

List parameter names declared by a `UMaterial` or `UMaterialInstance`. Discovery tool: pair with `find_assets` to find materials, then `inspect_material` to learn what parameters are available, then `set_mi_parameter` to override.

**Params**
- `path` (string, required) — material asset path. Both forms accepted.

**Result**
- `name`, `package_path`, `class`
- `scalar_parameters` (array of string) — sorted alphabetically
- `vector_parameters` (array of string) — sorted alphabetically
- `texture_parameters` (array of string) — sorted alphabetically
- `static_switch_parameters` (array of string) — sorted alphabetically

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_material`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_material","params":{
  "path": "/Game/Materials/M_Stone"
}}
```

Static-switch parameters are listed for visibility but cannot be set via `set_mi_parameter` in v0.9.0 — that mutator is deferred to v0.9.x because it triggers shader recompiles.

---

## inspect_material_instance

Read a `UMaterialInstanceConstant`'s parent and currently-overridden parameter values. Only **overridden** parameters appear in the output — parameters inherited unchanged from the parent are not listed. Pair with `inspect_material` (on the parent path) to see the full set of available parameters.

**Params**
- `path` (string, required) — material instance asset path.

**Result**
- `name`, `package_path`
- `parent_path` (string) — full path of the parent material; empty string if no parent (rare; usually means a partially-initialized asset)
- `scalar_overrides` (object) — `{parameter_name: number}` map of overridden scalar parameters
- `vector_overrides` (object) — `{parameter_name: {r, g, b, a}}` map of overridden vector parameters
- `texture_overrides` (object) — `{parameter_name: asset_path_string}` map of overridden texture parameters; empty string if a texture override exists but the texture pointer is null

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_material_instance`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_material_instance","params":{
  "path": "/Game/Materials/MI_Stone_Wet"
}}
```

---

## run_python_file

Execute a `.py` file from disk via the editor's embedded Python interpreter. Complement to `execute_unreal_python` — for non-trivial scripts, embedding the source as a JSON-RPC string requires double-escaping every quote and backslash. Pointing at a file on disk eliminates that pain entirely.

**Params**
- `path` (string, required) — filesystem path to a `.py` file. Absolute or relative; relative paths resolve via `FPaths::ConvertRelativePathToFull` against the editor's CWD (typically the project root for editor sessions).

**Result**
- `ok` (bool) — `true` if the script ran without raising
- `output` (string) — `FPythonCommandEx::CommandResult`. **Caveat:** `ExecuteFile` mode does NOT return script stdout / eval-result through this field; it's `"None"` for file-mode runs. To round-trip a result back, the script should emit `unreal.log("__MARKER__<json>__END__")` and the caller retrieves it via `get_log_lines` with `category_filter: "LogPython"`. See `scripts/seed_test_project.py` for the canonical pattern.
- `path` (string) — the resolved absolute path that was executed. Useful for confirming the path resolution.

**Errors:** `missing_required_field`, `file_not_found`, `python_unavailable`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"run_python_file","params":{
  "path": "C:/Users/me/Desktop/scripts/setup_lighting.py"
}}
```

Or relative to the editor's CWD (typically the project root):
```json
{"jsonrpc":"2.0","id":1,"method":"run_python_file","params":{
  "path": "Saved/MyScripts/quick_fixup.py"
}}
```

---

## fix_up_redirectors

Cascade-update consumers of `UObjectRedirector` assets under a folder, then delete the redirector `.uasset` stubs. This is the programmatic equivalent of right-clicking a folder in the Content Browser and selecting "Fix Up Redirectors in Folder" — the standard cleanup after `move_asset` / `rename_asset` operations or any project-wide reorganization that leaves stale path stubs behind.

**Params**
- `path` (string, required) — package path under which to recursively scan, e.g. `/Game/` or `/Game/Materials`. Required (no default) so a typo or missing param can't accidentally rewrite the entire project.

**Result**
- `ok` (bool) — `true` on dispatch success
- `path` (string) — the path that was scanned (with trailing slash normalized off)
- `redirectors_found` (int) — number of `UObjectRedirector` assets enumerated and queued for fixup
- `note` (string) — reminder that fixup may complete asynchronously when source-control is active. Use `IAssetTools::IsFixupReferencersInProgress()` from a follow-up `execute_unreal_python` call (or wait briefly) before assuming all redirectors are removed.

**Errors:** `missing_required_field`, `invalid_path`.

**Behavior notes**
- Uses `ERedirectFixupMode::DeleteFixedUpRedirectors` (the editor's default) — successfully-redirected stubs are deleted; only stubs whose consumers couldn't be updated remain.
- `bCheckoutDialogPrompt = false` — no interactive UI; runs silently against any non-read-only files.
- Returns `redirectors_found = 0` if the path exists but contains none — that's a success state, not an error.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"fix_up_redirectors","params":{
  "path": "/Game/Materials"
}}
```

Or for the whole `/Game/` tree (typical post-bulk-rename cleanup):
```json
{"jsonrpc":"2.0","id":1,"method":"fix_up_redirectors","params":{
  "path": "/Game/"
}}
```

---

## apply_python_to_selection

Run user Python with the editor's current selection pre-bound as Python locals. Convenience wrapper around `execute_unreal_python` that injects boilerplate to fetch:

| Local | Type | Source |
|---|---|---|
| `selection` | `list[unreal.Actor]` | `unreal.get_editor_subsystem(unreal.EditorActorSubsystem).get_selected_level_actors()` (UE 5.x), with fallback to `unreal.EditorLevelLibrary.get_selected_level_actors()` |
| `selected_assets` | `list[unreal.Object]` | `unreal.EditorUtilityLibrary.get_selected_assets()` |

Both bindings use try/except so a missing API in older Python plugin builds defaults to an empty list rather than killing the script. The user's code runs *after* the boilerplate, so it can reference either name directly without re-implementing the lookup.

**Params**
- `code` (string, required) — Python source. The boilerplate above is prepended.

**Result**
- `ok` (bool) — `true` if the script ran without raising
- `output` (string) — `FPythonCommandEx::CommandResult`. Same caveat as `run_python_file` and `execute_unreal_python`: `ExecuteFile` mode returns `"None"` here. Round-trip results via `unreal.log("__MARKER__<json>__END__")` + `get_log_lines{category_filter:"LogPython"}`.
- `temp_script` (string) — path to the wrapper script that was written and executed (cleaned up after run via `ON_SCOPE_EXIT`)

**Errors:** `missing_required_field`, `python_unavailable`, `write_failed`.

**Example — translate every selected actor up by 100 units**
```json
{"jsonrpc":"2.0","id":1,"method":"apply_python_to_selection","params":{
  "code": "for a in selection:\n    loc = a.get_actor_location()\n    a.set_actor_location(unreal.Vector(loc.x, loc.y, loc.z + 100), False, False)\nunreal.log(f'__MOVED__{len(selection)}__END__')"
}}
```

**Example — print parent class of every selected asset**
```json
{"jsonrpc":"2.0","id":1,"method":"apply_python_to_selection","params":{
  "code": "import json\nresult = [{'name': a.get_name(), 'class': a.get_class().get_name()} for a in selected_assets]\nunreal.log('__SEL__' + json.dumps(result) + '__END__')"
}}
```

---

## compile_blueprint

Explicit Blueprint recompile via `FKismetEditorUtilities::CompileBlueprint`. Pairs with `edit_widget_tree`'s `compile=true` flag for users who want to compile a Blueprint **without** mutating it first — e.g. when a BP was modified externally via `execute_unreal_python` and now needs a recompile, or to recover from a `BS_Dirty` state.

**Params**
- `path` (string, required) — Blueprint asset path, e.g. `/Game/Blueprints/BP_MyActor`.
- `skip_save` (bool, optional, default `false`) — suppress the project's "Save On Compile" auto-save. Passes `EBlueprintCompileOptions::SkipSave` to UE.

**Result**
- `ok` (bool) — `true` unless the BP's status is `BS_Error` after compile
- `path` (string) — package path of the BP that was compiled
- `status` (string) — one of `up_to_date`, `up_to_date_with_warnings`, `error`, `dirty`, `unknown`, `being_created` (see UE 5.7's `EBlueprintStatus` at `Blueprint.h:41`)
- `saved` (bool) — whether auto-save was allowed (`!skip_save`)
- `note` (string, only when `status == "error"`) — pointer to `get_log_lines{category_filter:"LogBlueprint"}` for compile-error detail

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_blueprint`.

**Behavior notes**
- Default flags = `EBlueprintCompileOptions::None`. The full pipeline runs: skeleton class regen, node expansion, validation, code gen, reinstancing.
- Compile errors aren't reported in the `result` directly — they're emitted by `FKismetCompilerContext` to `LogBlueprint`. Use `get_log_lines{category_filter:"LogBlueprint", count:50}` afterward to inspect them.
- Save-On-Compile is a project setting (`Project Settings → Editor → Blueprint`), not a hardcoded behavior. `skip_save:true` gives you the bypass when you want to compile transiently without touching disk.

**Example — compile a BP and check status**
```json
{"jsonrpc":"2.0","id":1,"method":"compile_blueprint","params":{
  "path": "/Game/Blueprints/BP_MyActor"
}}
```

**Example — recompile without auto-save (for transient work)**
```json
{"jsonrpc":"2.0","id":1,"method":"compile_blueprint","params":{
  "path": "/Game/Blueprints/BP_MyActor",
  "skip_save": true
}}
```

---

## get_console_variable

Read a single UE Console Variable by name. Returns the current value in all four representations (string / int / float / bool), the detected type, the read-only flag, the human-readable last-setter (e.g. `"Console"`, `"DeviceProfile"`, `"ProjectSetting"`), and the help text from the CVar's registration.

Distinct from `execute_console_command`: this reads CVar state directly via `IConsoleManager::FindConsoleVariable`, never invokes the console exec engine. Use this when you want the CVar's value without side effects, or when you want type metadata that an Exec call can't surface.

**Params**
- `name` (string, required) — exact CVar name, case-sensitive (e.g. `"r.ScreenPercentage"`, `"Slate.bAllowToolTips"`).

**Result**
- `ok` (bool)
- `name` (string) — echo of the requested CVar name
- `type` (string) — one of `int`, `float`, `bool`, `string`, `unknown`. Derived from `IConsoleVariable::IsVariable*()`.
- `read_only` (bool) — `(GetFlags() & ECVF_ReadOnly) != 0`. Read-only CVars only accept `set_console_variable` during very early init.
- `set_by` (string) — humanized last-setter, derived from `GetConsoleVariableSetByName(GetFlags())`. One of `Constructor`, `Scalability`, `GameSetting`, `ProjectSetting`, `SystemSettingsIni`, `PluginLowPriority`, `DeviceProfile`, `PluginHighPriority`, `GameOverride`, `ConsoleVariablesIni`, `Hotfix`, `Preview`, `Commandline`, `Code`, `Console`.
- `value_string` (string) — `GetString()`
- `value_int` (number) — `GetInt()`, coerced from underlying type
- `value_float` (number) — `GetFloat()`, coerced from underlying type
- `value_bool` (bool) — `GetBool()`, coerced from underlying type
- `help` (string) — `GetHelp()` text, or `""` if none

**Errors:** `missing_required_field`, `cvar_not_found`.

| Code | Trigger |
|---|---|
| `missing_required_field` | `name` was missing or empty. |
| `cvar_not_found` | `IConsoleManager::FindConsoleVariable` returned null. The error message points to `execute_console_command` for cases where the name is actually a console *command* rather than a CVar. |

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"get_console_variable","params":{
  "name": "r.ScreenPercentage"
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "r.ScreenPercentage",
  "type": "float",
  "read_only": false,
  "set_by": "Console",
  "value_string": "100",
  "value_int": 100,
  "value_float": 100.0,
  "value_bool": true,
  "help": "To render in lower resolution and upscale for better performance..."
}}
```

---

## set_console_variable

Mutate a UE Console Variable by name. The `value` param is polymorphic — accepts JSON `string`, `number`, or `boolean` — and is coerced to the canonical string form (`"42"`, `"1.5"`, `"1"`/`"0"` for bool) before being passed to `IConsoleVariable::Set`. UE's underlying parser handles type coercion against the CVar's declared type on the receiving side.

**SetBy priority:** all `Set` calls are issued at `ECVF_SetByConsole` priority — the highest tier (matches "user typed it in the editor console" semantics). This guarantees the call overrides values set by ini files, scalability profiles, code defaults, etc., rather than being silently dropped by UE's priority arbitration.

**Pre-rejection:** CVars with the `ECVF_ReadOnly` flag (`r.RHIThreadEnable`, `r.SkinCache.CompileShaders`, etc.) only accept `Set` during very early initialization. After editor startup, they silently no-op. Rather than letting that disappear, the handler returns a `read_only` error early.

**Post-verify:** after the `Set`, the handler reads the value back via `GetString()` and includes both the requested and the actual landed value. A mismatch (rare with `ECVF_SetByConsole` but possible — e.g. a CVar with a custom on-set callback that rejects certain values) is surfaced as a `note` field, not an error, since UE accepted the request.

**Params**
- `name` (string, required) — exact CVar name, case-sensitive.
- `value` (string | number | bool, required) — new value. JSON numbers and bools are formatted to canonical strings before being passed to UE. To set a string CVar, send a JSON string; to set a numeric CVar, send a JSON number; to set a bool CVar, send a JSON bool (or `0`/`1` as a number).

**Result**
- `ok` (bool)
- `name` (string)
- `type` (string) — one of `int`, `float`, `bool`, `string`, `unknown`
- `requested_value` (string) — the canonical string form that was passed to `IConsoleVariable::Set`
- `value_string` / `value_int` / `value_float` / `value_bool` — the post-set values in all four representations
- `set_by` (string) — humanized post-set last-setter (typically `"Console"` since that's what we set)
- `note` (string, only when post-set value differs from requested) — diagnostic explaining the mismatch

**Errors:** `missing_required_field`, `cvar_not_found`, `read_only`, `invalid_value_type`.

| Code | Trigger |
|---|---|
| `missing_required_field` | `name` or `value` missing/empty. |
| `cvar_not_found` | `IConsoleManager::FindConsoleVariable` returned null. |
| `read_only` | The CVar has `ECVF_ReadOnly`. The error message points to `DefaultEngine.ini` `[ConsoleVariables]` and Scalability/DeviceProfile inis as the legitimate setting sites. |
| `invalid_value_type` | `value` is a JSON object, array, or null. Only string / number / bool are accepted. |

**Example — set a numeric CVar**
```json
{"jsonrpc":"2.0","id":1,"method":"set_console_variable","params":{
  "name": "r.ScreenPercentage",
  "value": 75
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "name": "r.ScreenPercentage",
  "type": "float",
  "requested_value": "75",
  "value_string": "75",
  "value_int": 75,
  "value_float": 75.0,
  "value_bool": true,
  "set_by": "Console"
}}
```

**Example — set a bool CVar**
```json
{"jsonrpc":"2.0","id":1,"method":"set_console_variable","params":{
  "name": "Slate.bAllowToolTips",
  "value": false
}}
```

**Example — read-only rejection**
```json
{"jsonrpc":"2.0","id":1,"method":"set_console_variable","params":{
  "name": "r.SkinCache.CompileShaders",
  "value": 1
}}
```
```json
{"jsonrpc":"2.0","id":1,"error":{
  "code": -32603,
  "message": "set_console_variable: read_only: 'r.SkinCache.CompileShaders' has the ECVF_ReadOnly flag and only accepts changes during early initialization. Set it via DefaultEngine.ini ([ConsoleVariables] section) or a Scalability/DeviceProfile ini for persistent config."
}}
```

---

## poll_events

**Tier 2 entrypoint (PR #40 / v0.11.0).** Drain editor events fired since the caller's last poll. UE delegates push structured events into a 1000-entry ring buffer (`FUCMCPEventBus`); this handler returns the slice with `seq > since_seq`, capped at `max_count`.

This is the inversion of the rest of the catalog: instead of Claude querying UE state on demand, UE notifies Claude when state changes. Combined with regular polling, it enables reactive flows like "user dropped a chair into the level → reposition camera" or "asset import finished → trigger texture-config pipeline". See [`docs/superpowers/specs/2026-05-09-tier2-event-push-design.md`](superpowers/specs/2026-05-09-tier2-event-push-design.md) for the full Tier 2 multi-PR roadmap.

**Wired event types (8 total — PR #40 starter set + PR #41 expansion):**

| Event | Source | Payload fields |
|---|---|---|
| `actor_spawned` | `UEngine::OnLevelActorAdded(AActor*)` | `actor_label`, `actor_name`, `class`, `level` |
| `actor_deleted` | `UEngine::OnLevelActorDeleted(AActor*)` | `actor_label`, `actor_name`, `class`, `level` |
| `asset_added` | `IAssetRegistry::OnAssetAdded(const FAssetData&)` (TS_ delegate, fires from background scan threads) | `package_path`, `asset_path`, `name`, `class`, `class_path` |
| `asset_removed` | `IAssetRegistry::OnAssetRemoved(const FAssetData&)` (TS_) | `package_path`, `asset_path`, `name`, `class`, `class_path` |
| `asset_renamed` | `IAssetRegistry::OnAssetRenamed(const FAssetData&, const FString&)` (TS_) | `new_asset_path`, `old_asset_path`, `new_package_path`, `name`, `class`, `class_path` |
| `asset_post_import` | `FEditorDelegates::OnAssetPostImport(UFactory*, UObject*)` | `asset_path`, `name`, `class`, `factory` |
| `level_post_save` | `FEditorDelegates::PostSaveWorldWithContext(UWorld*, FObjectPostSaveContext)` | `level` |
| `map_changed` | `FEditorDelegates::MapChange(uint32 MapChangeEventFlags)` | `flags` (raw bitmap), `flag_names` (decoded array — `"new_map"` / `"map_rebuild"` / `"world_torn_down"`) |

Note on `asset_added` vs `asset_post_import`: `asset_added` fires for **any** new registry entry — including the project's startup scan (high volume) and in-memory creations. `asset_post_import` fires only when a `UFactory` actually imports an asset (single `import_texture`, batch reimport, drag-and-drop). If you want to react specifically to user-driven imports, filter on `asset_post_import`; if you want every new asset regardless of provenance, use `asset_added`.

Future PRs will add `blueprint_compiled` (no global delegate today; needs per-BP subscription), `mi_parameter_changed`, plus a `wait_for_events` long-poll variant for sub-second latency.

**Params**
- `since_seq` (int, optional, default `-1`) — return events with `seq >= since_seq` (**inclusive cursor**). `-1` = "from oldest buffered". On the first poll, leave at default to discover the current `next_seq`; on subsequent polls, pass the previous response's `next_seq` to consume only newly-fired events. Inclusive semantics matter: `next_seq` is the id about to be assigned (not yet pushed), so the next event to fire will land at exactly that seq, and an exclusive filter would silently drop it.
- `max_count` (int, optional, default `100`) — cap returned events. Hard max `1000` (= ring buffer size). Must be a finite integer; fractional values (e.g. `0.5`) are rejected with `invalid_value_shape` to prevent silent truncation to 0 from corrupting the caller's cursor state.
- `event_filter` (array of string, optional) — substring filters on event type names (e.g. `["actor_spawned", "asset_"]`). Multiple entries are OR-combined. Empty / omitted = no filter.

**Result**
- `ok` (bool)
- `next_seq` (int) — the seq the next-fired event would receive. Pass back as `since_seq` on the next poll for steady-state delta consumption.
- `first_seq_in_buffer` (int) — smallest seq currently in the ring (or `-1` if buffer is empty).
- `returned` (int) — count of events in `events` (≤ `max_count`).
- `dropped` (bool) — `true` iff `since_seq` was below `first_seq_in_buffer` (some events the caller asked for have been evicted from the ring). Recover by re-syncing whatever editor state matters via the explicit query handlers (`get_actors_in_level`, `find_assets`, etc.) and resume polling with `next_seq` from the response.
- `events` (array) — each entry has `seq` (int), `event` (string event type), `ts` (string `YYYY.MM.DD-HH.MM.SS`), `data` (object with event-specific payload).
- `note` (string, only when `dropped=true`) — diagnostic explaining the recovery action.

**Errors:** `invalid_value_shape`.

| Code | Trigger |
|---|---|
| `invalid_value_shape` | `since_seq` / `max_count` not numeric, `max_count` ≤ 0, `event_filter` not an array, or `event_filter` element not a string. |

**Behavior notes**
- The asset-registry initial scan at editor startup floods `asset_added` for every asset in the project. The 1000-entry ring will overflow; subsequent polls with a small `since_seq` will see `dropped=true` until the caller catches up. Workflows that don't care about startup-scan events should poll once after startup with the latest `next_seq` and discard the snapshot, then begin consuming deltas.
- The bus is type-agnostic: adding new event sources in future PRs means adding lambda subscriptions in `UnrealClaudeMCPModule::StartupModule`, not changing this handler or the bus itself.
- `IAssetRegistry::OnAssetAdded` is a `TS_` (thread-safe) delegate — it can fire from background threads. The bus's `FCriticalSection` discipline handles this safely.

**Example — first poll (discovery)**
```json
{"jsonrpc":"2.0","id":1,"method":"poll_events","params":{}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "next_seq": 4523,
  "first_seq_in_buffer": 3523,
  "returned": 100,
  "dropped": false,
  "events": [
    {"seq": 3523, "event": "asset_added", "ts": "2026.05.09-16.42.01",
     "data": {"package_path":"/Game/Textures/T_Stone","asset_path":"/Game/Textures/T_Stone.T_Stone",
              "name":"T_Stone","class":"Texture2D","class_path":"/Script/Engine.Texture2D"}},
    "..."
  ]
}}
```

**Example — steady-state delta consumption**
```json
{"jsonrpc":"2.0","id":2,"method":"poll_events","params":{
  "since_seq": 4523,
  "event_filter": ["actor_spawned", "actor_deleted"]
}}
```

---

## wait_for_events

**Tier 2 PR #42 / v0.11.x — bridge-side composition tool.** Repeatedly calls `poll_events` at `poll_interval_ms` cadence until matching events arrive or `timeout_ms` expires. Same buffer, cursor semantics, and event payloads as `poll_events`; adds bounded waiting and a `timed_out` field.

**Why this is implemented in the bridge (Python), not UE (C++):**

The MCP dispatcher runs synchronously inside `FUCMCPServer::TickClients`, which is an `FTSTicker` callback on UE's **game thread**. A C++ wait handler would freeze the same thread that *fires most editor delegates* (`actor_spawned`, `actor_deleted`, `map_changed`, `level_post_save`, etc., all game-thread events). Result: the wait would deterministically time out for game-thread events because the game thread is asleep — the events that should fire during the wait literally cannot fire.

The bridge runs in a separate Python process, so its `time.sleep` doesn't block UE at all. UE's game thread keeps running between polls (each poll is ~1ms under the bus's lock), events fire normally, and the wait actually waits for things that can happen.

This is the first **synthetic tool** — a bridge-side implementation that composes UE handlers (`poll_events` here) into a higher-level operation. Future tools that compose multiple UE handlers without needing new C++ can follow this same pattern.

**When to use `wait_for_events` vs `poll_events`:**

- **`poll_events`** for steady-state polling at human-meaningful intervals (1-2 s). Single round-trip per call.
- **`wait_for_events`** when you want sub-second latency for a specific reactive workflow ("user dropped a chair → reposition camera within 200 ms"). One MCP call → multiple cheap UE round-trips behind the scenes.

**Params**
- `timeout_ms` (int, optional, default `500`) — maximum wait in milliseconds. Hard cap `30000` (30 s); over-cap values are silently clamped.
- `poll_interval_ms` (int, optional, default `100`, range `25-1000`) — bridge-side polling cadence. Lower = faster reaction but more UE round-trips. Below 25 ms the round-trip overhead dominates; above 1 s defeats the long-poll purpose. Out-of-range values are silently clamped to the bracket.
- `since_seq` (int, optional, default `-1`) — same as `poll_events`: events with `seq >= since_seq` are returned (inclusive cursor).
- `max_count` (int, optional, default `100`) — cap returned events. Hard max `1000`.
- `event_filter` (array of string, optional) — substring filters on event type names; OR-combined.

**Result** (same shape as `poll_events`, plus `timed_out`)
- `ok` (bool)
- `next_seq` (int) — pass back as `since_seq` on the next call
- `first_seq_in_buffer` (int) — smallest seq currently buffered (or `-1` if empty)
- `returned` (int) — count of events in `events`
- `dropped` (bool) — caller's `since_seq` fell below `first_seq_in_buffer` at some point during the wait
- `timed_out` (bool) — `true` iff the wait elapsed without any matching events arriving (and `dropped` is also false). Distinguishes "nothing happened" from "I missed events".
- `events` (array) — same shape as `poll_events`
- `note` (string, only when `dropped=true`) — diagnostic

**Errors:** `invalid_value_shape` (any of the optional numeric params with non-numeric or non-integer JSON type).

**Example — wait up to 1 second for the next actor_spawned**
```json
{"jsonrpc":"2.0","id":1,"method":"wait_for_events","params":{
  "timeout_ms": 1000,
  "since_seq": 4523,
  "event_filter": ["actor_spawned"]
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "next_seq": 4524,
  "first_seq_in_buffer": 3523,
  "returned": 1,
  "dropped": false,
  "timed_out": false,
  "events": [
    {"seq": 4523, "event": "actor_spawned", "ts": "2026.05.09-17.05.42",
     "data": {"actor_label":"StaticMeshActor_2", "actor_name":"StaticMeshActor_2",
              "class":"StaticMeshActor", "level":"/Game/Maps/MyMap"}}
  ]
}}
```

**Example — wait that times out (no events fired in the window)**
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "next_seq": 4524,
  "first_seq_in_buffer": 3524,
  "returned": 0,
  "dropped": false,
  "timed_out": true,
  "events": []
}}
```

**Example — fast reaction (low poll interval)**
```json
{"jsonrpc":"2.0","id":1,"method":"wait_for_events","params":{
  "timeout_ms": 5000,
  "poll_interval_ms": 50,
  "event_filter": ["asset_post_import"]
}}
```
At 50 ms polling, latency is bounded by ~50 ms + UE round-trip. UE round-trip is typically <5 ms on localhost, so total reaction time is ~55 ms.

---

## register_subscription

**Tier 2 PR #43.** Create a server-side cursor + filter on the `FUCMCPEventBus`. Returns a `subscription_id` (FGuid string) that pairs with `poll_subscription` (drain matched events) and `unsubscribe` (release). The cursor starts at the bus's current `next_seq` — subscribers see events fired **after** subscription, not historical ones (avoids the asset-registry initial-scan flood being delivered to every newly-created subscription).

**Why use subscriptions vs `poll_events`:**
- `poll_events` makes the client manage `since_seq` across calls; if the client loses cursor state (restart, crash), it has to re-sync.
- `register_subscription` + `poll_subscription` puts the cursor on the server. The client just calls `poll_subscription` with the id and gets only events it hasn't seen.
- The filter is also server-side — no need to re-send it on every poll. Modest wire-savings for filter-heavy workflows.

**Lifecycle (PR #43):** subscriptions live until explicit `unsubscribe`. **No TTL** in PR #43 — orphan subscriptions accumulate if clients don't clean up. If observable in real workflows, a follow-up PR will add inactivity-based cleanup.

**Params**
- `event_filter` (array of string, optional) — substring filters on event type names; OR-combined. Empty / omitted = no filter.

**Result**
- `ok` (bool)
- `subscription_id` (string) — FGuid in canonical hyphenated form (e.g. `"5C2D8F1A-..."`); pass to `poll_subscription` and `unsubscribe`
- `initial_next_seq` (int) — the seq the next-fired event would receive, captured at subscription time
- `event_filter` (array of string) — echo of the supplied filter

**Errors:** `invalid_value_shape`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"register_subscription","params":{
  "event_filter": ["actor_spawned", "asset_added"]
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "subscription_id": "5C2D8F1A-1234-5678-9ABC-DEF012345678",
  "initial_next_seq": 4523,
  "event_filter": ["actor_spawned", "asset_added"]
}}
```

---

## unsubscribe

Remove a subscription created via `register_subscription`. **Idempotent**: calling on an unknown id returns `ok=true` with `was_present=false` rather than an error, so callers can blanket-unsubscribe on shutdown without worrying about partial state.

**Params**
- `subscription_id` (string, required) — id returned by `register_subscription`.

**Result**
- `ok` (bool)
- `subscription_id` (string) — echo
- `was_present` (bool) — `true` if the subscription existed (and was removed); `false` if the id was unknown

**Errors:** `missing_required_field`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"unsubscribe","params":{
  "subscription_id": "5C2D8F1A-..."
}}
```

---

## poll_subscription

Drain events for a server-side subscription. The per-sub cursor advances atomically with the read — a successful poll never returns the same events twice. No `since_seq` param (cursor is server-side); no `event_filter` param (filter was set at `register_subscription` time and is immutable for that sub — re-register if you need a different filter).

**Params**
- `subscription_id` (string, required) — id returned by `register_subscription`.
- `max_count` (int, optional, default `100`) — cap returned events. Hard max `1000`.

**Result** (same per-event shape as `poll_events`)
- `ok` (bool)
- `subscription_id` (string) — echo
- `next_seq` (int) — bus's current next seq (informational; subscription cursor is server-managed)
- `first_seq_in_buffer` (int) — smallest seq in the ring (or `-1` if empty)
- `returned` (int) — count of events in `events`
- `dropped` (bool) — subscription cursor fell below `first_seq_in_buffer` between polls (events the sub asked for were evicted)
- `events` (array)
- `note` (string, only when `dropped=true`) — recovery hint

**Errors:** `missing_required_field`, `invalid_value_shape`, `subscription_not_found`.

| Code | Trigger |
|---|---|
| `missing_required_field` | `subscription_id` missing or empty. |
| `invalid_value_shape` | `max_count` non-numeric, fractional, non-finite, or ≤ 0. |
| `subscription_not_found` | The id is not in the registry (never created, already unsubscribed, or editor restarted between subscription and poll). |

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"poll_subscription","params":{
  "subscription_id": "5C2D8F1A-..."
}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "subscription_id": "5C2D8F1A-...",
  "next_seq": 4530,
  "first_seq_in_buffer": 3530,
  "returned": 3,
  "dropped": false,
  "events": [
    {"seq": 4527, "event": "actor_spawned", "ts": "...", "data": {"actor_label":"...", ...}},
    {"seq": 4528, "event": "asset_added",   "ts": "...", "data": {"package_path":"...", ...}},
    {"seq": 4529, "event": "actor_spawned", "ts": "...", "data": {"actor_label":"...", ...}}
  ]
}}
```

A subsequent `poll_subscription` call with the same id (and no new events fired in between) will return `returned: 0`, `events: []` — the cursor advanced past seq 4529 on the previous call, so there's nothing new to deliver.

---

## start_sleep_task

**Tier 2 PR #44 — task framework tracer.** Spawns a background worker on `EAsyncExecution::ThreadPool` that sleeps for `duration_ms` then completes the task with `result: { slept_ms }`. Returns immediately with the `task_id`; poll via `poll_task` and cancel via `cancel_task`.

The framework around this handler (`FUCMCPTaskRegistry`) is the durable bit; future task types (cooks, MRQ renders, lightmap bakes) will reuse the same registry and the same `poll_task` / `cancel_task` handlers, just with different `start_*_task` entry points.

**Why this exists:** validates the registry's threading and cancellation paths end-to-end without UE-specific complications. Also genuinely useful for "wait N ms then do something" workflows, though `wait_for_events` covers that case better when you're waiting on editor state.

**Params**
- `duration_ms` (int, required) — how long the worker should sleep. Hard cap **1 hour** (3,600,000 ms); over-cap requests are silently clamped.

**Result** (returned immediately by `start_sleep_task`, not after the sleep)
- `ok` (bool)
- `task_id` (string) — pass to `poll_task` / `cancel_task`
- `type` (string) — `"sleep"`
- `status` (string) — `"pending"` (the worker may already be running by the time you read this; poll for the latest)
- `duration_ms` (int) — echo of the (possibly-clamped) duration
- `note` (string) — operational hint

**Errors:** `missing_required_field`, `invalid_value_shape`.

**Lifecycle:** task lives in the registry until editor restart. PR #44 has **no TTL** — completed/cancelled/failed tasks accumulate. If observable, a follow-up PR will add cleanup.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"start_sleep_task","params":{"duration_ms": 5000}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "task_id": "5C2D8F1A-...",
  "type": "sleep",
  "status": "pending",
  "duration_ms": 5000,
  "note": "Worker spawned on EAsyncExecution::ThreadPool. Poll via poll_task..."
}}
```

---

## poll_task

Read the current state of a task started via any `start_*_task` handler. **Non-blocking** — returns the registry snapshot and never waits for the task to advance.

**Status values**

| Status | Meaning |
|---|---|
| `pending` | Registered but worker hasn't started yet (briefly, between `start_*_task` returning and the worker's first `MarkRunning` call). |
| `running` | Worker is actively executing. |
| `completed` | Finished successfully. `result` populated; `end_time` populated. |
| `cancelled` | Cancellation was requested AND the worker observed it. `end_time` populated. |
| `failed` | Worker hit an error. `error` populated; `end_time` populated. |

**Params**
- `task_id` (string, required) — id from the `start_*_task` call.

**Result**
- `ok` (bool)
- `task_id`, `type` (string) — echoes
- `status` (string) — see table above
- `start_time` (string) — `YYYY.MM.DD-HH.MM.SS`
- `end_time` (string) — populated for terminal states; empty otherwise
- `cancel_requested` (bool) — has cancellation been requested? (independent of whether the worker has observed it yet)
- `result` (object) — only when `status == "completed"`; shape depends on the task type
- `error` (string) — only when `status == "failed"`

**Errors:** `missing_required_field`, `task_not_found`.

**Example — task in progress**
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "task_id": "5C2D8F1A-...",
  "type": "sleep",
  "status": "running",
  "start_time": "2026.05.09-17.40.00",
  "end_time": "",
  "cancel_requested": false
}}
```

**Example — task completed**
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "task_id": "5C2D8F1A-...",
  "type": "sleep",
  "status": "completed",
  "start_time": "2026.05.09-17.40.00",
  "end_time": "2026.05.09-17.40.05",
  "cancel_requested": false,
  "result": {"slept_ms": 5000}
}}
```

---

## cancel_task

Request **cooperative** cancellation of a running task. Sets the task's atomic cancellation flag; the worker observes the flag on its next polling iteration (typical cadence ~50 ms) and exits cleanly to `status="cancelled"`.

**Cancellation discipline:** UE 5.7 has no safe forced-thread-termination — `FRunnableThread::Kill(true)` risks corrupting game state. So workers that don't poll the cancellation flag will **run to completion regardless**. The framework's job is to provide the signal; the worker's job is to honor it. All PR #44+ task types are required to poll the flag at sub-second cadence.

**Idempotent.** Calling on an unknown id or an already-terminal task returns `ok=true` with `accepted=false` (and a `note` explaining why) rather than an error — safe to blanket-cancel on shutdown.

**Params**
- `task_id` (string, required) — id from the `start_*_task` call.

**Result**
- `ok` (bool)
- `task_id` (string) — echo
- `accepted` (bool) — `true` if the cancellation flag was set; `false` if the id was unknown OR the task was already in a terminal state
- `note` (string) — explanation

**Errors:** `missing_required_field`.

**Example — successful cancel**
```json
{"jsonrpc":"2.0","id":1,"method":"cancel_task","params":{"task_id":"5C2D8F1A-..."}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "task_id": "5C2D8F1A-...",
  "accepted": true,
  "note": "Cancellation requested. Worker will observe within ~50ms..."
}}
```

A subsequent `poll_task` will show `cancel_requested: true` immediately, then transition to `status: "cancelled"` once the worker's next polling slice fires.

---

## list_tasks

Enumerate all tasks in the `FUCMCPTaskRegistry` with optional filters and a limit. The natural complement to `poll_task` — answers "what's running right now?" without requiring callers to remember every `task_id`.

**Atomic snapshot.** The full task set is captured under the registry's lock so the result is internally consistent (no half-mutated entries from a worker transition mid-call). Filtering and limiting happen post-snapshot, so they don't extend the lock-hold time.

**Params** (all optional)
- `status_filter` (string, enum) — one of `"pending"` / `"running"` / `"completed"` / `"cancelled"` / `"failed"`. If absent, all statuses are returned.
- `type_filter` (string) — exact-match filter on task type (e.g. `"sleep"`). If absent, all types are returned.
- `limit` (integer) — max items to return. Default `100`. Clamped to `[1, 500]`.

**Result**
- `ok` (bool)
- `total` (int) — total tasks in the registry **before** any filter
- `matched` (int) — count after `status_filter` + `type_filter` (AND-combined)
- `returned` (int) — count after `limit` (`returned ≤ matched ≤ total`)
- `tasks` (array) — each entry mirrors `poll_task`'s shape: `{ task_id, type, status, start_time, end_time, cancel_requested, result?, error? }`. `result` is omitted when null; `error` is omitted when empty.

**Errors:** `unknown_status_value` (status_filter not in the enum), `invalid_value_shape` (limit not a finite integer).

**Behavior notes**
- The invariant is `returned ≤ matched ≤ total`. Filters reduce `total` to `matched`; the limit reduces `matched` to `returned`. With no filters and no truncation, all three are equal — to detect truncation specifically, compare `returned == matched` rather than relying on a strict inequality.
- Tasks accumulate indefinitely (no TTL in PR #44 framework). On long-lived editor sessions, expect `total` to grow until the editor restarts.
- Mirrors `find_assets`'s `total/matched/returned/assets` convention.

**Example — running tasks only**
```json
{"jsonrpc":"2.0","id":1,"method":"list_tasks","params":{
  "status_filter": "running"
}}
```

**Example — at most 10 sleep tasks of any status**
```json
{"jsonrpc":"2.0","id":1,"method":"list_tasks","params":{
  "type_filter": "sleep",
  "limit": 10
}}
```

---

## exec_python_persistent

**Tier 2 PR #45.** Like `execute_unreal_python` but state **persists across calls**. Variables, imports, and function/class definitions defined in one call are visible in the next — letting Claude build up state across turns without re-loading every time.

**Implementation:** UE's `FPythonCommandEx` has a `FileExecutionScope` field; the default `Private` (used by `execute_unreal_python`) creates a fresh globals dict per call, while `Public` (used here) shares the dict with the editor's Python console. **The persistent state is just UE's standard console namespace** — same one you'd get if you typed at the editor's `>>>` prompt.

**Why a separate handler instead of an opt-in flag on `execute_unreal_python`:** persistent state is a sticky semantic surprise. A handler that might-or-might-not share globals based on a flag is harder to reason about than two clearly-named variants. The handler name signals the contract.

**Output-capture caveat (same as `execute_unreal_python`, `run_python_file`, `apply_python_to_selection`):** `ExecuteFile` mode does not return stdout / eval-result via `CommandResult`. To round-trip results, emit a marker via `unreal.log("__MARKER__<json>__END__")` and retrieve through `get_log_lines{category_filter:"LogPython"}`.

**Params**
- `code` (string, required) — Python source to execute against the persistent globals dict.

**Result**
- `ok` (bool)
- `output` (string) — `FPythonCommandEx::CommandResult` (typically `"None"` for ExecuteFile; on failure, the Python exception trace)
- `temp_script` (string) — path to the wrapper script that was written and executed (cleaned up via `ON_SCOPE_EXIT`)
- `scope` (string) — always `"public"` (echo of the FileExecutionScope used)

**Errors:** `missing_required_field`, `python_unavailable`, `write_failed`.

**Example — build up state across calls**

Call 1:
```json
{"jsonrpc":"2.0","id":1,"method":"exec_python_persistent","params":{
  "code": "import unreal\nactors = unreal.EditorActorSubsystem.get_selected_level_actors()\nprint(f'captured {len(actors)} actors')"
}}
```

Call 2 (sees `unreal` import + `actors` variable from call 1):
```json
{"jsonrpc":"2.0","id":2,"method":"exec_python_persistent","params":{
  "code": "for a in actors:\n    a.set_actor_location(unreal.Vector(0, 0, 100), False, False)\nunreal.log(f'__MOVED__{len(actors)}__END__')"
}}
```

Then `get_log_lines{category_filter:"LogPython"}` to read the marker.

---

## reset_python_state

Clear all user-defined names from UE Python's public globals dict. Pairs with `exec_python_persistent`: lets Claude wipe accumulated state and start fresh without restarting the editor.

**Preservation rule:** names starting with `_` (Python dunders like `__name__` / `__builtins__`, plus conventional private names) are preserved. Imports the user explicitly added (e.g. `import unreal`) ARE cleared — re-import in the next `exec_python_persistent` call.

**Params:** none.

**Result**
- `ok` (bool)
- `scope` (string) — always `"public"`
- `note` (string) — explanatory text

**Errors:** `python_unavailable`, `reset_failed`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"reset_python_state","params":{}}
```
```json
{"jsonrpc":"2.0","id":1,"result":{
  "ok": true,
  "scope": "public",
  "note": "All user-defined names cleared from UE Python's public globals dict..."
}}
```

---

## find_console_variables

**Language-shim experiment, PR #46 (C++ canonical handler).** Prefix-search the `IConsoleManager` registry; returns matching CVar names with their types and read-only flags. Pairs with `get_console_variable` / `set_console_variable` for discovery workflows.

**Why C++:** iterating UE's internal console registry via `ForEachConsoleObjectThatStartsWith` is dramatically cleaner than the Python equivalent (which would multi-call into `unreal.SystemLibrary` per CVar). See `docs/LANGUAGE-CHOICE-RETROSPECTIVE.md` for the full comparison.

**Params**
- `prefix` (string, optional) — case-sensitive prefix to filter on (e.g. `"r.Screen"`, `"Slate."`, `"a."`). Empty / omitted = match all.
- `limit` (int, optional, default `100`) — cap returned variables. Hard max `1000`.

**Result**
- `ok`, `prefix`, `limit`, `returned` (counts)
- `variables` (array) — each entry: `{ name, type ("int"/"float"/"bool"/"string"/"unknown"), read_only (bool) }`
- `note` (only when result count hit the cap) — diagnostic

**Errors:** `invalid_value_shape`.

**Example — find all `r.Lumen.*` CVars**
```json
{"jsonrpc":"2.0","id":1,"method":"find_console_variables","params":{"prefix":"r.Lumen.","limit":50}}
```

---

## inspect_static_mesh

**Language-shim experiment, PR #46 (C++ canonical handler).** Read structural properties of a `UStaticMesh`: LOD count, per-LOD vertex/triangle counts, bounding box, material slots. Pairs with `inspect_asset` (registry-level metadata) and `inspect_material` (parameters).

**Why C++:** direct field access on `UStaticMesh` (`GetNumLODs`, `GetNumVertices(i)`, `GetBoundingBox`, `GetStaticMaterials`) — the Python equivalent would be multi-call FFI with reflection-limit risk on private struct fields.

**Params**
- `path` (string, required) — UE asset path of a `UStaticMesh`, e.g. `/Engine/BasicShapes/Cube`.

**Result**
- `ok`, `name`, `package_path`
- `num_lods`, `total_vertices`, `total_triangles` (across all LODs)
- `lods` (array) — `{ index, vertices, triangles }` per LOD
- `bounds` — `{ min: {x,y,z}, max: {x,y,z}, size: {x,y,z}, center: {x,y,z} }`
- `material_slots` (array) — `{ index, slot_name, material_path }`

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_static_mesh`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_static_mesh","params":{"path":"/Engine/BasicShapes/Cube"}}
```

---

## inspect_niagara_system

**Tier 3 (PR #51 — first multi-agent PR with explorer-fed Codex prompt).** Read structural properties of a `UNiagaraSystem`: emitters, user-exposed parameters, and system-level settings. Pairs with `inspect_asset` (registry-level metadata) for VFX-pipeline introspection.

**Why C++:** direct access to `UNiagaraSystem`'s emitter handle list, exposed parameter store, and warmup/bounds settings. The Python equivalent would need multi-call FFI through `unreal.NiagaraSystem` and per-emitter handle traversal.

**Why a separate handler from `inspect_blueprint`:** Niagara systems aren't `UBlueprint`s — they're a distinct asset class with their own emitter/parameter model that has no equivalent in regular blueprints.

**Required UE 5.7 discipline:** `UNiagaraSystem` uses `LoadBehavior = LazyOnDemand` (`NiagaraSystem.h:233`). The handler calls `EnsureFullyLoaded()` (`NiagaraSystem.h:526`) immediately after `Cast<UNiagaraSystem>` and before reading any emitters or parameters — otherwise lazy fields return uninitialized data. This is unique to Niagara among the inspect handlers.

**Required Build.cs deps:** `Niagara` and `NiagaraCore` (runtime modules — NOT `NiagaraEditor`, which is a heavy editor-only dep).

**Params**
- `path` (string, required) — UE asset path of a `UNiagaraSystem`, e.g. `/Game/FX/NS_Fire`.

**Result**
- `ok`, `name`, `package_path`
- `is_looping` (bool) — whether the system loops forever
- `has_gpu_emitters` (bool) — at least one emitter uses GPU simulation
- `needs_warmup` (bool); when true, also: `warmup_tick_count`, `warmup_time`, `warmup_tick_delta`
- `effect_type` (string) — asset path of the `UNiagaraEffectType` instance (e.g. `/Game/FX/EffectTypes/EFT_Hero.EFT_Hero`), omitted when none set
- `fixed_bounds` (`{ min: {x,y,z}, max: {x,y,z}, size: {x,y,z}, center: {x,y,z} }`) — only emitted when `bFixedBounds == true`. Mirrors `inspect_static_mesh`'s bounds shape.
- `emitter_count`, `emitters` (array of `{ name, enabled, mode }`) — `mode` is `"Standard"` or `"Stateless"`
- `user_parameter_count`, `user_parameters` (array of `{ name, type }`) — `type` is the runtime-safe class/struct name (e.g. `"float"`, `"NiagaraBool"`, `"LinearColor"`)

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_niagara_system`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_niagara_system","params":{"path":"/Game/FX/NS_Fire"}}
```

---

## inspect_anim_blueprint

**Tier 3 (PR #52 — first PR with full multi-agent collaboration).** Read structural properties of a `UAnimBlueprint`: parent class, target skeleton, template flag, compile status, baked state machines, anim functions (with `implemented` flag), sync groups, and parent anim blueprint chain. Pairs with the existing `Inspect*` family; complements `inspect_blueprint` by exposing the anim-specific surface that regular `UBlueprint` introspection can't see.

**Why C++:** direct access to `UAnimBlueprintGeneratedClass`'s baked state-machine list, anim-function metadata (with `bImplemented` flags), and sync-group names. The Python equivalent would require multi-call FFI through `unreal.AnimBlueprint` → generated class indirection.

**Why a separate handler from `inspect_blueprint`:** anim BPs *are* `UBlueprint`s, so the existing handler exposes their parent class / variables / function names — but the anim-specific surface (skeleton, state machines, sync groups, anim functions, parent anim BP chain) lives on `UAnimBlueprint` and `UAnimBlueprintGeneratedClass` and is invisible to a generic `UBlueprint` reader.

**Required UE 5.7 discipline:** `GetAnimBlueprintGeneratedClass()` returns null when the blueprint has never been compiled. The handler guards every compiled-data access (state machines, anim functions, sync groups) behind this null-check and emits `is_compiled: false` with empty arrays in that case, rather than crashing.

**Required Build.cs deps:** **none** — the runtime `Engine` module (already a dependency) provides `UAnimBlueprint`, `UAnimBlueprintGeneratedClass`, and the supporting types. **Do NOT add `AnimGraph`** (editor-only — would break server cooks).

**Params**
- `path` (string, required) — UE asset path of a `UAnimBlueprint`, e.g. `/Game/Animation/ABP_Hero`.

**Result**
- `ok`, `name`, `path`
- `parent_class` (string) — the immediate parent class name (often `AnimInstance` for natively-parented anim BPs)
- `is_template` (bool) — anim BP templates have no skeleton; affects whether `target_skeleton` is emitted
- `target_skeleton` (string) — asset path of the `USkeleton`. **Omitted** when `is_template == true` or the skeleton is null.
- `blueprint_status` (string) — one of `UpToDate` / `UpToDateWithWarnings` / `Dirty` / `Error` / `BeingCreated` / `Unknown`. `Error` means the blueprint failed to compile (look at the editor's compile log); `BeingCreated` is a transient construction state. `Status` is transient — treat `is_compiled` (below) as the authoritative "compiled data is available" signal; treat `blueprint_status: Error` as "compile failed; recompile or fix issues."
- `is_compiled` (bool) — true when `GetAnimBlueprintGeneratedClass()` is non-null. When false, all compiled-data arrays below are empty and the corresponding `*_count` fields are 0.
- `parent_anim_blueprint` (string) — asset path of the parent anim BP, when this blueprint subclasses another anim BP (rather than a native `UAnimInstance`). **Omitted** when null.
- `state_machine_count`, `state_machines` (array of `{ name }`)
- `anim_function_count`, `anim_functions` (array of `{ name, implemented }`)
- `sync_group_count`, `sync_groups` (array of strings — group names)

**Errors:** `missing_required_field`, `asset_not_found`, `not_an_anim_blueprint`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_anim_blueprint","params":{"path":"/Game/Animation/ABP_Hero"}}
```

---

## inspect_landscape

**Tier 3 (PR #53 — first Inspect\* handler that takes an actor reference instead of an asset path).** Read structural properties of an `ALandscape`: component dimensions, total component count across loaded streaming proxies, landscape material asset, world-space bounds, GUID identity (live and original).

**Critical divergence from sibling handlers**: UE landscapes are **scene actors, not standalone `.uasset` files**. There is no `/Game/...` content path for `ALandscape`. The handler accepts `name` (actor label) and/or `guid` (LandscapeGuid string) instead of `path`, and looks the actor up via `TActorIterator<ALandscape>` over the active editor world. With both filters omitted, the handler returns the sole landscape if exactly one exists; if multiple landscapes exist with no filter, the handler errors with `ambiguous_landscape` rather than guessing.

**Why C++:** direct access to `ALandscape` / `ALandscapeProxy` runtime fields and `ULandscapeInfo` aggregation. The Python equivalent would require multi-call FFI and editor-only reflection layers.

**Required Build.cs deps:** `Landscape` (runtime). Do **NOT** add `LandscapeEditor` (editor-only — would break server cooks).

**Params** (all optional)
- `name` (string) — actor label to match against `Actor->GetActorLabel()`. Empty/missing means "any landscape."
- `guid` (string) — matches against either `LandscapeGuid` (live, mutates on PIE/instancing) **or** `OriginalLandscapeGuid` (stable). Either matches.

**Result**
- `ok`, `name` (the actor's label), `actor_name` (the unique FName-style actor name)
- `landscape_guid`, `original_landscape_guid` — both emitted; the live GUID changes during PIE, the original is stable. Use `original_landscape_guid` for cross-session correlation.
- `component_size_quads`, `subsection_size_quads`, `num_subsections` — heightmap topology
- `landscape_material` (string) — asset path of the default `UMaterialInterface`. **Omitted** when null.
- `loaded_bounds` — `{ min: {x,y,z}, max: {x,y,z}, size: {x,y,z}, center: {x,y,z} }` from `GetLoadedBounds()` (runtime-safe; reflects only currently-loaded streaming proxies in a world-partition setup). Bounds shape matches `inspect_static_mesh` and `inspect_niagara_system`'s `fixed_bounds`.
- `streaming_proxy_count` (int) — number of `ALandscapeStreamingProxy` actors via `GetSortedStreamingProxies()`. Empty in non-streaming setups.
- `component_count_total` (int) — sum of `LandscapeComponents.Num()` over all proxies. 0 when `has_landscape_info` is false.
- `has_landscape_info` (bool) — `true` when `GetLandscapeInfo() != nullptr`. `ULandscapeInfo` is transient and may be null briefly after world load; the handler degrades gracefully (emits 0 / empty for the aggregated counts) rather than failing.

**Errors:** `no_editor_world`, `landscape_not_found`, `ambiguous_landscape`.

**Example — single-landscape world**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_landscape","params":{}}
```

**Example — by GUID (works against original or live GUID)**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_landscape","params":{"guid":"0123ABCD-..."}}
```

---

## inspect_skeletal_mesh

**Tier 3 (PR #55).** Read structural properties of a `USkeletalMesh`: per-LOD vertex / triangle / section counts, bounding box + sphere radius, target `USkeleton`, total + raw bone counts, material slots, morph targets, clothing assets, physics asset. Pairs with `inspect_static_mesh` (its non-skinned counterpart) and `inspect_anim_blueprint` (which references the same skeleton).

**Why C++:** direct field access on `USkeletalMesh` and indirection through `GetResourceForRendering()->LODRenderData` for per-LOD geometry data — the Python equivalent would be multi-call FFI through `unreal.SkeletalMesh` + per-LOD reflection lookups, which UE 5.7's reflection layer doesn't expose cleanly.

**Required Build.cs deps:** **none** — the runtime `Engine` module (already present) provides `USkeletalMesh`, `FSkeletalMeshRenderData`, `FReferenceSkeleton`, `UMorphTarget`, `UPhysicsAsset`, `UClothingAssetBase`. No new entries needed.

**API name trap:** Skeletal mesh uses `GetLODNum()`, **not** `GetNumLODs()` (which is what the static-mesh sibling uses). Easy to mix up.

**Critical defensive check:** `GetResourceForRendering()` can return `nullptr` on uninitialized meshes. The handler null-guards every access — when null, `num_lods` is 0 and `lods` is empty.

**Params**
- `path` (string, required) — UE asset path of a `USkeletalMesh`, e.g. `/Game/Characters/Hero/SK_Hero`.

**Result**
- `ok`, `name`, `package_path` (note: `package_path`, NOT `path` — sibling-consistent per cleanup PR #53)
- `num_lods`, `total_vertices`, `total_triangles` (sums across all LODs)
- `lods` (array of `{ index, vertices, triangles, section_count }`)
- `bounds` (`{ min: {x,y,z}, max: {x,y,z}, size: {x,y,z}, center: {x,y,z} }`) — matches `inspect_static_mesh` and `inspect_niagara_system`'s `fixed_bounds` shape
- `sphere_radius` (float) — from `FBoxSphereBounds.SphereRadius`; the imported-bounds sphere
- `skeleton` (string) — asset path of the `USkeleton`. **Omitted** when `GetSkeleton()` returns null.
- `bone_count` (int) — `FReferenceSkeleton::GetNum()` (includes virtual bones)
- `raw_bone_count` (int) — `FReferenceSkeleton::GetRawBoneNum()` (imported only; excludes virtual)
- `material_slots` (array of `{ index, slot_name, material_path }`) — `slot_name` is the FName from `FSkeletalMaterial`
- `morph_target_count`, `morph_targets` (array of strings)
- `has_clothing_assets` (bool — cached via `HasActiveClothingAssets()`), `clothing_asset_count` (int)
- `physics_asset` (string) — asset path. **Omitted** when `GetPhysicsAsset()` returns null.

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_skeletal_mesh`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_skeletal_mesh","params":{"path":"/Game/Characters/Hero/SK_Hero"}}
```

---

## inspect_anim_montage

**Tier 3 (PR #56).** Read structural properties of a `UAnimMontage`: target skeleton, play length, frame rate (rational), blend envelope, named composite sections (with start/end times and the section ladder via `NextSectionName`), slot animation tracks, notify events. Completes the **animation introspection trio** with `inspect_anim_blueprint` (which references the anim BP that drives this montage) and `inspect_skeletal_mesh` (which references the same skeleton). Callers can correlate all three through their shared `skeleton` asset path.

**Why C++:** direct field access on `UAnimMontage` (which inherits `UAnimCompositeBase : UAnimSequenceBase : UAnimationAsset`). Per-section start/end times come from `GetSectionStartAndEndTime` (runtime-safe) rather than the deprecated `WITH_EDITORONLY_DATA` `StartTime` field; per-event notify times come from `FAnimLinkableElement::GetTime()`. The Python equivalent would need multi-call FFI through `unreal.AnimMontage` with reflection limits on the `FCompositeSection` and `FAnimNotifyEvent` structs.

**Required Build.cs deps:** **none** — runtime `Engine` (already present) covers everything.

**Params**
- `path` (string, required) — UE asset path of a `UAnimMontage`, e.g. `/Game/Animation/AM_Attack`.

**Result**
- `ok`, `name`, `package_path`
- `skeleton` (string) — asset path of the `USkeleton`. **Omitted** when `GetSkeleton()` returns null. Cross-link to `inspect_anim_blueprint::target_skeleton` and `inspect_skeletal_mesh::skeleton`.
- `play_length` (float) — total duration in seconds
- `frame_rate` (`{ numerator, denominator }`) — rational form for exact NTSC rates (e.g. `30000/1001`)
- `is_dynamic` (bool) — `true` for runtime-created dynamic montages, `false` for authored assets
- `blend_in_time`, `blend_out_time` (float) — from `BlendIn.GetBlendTime()` / `BlendOut.GetBlendTime()` (avoids deprecated scalar fields)
- `blend_out_trigger_time` (float) — when blend-out begins relative to montage start; negative means "blend-out finishes exactly as the montage ends"
- `auto_blend_out` (bool)
- `parent_asset` (string) — when this montage is a child via asset remapping. **Omitted** when null. Child sections / slot tracks may be sparse overrides.
- `section_count`, `sections` (array of `{ name, next, start_time, end_time }`) — `next` is the empty string when there's no explicit next-section linkage (fall through to next index, or end if last)
- `slot_count`, `slots` (array of `{ slot_name, segment_count }`)
- `notify_count`, `notifies` (array of `{ name, time, duration, notify_class?, notify_state_class? }`) — `notify_class` is the one-shot notify's class; `notify_state_class` is the durational notify-state's class. Both are conditional: a "named" notify (no class) emits neither.

**Errors:** `missing_required_field`, `asset_not_found`, `not_an_anim_montage`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_anim_montage","params":{"path":"/Game/Animation/AM_Attack"}}
```

---

## inspect_widget_blueprint

**Tier 3.** Read `UWidgetBlueprint`-specific structural surface that **neither** `inspect_blueprint` (UBlueprint variables + function/event graphs) **nor** `inspect_widget_tree` (widget hierarchy via `WidgetTree->ForEachWidget`) covers. Callers stitch a complete view of a Widget BP across all three handlers via the shared asset path.

**What this handler emits (and the others don't):**
- `Animations` — `TArray<TObjectPtr<UWidgetAnimation>>` (editor-only). Per-animation: name, friendly display label, start/end time, derived length in seconds, count of `FWidgetAnimationBinding` entries.
- `Bindings` — `TArray<FDelegateEditorBinding>` (editor-only). Each is a property-binding from a widget's UPROPERTY to a function on the user widget's CDO.
- `PaletteCategory` — the string the widget appears under in the Designer's palette browser.
- `PropertyBindings` (count) — `AssetRegistrySearchable` UPROPERTY counting total property bindings; performance signal (high counts → expensive frame eval).
- Inherited named slots — `GetInheritedAvailableNamedSlots()` returns the slot names the parent generated class exposes for child override; `GetInheritedNamedSlotsWithContentInSameTree()` returns those already filled by the parent. The TSet result is sorted before emission for stable cross-call ordering.
- Compile status — `EBlueprintStatus` covering all five values (`UpToDate`, `UpToDateWithWarnings`, `Dirty`, `Error`, `Unknown`); the `Error` case surfaces a compile-failed asset rather than masking it as `Unknown` (PR #52→#53 lesson).

**Why C++:** Widget-BP fields like `Bindings` and `Animations` are declared without `EditAnywhere`/`BlueprintReadWrite`, so Python reflection can't reach them — same family as the `WidgetTree` reflection gap that `inspect_widget_tree` already addresses. Direct C++ access bypasses the limitation.

**Null-skip on `TArray<TObjectPtr<UWidgetAnimation>>`:** entries can be null after deletes, partial-load states, or reimport scenarios (PR #55→#57 lesson). `animation_count` reflects valid (non-null) entries only.

**Required Build.cs deps:** **none** — `UMG` and `UMGEditor` already present.

**Params**
- `path` (string, required) — UE asset path of a `UWidgetBlueprint`, e.g. `/Game/UI/WBP_HUD`.

**Result**
- `ok`, `name`, `path` (normalized via `UCMCPAssetPath::ToObjectPath`)
- `parent_class` (string) — `BP->ParentClass->GetName()` or empty
- `blueprint_status` (string enum) — one of `UpToDate | UpToDateWithWarnings | Dirty | Error | BeingCreated | Unknown`
- `palette_category` (string) — **omitted** when empty
- `can_init_without_player_context` (bool) — from `bCanCallInitializedWithoutPlayerContext`
- `property_bindings_allowed` (bool) — from `ArePropertyBindingsAllowed()`
- `property_bindings_count` (int) — from `WBP->PropertyBindings`
- `binding_count` (int), `bindings` (array of `{ object_name, property_name, function_name }`)
- `animation_count` (int — valid entries only), `animations` (array of `{ name, display_label, start_time, end_time, length_seconds, binding_count }`)
- `inherited_named_slot_count` (int), `inherited_named_slots` (array of string)
- `inherited_slots_with_content` (array of string, sorted for stable order)

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_widget_blueprint`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_widget_blueprint","params":{"path":"/Game/UI/WBP_HUD"}}
```

**Cross-link to siblings:** call `inspect_blueprint` on the same path for variables / function graphs; call `inspect_widget_tree` on the same path for the `WidgetTree` hierarchy. None of the three duplicate one another's output.

---

## inspect_data_table

**Tier 3.** Read structural properties of a `UDataTable` asset: the `RowStruct` it conforms to, every row name (sorted), and the field-by-field `name` + `type` of the `RowStruct` so callers know what each row is shaped like without having to load + introspect the row payload separately.

**Why C++:** `UDataTable::RowMap` is `TMap<FName, uint8*>` — raw byte pointers — and `RowStruct` is iterated via `TFieldIterator<FProperty>` with the engine's reflection registry. Both are awkward to reach through Python's `unreal` reflection (Python's `unreal.DataTable` exposes `get_row_names()` and `get_row()` but not the per-property type info on the `UScriptStruct`).

**Required Build.cs deps:** **none** — `Engine` and `CoreUObject` cover `UDataTable`, `UScriptStruct`, and `FProperty`.

**`TFieldIterator` flag choice:** the handler iterates with `EFieldIterationFlags::None` (NOT the default `IncludeSuper = true`) to skip inherited UObject/UScriptStruct base fields and surface only the user-declared row fields. This matches the row author's mental model — they expect "the columns of my CSV" not "all reachable FProperty objects on the C++ struct hierarchy."

**Null-safety:** a freshly-created `UDataTable` can exist with `RowStruct == nullptr` until the user assigns a struct in the editor. The handler null-guards: when `RowStruct` is null it omits the `row_struct` / `row_struct_name` fields and emits `row_property_count: 0`, `row_properties: []`. The data-only fields (row count, row names, flags) still emit so callers can still introspect a partially-set-up DataTable.

**Stable row ordering:** `TMap<FName, uint8*>` iteration order is unspecified. The `rows` array is sorted before emission for cross-call stability. Same convention as `inspect_widget_blueprint::inherited_slots_with_content`.

**Params**
- `path` (string, required) — UE asset path of a `UDataTable`, e.g. `/Game/Data/DT_Items`.

**Result**
- `ok`, `name`, `path` (normalized via `UCMCPAssetPath::ToObjectPath`)
- `row_struct` (string) — engine ground-truth asset path of the `UScriptStruct`. **Omitted** when `RowStruct` is null.
- `row_struct_name` (string) — bare `GetName()` of the `UScriptStruct`. **Omitted** when `RowStruct` is null.
- `row_count` (int) — `RowMap.Num()`
- `rows` (array of string) — sorted row names from `RowMap` keys
- `row_property_count` (int)
- `row_properties` (array of `{ name, type }`) — `name` from `FProperty::GetName()`, `type` from `FProperty::GetCPPType()` (language-friendly form like `int32`, `FString`, `FVector`, `TArray<...>`)
- `strip_from_client_builds` (bool) — from `bStripFromClientBuilds`
- `ignore_extra_fields` (bool) — from `bIgnoreExtraFields`
- `ignore_missing_fields` (bool) — from `bIgnoreMissingFields`
- `import_key_field` (string) — from `ImportKeyField`. **Omitted** when empty.

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_data_table`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_data_table","params":{"path":"/Game/Data/DT_Items"}}
```

---

## inspect_texture

**Tier 3.** Read structural properties of a `UTexture` asset (`UTexture2D` / `UTextureCube` / `UTextureRenderTarget` / `UTexture2DArray` / …): the texture class, surface dimensions, sRGB / compression / filter / LOD-group settings, mip-gen settings, virtual-texture-streaming flag, never-stream flag, optional composite-texture cross-link. For `UTexture2D` specifically, also emits exact pixel dimensions, mip count, pixel format, and the imported (pre-resize) source dimensions.

**Pairs with:**
- `configure_texture` — mutates `SRGB` / `CompressionSettings` / `LODGroup` / `Filter`. Round-trip: read with `inspect_texture`, mutate with `configure_texture`, read again to verify.
- `import_texture` — creates the asset from disk. After import, `inspect_texture` confirms the pipeline result (correct group, format, mip count).

**Subclass handling:** the handler casts to `UTexture2D` and emits `size_x` / `size_y` / `num_mips` / `pixel_format` / `imported_size_x|y` only when that cast succeeds. Other UTexture subclasses (cube, render target, array) still emit the base UTexture surface plus the polymorphic `surface_width` / `surface_height` / `surface_depth` accessors that every concrete subclass implements.

**Why C++:** `UTexture::CompressionSettings`, `LODGroup`, `Filter`, `MipGenSettings`, the bitfield flags `SRGB` / `VirtualTextureStreaming` / `NeverStream`, and the polymorphic `GetSurface*()` accessors are direct field reads on a UE engine asset. Going through Python's `unreal` reflection works for some but not all (the bitfield flags trip up the property-info path); a direct C++ handler is consistent and avoids per-field workarounds.

**Required Build.cs deps:** **none** — `Engine` covers `UTexture`, `UTexture2D`, `EPixelFormat`, and `UEnum`.

**Params**
- `path` (string, required) — UE asset path of a `UTexture`, e.g. `/Game/Textures/Environment/T_Stone_D`.

**Result**
- `ok`, `name`, `path` (normalized via `UCMCPAssetPath::ToObjectPath`)
- `texture_class` (string) — `Texture2D`, `TextureCube`, `TextureRenderTarget2D`, `Texture2DArray`, …
- `compression_settings` (string enum) — `Default | Normalmap | Masks | Grayscale | Displacementmap | VectorDisplacementmap | HDR | UserInterface2D | BC7 | HalfFloat | SingleFloat | Alpha | DistanceFieldFont | HDR_Compressed | HDR_F32 | Unknown`. Mirrors the parse table in `Handler_ConfigureTexture`'s `ParseCompression` for round-trip fidelity.
- `filter` (string enum) — `Nearest | Bilinear | Trilinear | Default | Unknown`
- `lod_group` (string) — `TextureGroup` name from `UTexture::GetTextureGroupString` (e.g. `World`, `WorldNormalMap`, `UI`, `Lightmap`)
- `mip_gen_settings` (string) — `TextureMipGenSettings` name from `UTexture::GetMipGenSettingsString` (e.g. `FromTextureGroup`, `Sharpen0`, `NoMipmaps`)
- `lod_bias` (int)
- `srgb` (bool) — from the `SRGB` bitfield
- `virtual_texture_streaming` (bool) — from the `VirtualTextureStreaming` bitfield
- `never_stream` (bool) — from the `NeverStream` bitfield (inherited from `UStreamableRenderAsset`)
- `composite_texture` (string) — engine ground-truth asset path of `UTexture::CompositeTexture`. **Omitted** when null.
- `surface_width`, `surface_height`, `surface_depth` (float) — from the polymorphic `GetSurface*()` accessors
- **`UTexture2D`-only (conditional):**
  - `size_x`, `size_y`, `num_mips` (int)
  - `pixel_format` (string) — `EPixelFormat` enum name via `UTexture::GetPixelFormatEnum()` (e.g. `PF_DXT1`, `PF_BC7`, `PF_FloatRGBA`)
  - `imported_size_x`, `imported_size_y` (int) — the original on-disk source dimensions, before any LOD-bias / max-texture-size resize. Useful for "did the engine downscale my source?"

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_texture`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_texture","params":{"path":"/Game/Textures/Environment/T_Stone_D"}}
```

---

## inspect_curve

**Tier 3.** Read structural properties of a `UCurveBase` asset (`UCurveFloat` / `UCurveLinearColor` / `UCurveVector` / any subclass): the curve class, channel count, the global time + value range across all channels, and per-channel name + key count + per-channel time/value ranges.

**Channel layout per concrete subclass:**
| Subclass | Channels |
|---|---|
| `UCurveFloat` | 1 (`FloatCurve`) |
| `UCurveLinearColor` | 4 (R, G, B, A) |
| `UCurveVector` | 3 (X, Y, Z) |

**Why C++:** `UCurveBase::GetCurves()` returns `TArray<FRichCurveEditInfo>` containing `FRealCurve*` pointers. Iterating those pointers + reading `Keys.Num()` requires a static cast to `FRichCurve*` (the universal carrier across UCurve* subclasses) plus the polymorphic `GetTimeRange` / `GetValueRange` accessors on the base. Python's `unreal` reflection exposes some curve query, but per-channel structural details bypass it.

**Required Build.cs deps:** **none** — `Engine` covers `UCurveBase`, `FRichCurve`, `FRealCurve`.

**Key-count strategy:** every UCurveBase subclass we care about populates the `FRealCurve*` slot in `FRichCurveEditInfo` with a concrete `FRichCurve` (the only commonly-used `FRealCurve` subclass). The handler `static_cast`s and reads `Keys.Num()`. When `CurveToEdit` is null (defensive guard for malformed assets), `key_count: -1` distinguishes "zero keys" from "not knowable."

**Params**
- `path` (string, required) — UE asset path of a `UCurveBase`, e.g. `/Game/Curves/Curve_Falloff`.

**Result**
- `ok`, `name`, `path` (normalized via `UCMCPAssetPath::ToObjectPath`)
- `curve_class` (string) — `CurveFloat`, `CurveLinearColor`, `CurveVector`, …
- `channel_count` (int)
- `time_range` (`{ min, max }` floats) — global across all channels
- `value_range` (`{ min, max }` floats) — global across all channels
- `channels` (array) — per-channel `{ name, key_count, time_range, value_range }`. `key_count` is `-1` when `CurveToEdit` is null.

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_curve`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_curve","params":{"path":"/Game/Curves/Curve_Falloff"}}
```

---

## inspect_physics_asset

**Tier 3.** Read structural properties of a `UPhysicsAsset`: the preview skeletal mesh, body setups (one per simulated bone, with the per-body bounds flags), constraint setups (joints between bodies, with child + parent bone names), the bounds-bodies subset count, and the named physical-animation + constraint profiles. Cross-links to `inspect_skeletal_mesh` via the shared `preview_skeletal_mesh` asset path — callers can stitch a complete "rigged + simulated character" view by following that path.

**Why C++:** `UPhysicsAsset::SkeletalBodySetups` and `UPhysicsAsset::ConstraintSetup` are `TArray<TObjectPtr<...>>`; `FConstraintInstance::JointName / ConstraintBone1 / ConstraintBone2` are direct field reads. Python's `unreal` reflection covers some of this but the constraint instance is a USTRUCT-by-value field, awkward to drill through reflection. C++ direct-access is cleaner.

**Required Build.cs deps:** **none** — `Engine` and `PhysicsCore` already cover `UPhysicsAsset`, `USkeletalBodySetup`, `UPhysicsConstraintTemplate`, `FConstraintInstance`.

**Null-skip discipline:** both `TArray<TObjectPtr<USkeletalBodySetup>>` and `TArray<TObjectPtr<UPhysicsConstraintTemplate>>` can carry null entries after deletes (PR #55→#57 lesson). `body_count` and `constraint_count` reflect valid (non-null) counts.

**Soft-object handling:** `PreviewSkeletalMesh` is a `TSoftObjectPtr<USkeletalMesh>`. The handler reads `ToSoftObjectPath().ToString()` *without loading* the mesh — keeps `inspect_physics_asset` cheap. If the caller wants the geometry, they pass the resulting path to `inspect_skeletal_mesh`.

**Bounds-bodies subset:** `UPhysicsAsset::BoundsBodies` stores indices into `SkeletalBodySetups` for the subset of bodies that contribute to the asset's bounds. Per-body emission includes:
- `consider_for_bounds` — the per-body `bConsiderForBounds` flag from `UBodySetup`
- `is_in_bounds_subset` — whether `BoundsBodies` actually contains this body's index

**Params**
- `path` (string, required) — UE asset path of a `UPhysicsAsset`, e.g. `/Game/Characters/Hero/PHYS_Hero`.

**Result**
- `ok`, `name`, `path`
- `preview_skeletal_mesh` (string) — soft-object asset path of the preview mesh. **Omitted** when null.
- `body_count` (int) — valid `USkeletalBodySetup` entries only
- `bodies` (array) — per-body `{ name, body_index, consider_for_bounds, is_in_bounds_subset }` (`name` = the bone name)
- `constraint_count` (int) — valid `UPhysicsConstraintTemplate` entries only
- `constraints` (array) — per-constraint `{ joint_name, child_bone, parent_bone }`
- `bounds_body_count` (int) — `BoundsBodies.Num()` (raw count; the per-body flag tells you which body is in the subset)
- `physical_animation_profile_count`, `physical_animation_profiles` (array of string)
- `constraint_profile_count`, `constraint_profiles` (array of string)

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_physics_asset`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_physics_asset","params":{"path":"/Game/Characters/Hero/PHYS_Hero"}}
```

---

## inspect_sound_cue

**Tier 3.** Read structural properties of a `USoundCue` asset: total duration, max distance, volume + pitch multipliers, subtitle priority, max audible distance, attenuation cross-link, root sound-node class, and the full graph of sound nodes (sorted by name with class taxonomy). Pairs with `import_texture` / `configure_texture` style asset-pipeline introspection — gives the LLM enough surface to reason about a cue's runtime behaviour without loading the full graph.

**Why C++:** `USoundCue::AllNodes` is `TArray<TObjectPtr<USoundNode>>`; iterating the graph and reading `GetClass()->GetName()` per node is straightforward in C++. Python's `unreal` reflection covers some node properties but the per-node class taxonomy + null-skip for stale entries is awkward via reflection.

**Required Build.cs deps:** **none** — `Engine` covers `USoundCue`, `USoundBase`, `USoundNode`, `USoundAttenuation`.

**Null-skip discipline:** `TArray<TObjectPtr<USoundNode>>` can carry null entries after deletes/reimports (PR #55→#57 lesson). `node_count` reflects valid (non-null) entries only.

**Stable node ordering:** `AllNodes` iteration order is technically deterministic (it's a TArray, not a TSet) but the in-asset order reflects authoring history rather than any human-meaningful sort. Sorted by node name before emission for stable cross-call output and diff-friendly JSON. Same convention as `inspect_widget_blueprint::inherited_slots_with_content` and `inspect_data_table::rows`.

**Params**
- `path` (string, required) — UE asset path of a `USoundCue`, e.g. `/Game/Audio/SC_Footstep`.

**Result**
- `ok`, `name`, `path` (normalized via `UCMCPAssetPath::ToObjectPath`)
- `duration` (float) — `USoundBase::Duration` (total cue length in seconds)
- `max_distance` (float) — `USoundBase::MaxDistance` (statically computed at load/edit time; runtime value via `GetMaxDistance()` may differ)
- `volume_multiplier`, `pitch_multiplier` (float) — `USoundCue` direct fields
- `subtitle_priority`, `max_audible_distance` (float)
- `attenuation_settings` (string) — engine ground-truth asset path of the `USoundAttenuation` asset. **Omitted** when null. Cross-link target.
- `first_node_class` (string) — class name of the root graph node (`FirstNode->GetClass()->GetName()`). **Omitted** when `FirstNode` is null.
- `node_count` (int) — valid (non-null) entries in `AllNodes`
- `nodes` (array) — sorted-by-name `{ name, class }` for every node in the graph

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_sound_cue`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sound_cue","params":{"path":"/Game/Audio/SC_Footstep"}}
```

---

## inspect_sound_wave

**Tier 3.** Read structural properties of a `USoundWave` asset: sample rate, channel count, frame count, duration, compression type + runtime format + compressed data size, sound group, looping flag, streaming flag, loading behavior, subtitle + cue-point + loop-region counts. Editor-only fields (`imported_sample_rate`, `lufs`, `sample_peak_db`, `comment`) emit conditionally when non-default.

**Pairs with:** `inspect_sound_cue` (which references USoundWave assets via its node graph) — together they let the LLM stitch a complete view of a SoundCue's audio playback chain. The forthcoming `inspect_sound_attenuation` will complete the audio introspection trio.

**Why C++:** `USoundWave::SoundAssetCompressionType` is an `ESoundAssetCompressionType` enum; `LoadingBehavior` is `ESoundWaveLoadingBehavior`; `SoundGroup` is a `TEnumAsByte<ESoundGroup>`. Mapping these to clean string output requires `UEnum::GetValueAsString` + prefix stripping. Bit-field flags (`bLooping`) require explicit `!= 0` for unambiguous bool conversion. Python reflection covers some but not all of this consistently.

**LazyOnDemand caveat:** USoundWave is declared with `LoadBehavior = "LazyOnDemand"` (SoundWave.h:415). This means transient runtime state (`RawPCMData`, `CachedRealtimeFirstBuffer`, `CookedPlatformData`, `AudioDecompressor`) may be uninitialized at inspection time. The handler avoids these fields entirely — it reads only **declarative** asset properties that are always populated post-PostLoad.

**Streaming flag note:** The `bStreaming` field on USoundWave is **deprecated** in 5.0+. The handler uses `USoundWave::IsStreaming()` method instead, which checks platform-specific stream caching state correctly.

**Required Build.cs deps:** **none** — `Engine` covers `USoundWave`, `USoundBase`, `FSoundWaveCuePoint`, `FSubtitleCue`.

**Params**
- `path` (string, required) — UE asset path of a `USoundWave`, e.g. `/Game/Audio/SW_Footstep`.

**Result (always present)**
- `ok`, `name`, `path`, `sound_wave_class`
- `sample_rate` (int), `num_channels` (int), `num_frames` (int — from `GetNumFrames()`)
- `duration` (float seconds — from `GetDuration()` polymorphic)
- `compression_type` (string — `ESoundAssetCompressionType` value, prefix-stripped)
- `runtime_format` (string — `GetRuntimeFormat().ToString()`)
- `sound_group` (string — `ESoundGroup` value, prefix-stripped)
- `loading_behavior` (string — `ESoundWaveLoadingBehavior`, prefix-stripped)
- `is_looping` (bool — `bLooping` bitfield with explicit `!= 0`)
- `is_streaming` (bool — `IsStreaming()` method, NOT the deprecated field)
- `resource_size` (int)
- `supports_subtitles` (bool), `subtitle_count` (int)
- `cue_point_count` (int — non-loop cue points via `GetCuePoints()`)
- `loop_region_count` (int — via `GetLoopRegions()`)

**Result (conditional)**
- `compressed_data_size` (int) — bytes via `GetCompressedDataSize(GetRuntimeFormat())`. Omitted when zero.
- Editor-only block (`#if WITH_EDITORONLY_DATA`):
  - `imported_sample_rate` (int) — omitted when zero
  - `lufs` (float) — omitted when zero (default-initialized)
  - `sample_peak_db` (float) — omitted when zero
  - `comment` (string) — omitted when empty

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_sound_wave`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sound_wave","params":{"path":"/Game/Audio/SW_Footstep"}}
```

---

## inspect_sound_attenuation

**Tier 3.** Read structural properties of a `USoundAttenuation` asset (the 3D playback rules used by SoundCues / SoundWaves to control distance falloff, spatialization, occlusion, reverb send, focus boost, etc.). Completes the **audio introspection trio**: `inspect_sound_cue` (graph) + `inspect_sound_wave` (decoded data) + `inspect_sound_attenuation` (3D playback rules). Cue/Wave handlers cross-link to attenuation via the `attenuation_settings` asset path field.

**JSON shape: feature-gated sub-objects.** Each major feature (`distance`, `spatialization`, `air_absorption`, `listener_focus`, `occlusion`, `reverb_send`, `priority_attenuation`) is a sub-object with an `enabled` boolean at the top. When the master bool is **false**, the sub-object emits ONLY `{"enabled": false}` — no other fields. When **true**, the full sub-fields list appears. Result: a default-asset attenuation (most features off) emits very compact JSON; a fully-tuned attenuation expands all the relevant subgroups.

**Why C++:** USoundAttenuation is a thin UObject wrapper around `FSoundAttenuationSettings` (USTRUCT) — the interesting data lives in the struct, not on the UObject. Direct field access on the struct's ~30 members is cleanest in C++; Python's `unreal` reflection on USTRUCT-by-value fields trips on the bitfield gate booleans.

**Required Build.cs deps:** **none** — `Engine` covers `USoundAttenuation`, `FSoundAttenuationSettings`, `FBaseAttenuationSettings`, `ECollisionChannel`.

**Sub-object reference:**
- `distance` (always emitted) — `algorithm` (Linear/Logarithmic/Inverse/LogReverse/NaturalSound/Custom), `shape` (Sphere/Capsule/Box/Cone), `falloff_mode` (Continues/Silent/Hold), `db_attenuation_at_max`, `shape_extents` (FVector → `{x,y,z}`), `falloff_distance`, `cone_offset`, `cone_sphere_radius`, `cone_sphere_falloff_distance`. The `enabled` bool reflects `bAttenuate` (master gate).
- `spatialization` (gated by `bSpatialize`) — `algorithm`, `binaural_radius`, `non_spatialized_radius_start/end/mode`, `stereo_spread`.
- `air_absorption` (gated by `bAttenuateWithLPF`) — `method` (Linear/CustomCurve), `lpf_radius_min/max`, `lpf_frequency_at_min/max`, `hpf_frequency_at_min/max`.
- `listener_focus` (gated by `bEnableListenerFocus`) — `focus_azimuth` (degrees), `non_focus_azimuth` (degrees), `focus_distance_scale`, `non_focus_distance_scale`, `focus_priority_scale`, `non_focus_priority_scale`, `focus_volume_attenuation` (dB), `non_focus_volume_attenuation` (dB). Plus `attack_speed` / `release_speed` only when `bEnableFocusInterpolation` is also true.
- `occlusion` (gated by `bEnableOcclusion`) — `trace_channel` (ECollisionChannel name), `use_complex_collision`, `lowpass_filter_frequency`, `volume_attenuation`, `interpolation_time`.
- `reverb_send` (gated by `bEnableReverbSend`) — `method` (Linear/CustomCurve/Manual), `wet_level_min/max`, `distance_min/max`, `manual_send_level`.
- `priority_attenuation` (gated by `bEnablePriorityAttenuation`) — `method`, `scale_min/max`, `distance_min/max`, `manual_priority`.
- `feature_flags` — assorted bool toggles: `apply_stereo_normalization`, `enable_log_frequency_scaling`, `enable_submix_sends`, `enable_source_data_override`, `enable_send_to_audio_link`.

**Out of scope this PR:** `FRuntimeFloatCurve` introspection (custom curves), `SubmixSendSettings` array, `PluginSettings` nested struct, `AudioLinkSettingsOverride`. Future cleanup PRs may add these.

**Params**
- `path` (string, required) — UE asset path of a `USoundAttenuation`, e.g. `/Game/Audio/Atten_Default`.

**Errors:** `missing_required_field`, `asset_not_found`, `not_a_sound_attenuation`.

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sound_attenuation","params":{"path":"/Game/Audio/Atten_Default"}}
```

---

## get_camera_transform

**Language-shim experiment, PR #46 (Python shim — bridge-side synthetic tool).** Read the level-editor viewport camera's location and rotation.

> Phase-H engine-version gate (best-effort/community path; UE 5.7 is officially supported & tested — ADR-0001): **Min engine: 5.0** — uses `unreal.get_editor_subsystem` (5.0+). On older engines the bridge returns a structured `unsupported_on_engine_version` error (Phase H gate, kept available — `docs/PHASE-H-COMPAT.md`).

**Implementation:** the bridge composes `execute_unreal_python` + `get_log_lines` via the **marker pattern** — runs Python that calls `UnrealEditorSubsystem.get_level_viewport_camera_info()` and emits `unreal.log("__CAM_<uuid>__" + json + "__END__")`, then drains LogPython lines and parses the marker. Two UE round-trips per call.

**Why a Python shim:** the underlying API is fully Python-reachable; the equivalent C++ handler would be ~150 LoC vs ~75 LoC for the shim. Trade-off: ~5× round-trip latency and marker-pattern fragility under high log volume. See `docs/LANGUAGE-CHOICE-RETROSPECTIVE.md`.

**Params:** none.

**Result**
- `ok` (bool)
- `location` — `{ x, y, z }` world-space
- `rotation` — `{ pitch, yaw, roll }` in degrees

**Errors** (Python-flow style, not stable codes):
- `python_failed` — UE Python raised; the trace is in the message
- `marker_not_found` — log buffer overflowed between exec and read; retry typically resolves
- `marker_parse_failed` — JSON corruption between log and bridge

---

## set_camera_transform

**Language-shim experiment, PR #46 (Python shim — bridge-side synthetic tool).** Set the level-editor viewport camera's location and/or rotation. Single UE round-trip (write-only — no result to round-trip).

> Phase-H engine-version gate (best-effort/community path; UE 5.7 is officially supported & tested — ADR-0001): **Min engine: 5.0** — uses `unreal.get_editor_subsystem` (5.0+). On older engines the bridge returns a structured `unsupported_on_engine_version` error (Phase H gate, kept available — `docs/PHASE-H-COMPAT.md`).

**Implementation:** validates location/rotation shape locally in the bridge (so a bad input fails before crossing the wire), then runs `unreal.UnrealEditorSubsystem.get_editor_subsystem(...).set_level_viewport_camera_info(unreal.Vector(...), unreal.Rotator(...))` via `execute_unreal_python`.

**Why a Python shim:** write-only setters that wrap Python-reachable APIs are the strongest case for shims (no marker-pattern tax, ~50 LoC).

**Params**
- `location` (object, optional) — `{ x, y, z }`. Missing fields default to `0`.
- `rotation` (object, optional) — `{ pitch, yaw, roll }` in degrees. Missing fields default to `0`.

At least one of `location` / `rotation` should be provided in practice; both omitted is a no-op.

**Result**
- `ok` (bool)
- `location`, `rotation` — the values that were applied (with defaults filled in)

**Errors:** `invalid_value_shape` (any field non-numeric), `python_failed` (UE Python raised).

**Example — frame top-down on origin**
```json
{"jsonrpc":"2.0","id":1,"method":"set_camera_transform","params":{
  "location": {"x": 0, "y": 0, "z": 1000},
  "rotation": {"pitch": -90, "yaw": 0, "roll": 0}
}}
```

---

## screenshot_actor

Frame the level-editor viewport on a specific actor and capture a focused PNG screenshot. Useful for asset-pipeline thumbnail generation and for giving the LLM a visual of one specific thing in the scene.

**Bridge-side synthetic tool.** Composes two existing handlers — no new C++ handler. The two-round-trip composition is structurally correct because UE's game thread runs at least one tick between the bridge's separate JSON-RPC requests, so the screenshot captures the post-move frame, not a pre-move frame mid-camera-animation. A single C++ handler doing both ops in one game-thread call would race the camera move against the readback.

**Composition**
1. `focus_actor { name }` — selects the actor and frames the viewport on it
2. `get_viewport_screenshot {}` — captures the (now-framed) viewport as base64 PNG

**Params**
- `name` (string, required) — actor label or unique name to focus on. Same matching rules as `focus_actor`.

**Result**
- `ok` (bool)
- `focused` (string) — the actor label that was focused
- `name` (string) — the actor's unique name
- `loc` (`{ x, y, z }`) — the focused actor's world location
- `width`, `height` (int) — viewport dimensions in pixels
- `png_bytes` (int) — size of the encoded PNG
- `png_base64` (string) — the PNG, base64-encoded inline

**Errors:** `missing_required_field`, `focus_failed` (actor not found, no GEditor, no editor world), `screenshot_failed` (no active viewport, ReadPixels failed, viewport size zero).

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"screenshot_actor","params":{
  "name": "MyHeroBP"
}}
```

---

## compile_mod_pak

Compile a UE mod plugin to a `.pak` file (`BuildMod`) or to a redistributable plugin package (`BuildPlugin`), headless, via `RunUAT.bat`. **No UE editor session required.** Originally contributed by [@daveCode-dot](https://github.com/daveCode-dot) (PR #84) for the Conan Exiles Enhanced UE5 mod pipeline; integrated with Gemini review fixes + manifest + tests + docs.

**Bridge-side synthetic tool.** Pure Python — shells out to `RunUAT.bat`, captures stdout/stderr tails, scans `output_dir` for the freshest `.pak`. No UE-side state means there's no reason to push this into a C++ handler (per the decision flow in [`LANGUAGE-CHOICE-RETROSPECTIVE.md`](LANGUAGE-CHOICE-RETROSPECTIVE.md) step 6).

**Primary use case:** game-specific UE5 Dev Kits in **installed-build mode** that block standard `BuildPlugin` because precompiled `UE5Rules.dll` / `UE5ProgramRules.dll` aren't shipped. Funcom's Conan Exiles Enhanced UE5 Dev Kit is the canonical example — it ships a custom `BuildMod` UAT command as the only working path. This tool exposes that pipeline behind a clean MCP interface.

**Success criterion is per-command:**
- `BuildMod` requires both `exit_code == 0` AND a `.pak` in `output_dir`. The `.pak` is the deployable artefact callers want.
- `BuildPlugin` requires `exit_code == 0` alone. This command produces a redistributable plugin package (`.uplugin` + `Binaries/` + `Resources/`) under `output_dir`, NOT a `.pak`. Insisting on a `.pak` would mark every successful `BuildPlugin` run as `ok=false` (Gemini PR #84 review).

**Pak identification:** when multiple `.pak` files exist in `output_dir` (shared dir across builds), the handler prefers:
1. A `.pak` whose name contains `mod_name` (case-insensitive substring); falls back to all `.pak`s if no match
2. Among those, the one with the latest mtime (most likely THIS build's output)
3. Filtered to only `.pak`s newer than the build's start time (skip stale artefacts); if none are fresh, returns the newest anyway

**Output capture:** `stdout` capped at 4 KB tail, `stderr` capped at 2 KB tail. UAT builds can emit 10–100 MB of output over a 5–30 min build; capping at return time keeps MCP responses sane. (Streaming the output line-by-line during the build would lower peak memory further but is deferred to a follow-up.)

**Auto-discovery:** `run_uat_path` defaults to walking up from `project_path` looking for `Engine/Build/BatchFiles/RunUAT.bat`. Override via the param if your Dev Kit lays out engine differently.

**Params**
- `project_path` (string, required) — absolute path to `.uproject` (e.g. `C:/Games/ConanSandbox/ConanSandbox.uproject`)
- `output_dir` (string, required) — directory for output `.pak` or package; created if missing
- `mod_name` (string, conditional) — required for `BuildMod`; matches `Content/Mods/<mod_name>/`. Also disambiguates the freshest `.pak` when `output_dir` is shared across multiple builds.
- `plugin_path` (string, conditional) — required for `BuildPlugin`; absolute path to `.uplugin`
- `uat_command` (string enum) — `BuildMod` (default) or `BuildPlugin`
- `run_uat_path` (string, optional) — override path to `RunUAT.bat`; auto-discovered otherwise
- `extra_args` (array of string, optional) — additional CLI args appended to `RunUAT`
- `timeout_sec` (int, optional) — max wait in seconds; default `1800` (30 min)

**Result**
- `ok` (bool) — see success-criterion section above
- `pak_path` (string or null) — absolute path of the chosen `.pak`; null for `BuildPlugin` (no `.pak` produced) or when no `.pak` exists in `output_dir`
- `exit_code` (int) — `RunUAT.bat` return code
- `stdout_tail` (string) — last 4 KB of stdout
- `stderr_tail` (string) — last 2 KB of stderr
- `duration_sec` (float) — wall-clock time, 2-decimal precision
- `uat_command` (string) — the command actually run
- `cmd` (array of string) — the full subprocess argv (useful for "why did UAT fail?" debugging)

**Errors:** `-32602` (missing/invalid `project_path`, missing `output_dir`, missing `mod_name` for BuildMod, missing `plugin_path` for BuildPlugin); `-32603` (RunUAT.bat not found, timeout, subprocess exception).

**Example — BuildMod (Conan Exiles Enhanced)**
```json
{"jsonrpc":"2.0","id":1,"method":"compile_mod_pak","params":{
  "project_path": "C:/ConanExilesEnhanced/ConanSandbox/ConanSandbox.uproject",
  "mod_name": "MyMod",
  "output_dir": "D:/Builds/MyMod",
  "uat_command": "BuildMod"
}}
```

**Example — BuildPlugin (vanilla UE5)**
```json
{"jsonrpc":"2.0","id":1,"method":"compile_mod_pak","params":{
  "project_path": "C:/Projects/MyGame/MyGame.uproject",
  "plugin_path": "C:/Projects/MyGame/Plugins/MyPlugin/MyPlugin.uplugin",
  "output_dir": "D:/Builds/MyPlugin",
  "uat_command": "BuildPlugin",
  "timeout_sec": 3600
}}
```

---

## compile_mod_pak_direct

Compile a UE5 mod into a `.pak` by invoking `UnrealPak.exe` directly with a response file, **bypassing `RunUAT` entirely**. Companion to [`compile_mod_pak`](#compile_mod_pak) for Dev Kits where `RunUAT BuildMod` is broken. Contributed by [@daveCode-dot](https://github.com/daveCode-dot) following the AEGIS-Admin mod ship pipeline (Steam Workshop item `3724162370`) where Funcom's Conan Exiles Enhanced UE5 Dev Kit (mayo 2026) failed `RunUAT BuildMod` with `"Found no script module records"` even after the [UE5Rules.dll fix](#compile_mod_pak) — UAT deleted its own `deps.json` as "invalid record" before `BuildMod` could run.

**Bridge-side synthetic tool.** Pure Python — shells out to `UnrealPak.exe`, captures stdout/stderr tails, verifies `output_pak` exists with non-zero size. No UE-side state, no UE editor session required.

**Pre-condition:** the `.uasset` files must already be cooked. Two common ways:
1. Via `execute_unreal_python` on a running Editor (the AEGIS-Admin path — produces shell BP/Widget/Actor assets in `/Game/Mods/<name>/Local/`)
2. Via a discrete `UnrealEditor-Cmd.exe -run=Cook` pass before calling `compile_mod_pak_direct`

`UnrealPak` itself is a standalone UE binary shipped under `Engine/Binaries/Win64/` and runs regardless of UAT state — typically seconds instead of the 5–30 min that `RunUAT BuildMod` takes.

**When to use which:**
- `compile_mod_pak` — Dev Kit's `RunUAT BuildMod` (or vanilla `BuildPlugin`) works. Single call: cook + pak.
- `compile_mod_pak_direct` — UAT is broken on your Dev Kit. Two-step: cook separately (e.g. via `execute_unreal_python`), then call this to pack the `.pak`. Verified on Funcom Conan Exiles Enhanced UE5 Dev Kit (Apr 2026 build).

**Success criterion:** `exit_code == 0` AND `output_pak` exists with size > 0. Same shape as `compile_mod_pak`'s `BuildMod` branch so downstream tooling can switch transparently. `UnrealPak` occasionally exits 0 but writes a zero-byte `.pak` on malformed response files; the size check catches that.

**Output capture:** `stdout` capped at 4 KB tail, `stderr` capped at 2 KB tail. `UnrealPak` output is bounded (file-count log + per-file compression stats), so tail-only is sufficient for typical mods.

**Params**
- `unreal_pak_path` (string, required) — absolute path to `UnrealPak.exe` (e.g. `<DevKit>/Engine/Binaries/Win64/UnrealPak.exe`)
- `response_file` (string, required) — absolute path to the UnrealPak response file (`.txt`). Standard format: one mapping per line, `"<absolute_source>" "<mount_in_pak>"`.
- `output_pak` (string, required) — absolute path where the `.pak` should be written; parent dir created if missing
- `compression` (string enum, optional) — `Zlib` (default) | `Gzip` | `Oodle` | `None`. `None` omits the `-compress<Algo>` flag entirely (uncompressed pak).
- `extra_args` (array of string, optional) — additional CLI args appended to `UnrealPak.exe` (e.g. `-encryptionkey`)
- `timeout_sec` (int, optional) — max wait in seconds; default `600` (10 min)

**Result**
- `ok` (bool) — `exit_code == 0` AND `output_pak` exists with size > 0
- `pak_path` (string or null) — absolute path of the produced `.pak`; null on missing/empty output
- `pak_size_bytes` (int or null) — size in bytes; null when pak missing
- `exit_code` (int) — `UnrealPak.exe` return code
- `stdout_tail` (string) — last 4 KB of stdout
- `stderr_tail` (string) — last 2 KB of stderr
- `duration_sec` (float) — wall-clock time, 2-decimal precision
- `cmd` (array of string) — the full subprocess argv (useful for "why did UnrealPak fail?" debugging)

**Errors:** `-32602` (missing/invalid `unreal_pak_path`, missing/invalid `response_file`, missing `output_pak`); `-32603` (timeout, subprocess exception).

**Example response file** (`mymod_response.txt`):
```
"C:/DevKit/Mods/MyMod/Cooked/WindowsNoEditor/MyGame/Content/Mods/MyMod/Local/BP_Entry.uasset" "../../../MyGame/Content/Mods/MyMod/Local/BP_Entry.uasset"
"C:/DevKit/Mods/MyMod/Cooked/WindowsNoEditor/MyGame/Content/Mods/MyMod/Local/WBP_Panel.uasset" "../../../MyGame/Content/Mods/MyMod/Local/WBP_Panel.uasset"
```

**Example call** (Conan Exiles Enhanced UE5 Dev Kit, AEGIS-Admin workflow)
```json
{"jsonrpc":"2.0","id":1,"method":"compile_mod_pak_direct","params":{
  "unreal_pak_path": "C:/DevKit/Engine/Binaries/Win64/UnrealPak.exe",
  "response_file": "C:/DevKit/Mods/AEGIS_Admin/response.txt",
  "output_pak": "C:/DevKit/Builds/AEGIS_Admin/AEGIS_Admin.pak",
  "compression": "Zlib"
}}
```

**Verified ship:** AEGIS-Admin v0.1.0 packed via this workflow as 11.6 KB Zlib `.pak`, published as Steam Workshop item `3724162370`.

---

## bulk_delete_assets

Delete multiple assets in one MCP call by composing the [`delete_asset`](#delete_asset) C++ handler bridge-side. Returns per-path results plus aggregate counts, suitable for cleanup workflows over collections (test fixtures, generated content, temp imports).

**Bridge-side synthetic tool.** Pure Python — loops over `paths` and dispatches one `call_ue("delete_asset", {"path": ...})` per entry, collecting result records. Each delete is a discrete UE round-trip, so in-editor events fire per-asset and the caller can watch progress via the event bus (`poll_events` / `register_subscription`). Synthetic rather than C++ because the bulk loop is pure protocol-level composition — a native C++ bulk handler would just duplicate `delete_asset`'s logic per path while forcing partial-failure aggregation into one game-thread call, needlessly coupling N delete operations into one round-trip.

**Partial-failure model (default):**
- `continue_on_error: true` (default) — the loop attempts every path regardless of failures. Per-path errors land in `results[].error_code` + `error_message`. The overall response is still a JSON-RPC success (no top-level `error`), with `ok: False` and `failed > 0` signalling partial success.
- `continue_on_error: false` — abort on the first failure, return whatever has accumulated.

The partial-failure-as-success-envelope shape is deliberate: a bulk operation rarely needs to be transactional, and surfacing per-path errors lets the caller decide whether to retry, log, or escalate.

**Upstream error preservation:** the `error_code` recorded per failed path is the literal JSON-RPC code returned by `delete_asset` (or by the transport layer if the UE server was unreachable mid-loop — `-32099`). Callers can distinguish transport failures from logical delete errors without re-parsing messages.

**Params**
- `paths` (array of string, required) — asset object paths to delete, e.g. `["/Game/Foo", "/Game/Bar/Baz"]`. Each entry must be a non-empty string; the same path-normalisation rules as `delete_asset` apply.
- `continue_on_error` (bool, optional, default `true`) — when `false`, stop after the first per-path failure and return the partial results.

**Result**
- `ok` (bool) — `true` only when `failed == 0`
- `total` (int) — `paths.length`
- `deleted` (int) — count of per-path successes
- `failed` (int) — count of per-path failures
- `results` (array) — one entry per attempted path, in input order
  - `path` (string)
  - `ok` (bool)
  - `error_code` (int or null) — preserved from the upstream `delete_asset` response on failure; `null` on success
  - `error_message` (string or null) — preserved from the upstream `delete_asset` response on failure; `null` on success

**Errors (envelope-level, not per-path):** `-32602` (missing or non-list `paths`, non-string entry, non-bool `continue_on_error`).

**Example — happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_delete_assets","params":{
  "paths": ["/Game/TestFixtures/Temp_A", "/Game/TestFixtures/Temp_B"]
}}
```

**Example — partial failure, continue on error (default)**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_delete_assets","params":{
  "paths": ["/Game/Foo", "/Game/Bar", "/Game/Baz"]
}}
```
Returns `{"ok": false, "total": 3, "deleted": 2, "failed": 1, "results": [...]}` if one of the paths can't be deleted (e.g. has referencers without `force` set on the underlying handler).

**Example — fail fast**
```json
{"jsonrpc":"2.0","id":3,"method":"bulk_delete_assets","params":{
  "paths": ["/Game/Foo", "/Game/Bar", "/Game/Baz"],
  "continue_on_error": false
}}
```

---

## bulk_move_assets

Move multiple assets into a single destination folder by composing the [`move_asset`](#move_asset) C++ handler bridge-side. Each move leaves a redirector at the source per UE's standard move semantics, so existing references stay resolvable until [`fix_up_redirectors`](#fix_up_redirectors) sweeps them.

**Bridge-side synthetic tool.** Pure Python — loops over `paths` and dispatches one `call_ue("move_asset", {"path": ..., "dest_folder": ...})` per entry. Mirrors `bulk_delete_assets`'s shape so client code can switch between the two with a one-tool-name change (delete vs. archive workflows).

**Partial-failure model:** identical to [`bulk_delete_assets`](#bulk_delete_assets). `continue_on_error: true` (default) keeps moving after individual failures and surfaces per-path errors in `results[]`; `continue_on_error: false` stops at the first failure and returns the partial results collected so far.

**Path validation:** each entry in `paths` must be a non-empty string. NUL byte (`\x00`) and `..` path segments are rejected envelope-level (`-32602`) before any move is attempted.

**Params**
- `paths` (array of string, required) - asset object paths to move, e.g. `["/Game/Foo", "/Game/Bar/Baz"]`. Same shape rules as [`bulk_delete_assets`](#bulk_delete_assets).
- `dest_folder` (string, required) - destination folder for ALL moved assets, e.g. `/Game/Archive`. Same folder applies to every path; for per-asset destinations, call [`move_asset`](#move_asset) directly.
- `continue_on_error` (bool, optional, default `true`) - when `false`, abort after the first per-path failure.

**Result**
- `ok` (bool) - `true` only when `failed == 0`
- `total` (int) - `paths.length`
- `moved` (int) - count of per-path successes
- `failed` (int) - count of per-path failures
- `results` (array) - one entry per attempted path, in input order:
  - `path` (string) - source path
  - `ok` (bool)
  - `dest_path` (string or null) - destination path on success
  - `error_code` (int or null) - preserved from the upstream `move_asset` response on failure
  - `error_message` (string or null) - preserved from the upstream `move_asset` response on failure

**Errors (envelope-level):** `-32602` (missing or non-list `paths`, non-string entry, NUL or `..` in any path, missing `dest_folder`, non-bool `continue_on_error`).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_move_assets","params":{
  "paths": ["/Game/Foo", "/Game/Bar/Baz"],
  "dest_folder": "/Game/Archive"
}}
```

**Example - fail fast**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_move_assets","params":{
  "paths": ["/Game/A", "/Game/B"],
  "dest_folder": "/Game/Archive",
  "continue_on_error": false
}}
```

---

## bulk_rename_assets

Rename multiple assets in one MCP call by composing the [`rename_asset`](#rename_asset) C++ handler bridge-side. Each rename leaves a redirector at the source per UE's standard rename semantics, so existing references continue to resolve until [`fix_up_redirectors`](#fix_up_redirectors) sweeps them.

**Bridge-side synthetic tool.** Pure Python — loops over `renames[]` and dispatches one `call_ue("rename_asset", {"path": ..., "new_name": ...})` per entry. Each entry takes its own new name, unlike `bulk_move_assets` where all assets share one `dest_folder`.

**Partial-failure model:** identical shape to the other `bulk_*_assets` synthetics. `continue_on_error: true` (default) keeps going after individual failures; `continue_on_error: false` aborts at the first failure.

**Per-entry validation:**
- `path` must be a non-empty string; NUL byte (`\x00`) and `..` segments rejected envelope-level.
- `new_name` must be a non-empty string with no slash (`/`) or dot (`.`) characters — `rename_asset` is name-only, not a move. Cross-folder renames go through `bulk_move_assets`.

**Params**
- `renames` (array of object, required) - per-entry rename instructions:
  - `path` (string, required) - source asset object path
  - `new_name` (string, required) - new asset name (no slashes or dots)
- `continue_on_error` (bool, optional, default `true`) - when `false`, abort after the first per-entry failure.

**Result**
- `ok` (bool) - `true` only when `failed == 0`
- `total` (int) - `renames.length`
- `renamed` (int) - count of per-entry successes
- `failed` (int) - count of per-entry failures
- `results` (array) - one entry per attempted item, in input order:
  - `path` (string) - source path
  - `new_name` (string) - requested new name
  - `ok` (bool)
  - `new_path` (string or null) - resulting asset path on success
  - `error_code` (int or null) - preserved from the upstream `rename_asset` response on failure
  - `error_message` (string or null) - preserved from the upstream `rename_asset` response on failure

**Errors (envelope-level):** `-32602` (missing or non-list `renames`, non-dict entry, missing `path` or `new_name`, NUL or `..` in `path`, slash or dot in `new_name`, non-bool `continue_on_error`).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_rename_assets","params":{
  "renames": [
    {"path": "/Game/Foo/T_Old", "new_name": "T_New"},
    {"path": "/Game/Bar/M_Old", "new_name": "M_New"}
  ]
}}
```

**Example - fail fast**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_rename_assets","params":{
  "renames": [
    {"path": "/Game/A", "new_name": "A2"},
    {"path": "/Game/B", "new_name": "B2"}
  ],
  "continue_on_error": false
}}
```

---

## bulk_duplicate_assets

Duplicate multiple assets into per-entry destinations in one MCP call by composing the [`duplicate_asset`](#duplicate_asset) C++ handler bridge-side. Unlike `bulk_move_assets`, each item has its own `dest_path` and (optionally) `new_name`, so a single call can scatter copies across folders.

**Bridge-side synthetic tool.** Pure Python — loops over `duplicates[]` and dispatches one `call_ue("duplicate_asset", {"path": ..., "dest_path": ..., "new_name": ...})` per entry. Mirrors `bulk_rename_assets`'s per-entry duplicates shape.

**Partial-failure model:** identical shape to the other `bulk_*_assets` synthetics. `continue_on_error: true` (default) keeps going after individual failures; `continue_on_error: false` aborts at the first failure.

**Per-entry validation:**
- `path` (source) must be a non-empty string; NUL byte (`\x00`) and `..` segments rejected envelope-level.
- `dest_path` must be a non-empty string; NUL byte (`\x00`) and `..` segments rejected.
- `new_name` (optional override) — when provided, forwarded to `duplicate_asset` as-is. Unlike `bulk_rename_assets`, the bridge does NOT pre-validate `new_name` for slashes or dots; downstream `duplicate_asset` is responsible for any naming rules. When omitted, the duplicate gets the asset name implied by `dest_path`.

**Params**
- `duplicates` (array of object, required) - per-entry duplicate instructions:
  - `path` (string, required) - source asset object path
  - `dest_path` (string, required) - destination package path (folder + optional new name)
  - `new_name` (string, optional) - asset name override; forwarded to duplicate_asset
- `continue_on_error` (bool, optional, default `true`) - when `false`, abort after the first per-entry failure.

**Result**
- `ok` (bool) - `true` only when `failed == 0`
- `total` (int) - `duplicates.length`
- `duplicated` (int) - count of per-entry successes
- `failed` (int) - count of per-entry failures
- `results` (array) - one entry per attempted duplicate, in input order:
  - `path` (string) - source path
  - `dest_path` (string) - requested destination
  - `new_name` (string or null) - name override if provided
  - `ok` (bool)
  - `created_path` (string or null) - resulting duplicate path on success
  - `error_code` (int or null) - preserved from the upstream `duplicate_asset` response on failure
  - `error_message` (string or null) - preserved from the upstream `duplicate_asset` response on failure

**Errors (envelope-level):** `-32602` (missing or non-list `duplicates`, non-dict entry, missing `path` or `dest_path`, NUL or `..` in `path` or `dest_path`, non-bool `continue_on_error`).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_duplicate_assets","params":{
  "duplicates": [
    {"path": "/Game/Templates/T_Base", "dest_path": "/Game/A/T_BaseCopy"},
    {"path": "/Game/Templates/M_Base", "dest_path": "/Game/B/M_BaseCopy"}
  ]
}}
```

**Example - per-entry name override**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_duplicate_assets","params":{
  "duplicates": [
    {"path": "/Game/T_Stamp", "dest_path": "/Game/Variants", "new_name": "T_Stamp_A"},
    {"path": "/Game/T_Stamp", "dest_path": "/Game/Variants", "new_name": "T_Stamp_B"}
  ]
}}
```

---

## inspect_data_asset

Shallow-reflect a UDataAsset by package path: returns leaf class name, parent class name, full package path, and a per-property reflection list (name, Python type, stringified value).

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern (see `synthetic_get_camera_transform` for the canonical two-round-trip flow). Why synthetic instead of C++: generic UDataAsset reflection is well-served by UE's Python `get_editor_property` introspection; a C++ handler would have to enumerate `FProperty` fields manually and stringify them, while `dir(obj)` + Python's type-aware `repr` does the same work with less code.

**Marker flow:**
1. Bridge generates a per-call UUID marker (`__DATA_<12-hex>__`).
2. First round-trip: `execute_unreal_python` runs the embedded reflection script in the editor; the script `unreal.log()`s a JSON payload wrapped in the marker + `__END__` sentinel.
3. Second round-trip: `get_log_lines` retrieves recent `LogPython` lines.
4. Bridge scans in reverse, finds the marker, extracts + parses the JSON, returns it via `_wrap_tool_result`.

**Property enumeration heuristic:** the embedded script iterates `dir(obj)` filtered to non-underscore names, then attempts `obj.get_editor_property(n)` on each. UE returns the value for real UPROPERTYs and raises for everything else (methods, transient attrs, parent-class slots that aren't editor-exposed) — the script catches and skips those silently. This keeps the reflection robust without needing a curated list of property types per asset class.

**Value stringification (shallow, no recursion):**
- Scalars (`int`, `float`, `bool`, `str`) → `str(value)`.
- Containers (`list`, `tuple`, `dict`) → `"<container:<typename>>"`.
- Everything else (UE structs, soft references, actor handles, …) → `"<unsupported>"`.

Don't try to recurse — if you need deep inspection of a nested struct, build a more specific handler or run a custom Python via `execute_unreal_python`.

**Logical errors return as ok=False success envelopes:**
- `asset_not_found` — `EditorAssetLibrary.load_asset(path)` returned `None`.
- `marker_not_found` — the LogPython buffer didn't contain the marker after the exec round-trip. Usually means log buffer overflowed between exec and read; **retry typically resolves**.
- `invalid_json` — marker was found but the payload between marker and `__END__` didn't JSON-decode. Should not happen in practice unless the embedded script emits malformed output.

These are **logical** errors (caller may want to retry or inspect), not transport errors. Transport-level errors (UE editor not running, `-32099`; Python interpreter raised an exception, `-32603`) come back as JSON-RPC errors per the existing convention.

**Params**
- `path` (string, required) — package path to a UDataAsset, e.g. `/Game/Data/DA_PlayerStats`.

**Result (ok=true path)**
- `ok` (bool) — `true`
- `path` (string) — echoes the input
- `class` (string) — leaf class name from `cls.get_name()`
- `parent_class` (string or null) — `cls.get_super_class().get_name()`; null when at `UObject` root
- `package_path` (string) — full path from `obj.get_path_name()`, including the asset's object name (e.g. `/Game/Data/DA_PlayerStats.DA_PlayerStats`)
- `properties` (array of `{name, type, value}`) — shallow reflection of editor-visible properties

**Result (ok=false path)**
- `ok` (bool) — `false`
- `error_code` (string) — `asset_not_found` / `marker_not_found` / `invalid_json`
- `error_message` (string) — human-readable detail (`marker_not_found` includes a "retry typically resolves" hint)

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised — `output` field of the Python response is surfaced in the error message).

**Example — happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_data_asset","params":{
  "path": "/Game/Data/DA_PlayerStats"
}}
```

**Example response (ok=true)**
```json
{
  "ok": true,
  "path": "/Game/Data/DA_PlayerStats",
  "class": "MyDataAsset",
  "parent_class": "PrimaryDataAsset",
  "package_path": "/Game/Data/DA_PlayerStats.DA_PlayerStats",
  "properties": [
    {"name": "MaxHealth", "type": "float", "value": "100.0"},
    {"name": "DisplayName", "type": "str", "value": "Hero"},
    {"name": "Tags", "type": "Array", "value": "<container:Array>"}
  ]
}
```

**Example — asset not found**
```json
{"jsonrpc":"2.0","id":2,"method":"inspect_data_asset","params":{
  "path": "/Game/Data/DoesNotExist"
}}
```
Returns `{"ok": false, "error_code": "asset_not_found", "error_message": "Asset not found: /Game/Data/DoesNotExist"}`.

---

## inspect_sound_class

Inspect a `USoundClass` by package path: returns the leaf class name, full package path, **parent USoundClass asset path** (for chaining), **child USoundClass asset paths**, and the editable `FSoundClassProperties` values (Volume, Pitch, low-pass filter, attenuation distance scale, voice-center-channel volume, radio-filter volume, eight boolean flags, OutputTarget enum).

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern, same canonical six-step flow as [`inspect_data_asset`](#inspect_data_asset) and [`get_camera_transform`](#get_camera_transform).

**Marker token:** `__SOUNDCLASS_<12-hex>__...__END__`.

**Parent/child chaining:** `parent_class` + `child_classes` return **asset package paths** (e.g. `/Game/Audio/SC_Master`), NOT C++ class names. Callers can immediately chain another `inspect_sound_class { path: <parent_class> }` call to traverse the USoundClass hierarchy. Empty `child_classes: []` means the class is a leaf (no children).

**Snake_case → PascalCase remap:** UE Python field names are snake_case internally (`volume`, `pitch`, `b_apply_ambient_volumes`, `output_target`). The embedded reflection script reads via the snake_case names and remaps to the PascalCase C++ names in the JSON output so callers can cross-reference UE editor / docs without translation. The `properties` block keys exactly match the C++ `FSoundClassProperties` struct layout.

**Logical errors as `ok=False` success envelopes:**
- `asset_not_found` — `EditorAssetLibrary.load_asset(path)` returned `None`.
- `wrong_asset_type` — path resolved to a non-USoundClass asset. Response includes `actual_class` for the caller's diagnostic.
- `marker_not_found` — LogPython buffer didn't contain the marker after exec. Usually means log buffer overflowed between exec and read; **retry typically resolves**.
- `invalid_json` — marker found but payload didn't JSON-decode.

Transport-level errors (UE editor not running → `-32099`; Python interpreter raised → `-32603`) come back as JSON-RPC errors.

**Params**
- `path` (string, required) — package path to a USoundClass asset, e.g. `/Game/Audio/SC_Music`.

**Result (ok=true path)**
- `ok` (bool) — `true`.
- `path` (string) — echoes input.
- `class` (string) — leaf class name from `cls.get_name()`, typically `"SoundClass"`.
- `package_path` (string) — full path with object name (e.g. `/Game/Audio/SC_Music.SC_Music`).
- `parent_class` (string or null) — parent USoundClass asset path; null when this is a root SoundClass.
- `child_classes` (array of string) — child USoundClass asset paths; may be empty.
- `properties` (object) — PascalCase keys:
  - `Volume`, `Pitch`, `LowPassFilterFrequency`, `AttenuationDistanceScale`, `VoiceCenterChannelVolume`, `RadioFilterVolume` — floats
  - `bApplyAmbientVolumes`, `bApplyEffects`, `bAlwaysPlay`, `bIsUISound`, `bIsMusic`, `bReverb`, `bCenterChannelOnly`, `bApplyDoppler`, `bApplyMixerOverrides` — booleans
  - `OutputTarget` — enum stringified (e.g. `"Master"`, `"Controller"`)

**Result (ok=false path)**
- `ok` (bool) — `false`
- `error_code` (string) — `asset_not_found` / `wrong_asset_type` / `marker_not_found` / `invalid_json`
- `error_message` (string) — human-readable detail
- `actual_class` (string, only on `wrong_asset_type`) — the leaf class name of the actually-loaded asset

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised — Python traceback surfaced in the error message).

**Example — happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sound_class","params":{
  "path": "/Game/Audio/SC_Music"
}}
```

**Example response (ok=true)**
```json
{
  "ok": true,
  "path": "/Game/Audio/SC_Music",
  "class": "SoundClass",
  "package_path": "/Game/Audio/SC_Music.SC_Music",
  "parent_class": "/Game/Audio/SC_Master",
  "child_classes": ["/Game/Audio/SC_MusicLayered"],
  "properties": {
    "Volume": 0.8,
    "Pitch": 1.0,
    "LowPassFilterFrequency": 20000.0,
    "AttenuationDistanceScale": 1.0,
    "VoiceCenterChannelVolume": 0.0,
    "RadioFilterVolume": 0.0,
    "bApplyAmbientVolumes": false,
    "bApplyEffects": true,
    "bAlwaysPlay": false,
    "bIsUISound": false,
    "bIsMusic": true,
    "bReverb": true,
    "bCenterChannelOnly": false,
    "bApplyDoppler": true,
    "bApplyMixerOverrides": false,
    "OutputTarget": "Master"
  }
}
```

**Example — chain to parent**
```json
{"jsonrpc":"2.0","id":2,"method":"inspect_sound_class","params":{
  "path": "/Game/Audio/SC_Master"
}}
```
Use the `parent_class` value from a child's response to walk up the SoundClass hierarchy.

**Example — wrong asset type**
```json
{"jsonrpc":"2.0","id":3,"method":"inspect_sound_class","params":{
  "path": "/Game/Data/DA_PlayerStats"
}}
```
Returns `{"ok": false, "error_code": "wrong_asset_type", "error_message": "Asset is not a USoundClass: /Game/Data/DA_PlayerStats", "actual_class": "MyDataAsset"}`.

---

## inspect_sound_submix

Inspect a `USoundSubmix` (or any `USoundSubmixBase` subclass) by package path: returns the leaf class name, full package path, parent submix asset path, child submix asset paths, and editor-exposed properties (output volume / wet / dry levels, parent-effect-chain count, etc.) via permissive `dir()` enumeration.

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern, identical canonical six-step flow as [`inspect_sound_class`](#inspect_sound_class).

**Marker token:** `__SUBMIX_<12-hex>__...__END__`.

**Permissive property enumeration:** USoundSubmix exposes several editor-only properties that aren't worth hand-curating (output level scaling, dynamic processor chains, effect presets). The embedded script iterates `dir(obj)` filtered to non-underscore names and attempts `get_editor_property(n)` per name — UE raises for non-UPROPERTYs and the script catches + skips silently. Same heuristic as [`inspect_data_asset`](#inspect_data_asset).

**Logical errors as `ok=False` success envelopes:**
- `asset_not_found` - `EditorAssetLibrary.load_asset(path)` returned `None`.
- `wrong_asset_type` - path resolved to a non-USoundSubmixBase asset. Response includes `actual_class`.
- `marker_not_found` - LogPython buffer didn't contain the marker after exec. **Retry typically resolves.**
- `invalid_json` - marker found but payload didn't JSON-decode.

Transport-level errors (UE editor not running → `-32099`; Python interpreter raised → `-32603`) come back as JSON-RPC errors.

**Params**
- `path` (string, required) - package path to a USoundSubmix asset, e.g. `/Game/Audio/Submix_Music`.

**Result (ok=true path)**
- `ok` (bool) - `true`
- `path` (string) - echoes input
- `class` (string) - leaf class name from `cls.get_name()`, typically `"SoundSubmix"`
- `package_path` (string) - full path with object name
- `parent_submix` (string or null) - parent submix asset path; null when this is a root submix
- `child_submixes` (array of string) - child submix asset paths; may be empty
- `properties` (array of `{name, type, value}`) - permissive `dir()` reflection of editor-exposed properties

**Result (ok=false path)**
- `ok` (bool) - `false`
- `error_code` (string) - `asset_not_found` / `wrong_asset_type` / `marker_not_found` / `invalid_json`
- `error_message` (string)
- `actual_class` (string, only on `wrong_asset_type`)

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_sound_submix","params":{
  "path": "/Game/Audio/Submix_Music"
}}
```

---

## inspect_audio_bus

Inspect a `UAudioBus` by package path: returns the leaf class name, full package path, the `AudioBusChannels` enum (Mono / Stereo / Quad / 5.1 / 7.1), and any other editor-exposed properties via permissive `dir()` enumeration.

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern, identical flow to [`inspect_sound_class`](#inspect_sound_class).

**Marker token:** `__AUDIOBUS_<12-hex>__...__END__`.

**Permissive property enumeration:** UAudioBus exposes only a small fixed property set in stock UE 5.7 (primarily channel count + tags), but plugins / project subclasses may add more. The embedded script reflects via `dir(obj)` + `get_editor_property(n)` and silently skips non-UPROPERTYs.

**Logical errors as `ok=False` success envelopes:**
- `asset_not_found` - load returned `None`.
- `wrong_asset_type` - path resolved to a non-UAudioBus asset. Response includes `actual_class`.
- `marker_not_found` - LogPython buffer didn't contain the marker. **Retry typically resolves.**
- `invalid_json` - marker found but payload didn't JSON-decode.

**Params**
- `path` (string, required) - package path to a UAudioBus asset, e.g. `/Game/Audio/AB_MusicSends`.

**Result (ok=true path)**
- `ok` (bool) - `true`
- `path` (string)
- `class` (string) - typically `"AudioBus"`
- `package_path` (string)
- `audio_bus_channels` (string) - enum stringified, e.g. `"Stereo"`
- `properties` (array of `{name, type, value}`) - permissive reflection of remaining editor-exposed properties

**Result (ok=false path)**
- `ok` (bool) - `false`
- `error_code` (string)
- `error_message` (string)
- `actual_class` (string, only on `wrong_asset_type`)

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_audio_bus","params":{
  "path": "/Game/Audio/AB_MusicSends"
}}
```

---

## inspect_material_function

Inspect a `UMaterialFunction` (or `UMaterialFunctionMaterialLayer` / `UMaterialFunctionMaterialLayerBlend`) by package path: returns the leaf class name, full package path, exposed input/output pin names, and remaining editor-exposed properties via permissive `dir()` enumeration. Graph topology (node-to-node wiring) is NOT reflected here — that requires a dedicated traversal pass beyond surface-level metadata.

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern, identical flow to [`inspect_material`](#inspect_material) and [`inspect_sound_class`](#inspect_sound_class).

**Marker token:** `__MATFUNC_<12-hex>__...__END__`.

**Input/output pin enumeration:** UMaterialFunction's input pins live in `FunctionExpressions` (a TArray of `UMaterialExpressionFunctionInput*`); outputs live in matching `UMaterialExpressionFunctionOutput*`. The embedded script walks the expression list, casts each to the input/output expression classes, and reads `input_name` / `output_name`. Both arrays are returned as plain strings; type info (which scalar / vector / texture pin type) is deferred to a later release.

**Logical errors as `ok=False` success envelopes:**
- `asset_not_found` - load returned `None`.
- `wrong_asset_type` - path resolved to a non-UMaterialFunction asset. Response includes `actual_class`.
- `marker_not_found` - LogPython buffer didn't contain the marker. **Retry typically resolves.**
- `invalid_json` - marker found but payload didn't JSON-decode.

**Params**
- `path` (string, required) - package path to a UMaterialFunction asset, e.g. `/Game/Materials/MF_BlendThreshold`.

**Result (ok=true path)**
- `ok` (bool) - `true`
- `path` (string)
- `class` (string) - typically `"MaterialFunction"` / `"MaterialFunctionMaterialLayer"` / `"MaterialFunctionMaterialLayerBlend"`
- `package_path` (string)
- `inputs` (array of string) - input pin names; may be empty
- `outputs` (array of string) - output pin names; may be empty
- `properties` (array of `{name, type, value}`) - permissive reflection of remaining editor-exposed properties

**Result (ok=false path)**
- `ok` (bool) - `false`
- `error_code` (string)
- `error_message` (string)
- `actual_class` (string, only on `wrong_asset_type`)

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_material_function","params":{
  "path": "/Game/Materials/MF_BlendThreshold"
}}
```

---

## inspect_metasound

Inspect a `UMetaSoundSource` or `UMetaSoundPatch` asset by package path: returns the leaf class name (which of the two it is), full package path, and any other editor-exposed UPROPERTYs via permissive `dir()` enumeration. Graph structure (node / connection topology) is NOT reflected here — that requires a dedicated traversal pass.

**Bridge-side synthetic tool.** Pure Python — composes [`execute_unreal_python`](#execute_unreal_python) + [`get_log_lines`](#get_log_lines) via the marker pattern, identical flow to [`inspect_sound_class`](#inspect_sound_class).

**Marker token:** `__METASOUND_<12-hex>__...__END__`.

**Accepts both subclasses:**
- `UMetaSoundSource` - the player-attached emitter form (most common).
- `UMetaSoundPatch` - a reusable subgraph that another MetaSound includes.

Both inherit from common base; this tool accepts either and returns `class` distinguishing them in the response.

**Permissive property enumeration:** MetaSound exposes a moderate fixed property set (output target, volume modulation, parameter pack metadata). Plugins / project subclasses may add more. The embedded script reflects via `dir(obj)` + `get_editor_property(n)` and silently skips non-UPROPERTYs. For graph-level introspection (nodes, connections, parameter inputs), use [`execute_unreal_python`](#execute_unreal_python) with a custom traversal.

**Logical errors as `ok=False` success envelopes:**
- `asset_not_found` - load returned `None`.
- `wrong_asset_type` - path resolved to neither UMetaSoundSource nor UMetaSoundPatch. Response includes `actual_class`.
- `metasound_unavailable` - the MetaSound plugin is not enabled in the running project; the embedded reflection couldn't `import unreal.MetaSoundSource`. Enable the MetaSound plugin to use this tool.
- `marker_not_found` - LogPython buffer didn't contain the marker. **Retry typically resolves.**
- `invalid_json` - marker found but payload didn't JSON-decode.

**Params**
- `path` (string, required) - package path to a MetaSound asset, e.g. `/Game/Audio/MS_Music`.

**Result (ok=true path)**
- `ok` (bool) - `true`
- `path` (string)
- `class` (string) - `"MetaSoundSource"` or `"MetaSoundPatch"`
- `package_path` (string)
- `properties` (array of `{name, type, value}`) - permissive reflection

**Result (ok=false path)**
- `ok` (bool) - `false`
- `error_code` (string) - one of the five codes above
- `error_message` (string)
- `actual_class` (string, only on `wrong_asset_type`)

**Errors (envelope-level):** `-32602` (missing or non-string `path`); `-32603` (UE editor unreachable, or `execute_unreal_python` raised).

**Example - happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_metasound","params":{
  "path": "/Game/Audio/MS_Music"
}}
```

**Example - metasound plugin not enabled**
```json
{"jsonrpc":"2.0","id":2,"method":"inspect_metasound","params":{
  "path": "/Game/Audio/MS_Music"
}}
```
Returns `{"ok": false, "error_code": "metasound_unavailable", "error_message": "MetaSound plugin not enabled - enable it in Plugins > Audio"}`.

---

## find_unused_assets

Enumerate assets under a content path and report which have **zero referencers** — i.e. nothing in the project links to them. Useful for content cleanup audits before shipping (test fixtures, abandoned variants, generated-then-forgotten content).

**Bridge-side synthetic tool.** Pure Python — composes [`find_assets`](#find_assets) (one round-trip to enumerate candidates) + [`inspect_asset`](#inspect_asset) per result (reads each asset's `referencers` array; empty list = unused). Stops once `limit` unused assets are found OR the scan exhausts. `truncated` flags when there were more candidates than the inner scan window covered.

When no `class_filter` is supplied, the synthetic falls back to `/Script/CoreUObject.Object` (root of every UE asset class) so the `path_under` filter alone controls the scan range.

**Soft-failure semantics:** per-asset `inspect_asset` failures are swallowed silently; only when EVERY candidate's inspect failed does the synthetic surface an `inspect_failed` error (otherwise a confusing "0 unused found" would mask the underlying issue).

**Params**
- `path_under` (string, optional, default `/Game`) — folder to scan recursively
- `class_filter` (string, optional) — UE class path (e.g. `/Script/Engine.Texture2D`) to scan only assets of one type
- `limit` (integer, optional, default 100, max 10000) — max unused assets to return; scan stops once this many are found

**Result**
- `ok` (bool) — always `true` on success (use the envelope-level error for failures)
- `scanned` (int) — number of candidates inspected
- `unused_count` (int) — length of `unused`
- `unused` (array of `{path, class}`) — assets with zero referencers (in scan order)
- `truncated` (bool) — `true` when more candidates existed beyond what was scanned

**Errors (envelope-level):** `-32602` (invalid `path_under` / `class_filter` / `limit`); `-32603` (transport / every inspect failed — `find_failed` / `inspect_failed`).

**Example — happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"find_unused_assets","params":{
  "path_under": "/Game/TestFixtures",
  "limit": 50
}}
```

**Example — narrow to one asset type**
```json
{"jsonrpc":"2.0","id":2,"method":"find_unused_assets","params":{
  "path_under": "/Game/Textures",
  "class_filter": "/Script/Engine.Texture2D",
  "limit": 200
}}
```

---

## get_reference_chain

Walk the asset reference graph BFS from a root, returning every node and edge up to a depth bound. Useful for impact analysis (`direction=up`: "if I delete this asset, what breaks?") and dependency audits (`direction=down`: "what does this asset pull in?").

**Bridge-side synthetic tool.** Pure Python — composes [`inspect_asset`](#inspect_asset) recursively (BFS). Each call reads `referencers` (direction=up) or `dependencies` (direction=down). De-duplicates visited nodes so cycles in the asset graph don't loop infinitely.

**Direction semantics:**
- `up` — start at `root`, expand to every asset that has `root` in its dependencies. Edges flow `neighbor -> node` (the neighbor references the node).
- `down` — start at `root`, expand to every asset listed in `root`'s dependencies. Edges flow `node -> neighbor` (the node depends on the neighbor).

The result is a flat node + edge list rather than a tree because real asset graphs are DAGs; a flat shape lets the caller render whatever they want (tree, graph, table).

**Truncation:** `truncated: true` when the BFS hit the depth bound and there were still neighbors to expand at that frontier. Increase `depth` to widen the walk.

**Soft-failure semantics:** non-root per-node `inspect_asset` failures are swallowed; the BFS continues. The root's failure DOES surface (`asset_not_found` -> `-32602`, other inspect failures -> `-32603`).

**Params**
- `path` (string, required) — root asset path
- `depth` (integer, optional, default 3, max 8) — BFS depth bound; 8 hops is already a vast subgraph in any non-trivial project
- `direction` (string, optional, default `up`) — one of `up` or `down`

**Result**
- `ok` (bool) — always `true` on success
- `root` (string) — echo of input
- `direction` (string) — `"up"` or `"down"`
- `depth` (int) — echo of input
- `node_count` (int) — de-duplicated visited count, including the root
- `edge_count` (int) — length of `edges`
- `edges` (array of `{from, to}`) — asset path pairs
- `truncated` (bool) — `true` when the BFS hit `depth` with neighbors remaining

**Errors (envelope-level):** `-32602` (missing or non-string `path`, invalid `depth`, invalid `direction`, NUL or `..` in `path`, root `asset_not_found`); `-32603` (transport / non-not-found root inspect failure).

**Example — impact analysis (who references this material?)**
```json
{"jsonrpc":"2.0","id":1,"method":"get_reference_chain","params":{
  "path": "/Game/Materials/M_Stone.M_Stone",
  "depth": 2,
  "direction": "up"
}}
```

**Example — dependency audit (what does this BP pull in?)**
```json
{"jsonrpc":"2.0","id":2,"method":"get_reference_chain","params":{
  "path": "/Game/Blueprints/BP_Player.BP_Player",
  "depth": 4,
  "direction": "down"
}}
```

---

## bulk_compile_blueprints

Recompile multiple Blueprints in one MCP call by composing the [`compile_blueprint`](#compile_blueprint) C++ handler bridge-side. Useful after batch-mutating Blueprint properties via [`execute_unreal_python`](#execute_unreal_python) or other tooling.

**Bridge-side synthetic tool.** Pure Python — loops over `paths` and dispatches one `call_ue("compile_blueprint", {"path": ...})` per entry. Mirrors the [`bulk_inspect_assets`](#bulk_inspect_assets) shape (paths list + continue_on_error + per-path result envelope).

**Path validation:** each entry must be a non-empty string. NUL byte and `..` segment rejected envelope-level (`-32602`) before any compile is attempted. Max 1000 entries per call.

**Partial-failure model:** identical to [`bulk_delete_assets`](#bulk_delete_assets). `continue_on_error: true` (default) keeps compiling after individual failures; `continue_on_error: false` stops at the first failure and returns partial results.

**Params**
- `paths` (array of string, required) — Blueprint asset paths to compile (each non-empty, NUL + `..` segments rejected, max 1000 entries)
- `continue_on_error` (bool, optional, default `true`) — when `false`, abort after the first per-path compile failure

**Result**
- `ok` (bool) — `true` only when `failed == 0`
- `total` (int) — `paths.length`
- `succeeded` (int) — count of per-path successes
- `failed` (int) — count of per-path failures
- `results` (array) — one entry per attempted compile, in input order:
  - `path` (string)
  - `ok` (bool)
  - `error` (object, only on failure) — `{code: int, message: string}` preserved from the upstream `compile_blueprint` response

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_paths_shape` / `path_must_be_string` / `path_invalid` / non-bool `continue_on_error`).

**Example — happy path**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_compile_blueprints","params":{
  "paths": ["/Game/Blueprints/BP_A", "/Game/Blueprints/BP_B"]
}}
```

**Example — fail fast**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_compile_blueprints","params":{
  "paths": ["/Game/Blueprints/BP_A", "/Game/Blueprints/BP_B"],
  "continue_on_error": false
}}
```

---

## audit_blueprint_compile_status

Enumerate every Blueprint under a content path and bucket each by compile-status (`UpToDate` / `Dirty` / `Error` / `Unknown` / `BeingCreated`). **READ-ONLY** — no recompile triggered. Pair with [`bulk_compile_blueprints`](#bulk_compile_blueprints) to actually fix anything found.

**Bridge-side synthetic tool.** Pure Python — composes [`find_assets`](#find_assets) (one round-trip enumerating `/Script/Engine.Blueprint` under `path_under`) + [`inspect_blueprint`](#inspect_blueprint) per result. Reads each asset's `blueprint_status` field.

**Status:** `inspect_blueprint` emits `blueprint_status` as of the PR that closed scorecard follow-up #4 (mirrors the helper already used by `inspect_widget_blueprint`). The audit surfaces the real bucket counts; the previous "every BP is Unknown" caveat applies only to older plugin DLLs running against a newer bridge until the host editor is cold-rebuilt.

**Soft-failure semantics:** per-asset `inspect_blueprint` failures count toward the `Unknown` bucket rather than aborting; only when EVERY candidate's inspect failed does the synthetic surface `inspect_failed` (a meaningless audit would otherwise hide the underlying issue).

**Params**
- `path_under` (string, optional, default `/Game`) — content path to scan recursively
- `compile_failures_only` (bool, optional, default `true`) — when `true`, `problem_assets` only lists Blueprints whose status is `Error` or `Unknown`; when `false`, every scanned BP is listed

**Result**
- `ok` (bool) — always `true` on success
- `scanned` (int) — number of BPs inspected
- `by_status` (object) — `{UpToDate, Dirty, Error, Unknown, BeingCreated}` integer counts
- `problem_assets` (array of `{path, status}`) — filtered per `compile_failures_only`

**Errors (envelope-level):** `-32602` (invalid `path_under` / `compile_failures_only`); `-32603` (transport / every inspect failed — `find_failed` / `inspect_failed`).

**Example — failures-only (default)**
```json
{"jsonrpc":"2.0","id":1,"method":"audit_blueprint_compile_status","params":{
  "path_under": "/Game/Blueprints"
}}
```

**Example — full breakdown**
```json
{"jsonrpc":"2.0","id":2,"method":"audit_blueprint_compile_status","params":{
  "path_under": "/Game",
  "compile_failures_only": false
}}
```

---

## find_actors_by_class

Filter the active level's actors by class in one MCP call. Composes [`get_actors_in_level`](#get_actors_in_level) bridge-side and matches each actor's short class name against the supplied `class_name` (which may itself be a short name like `StaticMeshActor` or a full class path like `/Script/Engine.StaticMeshActor` — the synthetic strips everything up to and including the final `.` then compares case-insensitively).

**Bridge-side synthetic tool.** Pure Python — one round-trip to `get_actors_in_level`, then client-side filtering. When `level` is supplied, runs [`load_level_by_path`](#load_level_by_path) first because `get_actors_in_level` only enumerates the active editor world.

**Caveat:** the C++ `get_actors_in_level` handler currently emits the short `class` name only (no `class_path` field). The synthetic re-projects each actor's flat `loc_x/y/z` + `yaw/pitch/roll` into a structured `transform: {loc, rot}` envelope; scale is not emitted by the handler so it is omitted rather than fabricated.

**Params**
- `class_name` (string, required) — short class name (e.g. `StaticMeshActor`) or full class path (e.g. `/Script/Engine.StaticMeshActor`); class-path inputs are stripped to the trailing short name
- `level` (string, optional) — UWorld package path; when supplied, `load_level_by_path` runs first

**Result**
- `ok` (bool) — always `true` on success
- `class_name` (string) — echo of input
- `total_in_level` (int) — total actors the C++ handler enumerated before filtering
- `count` (int) — number of actors matching `class_name`
- `actors` (array) — one entry per match: `{name, label, class, class_path: string|null, transform: {loc: {x, y, z}, rot: {pitch, yaw, roll}}}`

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_field`); `-32603` (transport — `get_actors_failed` for either the optional level-load step or the actors enumeration).

**Example — filter the current level**
```json
{"jsonrpc":"2.0","id":1,"method":"find_actors_by_class","params":{
  "class_name": "StaticMeshActor"
}}
```

**Example — switch level first**
```json
{"jsonrpc":"2.0","id":2,"method":"find_actors_by_class","params":{
  "class_name": "/Script/Engine.PointLight",
  "level": "/Game/Maps/StressTest"
}}
```

---

## bulk_focus_actors

Frame the viewport on each actor in a sequence, optionally capturing a screenshot after each focus settles. Composes [`focus_actor`](#focus_actor) per name (plus [`get_viewport_screenshot`](#get_viewport_screenshot) when `screenshot_each=true`). Useful for "show me each enemy / spawn / light in turn" walkthroughs where one [`screenshot_actor`](#screenshot_actor) at a time would force the caller into a polling loop.

**Bridge-side synthetic tool.** Pure Python — loops over `names`, sleeping `delay_ms` between focus calls (not after the last) so LOD and viewport settling stays bounded. Per-name failures land in `failed[]` and the loop keeps running — there is no `continue_on_error` knob because frame-then-capture is independently retryable per actor.

**Params**
- `names` (array of string, required) — actor labels or unique names, each non-empty, max 100 entries
- `delay_ms` (int, optional, default `500`) — settle delay between focus calls in milliseconds; clamped to `0..10000`
- `screenshot_each` (bool, optional, default `false`) — when `true`, capture a viewport PNG after each focus and emit a parallel `screenshots` array

**Result**
- `ok` (bool) — `true` only when `failed` is empty
- `total` (int) — `names.length`
- `focused` (int) — number of successful focuses
- `failed` (array) — `{name, error: {code, message}}` per failure
- `screenshots` (array) — `{name, png_base64}` entries; present **only** when `screenshot_each=true`

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_names_shape` / `name_must_be_string` / `too_many_names` / `invalid_delay` / non-bool `screenshot_each`).

**Example — walk the list, no screenshots**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_focus_actors","params":{
  "names": ["Spawn_Player", "Spawn_Boss", "Spawn_Loot"],
  "delay_ms": 250
}}
```

**Example — walk the list and grab a thumbnail per actor**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_focus_actors","params":{
  "names": ["Enemy_01", "Enemy_02", "Enemy_03"],
  "screenshot_each": true
}}
```

---

## bulk_screenshot_actors

Frame and screenshot each actor in a sequence. Composes [`screenshot_actor`](#screenshot_actor) (itself a synthetic over `focus_actor` + `get_viewport_screenshot`) per name. Same shape as [`bulk_focus_actors`](#bulk_focus_actors) but every entry always yields a PNG — convenient for thumbnail-pipeline runs.

**Bridge-side synthetic tool.** Pure Python. Caps at 50 entries (tighter than `bulk_focus_actors`' 100) because each entry yields a base64 PNG payload — at full cap the response body is non-trivial.

**Params**
- `names` (array of string, required) — actor labels or unique names, each non-empty, max 50 entries
- `delay_ms` (int, optional, default `500`) — settle delay between actors in milliseconds; clamped to `0..10000`

**Result**
- `ok` (bool) — `true` only when every entry succeeded
- `total` (int) — `names.length`
- `succeeded` (int) — count of per-name successes
- `results` (array) — one entry per name in input order: `{name, ok, png_base64?, focused?, loc?, error?}`

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_names_shape` / `name_must_be_string` / `too_many_names` / `invalid_delay`).

**Example**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_screenshot_actors","params":{
  "names": ["Prop_Crate", "Prop_Barrel", "Prop_Sign"],
  "delay_ms": 750
}}
```

---

## bulk_set_actor_property

Apply many UPROPERTY mutations across many actors in one MCP call. Composes [`set_actor_property`](#set_actor_property) per assignment; mirrors the [`bulk_compile_blueprints`](#bulk_compile_blueprints) partial-failure semantics (`continue_on_error` default `true`).

**Bridge-side synthetic tool.** Each assignment is independent — this is **NOT** "set the same property on N actors", it is "run N individual sets". Useful after batch-spawning to push initial-state mutations (paint per-actor team tags, set initial AI states, etc.) without N round-trips.

**Params**
- `assignments` (array of object, required) — `{actor: string, property: string, value: any}` triples; each actor and property non-empty, `value` mandatory (use `null` for explicit-null intent), max 200 entries
- `continue_on_error` (bool, optional, default `true`) — when `false`, halt at the first per-assignment failure and emit `halted_at_index`

**Result**
- `ok` (bool) — `true` only when `failed` is empty
- `total` (int) — `assignments.length`
- `succeeded` (int) — count of per-assignment successes
- `failed` (array) — `{actor, property, error: {code, message}}` per failure, preserving the upstream `set_actor_property` error code
- `halted_at_index` (int) — present **only** when `continue_on_error=false` stopped the loop early; the 0-based index of the failed assignment

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_assignments_shape` / `assignment_must_be_object` / `assignment_missing_field` / `too_many_assignments` / non-bool `continue_on_error`).

**Example — paint team tags across many actors**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_set_actor_property","params":{
  "assignments": [
    {"actor": "Enemy_01", "property": "Tags", "value": ["Red"]},
    {"actor": "Enemy_02", "property": "Tags", "value": ["Red"]},
    {"actor": "Ally_01",  "property": "Tags", "value": ["Blue"]}
  ]
}}
```

**Example — fail fast**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_set_actor_property","params":{
  "assignments": [
    {"actor": "Crate_A", "property": "bHidden", "value": true},
    {"actor": "Crate_B", "property": "bHidden", "value": true}
  ],
  "continue_on_error": false
}}
```

---

## compare_assets

Symmetric diff between two assets' [`inspect_asset`](#inspect_asset) outputs. Useful for "what changed between these two versions of the same Blueprint?" walkthroughs and cross-checking duplicated assets that should be identical.

**Bridge-side synthetic tool.** Pure Python — composes [`inspect_asset`](#inspect_asset) on both paths and diffs the resulting dicts. The `path` field is excluded from comparison (each response trivially echoes its own path).

**Scoping the diff:** pass `fields` to restrict comparison to a whitelist of inspect_asset field names. When omitted, the synthetic compares the union of both responses' keys (minus `path`).

**Params**
- `path_a` (string, required) — first asset path
- `path_b` (string, required) — second asset path
- `fields` (array of string, optional) — whitelist of field names to compare; `path` is always excluded

**Result**
- `ok` (bool) — always `true` on success
- `path_a` (string) — echo of input
- `path_b` (string) — echo of input
- `identical` (bool) — `true` when `differences` is empty
- `fields_compared` (array of string) — fields actually diffed
- `differences` (array of `{field, value_a, value_b}`) — one entry per differing field

**Errors (envelope-level):** `-32602` (`missing_required_field` for `path_a` or `path_b`, `invalid_field` for non-list / non-string-element `fields`, NUL or `..` in either path); `inspect_failed_a` / `inspect_failed_b` (one per side) preserving the upstream `inspect_asset` error code.

**Example — full diff**
```json
{"jsonrpc":"2.0","id":1,"method":"compare_assets","params":{
  "path_a": "/Game/Blueprints/BP_Stone_v1.BP_Stone_v1",
  "path_b": "/Game/Blueprints/BP_Stone_v2.BP_Stone_v2"
}}
```

**Example — scoped diff**
```json
{"jsonrpc":"2.0","id":2,"method":"compare_assets","params":{
  "path_a": "/Game/Materials/M_Stone.M_Stone",
  "path_b": "/Game/Materials/M_Stone_LOW.M_Stone_LOW",
  "fields": ["dependencies", "referencers"]
}}
```

---

## bulk_set_console_variables

Set multiple Console Variables in one MCP call with optional atomic rollback. Useful for "apply a scalability set then revert if any single CVar fails" patterns.

**Bridge-side synthetic tool.** Pure Python — composes [`get_console_variable`](#get_console_variable) (to capture each pre-value) plus [`set_console_variable`](#set_console_variable) (to apply each new value). On any per-CVar failure when `rollback_on_error=true`, the synthetic walks back every already-applied change to its captured pre-value; per-restore failures are surfaced in `rollback_failures` so the caller knows which CVars are still in their mutated state.

**Rollback model:**
- `rollback_on_error: true` (default) — first failure halts the loop, then every already-applied change is restored to its captured pre-value.
- `rollback_on_error: false` — failures are recorded but applied changes are NOT restored.

**Params**
- `assignments` (object, required) — mapping of `{cvar_name: new_value}`; max 50 entries; each value must be string, number, or boolean
- `rollback_on_error` (bool, optional, default `true`)

**Result**
- `ok` (bool) — `true` only when `failed` is empty
- `total` (int) — `assignments` size
- `applied` (array) — `{name, old_value, new_value}` per successfully applied CVar; `old_value` is captured from `get_console_variable`'s `value_string`
- `failed` (array) — `{name, error: {code, message}}` per failure
- `rolled_back` (bool) — `true` when a rollback pass was performed
- `rollback_failures` (array, optional) — `{name, error}` per failed restore; present only when rollback was attempted AND some restore failed

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_assignments_shape` / `assignment_value_invalid_type` / `too_many_assignments` / non-bool `rollback_on_error`); per-CVar `get_failed` / `set_failed` (recorded in `failed`); per-CVar `rollback_failed` (recorded in `rollback_failures`).

**Example — apply scalability set**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_set_console_variables","params":{
  "assignments": {
    "r.ScreenPercentage": 50,
    "r.Shadow.Quality": 2,
    "r.PostProcessAAQuality": 3
  }
}}
```

**Example — apply best-effort (no rollback)**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_set_console_variables","params":{
  "assignments": {
    "r.VSync": false,
    "r.ScreenPercentage": 100
  },
  "rollback_on_error": false
}}
```

---

## inspect_dependency_graph

Walk the asset dependency graph BFS from a root, defaulting to following dependencies (downward). Useful for packaging audits ("what does this asset pull in?") and for the bidirectional sweep that asks "what's the full neighborhood of this asset?".

**Bridge-side synthetic tool.** Pure Python — composes [`inspect_asset`](#inspect_asset) recursively. Distinct from [`get_reference_chain`](#get_reference_chain) in that it defaults to `direction=down` (packaging-audit framing) and supports a single bidirectional pass via `include_referencers=true` instead of two separate calls. De-duplicates visited nodes across both directions.

**Edge orientation:** each edge carries a `direction` field — `"down"` for dependency edges (`node -> neighbor`) and `"up"` for referencer edges (`neighbor -> node`).

**Truncation:** `truncated: true` when the BFS hit the depth bound and there were still neighbors to expand at that frontier. Increase `depth` to widen the walk.

**Soft-failure semantics:** non-root per-node `inspect_asset` failures are swallowed (BFS continues from known neighbors). Root failure SURFACES (`asset_not_found` -> `-32602`, other inspect failures -> `-32603`).

**Params**
- `path` (string, required) — root asset path
- `depth` (int, optional, default 2, range 1..8) — BFS depth bound
- `include_referencers` (bool, optional, default `false`) — when `true`, also follow referencers upward in the same BFS

**Result**
- `ok` (bool) — always `true` on success
- `root` (string) — echo of input
- `depth` (int) — echo of input
- `include_referencers` (bool) — echo of input
- `node_count` (int) — de-duplicated visited count, including the root
- `edge_count` (int) — length of `edges`
- `nodes` (array of string) — sorted asset paths visited
- `edges` (array of `{from, to, direction}`) — `direction` is `"down"` or `"up"`
- `truncated` (bool)

**Errors (envelope-level):** `-32602` (`missing_required_field` for `path`, `invalid_depth`, NUL or `..` in `path`, non-bool `include_referencers`, root `asset_not_found`); `-32603` (transport / non-not-found root inspect failure).

**Example — packaging audit (downstream only)**
```json
{"jsonrpc":"2.0","id":1,"method":"inspect_dependency_graph","params":{
  "path": "/Game/Blueprints/BP_Player.BP_Player",
  "depth": 3
}}
```

**Example — bidirectional neighborhood**
```json
{"jsonrpc":"2.0","id":2,"method":"inspect_dependency_graph","params":{
  "path": "/Game/Materials/M_Stone.M_Stone",
  "depth": 2,
  "include_referencers": true
}}
```

---

## bulk_fix_redirectors

Resolve UObjectRedirector stubs across multiple content folders in one MCP call. Useful as a follow-up after a sweep of [`bulk_move_assets`](#bulk_move_assets) / [`bulk_rename_assets`](#bulk_rename_assets) calls (each of which leaves redirectors at source paths) so the LLM does not have to issue one [`fix_up_redirectors`](#fix_up_redirectors) per touched folder.

**Bridge-side synthetic tool.** Pure Python — loops over `folders` and dispatches one `call_ue("fix_up_redirectors", {"path": ...})` per entry. Mirrors the [`bulk_compile_blueprints`](#bulk_compile_blueprints) partial-failure shape.

**Path validation:** each entry must be a non-empty string. NUL byte and `..` segment rejected envelope-level (`-32602` + `folder_invalid`) before any UE call. Max 100 entries per call.

**Recursive flag:** `recursive` is informational and echoed back in the response. [`fix_up_redirectors`](#fix_up_redirectors) itself always operates recursively under the supplied path; the flag exists so callers can capture intent without tracking it separately.

**Params**
- `folders` (array of string, required) — content folder paths (each non-empty, NUL + `..` segments rejected, max 100 entries)
- `recursive` (bool, optional, default `true`)
- `continue_on_error` (bool, optional, default `true`) — when `false`, abort after the first per-folder failure and emit `halted_at_index`

**Result**
- `ok` (bool) — `true` only when `failed` is empty
- `total` (int) — `folders.length`
- `succeeded` (int) — count of per-folder successes
- `failed` (array) — `{folder, error: {code, message}}` per failure, preserving the upstream `fix_up_redirectors` error code
- `recursive` (bool) — echo of input
- `halted_at_index` (int, optional) — present only when `continue_on_error=false` stopped the loop early

**Errors (envelope-level):** `-32602` (`missing_required_field` / `invalid_folders_shape` / `folder_must_be_string` / `folder_invalid` / `too_many_folders` / non-bool `recursive` / non-bool `continue_on_error`).

**Example — clean up after batch moves**
```json
{"jsonrpc":"2.0","id":1,"method":"bulk_fix_redirectors","params":{
  "folders": [
    "/Game/Materials",
    "/Game/Textures",
    "/Game/Audio"
  ]
}}
```

**Example — halt on first failure**
```json
{"jsonrpc":"2.0","id":2,"method":"bulk_fix_redirectors","params":{
  "folders": ["/Game/A", "/Game/B", "/Game/C"],
  "continue_on_error": false
}}
```

---

## marketplace_search

Search free CC0 asset marketplaces (Polyhaven, AmbientCG) for textures, HDRIs, or models matching a keyword and return a normalised list of matches.

**Bridge-side synthetic tool.** Fetches the source's public JSON catalog via plain HTTPS (`urllib`, no auth, no API key). Every asset returned is CC0 (public domain — free for any use including commercial, no attribution required). Pair with [`marketplace_import`](#marketplace_import) to actually download and import a chosen result.

**Sources**:

- **Polyhaven** — `https://api.polyhaven.com/assets`. Default. Hand-curated CC0 catalog of textures, HDRI skies, and models.
- **AmbientCG** — `https://ambientcg.com/api/v2/full_json`. Larger PBR-texture / HDRI / model catalog, all CC0.

**Params**:

- `query` *(string, optional, default `""`)* — search keyword(s); matched against name + tags + categories on the source side. Empty string lists popular assets.
- `source` *(string, optional, default `"polyhaven"`)* — one of `polyhaven` / `ambientcg` / `all`. `all` fans out across both.
- `asset_type` *(string, optional, default `"texture"`)* — one of `texture` / `hdri` / `model` / `all`.
- `limit` *(int, optional, default `10`, max `50`)* — max results to return.

**Result**:

- `ok` — bool.
- `query`, `source`, `asset_type`, `limit` — echoes of input.
- `count` — length of `results`.
- `results` — array of:
  - `slug` — source-specific asset identifier (use as `slug` for `marketplace_import`).
  - `name` — human-readable display name.
  - `source` — `"polyhaven"` or `"ambientcg"`.
  - `asset_type` — `"texture"` / `"hdri"` / `"model"`.
  - `thumbnail_url` — preview image URL.
  - `tags`, `categories` — source-side classification arrays.
  - `description` — short description (may be empty).
  - `max_resolution` — `[w, h]` array or `null`.
  - `download_count` — popularity counter (Polyhaven only).
- `partial_errors` *(present only when at least one source failed but another returned results)* — array of source-error strings.

**Errors**:

- `-32602 invalid_arguments` / `invalid_field: ...` — argument validation.
- `-32603 all_sources_failed: ...` — every requested source returned an error (network, HTTP, or JSON parsing); message concatenates per-source reasons.

**Example — search for desert sand textures**
```json
{"jsonrpc":"2.0","id":1,"method":"marketplace_search","params":{
  "query": "sand desert",
  "asset_type": "texture",
  "limit": 5
}}
```

**Example — find a daylight HDRI sky across both sources**
```json
{"jsonrpc":"2.0","id":2,"method":"marketplace_search","params":{
  "query": "noon sky",
  "source": "all",
  "asset_type": "hdri",
  "limit": 10
}}
```

---

## marketplace_import

Download a CC0 asset from a marketplace (Polyhaven or AmbientCG) and import it into the project as a `UTexture2D` via the native [`import_texture`](#import_texture) handler.

**Bridge-side synthetic tool.** Branches per source:

**Polyhaven path** — direct file download:

1. `GET https://api.polyhaven.com/files/{slug}` — resolves the per-resolution / per-format download URL.
2. `urllib` streaming download of the raw image to a temp file under the system tempdir (atomic via `.part` rename).
3. `call_ue("import_texture", { source_path, dest_path, dest_name, replace_existing, automated, save })` — the native handler does the UE-side import via the canonical asset import pipeline.

**AmbientCG path** — zip-archive extraction:

1. `GET https://ambientcg.com/api/v2/full_json?id={slug}&include=downloadData` — resolves the per-attribute (`<Res>K-<FMT>`, e.g. `2K-JPG`) zip URL.
2. `urllib` streaming download of the zip to a temp file.
3. `zipfile`-based extraction of the Color/Diffuse map (textures) or sole EXR/HDR (hdris) into a per-asset subdir under the system tempdir. Path-traversal sequences in zip entry names are flattened to the basename so extraction can't escape the destination dir.
4. `call_ue("import_texture", { ... })` against the extracted file.

**Scope**:

- **Textures (single-map, default)** — Color/Diffuse map only. Default format `png` for both sources; AmbientCG falls back to `jpg` at the same resolution when `png` isn't published.
- **Textures (multi-map, opt-in via `multi_map=true`)** — pulls every canonical PBR map the source ships: `color`, `normal`, `roughness`, `ao`, `displacement`, `metalness`. Color is required; other maps are best-effort (just absent from the result dict when the source doesn't ship them). One `import_texture` call per map. Naming: Color stays at `<dest_name>` for back-compat; other maps land at `<dest_name>_<map>` (e.g. `<dest_name>_normal`, `<dest_name>_roughness`).
- **HDRIs** — single-file EXR (or HDR fallback). Default format `exr`. `multi_map` is rejected for HDRIs.
- **Models** — `asset_type=model` returns `not_implemented`. Native side has no glTF/FBX import wrapper today.
- **Source**: `polyhaven` (default) or `ambientcg`.

**Params**:

- `source` *(string, optional, default `"polyhaven"`)* — one of `"polyhaven"` / `"ambientcg"`.
- `slug` *(string, **required**)* — source-specific asset identifier. Polyhaven slugs are lowercase-snake (`"aerial_beach_01"`); AmbientCG slugs are PascalCase (`"Wood050"`). Obtain via [`marketplace_search`](#marketplace_search).
- `asset_type` *(string, optional, default `"texture"`)* — `texture` / `hdri` / `model` (model → `not_implemented`).
- `resolution` *(string, optional, default `"2k"`)* — common values `1k` / `2k` / `4k` / `8k`. Case-insensitive: AmbientCG normalises internally to `2K`. The error message lists what the source actually offers when invalid.
- `format` *(string, optional)* — defaults to `png` for textures and `exr` for HDRIs. Both sources fall back to alternate formats at the same resolution (`png ↔ jpg` for textures, `exr ↔ hdr` for HDRIs) when the requested format isn't published.
- `dest_path` *(string, optional, default `/Game/Marketplace`)* — UE package path; must start with `/Game/`.
- `dest_name` *(string, optional)* — asset name override; defaults to the slug.
- `replace_existing` *(bool, optional, default `false`)* — overwrite an existing asset at `dest_path/dest_name`. Must be an actual boolean — `"false"` (string) is rejected.
- `multi_map` *(bool, optional, default `false`)* — texture only; valid solely when `asset_type='texture'`. When `true`, fan out per-map download + import so Normal/Roughness/AO/Displacement/Metalness land alongside Color. Rejected for HDRIs and models.

**Result**:

- `ok` — bool.
- `source`, `slug`, `asset_type`, `resolution`, `format` — echoes of input.
- `downloaded_from` — resolved download URL (the per-asset Polyhaven file URL, or the AmbientCG zip URL).
- `temp_path` *(single-map mode only)* — filesystem path of the file handed to `import_texture` (the raw image for Polyhaven, the extracted Color/HDRI file for AmbientCG).
- `ue_asset_path` — final asset path in the project (`<dest_path>/<dest_name>`); in multi-map mode this is the **color** map's asset path.
- `available_resolutions` — array of resolutions/attributes the source offers for this asset (`["1k","2k","4k","8k"]` for Polyhaven; `["1K-JPG","2K-JPG",...]` for AmbientCG).
- `import_result` *(single-map mode only)* — passthrough of native `import_texture` result.
- `maps` *(multi-map mode only)* — dict of `canonical_name -> UE asset path` for every map imported. Keys are drawn from `{color, normal, roughness, ao, displacement, metalness}`; only maps the source actually shipped appear.
- `import_results` *(multi-map mode only)* — dict of `canonical_name -> native import_texture result` (passthrough, one entry per map imported).
- `license` — `"CC0"`.

**Errors**:

- `-32602 invalid_arguments` / `invalid_field: ...` — argument validation (including unknown `source`).
- `-32603 network_error: ...` — DNS / TCP / TLS failure.
- `-32603 http_error: status=NNN` — non-2xx response from the catalog or CDN.
- `-32603 not_implemented` — `asset_type=model` (parked).
- `-32603 resolution_unavailable: ...` (Polyhaven) / `resolution_or_format_unavailable: ...` (AmbientCG) — the requested resolution/format isn't published; message lists what is.
- `-32603 format_unavailable: ...` — (Polyhaven only) the requested format isn't published at that resolution.
- `-32603 ambientcg: asset_not_found: id=<slug>` — AmbientCG's `foundAssets` array is empty for the slug.
- `-32603 ambientcg: bad_zip: ...` — downloaded zip is corrupt.
- `-32603 ambientcg: zip_has_no_color_map: ...` — texture zip is missing `_Color`/`_Diffuse` map files (rare; the error lists the actual file set).
- `-32603 ambientcg: zip_has_no_hdri: ...` — HDRI zip is missing both `.exr` and `.hdr` files.
- `-32603 ue_import_failed: ...` — native `import_texture` returned an error (propagated verbatim). In multi-map mode the message includes `map=<canonical>` so the failing map is identifiable.
- `-32602 invalid_field: 'multi_map=true' applies to asset_type='texture' only` — caller set `multi_map=true` with an HDRI or model asset_type.

**Example — import a 2k sand diffuse map**
```json
{"jsonrpc":"2.0","id":1,"method":"marketplace_import","params":{
  "slug": "aerial_beach_01",
  "asset_type": "texture",
  "resolution": "2k",
  "dest_path": "/Game/Validation/Marketplace",
  "dest_name": "T_Sand_Beach_01"
}}
```

**Example — import an HDRI sky (4k EXR)**
```json
{"jsonrpc":"2.0","id":2,"method":"marketplace_import","params":{
  "slug": "kloppenheim_06_puresky",
  "asset_type": "hdri",
  "resolution": "4k",
  "format": "exr",
  "dest_path": "/Game/Validation/HDRI"
}}
```

**Example — import an AmbientCG texture (2k JPG)**
```json
{"jsonrpc":"2.0","id":3,"method":"marketplace_import","params":{
  "source": "ambientcg",
  "slug": "Wood050",
  "asset_type": "texture",
  "resolution": "2k",
  "format": "jpg",
  "dest_path": "/Game/Validation/Marketplace",
  "dest_name": "T_Wood_050"
}}
```

**Example — import a full PBR set (multi-map) from AmbientCG**
```json
{"jsonrpc":"2.0","id":4,"method":"marketplace_import","params":{
  "source": "ambientcg",
  "slug": "Rocks023",
  "asset_type": "texture",
  "resolution": "2k",
  "format": "jpg",
  "multi_map": true,
  "dest_path": "/Game/Validation/Marketplace",
  "dest_name": "T_Rocks_023"
}}
```
Result `maps` dict lists every imported map: `{"color": "/Game/Validation/Marketplace/T_Rocks_023.T_Rocks_023", "normal": "/Game/Validation/Marketplace/T_Rocks_023_normal.T_Rocks_023_normal", "roughness": ..., "ao": ..., "displacement": ...}`. Maps the source doesn't ship are simply absent.

---

## convert_hdri_to_cubemap

Convert a longlat-projection HDRI (`UTexture2D`) into a `UTextureCube` so it can drive a `SkyLight`'s SpecifiedCubemap slot. Closes the longlat→cubemap parked item — UE 5.7 has no Python-exposed direct converter, but the canonical editor pipeline (`SceneCaptureCube` against an inside-out sphere with the HDRI as an unlit emissive material) is fully scriptable and that's what this tool wraps.

> Phase-H engine-version gate (best-effort/community path; UE 5.7 is officially supported & tested — ADR-0001): **Min engine: 5.0** — uses 5.0+ `unreal.*` editor-subsystem / render-target-cube APIs. On older engines the bridge returns a structured `unsupported_on_engine_version` error (Phase H gate, kept available — `docs/PHASE-H-COMPAT.md`).

**Bridge-side synthetic tool.** Pipeline:

1. Create a `TextureRenderTargetCube` asset under `dest_path`.
2. Create a `Material` flagged `MSM_UNLIT` with a single `TextureSample` (sampler `LinearColor`) wired into `MP_EMISSIVE_COLOR`.
3. Spawn a unit `Sphere` scaled `(-50, 50, 50)` so its faces flip inside-out, with the unlit HDRI material applied. The camera will see the HDRI projected from inside.
4. Spawn a `SceneCaptureCube` actor at the origin, point its `texture_target` at the render-target cube, set `capture_source=SCS_FINAL_COLOR_LDR`, and call `capture_scene()` once.
5. `unreal.RenderingLibrary.render_target_create_static_texture_cube_editor_only(rt, dest_name, compression)` materializes the static `UTextureCube` next to the render target.
6. Destroy the temp sphere + capture actor; delete the temp render-target + temp material assets. The new cube is the only artifact left in the project.

**Params**:

- `hdri_path` *(string, **required**)* — UE asset path of the source longlat `UTexture2D`. Must start with `/Game/`.
- `dest_path` *(string, optional)* — UE package path for the new cube. Defaults to the source HDRI's folder.
- `dest_name` *(string, optional)* — asset name override. Defaults to `<source_basename>_Cube`. Must not contain path separators or quotes (input is interpolated into a generated UE Python literal; the validator rejects shell-meta characters).
- `cube_size` *(int, optional, default `1024`)* — square face size in pixels. Range `[16, 8192]`; powers of two recommended.
- `compression` *(string, optional, default `"TC_HDR"`)* — one of `TC_HDR` / `TC_HDR_COMPRESSED` / `TC_HDR_F32` / `TC_DEFAULT`. Any other value is rejected — the allowlist exists to keep caller input from injecting arbitrary enum names into the generated Python.

**Result**:

- `ok` — bool.
- `source_hdri`, `dest_path`, `dest_name`, `cube_size`, `compression` — echoes of input.
- `cube_asset_path` — final UE asset path of the new `UTextureCube` (`<dest_path>/<dest_name>.<dest_name>`).

**Errors**:

- `-32602 invalid_arguments` / `invalid_field: ...` — argument validation.
- `-32603 convert_hdri_to_cubemap: ue_exec_failed: ...` — `execute_unreal_python` returned an error before the script ran.
- `-32603 convert_hdri_to_cubemap: ue_python_error: ...` — the generated UE Python raised inside the editor (e.g. `hdri_not_found`, `cube_create_failed`); the inner traceback is included.

**Example — convert Polyhaven Venice Sunset HDRI to a SkyLight-ready cubemap**
```json
{"jsonrpc":"2.0","id":1,"method":"convert_hdri_to_cubemap","params":{
  "hdri_path": "/Game/Validation/Florence/HDRI_Venice_Sunset",
  "dest_name": "HDRI_Venice_Sunset_Cube",
  "cube_size": 2048,
  "compression": "TC_HDR"
}}
```

After this returns, assign the cube to `SkyLight.cubemap` and set `SkyLight.source_type=SLS_SPECIFIED_CUBEMAP` — that completes the pipeline the SkyLight-from-longlat workaround in the 23rd HANDOFF note was avoiding.

---

## sequencer_add_transform_keyframe

Add a single keyframe on a Level Sequence's 3D Transform Track for a previously-bound actor. Closes the keyframe-authoring half of the 21st HANDOFF note's Sequencer parked item — `create_sequence` and `bind_actor_to_sequence` already exist; this tool fills in the missing leg that writes keys on a binding's transform. Movie Render Queue remains parked.

> Phase-H engine-version gate (best-effort/community path; UE 5.7 is officially supported & tested — ADR-0001): **Min engine: 5.0** — uses `unreal.MovieSceneTimeUnit` and the 5.0+ MovieScene scripting channel APIs. On older engines the bridge returns a structured `unsupported_on_engine_version` error (Phase H gate, kept available — `docs/PHASE-H-COMPAT.md`).

**Bridge-side synthetic tool.** Pipeline:

1. Load the LevelSequence via `EditorAssetLibrary.load_asset`; aborts with `sequence_not_found` / `not_a_sequence` if the load returns `None` or the wrong class.
2. Parse `binding_id` via `unreal.GuidLibrary.parse_string_to_guid` (returns `(Guid, success)`); aborts with `binding_not_found` if the parse fails.
3. Resolve the binding proxy with `MovieSceneSequenceExtensions.find_binding_by_id(seq, parsed_guid)`. An unmatched GUID still returns a proxy struct, so the script re-validates against the proxy's own `get_id()` — a stale GUID surfaces as an invalid Guid here.
4. Find or create a single `MovieScene3DTransformTrack` on the binding (`find_tracks_by_exact_type` / `add_track` are bound methods on the proxy class in UE 5.7).
5. Find or create the section. Optionally extend the section's seconds-range to cover `time_seconds`.
6. Walk `section.get_all_channels()` (returns nine `MovieSceneScriptingDoubleChannel`s named `Location.X / Y / Z`, `Rotation.X / Y / Z`, `Scale.X / Y / Z`) and match each requested component. Caller's `rotation = [pitch, yaw, roll]` is remapped to the channel layout (Roll → Rotation.X, Pitch → Rotation.Y, Yaw → Rotation.Z).
7. Call `ch.add_key(frame_no, value, 0.0, MovieSceneTimeUnit.TICK_RESOLUTION, MovieSceneKeyInterpolation.<member>)` once per channel.
8. Save the loaded sequence.

**Params**:

- `sequence_path` *(string, **required**)* — UE asset path of the LevelSequence. Must start with `/Game/`.
- `binding_id` *(string, **required**)* — GUID string returned by `bind_actor_to_sequence`. Accepts the bare 32-hex form (no dashes) UE produces by default; dashed/braced forms also parse. Validator enforces the GUID character set (hex + `-` + `{` + `}`) before passing through to UE.
- `time_seconds` *(number, **required**, ≥ 0)* — time in seconds at which to place the keyframe. Converted internally to a tick-resolution `FrameNumber` using `MovieSceneSequenceExtensions.get_tick_resolution(seq)`.
- `location` *(list[3] of numbers, optional)* — `[x, y, z]` translation. Omit to skip the three Location channels entirely.
- `rotation` *(list[3] of numbers, optional)* — `[pitch, yaw, roll]` in degrees (matches `unreal.Rotator`'s constructor). Internally remapped to the channel layout (Roll → `Rotation.X`, Pitch → `Rotation.Y`, Yaw → `Rotation.Z`). Omit to skip the three Rotation channels.
- `scale` *(list[3] of numbers, optional)* — `[x, y, z]` scale. Omit to skip the three Scale channels.
- `interpolation` *(string, optional, default `"linear"`)* — one of `linear` / `constant` / `auto` / `smart_auto` / `cubic`. Maps to the `MovieSceneKeyInterpolation` enum; `cubic` is an alias for `SMART_AUTO`.
- `auto_extend_section` *(bool, optional, default `true`)* — if the section's seconds-range doesn't cover `time_seconds`, extend it via `section.set_range_seconds(min(start, t), max(end, t))`.

At least one of `location` / `rotation` / `scale` must be present — callers passing none get a `nothing_to_key` `-32602` error rather than a no-op round trip into UE.

**Result**:

- `ok` — bool.
- `sequence_path`, `binding_id`, `time_seconds`, `interpolation` — echoes of input (`interpolation` is the resolved enum member, e.g. `LINEAR`).
- `keys_added` *(int)* — total channel-keys created (sum across the components actually written; 3 per supplied component).
- `channels_keyed` *(list of string)* — e.g. `["Location.X", "Location.Y", "Location.Z", "Rotation.Y"]`.
- `track_path` *(string or null)* — full path of the underlying `MovieScene3DTransformTrack` asset when the inner UE script's marker line is captured; UE's `execute_unreal_python` may discard stdout on success, in which case `track_path` is `null` but the keyframe is still written.

**Errors**:

- `-32602 invalid_arguments` / `invalid_field: ...` — argument validation (missing/wrong-type/out-of-range fields, malformed `binding_id` characters, empty triples).
- `-32602 nothing_to_key: ...` — caller passed no `location` / `rotation` / `scale`.
- `-32603 sequencer_add_transform_keyframe: ue_exec_failed: ...` — `execute_unreal_python` returned an error before the script ran (e.g. UE not reachable).
- `-32603 sequencer_add_transform_keyframe: ue_python_error: ...` — the generated UE Python raised inside the editor; surfaces structured inner codes (`sequence_not_found`, `not_a_sequence`, `binding_not_found`, `channel_missing`).

**Example — place a 1-second keyframe at world-origin with a 45° yaw**
```json
{"jsonrpc":"2.0","id":1,"method":"sequencer_add_transform_keyframe","params":{
  "sequence_path": "/Game/Cinematics/MyShot",
  "binding_id": "6B56AE0E4688B1279C68D0AE6BFB5FC7",
  "time_seconds": 1.0,
  "location": [0, 0, 100],
  "rotation": [0, 45, 0],
  "interpolation": "linear"
}}
```

The proven end-to-end pipeline (sequence load → GUID parse → binding lookup → track/section get-or-create → channel-wise `add_key` → save) lives in `scripts/poc_sequencer_keyframe.py` for offline inspection.

---

## Adding more tools

See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the recipe. Short version: one `.cpp` file in `Source/UnrealClaudeMCP/Private/MCP/Handlers/`, two registration lines in `UnrealClaudeMCPModule.cpp`, one entry in `Resources/mcp_manifest.json`, one entry in `bridge/unreal_ai_connection_bridge.py`'s `TOOLS` list, rebuild, restart.
