# v0.3.0 Build-a-Level Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add 6 MCP handlers (`find_assets`, `spawn_actor`, `set_actor_transform`, `delete_actor`, `set_actor_property`, `add_component`) plus 2 shared C++ modules (`ActorIdentity`, `PropertyCoercion`) that let an LLM construct a non-trivial UE 5.7 scene end-to-end without falling back to `execute_unreal_python`. Tool count grows 13 → 19. Version bumps 0.2.0 → 0.3.0.

**Architecture:** Each handler is a single C++ leaf in `Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_*.cpp`, mirroring the existing 13-handler pattern. Two new shared modules (`ActorIdentity`, `PropertyCoercion`) extract cross-cutting concerns (label-or-FName lookup with ambiguity detection, JSON ↔ FProperty coercion) into testable units used by 5+ handlers. Same TDD-driven Python bridge tests + deferred UE build verification on the user's workstation.

**Tech Stack:** UE 5.7 C++ (UnrealEd, AssetRegistry, Engine, CoreUObject modules — all already linked from v0.2.0), Python 3.11+ (pytest for bridge tests), JSON-RPC 2.0 over local TCP.

**Spec:** [`docs/superpowers/specs/2026-05-08-v0.3.0-build-a-level-design.md`](../specs/2026-05-08-v0.3.0-build-a-level-design.md). Read this first if you are picking up the plan cold.

**Branch:** `feat/v0.3.0-build-a-level` (already created; spec committed at `d1944b0`).

---

## Phase 1 — Bridge + manifest catalog (Python only, runs in CI)

Pure Python phase. No UE editor required. Updates the static catalogs and pytest assertions in lockstep. Same TDD-red-then-green pattern as v0.2.0 Task 1.

### Task 1: Add 6 new tool entries to bridge + manifest, bump count assertions and version

**Files:**
- Modify: `tests/test_bridge.py` (count assertions + 6 new schema tests)
- Modify: `tests/test_manifest_sync.py` (count assertion 13 → 19)
- Modify: `bridge/unreal_claude_mcp_bridge.py` (TOOLS list + SERVER_VERSION)
- Modify: `UnrealClaudeMCP/Resources/mcp_manifest.json` (tools array + description + version)
- Modify: `UnrealClaudeMCP/UnrealClaudeMCP.uplugin` (VersionName + Description)
- Modify: `examples/smoke_test.py` (list_tools count assertion 13 → 19; full smoke step deferred to Task 10)

- [ ] **Step 1: Update existing count tests in `tests/test_bridge.py`**

Replace the three count-related tests:

```python
# Replace test_tools_list_has_thirteen_entries:
def test_tools_list_has_nineteen_entries():
    assert len(bridge.TOOLS) == 19


# Replace test_tool_names_are_unique_and_match_handlers:
def test_tool_names_are_unique_and_match_handlers():
    names = [t["name"] for t in bridge.TOOLS]
    assert len(names) == len(set(names)), "duplicate tool names"
    expected = {
        "execute_unreal_python", "get_project_summary", "inspect_blueprint",
        "inspect_widget_tree", "edit_widget_tree", "get_viewport_screenshot",
        "list_tools", "get_actors_in_level", "focus_actor",
        "load_level_by_path", "take_high_res_screenshot",
        "import_texture", "configure_texture",
        "find_assets", "spawn_actor", "set_actor_transform", "delete_actor",
        "set_actor_property", "add_component",
    }
    assert set(names) == expected


# Replace test_handle_tools_list_returns_all_tools:
def test_handle_tools_list_returns_all_tools():
    resp = bridge.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
    assert resp["id"] == 2
    assert "tools" in resp["result"]
    assert len(resp["result"]["tools"]) == 19
```

- [ ] **Step 2: Append 6 new schema tests at the bottom of the `# -------- TOOLS schema --------` section**

```python
def test_find_assets_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "find_assets")
    schema = tool["inputSchema"]
    assert schema["required"] == ["class_path"]
    props = schema["properties"]
    assert props["class_path"]["type"] == "string"
    assert props["path_under"]["type"] == "string"
    assert props["name_contains"]["type"] == "string"
    assert props["limit"]["type"] == "integer"


def test_spawn_actor_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "spawn_actor")
    schema = tool["inputSchema"]
    assert schema["required"] == ["class_path"]
    props = schema["properties"]
    assert props["class_path"]["type"] == "string"
    assert props["location"]["type"] == "object"
    assert props["rotation"]["type"] == "object"
    assert props["label"]["type"] == "string"
    assert props["properties"]["type"] == "object"


def test_set_actor_transform_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "set_actor_transform")
    schema = tool["inputSchema"]
    assert schema["required"] == ["name"]
    props = schema["properties"]
    assert props["name"]["type"] == "string"
    assert props["location"]["type"] == "object"
    assert props["rotation"]["type"] == "object"
    assert props["scale"]["type"] == "object"
    assert props["relative"]["type"] == "boolean"


def test_delete_actor_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "delete_actor")
    schema = tool["inputSchema"]
    assert schema["required"] == ["name"]
    props = schema["properties"]
    assert props["name"]["type"] == "string"
    assert props["force"]["type"] == "boolean"


def test_set_actor_property_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "set_actor_property")
    schema = tool["inputSchema"]
    assert schema["required"] == ["name", "property", "value"]
    props = schema["properties"]
    assert props["name"]["type"] == "string"
    assert props["property"]["type"] == "string"
    # value type is intentionally any — no type assertion


def test_add_component_schema():
    tool = next(t for t in bridge.TOOLS if t["name"] == "add_component")
    schema = tool["inputSchema"]
    assert schema["required"] == ["actor_name", "class_path"]
    props = schema["properties"]
    assert props["actor_name"]["type"] == "string"
    assert props["class_path"]["type"] == "string"
    assert props["component_name"]["type"] == "string"
    assert props["attach_to"]["type"] == "string"
    assert props["socket"]["type"] == "string"
    assert props["relative_transform"]["type"] == "object"
```

- [ ] **Step 3: Run tests to confirm RED**

```bash
py -m pytest tests/ -v
```

Expected: 9 failures (3 count tests + 6 new schema tests + 1 manifest_sync count + 1 list_tools smoke). All other tests pass.

- [ ] **Step 4: Append 6 entries to `bridge/unreal_claude_mcp_bridge.py` TOOLS list, bump SERVER_VERSION**

In the `TOOLS` list (after the existing `configure_texture` entry), append these six dictionaries before the closing `]`:

```python
    {
        "name": "find_assets",
        "description": "Query the asset registry by class + optional path + optional name substring. Returns matching assets with structured records (name, package_path, class).",
        "inputSchema": {
            "type": "object",
            "properties": {
                "class_path": {"type": "string", "description": "UE class path, e.g. /Script/Engine.StaticMesh, /Script/Engine.Blueprint, /Script/Engine.Texture2D."},
                "path_under": {"type": "string", "description": "Recursive path filter; defaults to /Game/. Must start with /Game/ or /Engine/."},
                "name_contains": {"type": "string", "description": "Case-insensitive substring filter on asset name."},
                "limit": {"type": "integer", "description": "Cap result count. Default 100, max 500."},
            },
            "required": ["class_path"],
        },
    },
    {
        "name": "spawn_actor",
        "description": "Create an actor in the current editor world at a location with optional rotation, label, and initial properties. Class path can be built-in (/Script/Engine.StaticMeshActor) or Blueprint (/Game/Blueprints/BP_X.BP_X_C).",
        "inputSchema": {
            "type": "object",
            "properties": {
                "class_path": {"type": "string", "description": "Actor class path."},
                "location": {"type": "object", "description": "World-space {x, y, z}. Defaults to {0,0,0}."},
                "rotation": {"type": "object", "description": "{pitch, yaw, roll} in degrees. Defaults to {0,0,0}."},
                "label": {"type": "string", "description": "Visible name in World Outliner; defaults to UE auto-naming."},
                "properties": {"type": "object", "description": "Map of {PropertyName: value} applied immediately after spawn via PropertyCoercion."},
            },
            "required": ["class_path"],
        },
    },
    {
        "name": "set_actor_transform",
        "description": "Move / rotate / scale an existing actor by name (label or FName). Supports both absolute and relative modes.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Actor label OR FName. If label is ambiguous, returns ambiguous_actor error."},
                "location": {"type": "object", "description": "{x, y, z}. Omit to leave unchanged."},
                "rotation": {"type": "object", "description": "{pitch, yaw, roll} in degrees. Omit to leave unchanged."},
                "scale": {"type": "object", "description": "{x, y, z} multiplier. Omit to leave unchanged."},
                "relative": {"type": "boolean", "description": "When true, deltas are added to current values instead of replacing. Default false."},
            },
            "required": ["name"],
        },
    },
    {
        "name": "delete_actor",
        "description": "Remove an actor from the editor world by name (label or FName). Children are detached, not destroyed (UE's default behavior). Force flag overrides the children-attached safety check.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Actor label OR FName."},
                "force": {"type": "boolean", "description": "When false (default), refuses to delete if children are attached and returns has_children error. When true, deletes anyway."},
            },
            "required": ["name"],
        },
    },
    {
        "name": "set_actor_property",
        "description": "Mutate any UPROPERTY on an actor. v0.3.0 supports primitives (bool/ints/floats/strings), FName/FText, FVector/FVector2D/FRotator/FLinearColor/FColor, enums, and TSoftObjectPtr. USTRUCT/TArray/TMap deferred to v0.4.0.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Actor label or FName."},
                "property": {"type": "string", "description": "UPROPERTY name (case-sensitive)."},
                "value": {"description": "JSON value coerced based on the FProperty type. See docs/TOOLS.md for the supported types table."},
            },
            "required": ["name", "property", "value"],
        },
    },
    {
        "name": "add_component",
        "description": "Attach a component (UActorComponent or USceneComponent subclass) to an existing actor at runtime, optionally socketed and transformed relative to a parent component.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "actor_name": {"type": "string", "description": "Host actor label or FName."},
                "class_path": {"type": "string", "description": "Component class path, e.g. /Script/Engine.StaticMeshComponent, /Script/Engine.PointLightComponent."},
                "component_name": {"type": "string", "description": "FName for the new component; defaults to UE auto-naming."},
                "attach_to": {"type": "string", "description": "Existing component name to attach as child of; defaults to root component."},
                "socket": {"type": "string", "description": "Socket name on the parent component."},
                "relative_transform": {"type": "object", "description": "{location, rotation, scale} relative to the parent component."},
            },
            "required": ["actor_name", "class_path"],
        },
    },
```

Also bump `SERVER_VERSION` (around line 38):

```python
SERVER_VERSION = "0.3.0"
```

And bump the docstring comment "13 handlers" → "19 handlers".

- [ ] **Step 5: Append 6 entries to `UnrealClaudeMCP/Resources/mcp_manifest.json` and bump version**

In the top-level fields:
- Change `"version": "0.2.0"` → `"version": "0.3.0"`
- Change `"description": "..., Thirteen generic..."` → `"description": "..., Nineteen generic..."`

In the `tools` array, before the closing `]`, append:

```json
    {
      "name": "find_assets",
      "description": "Query the asset registry by class + optional path + optional name substring. Returns matching assets with structured records (name, package_path, class).",
      "params": {
        "class_path": "string (required) - UE class path, e.g. /Script/Engine.StaticMesh",
        "path_under": "string (optional, default /Game/) - recursive path filter",
        "name_contains": "string (optional) - case-insensitive substring filter",
        "limit": "int (optional, default 100, max 500)"
      },
      "returns": { "ok": "bool", "matched": "int", "returned": "int", "assets": "array of {name, package_path, class}" }
    },
    {
      "name": "spawn_actor",
      "description": "Create an actor at a location with optional rotation, label, and initial properties.",
      "params": {
        "class_path": "string (required) - actor class path",
        "location": "object (optional, default {0,0,0}) - {x, y, z}",
        "rotation": "object (optional, default {0,0,0}) - {pitch, yaw, roll}",
        "label": "string (optional) - World Outliner label",
        "properties": "object (optional) - {PropertyName: value} applied post-spawn"
      },
      "returns": { "ok": "bool", "name": "string", "label": "string", "class": "string", "location": "object", "rotation": "object" }
    },
    {
      "name": "set_actor_transform",
      "description": "Move / rotate / scale an existing actor by name (label or FName). Supports absolute and relative modes.",
      "params": {
        "name": "string (required) - actor label OR FName",
        "location": "object (optional)",
        "rotation": "object (optional)",
        "scale": "object (optional)",
        "relative": "bool (optional, default false)"
      },
      "returns": { "ok": "bool", "name": "string", "applied": "object" }
    },
    {
      "name": "delete_actor",
      "description": "Remove an actor by name. Children are detached. Force overrides the children-attached safety check.",
      "params": {
        "name": "string (required) - actor label OR FName",
        "force": "bool (optional, default false)"
      },
      "returns": { "ok": "bool", "name": "string", "deleted": "bool" }
    },
    {
      "name": "set_actor_property",
      "description": "Mutate any UPROPERTY on an actor. Supports primitives, FName, FText, FVector/Rotator/Color, enums, TSoftObjectPtr in v0.3.0.",
      "params": {
        "name": "string (required) - actor label OR FName",
        "property": "string (required) - UPROPERTY name",
        "value": "any (required) - coerced to FProperty type"
      },
      "returns": { "ok": "bool", "name": "string", "property": "string", "old_value": "any", "new_value": "any" }
    },
    {
      "name": "add_component",
      "description": "Attach a component to an existing actor at runtime, optionally socketed.",
      "params": {
        "actor_name": "string (required) - host actor label OR FName",
        "class_path": "string (required) - component class path",
        "component_name": "string (optional)",
        "attach_to": "string (optional, default root)",
        "socket": "string (optional)",
        "relative_transform": "object (optional)"
      },
      "returns": { "ok": "bool", "actor": "string", "component": "string", "class": "string", "attached_to": "string" }
    }
```

- [ ] **Step 6: Bump `tests/test_manifest_sync.py` count assertion**

Find the line `assert len(manifest["tools"]) == len(bridge.TOOLS) == 13` and change `13` to `19`.

- [ ] **Step 7: Bump `examples/smoke_test.py` list_tools assertion**

Find the section currently labeled `header("1. list_tools (should list 13 tool names)")`. Update header string and `if len(tools) != 13` → `if len(tools) != 19` (3 occurrences in that step including the header text, the SmokeFailure message, and the count check).

- [ ] **Step 8: Bump `UnrealClaudeMCP/UnrealClaudeMCP.uplugin`**

Change `"VersionName": "0.2.0"` → `"VersionName": "0.3.0"`.
Change `"Description": "...Thirteen..."` → `"Description": "...Nineteen..."`.

- [ ] **Step 9: Run tests to confirm GREEN**

```bash
py -m pytest tests/ -v
```

Expected: 65 passing (was 59; added 6 schema tests). Zero failures.

- [ ] **Step 10: Commit**

```bash
git add bridge/unreal_claude_mcp_bridge.py UnrealClaudeMCP/Resources/mcp_manifest.json UnrealClaudeMCP/UnrealClaudeMCP.uplugin tests/test_bridge.py tests/test_manifest_sync.py examples/smoke_test.py && git commit -m "$(cat <<'EOF'
feat(bridge): add v0.3.0 build-a-level catalog entries (6 tools)

Adds the v0.3.0 build-a-level tool surface to the static MCP catalog:
  - find_assets         (asset registry queries)
  - spawn_actor         (create actor at location)
  - set_actor_transform (move/rotate/scale by name)
  - delete_actor        (remove by name with force flag)
  - set_actor_property  (mutate UPROPERTY with type coercion)
  - add_component       (attach component at runtime)

Tool count grows 13 -> 19. Versions bumped 0.2.0 -> 0.3.0 across
.uplugin, mcp_manifest.json, and bridge SERVER_VERSION (the
test_manifest_version_matches_bridge_server_version test continues
to pass with both at 0.3.0).

C++ handlers + shared modules (ActorIdentity, PropertyCoercion) are
NOT yet implemented in this commit. The bridge will forward calls
to UE which will return MethodNotFound until Phase 3-9 land.

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```

---

## Phase 2 — Shared module: ActorIdentity (C++ helper, no MCP exposure)

Implements the hybrid label-or-FName actor lookup with ambiguity detection. Used by 5 of 6 handlers in Phase 3.

### Task 2: Create ActorIdentity shared module

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.h`
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.cpp`

- [ ] **Step 1: Create the header file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.h`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// ActorIdentity - shared helper for hybrid label-or-FName actor lookup
// with explicit ambiguity detection. Used by all v0.3.0 write handlers
// (set_actor_transform, delete_actor, set_actor_property, add_component)
// to provide a consistent identification scheme.
//
// Resolution rule:
//   1. Try label match first against AActor::GetActorLabel().
//   2. If exactly one actor matches the label -> return it.
//   3. If multiple actors match the label -> return EAmbiguous, fill
//      OutAmbiguousFNames with all candidates' FNames so the handler
//      can produce an "ambiguous_actor" error message listing them.
//   4. If zero actors match the label -> try FName match.
//   5. If FName match succeeds -> return it.
//   6. If FName match fails -> return ENotFound.

#pragma once

#include "CoreMinimal.h"

class AActor;

namespace UCMCP::ActorIdentity
{
    enum class EResolveResult : uint8
    {
        Found,          // OutActor is set
        NotFound,       // No actor matched label or FName
        Ambiguous,      // Multiple actors matched the label;
                        // OutAmbiguousFNames lists candidates
    };

    /**
     * Resolve a name (label OR FName) to a single AActor in the current
     * editor world. See file header for resolution rules.
     *
     * @param Name                 The label or FName to resolve.
     * @param OutActor             Set to the resolved actor on Found.
     * @param OutAmbiguousFNames   On Ambiguous, lists the FNames of all
     *                             actors that matched the label.
     * @return                     One of EResolveResult.
     */
    EResolveResult Resolve(
        const FString& Name,
        AActor*& OutActor,
        TArray<FString>& OutAmbiguousFNames);
}
```

- [ ] **Step 2: Create the implementation file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.cpp`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.

#include "MCP/ActorIdentity.h"

#include "Editor.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "GameFramework/Actor.h"

namespace UCMCP::ActorIdentity
{
    EResolveResult Resolve(
        const FString& Name,
        AActor*& OutActor,
        TArray<FString>& OutAmbiguousFNames)
    {
        OutActor = nullptr;
        OutAmbiguousFNames.Empty();

        UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
        if (!World)
        {
            return EResolveResult::NotFound;
        }

        // Pass 1: collect all actors whose label matches Name (case-insensitive).
        TArray<AActor*> LabelMatches;
        for (TActorIterator<AActor> It(World); It; ++It)
        {
            AActor* Actor = *It;
            if (!Actor) continue;
            if (Actor->GetActorLabel().Equals(Name, ESearchCase::IgnoreCase))
            {
                LabelMatches.Add(Actor);
            }
        }

        if (LabelMatches.Num() == 1)
        {
            OutActor = LabelMatches[0];
            return EResolveResult::Found;
        }
        if (LabelMatches.Num() > 1)
        {
            // Ambiguous: surface all FNames so the handler error can list them.
            for (AActor* Actor : LabelMatches)
            {
                OutAmbiguousFNames.Add(Actor->GetFName().ToString());
            }
            return EResolveResult::Ambiguous;
        }

        // Pass 2: no label match — try FName.
        const FName Target(*Name);
        for (TActorIterator<AActor> It(World); It; ++It)
        {
            AActor* Actor = *It;
            if (!Actor) continue;
            if (Actor->GetFName() == Target)
            {
                OutActor = Actor;
                return EResolveResult::Found;
            }
        }

        return EResolveResult::NotFound;
    }
}
```

- [ ] **Step 3: Run pytest as a regression sanity check**

```bash
py -m pytest tests/ -v
```

Expected: 65 passing. C++ changes don't affect Python tests; this just confirms.

- [ ] **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.h UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/ActorIdentity.cpp && git commit -m "$(cat <<'EOF'
feat(unreal): add ActorIdentity shared module for hybrid label-or-FName lookup

New shared C++ helper used by the v0.3.0 write handlers to resolve
an actor name (label OR FName) to a single AActor, with explicit
ambiguity detection.

Resolution rule:
  1. Try label match first (case-insensitive).
  2. If exactly one match -> Found.
  3. If multiple matches -> Ambiguous (return list of candidate FNames
     so the handler can produce an actionable error message).
  4. If zero matches -> try FName match.
  5. FName match -> Found, else NotFound.

Used by all 5 write handlers in this PR: set_actor_transform,
delete_actor, set_actor_property, add_component (and indirectly by
spawn_actor when a label is provided post-spawn).

Build verification deferred to user's UE 5.7 workstation; subagent
does not have UnrealBuildTool access.

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```

---

## Phase 3 — Shared module: PropertyCoercion (C++ helper, no MCP exposure)

Implements JSON ↔ FProperty value coercion for the v0.3.0 supported types. Used by `spawn_actor.properties`, `set_actor_property`, and `add_component.relative_transform`.

### Task 3: Create PropertyCoercion shared module

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.h`
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.cpp`

- [ ] **Step 1: Create the header file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.h`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// PropertyCoercion - shared helper that bridges JSON values from the
// MCP layer to UE's FProperty system. Supports the v0.3.0 type list:
//
//   bool, int8/16/32/64, uint8/16/32/64, float, double,
//   FString, FText, FName,
//   FVector, FVector2D, FRotator, FLinearColor, FColor,
//   UEnum-decorated enums (by value name string),
//   TSoftObjectPtr<T> (by asset path string).
//
// USTRUCT (custom structs), TArray, TMap, TSet, FInstancedStruct, and
// hard UObject* references are deferred to v0.4.0 — calling these
// helpers on those types returns ECoerceResult::Unsupported.

#pragma once

#include "CoreMinimal.h"
#include "Dom/JsonValue.h"

class FProperty;

namespace UCMCP::PropertyCoercion
{
    enum class ECoerceResult : uint8
    {
        Success,
        Unsupported,    // FProperty type not in v0.3.0 supported list
        TypeMismatch,   // JSON value type can't coerce to FProperty type
                        // (e.g., string for an int)
        OutOfRange,     // Numeric overflow, enum value not found, etc.
    };

    struct FCoerceOutcome
    {
        ECoerceResult Result = ECoerceResult::Success;
        FString Detail;            // human-readable diagnosis (filled on failure)
        FString FPropertyClass;    // e.g., "StructProperty", "ArrayProperty"
                                   // (filled on Unsupported, for the error msg)
    };

    /**
     * Coerce a JSON value into the given FProperty on the target object.
     * The target object's memory is mutated in-place.
     */
    FCoerceOutcome SetProperty(
        UObject* Target,
        FProperty* Property,
        const TSharedPtr<FJsonValue>& Value);

    /**
     * Read the current value of a property from the target object and
     * encode it as a JSON value. Used to return old_value/new_value
     * in set_actor_property's success response.
     */
    TSharedPtr<FJsonValue> GetProperty(
        UObject* Target,
        FProperty* Property);

    /**
     * Find a property by name on the object's class. Returns null if
     * no property matches. (Convenience wrapper.)
     */
    FProperty* FindProperty(UObject* Target, const FString& PropertyName);
}
```

- [ ] **Step 2: Create the implementation file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.cpp`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.

#include "MCP/PropertyCoercion.h"

#include "Dom/JsonObject.h"
#include "UObject/UnrealType.h"
#include "UObject/EnumProperty.h"
#include "UObject/TextProperty.h"
#include "Math/Color.h"
#include "Math/Vector.h"
#include "Math/Vector2D.h"
#include "Math/Rotator.h"

namespace UCMCP::PropertyCoercion
{
    static FCoerceOutcome MakeOutcome(ECoerceResult R, const FString& Detail = FString(), const FString& Cls = FString())
    {
        return FCoerceOutcome{ R, Detail, Cls };
    }

    FProperty* FindProperty(UObject* Target, const FString& PropertyName)
    {
        if (!Target) return nullptr;
        return Target->GetClass()->FindPropertyByName(FName(*PropertyName));
    }

    static bool ReadVector(const TSharedPtr<FJsonObject>& Obj, FVector& Out)
    {
        if (!Obj.IsValid()) return false;
        double X, Y, Z;
        if (!Obj->TryGetNumberField(TEXT("x"), X)) return false;
        if (!Obj->TryGetNumberField(TEXT("y"), Y)) return false;
        if (!Obj->TryGetNumberField(TEXT("z"), Z)) return false;
        Out = FVector(X, Y, Z);
        return true;
    }

    static bool ReadVector2D(const TSharedPtr<FJsonObject>& Obj, FVector2D& Out)
    {
        if (!Obj.IsValid()) return false;
        double X, Y;
        if (!Obj->TryGetNumberField(TEXT("x"), X)) return false;
        if (!Obj->TryGetNumberField(TEXT("y"), Y)) return false;
        Out = FVector2D(X, Y);
        return true;
    }

    static bool ReadRotator(const TSharedPtr<FJsonObject>& Obj, FRotator& Out)
    {
        if (!Obj.IsValid()) return false;
        double Pitch, Yaw, Roll;
        if (!Obj->TryGetNumberField(TEXT("pitch"), Pitch)) return false;
        if (!Obj->TryGetNumberField(TEXT("yaw"), Yaw)) return false;
        if (!Obj->TryGetNumberField(TEXT("roll"), Roll)) return false;
        Out = FRotator(Pitch, Yaw, Roll);
        return true;
    }

    static bool ReadLinearColor(const TSharedPtr<FJsonObject>& Obj, FLinearColor& Out)
    {
        if (!Obj.IsValid()) return false;
        double R, G, B, A;
        if (!Obj->TryGetNumberField(TEXT("r"), R)) return false;
        if (!Obj->TryGetNumberField(TEXT("g"), G)) return false;
        if (!Obj->TryGetNumberField(TEXT("b"), B)) return false;
        if (!Obj->TryGetNumberField(TEXT("a"), A)) A = 1.0;
        Out = FLinearColor(R, G, B, A);
        return true;
    }

    static bool ReadColor(const TSharedPtr<FJsonObject>& Obj, FColor& Out)
    {
        if (!Obj.IsValid()) return false;
        double R, G, B, A;
        if (!Obj->TryGetNumberField(TEXT("r"), R)) return false;
        if (!Obj->TryGetNumberField(TEXT("g"), G)) return false;
        if (!Obj->TryGetNumberField(TEXT("b"), B)) return false;
        if (!Obj->TryGetNumberField(TEXT("a"), A)) A = 255.0;
        Out = FColor((uint8)FMath::Clamp(R, 0.0, 255.0),
                     (uint8)FMath::Clamp(G, 0.0, 255.0),
                     (uint8)FMath::Clamp(B, 0.0, 255.0),
                     (uint8)FMath::Clamp(A, 0.0, 255.0));
        return true;
    }

    FCoerceOutcome SetProperty(UObject* Target, FProperty* Property, const TSharedPtr<FJsonValue>& Value)
    {
        if (!Target || !Property || !Value.IsValid())
        {
            return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("null target/property/value"));
        }

        void* PropAddr = Property->ContainerPtrToValuePtr<void>(Target);

        // Bool
        if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Property))
        {
            bool B;
            if (!Value->TryGetBool(B))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected bool"));
            }
            BoolProp->SetPropertyValue(PropAddr, B);
            return MakeOutcome(ECoerceResult::Success);
        }

        // Numeric (int*, uint*, float, double)
        if (FNumericProperty* NumProp = CastField<FNumericProperty>(Property))
        {
            double D;
            if (!Value->TryGetNumber(D))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected number"));
            }
            if (NumProp->IsFloatingPoint())
            {
                NumProp->SetFloatingPointPropertyValue(PropAddr, D);
            }
            else
            {
                NumProp->SetIntPropertyValue(PropAddr, (int64)D);
            }
            return MakeOutcome(ECoerceResult::Success);
        }

        // FString
        if (FStrProperty* StrProp = CastField<FStrProperty>(Property))
        {
            FString S;
            if (!Value->TryGetString(S))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected string"));
            }
            StrProp->SetPropertyValue(PropAddr, S);
            return MakeOutcome(ECoerceResult::Success);
        }

        // FName
        if (FNameProperty* NameProp = CastField<FNameProperty>(Property))
        {
            FString S;
            if (!Value->TryGetString(S))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected string"));
            }
            NameProp->SetPropertyValue(PropAddr, FName(*S));
            return MakeOutcome(ECoerceResult::Success);
        }

        // FText
        if (FTextProperty* TextProp = CastField<FTextProperty>(Property))
        {
            FString S;
            if (!Value->TryGetString(S))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected string"));
            }
            TextProp->SetPropertyValue(PropAddr, FText::FromString(S));
            return MakeOutcome(ECoerceResult::Success);
        }

        // Enum
        if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Property))
        {
            FString S;
            if (!Value->TryGetString(S))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected enum value name string"));
            }
            UEnum* Enum = EnumProp->GetEnum();
            const int64 EnumVal = Enum->GetValueByNameString(S);
            if (EnumVal == INDEX_NONE)
            {
                return MakeOutcome(ECoerceResult::OutOfRange,
                    FString::Printf(TEXT("enum '%s' has no value named '%s'"),
                        *Enum->GetName(), *S));
            }
            EnumProp->GetUnderlyingProperty()->SetIntPropertyValue(PropAddr, EnumVal);
            return MakeOutcome(ECoerceResult::Success);
        }

        // Struct (FVector / FRotator / FLinearColor / FColor / FVector2D)
        if (FStructProperty* StructProp = CastField<FStructProperty>(Property))
        {
            UScriptStruct* Struct = StructProp->Struct;
            const TSharedPtr<FJsonObject>* ObjPtr = nullptr;
            if (!Value->TryGetObject(ObjPtr))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected JSON object for struct"));
            }
            const TSharedPtr<FJsonObject>& Obj = *ObjPtr;

            if (Struct == TBaseStructure<FVector>::Get())
            {
                FVector V; if (!ReadVector(Obj, V)) return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected {x,y,z}"));
                *(FVector*)PropAddr = V;
                return MakeOutcome(ECoerceResult::Success);
            }
            if (Struct == TBaseStructure<FVector2D>::Get())
            {
                FVector2D V; if (!ReadVector2D(Obj, V)) return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected {x,y}"));
                *(FVector2D*)PropAddr = V;
                return MakeOutcome(ECoerceResult::Success);
            }
            if (Struct == TBaseStructure<FRotator>::Get())
            {
                FRotator R; if (!ReadRotator(Obj, R)) return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected {pitch,yaw,roll}"));
                *(FRotator*)PropAddr = R;
                return MakeOutcome(ECoerceResult::Success);
            }
            if (Struct == TBaseStructure<FLinearColor>::Get())
            {
                FLinearColor C; if (!ReadLinearColor(Obj, C)) return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected {r,g,b,a}"));
                *(FLinearColor*)PropAddr = C;
                return MakeOutcome(ECoerceResult::Success);
            }
            if (Struct == TBaseStructure<FColor>::Get())
            {
                FColor C; if (!ReadColor(Obj, C)) return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected {r,g,b,a}"));
                *(FColor*)PropAddr = C;
                return MakeOutcome(ECoerceResult::Success);
            }

            // Custom structs deferred to v0.4.0
            return MakeOutcome(ECoerceResult::Unsupported,
                FString::Printf(TEXT("custom USTRUCT '%s' deferred to v0.4.0"), *Struct->GetName()),
                TEXT("StructProperty"));
        }

        // TSoftObjectPtr<T>
        if (FSoftObjectProperty* SoftProp = CastField<FSoftObjectProperty>(Property))
        {
            FString S;
            if (!Value->TryGetString(S))
            {
                return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("expected asset path string"));
            }
            FSoftObjectPath Path(S);
            SoftProp->SetPropertyValue(PropAddr, Path);
            return MakeOutcome(ECoerceResult::Success);
        }

        // Anything else (TArray, TMap, TSet, FObjectProperty, FInstancedStruct, etc.)
        return MakeOutcome(ECoerceResult::Unsupported,
            FString::Printf(TEXT("FProperty class '%s' not in v0.3.0 supported list"),
                *Property->GetClass()->GetName()),
            Property->GetClass()->GetName());
    }

    TSharedPtr<FJsonValue> GetProperty(UObject* Target, FProperty* Property)
    {
        if (!Target || !Property)
        {
            return MakeShared<FJsonValueNull>();
        }
        const void* PropAddr = Property->ContainerPtrToValuePtr<void>(Target);

        if (FBoolProperty* P = CastField<FBoolProperty>(Property))
        {
            return MakeShared<FJsonValueBoolean>(P->GetPropertyValue(PropAddr));
        }
        if (FNumericProperty* P = CastField<FNumericProperty>(Property))
        {
            const double D = P->IsFloatingPoint()
                ? P->GetFloatingPointPropertyValue(PropAddr)
                : (double)P->GetSignedIntPropertyValue(PropAddr);
            return MakeShared<FJsonValueNumber>(D);
        }
        if (FStrProperty* P = CastField<FStrProperty>(Property))
        {
            return MakeShared<FJsonValueString>(P->GetPropertyValue(PropAddr));
        }
        if (FNameProperty* P = CastField<FNameProperty>(Property))
        {
            return MakeShared<FJsonValueString>(P->GetPropertyValue(PropAddr).ToString());
        }
        if (FTextProperty* P = CastField<FTextProperty>(Property))
        {
            return MakeShared<FJsonValueString>(P->GetPropertyValue(PropAddr).ToString());
        }
        if (FEnumProperty* P = CastField<FEnumProperty>(Property))
        {
            const int64 EnumVal = P->GetUnderlyingProperty()->GetSignedIntPropertyValue(PropAddr);
            return MakeShared<FJsonValueString>(P->GetEnum()->GetNameStringByValue(EnumVal));
        }
        if (FStructProperty* P = CastField<FStructProperty>(Property))
        {
            UScriptStruct* Struct = P->Struct;
            TSharedRef<FJsonObject> Obj = MakeShared<FJsonObject>();
            if (Struct == TBaseStructure<FVector>::Get())
            {
                const FVector& V = *(const FVector*)PropAddr;
                Obj->SetNumberField(TEXT("x"), V.X);
                Obj->SetNumberField(TEXT("y"), V.Y);
                Obj->SetNumberField(TEXT("z"), V.Z);
                return MakeShared<FJsonValueObject>(Obj);
            }
            if (Struct == TBaseStructure<FRotator>::Get())
            {
                const FRotator& R = *(const FRotator*)PropAddr;
                Obj->SetNumberField(TEXT("pitch"), R.Pitch);
                Obj->SetNumberField(TEXT("yaw"), R.Yaw);
                Obj->SetNumberField(TEXT("roll"), R.Roll);
                return MakeShared<FJsonValueObject>(Obj);
            }
            if (Struct == TBaseStructure<FLinearColor>::Get())
            {
                const FLinearColor& C = *(const FLinearColor*)PropAddr;
                Obj->SetNumberField(TEXT("r"), C.R);
                Obj->SetNumberField(TEXT("g"), C.G);
                Obj->SetNumberField(TEXT("b"), C.B);
                Obj->SetNumberField(TEXT("a"), C.A);
                return MakeShared<FJsonValueObject>(Obj);
            }
            // Other structs: fall through to null (caller's old_value will just be null)
        }
        if (FSoftObjectProperty* P = CastField<FSoftObjectProperty>(Property))
        {
            return MakeShared<FJsonValueString>(P->GetPropertyValue(PropAddr).ToString());
        }
        return MakeShared<FJsonValueNull>();
    }
}
```

- [ ] **Step 3: pytest regression check**

```bash
py -m pytest tests/ -v
```

Expected: 65 passing.

- [ ] **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.h UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.cpp && git commit -m "$(cat <<'EOF'
feat(unreal): add PropertyCoercion shared module for JSON <-> FProperty

New shared C++ helper that bridges JSON values from the MCP layer
to UE's FProperty system. Used by spawn_actor.properties,
set_actor_property, and add_component.relative_transform.

Supported FProperty types in v0.3.0:
  - bool, int8/16/32/64, uint8/16/32/64, float, double
  - FString, FName, FText
  - FVector, FVector2D, FRotator, FLinearColor, FColor
  - UEnum-decorated enums (by value name string)
  - TSoftObjectPtr<T> (by asset path string)

Returns ECoerceResult::Unsupported with the FProperty class name
filled in for anything outside that list (TArray, TMap, TSet,
FObjectProperty, FInstancedStruct, custom USTRUCTs) — the calling
handler surfaces this as the "unsupported_property_type" error
code so the LLM can fall back to execute_unreal_python.

GetProperty() reads current values back as JSON for the old_value
half of set_actor_property's response.

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```

---

## Phase 4 — Handler 1: find_assets

### Task 4: Create Handler_FindAssets

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_FindAssets.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp` (extern + Reg.Register)

- [ ] **Step 1: Create the handler file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_FindAssets.cpp`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// find_assets - query the asset registry by class + path + name substring.
//
// Error format: "find_assets: <error_code>: <human-readable detail>".
// Stable error codes (parseable by clients): missing_params,
// missing_required_field, invalid_class_path, invalid_path_filter.

#include "MCP/MCPHandler.h"

#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetRegistry/ARFilter.h"
#include "Modules/ModuleManager.h"

class FHandler_FindAssets : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("find_assets"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("find_assets: missing_params: request had no params object");
            return nullptr;
        }

        FString ClassPath;
        if (!Params->TryGetStringField(TEXT("class_path"), ClassPath) || ClassPath.IsEmpty())
        {
            OutError = TEXT("find_assets: missing_required_field: 'class_path' is required and must be non-empty");
            return nullptr;
        }

        FString PathUnder = TEXT("/Game/");
        Params->TryGetStringField(TEXT("path_under"), PathUnder);
        if (!PathUnder.StartsWith(TEXT("/Game/")) && !PathUnder.StartsWith(TEXT("/Engine/")))
        {
            OutError = FString::Printf(
                TEXT("find_assets: invalid_path_filter: 'path_under' must start with /Game/ or /Engine/, got '%s'"),
                *PathUnder);
            return nullptr;
        }

        FString NameContains;
        Params->TryGetStringField(TEXT("name_contains"), NameContains);

        int32 Limit = 100;
        Params->TryGetNumberField(TEXT("limit"), Limit);
        Limit = FMath::Clamp(Limit, 1, 500);

        // Build the asset registry filter
        FAssetRegistryModule& Module = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
        IAssetRegistry& Registry = Module.Get();

        FARFilter Filter;
        Filter.ClassPaths.Add(FTopLevelAssetPath(ClassPath));
        Filter.PackagePaths.Add(FName(*PathUnder));
        Filter.bRecursivePaths = true;

        TArray<FAssetData> Found;
        Registry.GetAssets(Filter, Found);

        if (Found.Num() == 0 && Filter.ClassPaths.Num() == 1)
        {
            // Validate that the class path actually resolved (otherwise it's an
            // invalid class, not "no matches").
            UClass* Resolved = LoadClass<UObject>(nullptr, *ClassPath);
            if (!Resolved)
            {
                OutError = FString::Printf(
                    TEXT("find_assets: invalid_class_path: '%s' did not resolve to a UClass"),
                    *ClassPath);
                return nullptr;
            }
        }

        // Apply the name_contains filter in-memory (case-insensitive)
        TArray<FAssetData> NameFiltered;
        if (!NameContains.IsEmpty())
        {
            for (const FAssetData& Data : Found)
            {
                if (Data.AssetName.ToString().Contains(NameContains, ESearchCase::IgnoreCase))
                {
                    NameFiltered.Add(Data);
                }
            }
        }
        else
        {
            NameFiltered = Found;
        }

        // Sort by asset name for stable output
        NameFiltered.Sort([](const FAssetData& A, const FAssetData& B) {
            return A.AssetName.LexicalLess(B.AssetName);
        });

        const int32 Matched = NameFiltered.Num();
        const int32 Returned = FMath::Min(Matched, Limit);

        TArray<TSharedPtr<FJsonValue>> JsonAssets;
        JsonAssets.Reserve(Returned);
        for (int32 i = 0; i < Returned; ++i)
        {
            const FAssetData& Data = NameFiltered[i];
            TSharedRef<FJsonObject> A = MakeShared<FJsonObject>();
            A->SetStringField(TEXT("name"), Data.AssetName.ToString());
            A->SetStringField(TEXT("package_path"), Data.PackageName.ToString());
            A->SetStringField(TEXT("class"), Data.AssetClassPath.GetAssetName().ToString());
            JsonAssets.Add(MakeShared<FJsonValueObject>(A));
        }

        TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
        Result->SetBoolField(TEXT("ok"), true);
        Result->SetNumberField(TEXT("matched"), Matched);
        Result->SetNumberField(TEXT("returned"), Returned);
        Result->SetArrayField(TEXT("assets"), JsonAssets);
        return Result;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_FindAssets()
{
    return MakeShared<FHandler_FindAssets>();
}
```

- [ ] **Step 2: Register in module**

In `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`, append after the existing `Make_Handler_ConfigureTexture` extern:

```cpp
extern TSharedRef<IUCMCPHandler> Make_Handler_FindAssets();
```

In `StartupModule()` after the `Reg.Register(Make_Handler_ConfigureTexture())` line:

```cpp
Reg.Register(Make_Handler_FindAssets());
```

- [ ] **Step 3: pytest regression check**

```bash
py -m pytest tests/ -v
```

Expected: 65 passing.

- [ ] **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_FindAssets.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "$(cat <<'EOF'
feat(unreal): add find_assets handler (asset registry queries)

Implements query-by-class + optional path filter + optional name
substring against UE's IAssetRegistry. Returns up to 'limit' matching
assets sorted alphabetically by name with structured records
(name, package_path, class).

Common queries:
  find_assets class=/Script/Engine.StaticMesh path=/Engine/BasicShapes
  find_assets class=/Script/Engine.Blueprint path=/Game/Blueprints
                name_contains=Player
  find_assets class=/Script/Engine.Texture2D limit=50

Stable error codes: missing_params, missing_required_field,
invalid_class_path, invalid_path_filter.

Build verification deferred to user's UE 5.7 workstation.

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```

---

## Phase 5 — Handler 2: spawn_actor

### Task 5: Create Handler_SpawnActor

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SpawnActor.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`

- [ ] **Step 1: Create the handler file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SpawnActor.cpp`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// spawn_actor - create an actor in the editor world at a location with
// optional rotation, label, and initial properties.
//
// Error format: "spawn_actor: <error_code>: <human-readable detail>".
// Stable error codes: missing_params, missing_required_field,
// invalid_class_path, class_not_spawnable, spawn_failed,
// property_application_failed.

#include "MCP/MCPHandler.h"
#include "MCP/PropertyCoercion.h"

#include "Dom/JsonObject.h"
#include "Editor.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"

class FHandler_SpawnActor : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("spawn_actor"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("spawn_actor: missing_params: request had no params object");
            return nullptr;
        }

        FString ClassPath;
        if (!Params->TryGetStringField(TEXT("class_path"), ClassPath) || ClassPath.IsEmpty())
        {
            OutError = TEXT("spawn_actor: missing_required_field: 'class_path' is required and must be non-empty");
            return nullptr;
        }

        UClass* Class = LoadClass<AActor>(nullptr, *ClassPath);
        if (!Class)
        {
            OutError = FString::Printf(TEXT("spawn_actor: invalid_class_path: '%s' did not resolve to a UClass"), *ClassPath);
            return nullptr;
        }
        if (Class->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated))
        {
            OutError = FString::Printf(TEXT("spawn_actor: class_not_spawnable: '%s' is abstract or deprecated"), *ClassPath);
            return nullptr;
        }
        if (!Class->IsChildOf(AActor::StaticClass()))
        {
            OutError = FString::Printf(TEXT("spawn_actor: class_not_spawnable: '%s' is not an AActor subclass"), *ClassPath);
            return nullptr;
        }

        // Location — initialize to (0,0,0) so missing JSON keys don't leak garbage
        FVector Location(0, 0, 0);
        const TSharedPtr<FJsonObject>* LocObj = nullptr;
        if (Params->TryGetObjectField(TEXT("location"), LocObj) && LocObj && (*LocObj).IsValid())
        {
            double X = 0, Y = 0, Z = 0;
            (*LocObj)->TryGetNumberField(TEXT("x"), X); Location.X = X;
            (*LocObj)->TryGetNumberField(TEXT("y"), Y); Location.Y = Y;
            (*LocObj)->TryGetNumberField(TEXT("z"), Z); Location.Z = Z;
        }

        // Rotation — same initialization discipline
        FRotator Rotation(0, 0, 0);
        const TSharedPtr<FJsonObject>* RotObj = nullptr;
        if (Params->TryGetObjectField(TEXT("rotation"), RotObj) && RotObj && (*RotObj).IsValid())
        {
            double Pitch = 0, Yaw = 0, Roll = 0;
            (*RotObj)->TryGetNumberField(TEXT("pitch"), Pitch); Rotation.Pitch = Pitch;
            (*RotObj)->TryGetNumberField(TEXT("yaw"), Yaw); Rotation.Yaw = Yaw;
            (*RotObj)->TryGetNumberField(TEXT("roll"), Roll); Rotation.Roll = Roll;
        }

        UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
        if (!World)
        {
            OutError = TEXT("spawn_actor: spawn_failed: no editor world available");
            return nullptr;
        }

        FActorSpawnParameters SpawnParams;
        SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

        AActor* Actor = World->SpawnActor<AActor>(Class, Location, Rotation, SpawnParams);
        if (!Actor)
        {
            OutError = FString::Printf(TEXT("spawn_actor: spawn_failed: World::SpawnActor returned null for '%s'"), *ClassPath);
            return nullptr;
        }

        // Apply optional label
        FString Label;
        if (Params->TryGetStringField(TEXT("label"), Label) && !Label.IsEmpty())
        {
            Actor->SetActorLabel(Label);
        }

        // Apply optional properties via PropertyCoercion
        FString FailedProperty;
        const TSharedPtr<FJsonObject>* PropsObj = nullptr;
        if (Params->TryGetObjectField(TEXT("properties"), PropsObj) && PropsObj && (*PropsObj).IsValid())
        {
            for (const auto& Pair : (*PropsObj)->Values)
            {
                FProperty* Prop = UCMCP::PropertyCoercion::FindProperty(Actor, Pair.Key);
                if (!Prop)
                {
                    FailedProperty = FString::Printf(TEXT("'%s' (property_not_found)"), *Pair.Key);
                    break;
                }
                UCMCP::PropertyCoercion::FCoerceOutcome Outcome =
                    UCMCP::PropertyCoercion::SetProperty(Actor, Prop, Pair.Value);
                if (Outcome.Result != UCMCP::PropertyCoercion::ECoerceResult::Success)
                {
                    FailedProperty = FString::Printf(TEXT("'%s' (%s)"), *Pair.Key, *Outcome.Detail);
                    break;
                }
            }
        }

        if (!FailedProperty.IsEmpty())
        {
            OutError = FString::Printf(
                TEXT("spawn_actor: property_application_failed: actor was spawned but property %s failed; call delete_actor to clean up"),
                *FailedProperty);
            // Return error AND keep the actor — caller decides whether to delete.
            return nullptr;
        }

        TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
        Result->SetBoolField(TEXT("ok"), true);
        Result->SetStringField(TEXT("name"), Actor->GetFName().ToString());
        Result->SetStringField(TEXT("label"), Actor->GetActorLabel());
        Result->SetStringField(TEXT("class"), Actor->GetClass()->GetName());

        TSharedRef<FJsonObject> Loc = MakeShared<FJsonObject>();
        Loc->SetNumberField(TEXT("x"), Location.X);
        Loc->SetNumberField(TEXT("y"), Location.Y);
        Loc->SetNumberField(TEXT("z"), Location.Z);
        Result->SetObjectField(TEXT("location"), Loc);

        TSharedRef<FJsonObject> Rot = MakeShared<FJsonObject>();
        Rot->SetNumberField(TEXT("pitch"), Rotation.Pitch);
        Rot->SetNumberField(TEXT("yaw"), Rotation.Yaw);
        Rot->SetNumberField(TEXT("roll"), Rotation.Roll);
        Result->SetObjectField(TEXT("rotation"), Rot);

        return Result;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_SpawnActor()
{
    return MakeShared<FHandler_SpawnActor>();
}
```

- [ ] **Step 2: Register in module**

Same pattern as Task 4 — append `extern TSharedRef<IUCMCPHandler> Make_Handler_SpawnActor();` and `Reg.Register(Make_Handler_SpawnActor());`.

- [ ] **Step 3: pytest regression check** + **Step 4: Commit** (same shape as Task 4 with appropriate commit message)

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SpawnActor.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "feat(unreal): add spawn_actor handler

Creates an actor in the editor world via World::SpawnActor with
optional location, rotation, label override, and initial properties
applied via the PropertyCoercion shared module. Validates class is
non-abstract, non-deprecated, and an AActor subclass.

Stable error codes: missing_params, missing_required_field,
invalid_class_path, class_not_spawnable, spawn_failed,
property_application_failed.

If property application fails, the actor is still spawned — caller
can delete_actor for atomic-like cleanup.

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 6 — Handler 3: set_actor_transform

### Task 6: Create Handler_SetActorTransform

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorTransform.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`

- [ ] **Step 1: Create the handler file**

`UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorTransform.cpp`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// set_actor_transform - move/rotate/scale an existing actor by name.
//
// Error format: "set_actor_transform: <error_code>: <human-readable detail>".
// Stable error codes: missing_params, missing_required_field,
// actor_not_found, ambiguous_actor, no_changes_specified.

#include "MCP/MCPHandler.h"
#include "MCP/ActorIdentity.h"

#include "Dom/JsonObject.h"
#include "GameFramework/Actor.h"

static FString JoinStrings(const TArray<FString>& In, const TCHAR* Sep)
{
    FString Out;
    for (int32 i = 0; i < In.Num(); ++i)
    {
        if (i > 0) Out += Sep;
        Out += In[i];
    }
    return Out;
}

class FHandler_SetActorTransform : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("set_actor_transform"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("set_actor_transform: missing_params: request had no params object");
            return nullptr;
        }

        FString Name;
        if (!Params->TryGetStringField(TEXT("name"), Name) || Name.IsEmpty())
        {
            OutError = TEXT("set_actor_transform: missing_required_field: 'name' is required");
            return nullptr;
        }

        const bool bHasLocation = Params->HasField(TEXT("location"));
        const bool bHasRotation = Params->HasField(TEXT("rotation"));
        const bool bHasScale = Params->HasField(TEXT("scale"));
        if (!bHasLocation && !bHasRotation && !bHasScale)
        {
            OutError = TEXT("set_actor_transform: no_changes_specified: provide at least one of location / rotation / scale");
            return nullptr;
        }

        bool bRelative = false;
        Params->TryGetBoolField(TEXT("relative"), bRelative);

        AActor* Actor = nullptr;
        TArray<FString> Ambiguous;
        const auto Result = UCMCP::ActorIdentity::Resolve(Name, Actor, Ambiguous);
        if (Result == UCMCP::ActorIdentity::EResolveResult::NotFound)
        {
            OutError = FString::Printf(TEXT("set_actor_transform: actor_not_found: no actor matches '%s'"), *Name);
            return nullptr;
        }
        if (Result == UCMCP::ActorIdentity::EResolveResult::Ambiguous)
        {
            OutError = FString::Printf(
                TEXT("set_actor_transform: ambiguous_actor: '%s' matched %d actors: [%s]"),
                *Name, Ambiguous.Num(), *JoinStrings(Ambiguous, TEXT(", ")));
            return nullptr;
        }

        FVector NewLocation = Actor->GetActorLocation();
        FRotator NewRotation = Actor->GetActorRotation();
        FVector NewScale = Actor->GetActorScale3D();

        TSharedPtr<FJsonObject> Applied = MakeShared<FJsonObject>();

        if (bHasLocation)
        {
            const TSharedPtr<FJsonObject>* L = nullptr;
            Params->TryGetObjectField(TEXT("location"), L);
            if (L && (*L).IsValid())
            {
                double X = 0, Y = 0, Z = 0;
                (*L)->TryGetNumberField(TEXT("x"), X);
                (*L)->TryGetNumberField(TEXT("y"), Y);
                (*L)->TryGetNumberField(TEXT("z"), Z);
                FVector V(X, Y, Z);
                NewLocation = bRelative ? (NewLocation + V) : V;
                TSharedRef<FJsonObject> A = MakeShared<FJsonObject>();
                A->SetNumberField(TEXT("x"), NewLocation.X);
                A->SetNumberField(TEXT("y"), NewLocation.Y);
                A->SetNumberField(TEXT("z"), NewLocation.Z);
                Applied->SetObjectField(TEXT("location"), A);
            }
        }
        if (bHasRotation)
        {
            const TSharedPtr<FJsonObject>* R = nullptr;
            Params->TryGetObjectField(TEXT("rotation"), R);
            if (R && (*R).IsValid())
            {
                double Pitch = 0, Yaw = 0, Roll = 0;
                (*R)->TryGetNumberField(TEXT("pitch"), Pitch);
                (*R)->TryGetNumberField(TEXT("yaw"), Yaw);
                (*R)->TryGetNumberField(TEXT("roll"), Roll);
                FRotator Rot(Pitch, Yaw, Roll);
                NewRotation = bRelative ? (NewRotation + Rot) : Rot;
                TSharedRef<FJsonObject> A = MakeShared<FJsonObject>();
                A->SetNumberField(TEXT("pitch"), NewRotation.Pitch);
                A->SetNumberField(TEXT("yaw"), NewRotation.Yaw);
                A->SetNumberField(TEXT("roll"), NewRotation.Roll);
                Applied->SetObjectField(TEXT("rotation"), A);
            }
        }
        if (bHasScale)
        {
            const TSharedPtr<FJsonObject>* S = nullptr;
            Params->TryGetObjectField(TEXT("scale"), S);
            if (S && (*S).IsValid())
            {
                double X = 1, Y = 1, Z = 1;
                (*S)->TryGetNumberField(TEXT("x"), X);
                (*S)->TryGetNumberField(TEXT("y"), Y);
                (*S)->TryGetNumberField(TEXT("z"), Z);
                FVector V(X, Y, Z);
                NewScale = bRelative ? (NewScale * V) : V;
                TSharedRef<FJsonObject> A = MakeShared<FJsonObject>();
                A->SetNumberField(TEXT("x"), NewScale.X);
                A->SetNumberField(TEXT("y"), NewScale.Y);
                A->SetNumberField(TEXT("z"), NewScale.Z);
                Applied->SetObjectField(TEXT("scale"), A);
            }
        }

        FTransform NewTransform(NewRotation, NewLocation, NewScale);
        Actor->SetActorTransform(NewTransform);

        TSharedPtr<FJsonObject> Out = MakeShared<FJsonObject>();
        Out->SetBoolField(TEXT("ok"), true);
        Out->SetStringField(TEXT("name"), Actor->GetFName().ToString());
        Out->SetObjectField(TEXT("applied"), Applied);
        return Out;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_SetActorTransform()
{
    return MakeShared<FHandler_SetActorTransform>();
}
```

- [ ] **Step 2: Register in module** (same extern + Reg.Register pattern)

- [ ] **Step 3: pytest regression** + **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorTransform.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "feat(unreal): add set_actor_transform handler

Move/rotate/scale an existing actor by name (label or FName via
ActorIdentity). Supports absolute mode (default) and relative mode
(deltas applied to current values). Returns 'applied' map with
the values actually set.

Stable error codes: missing_params, missing_required_field,
actor_not_found, ambiguous_actor, no_changes_specified.

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 7 — Handler 4: delete_actor

### Task 7: Create Handler_DeleteActor

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_DeleteActor.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`

- [ ] **Step 1: Create the handler**

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// delete_actor - remove an actor from the editor world by name.
// Children are detached, not destroyed (UE's default behavior).
// The 'force' flag overrides the children-attached safety check.
//
// Error format: "delete_actor: <error_code>: <human-readable detail>".
// Stable error codes: missing_params, missing_required_field,
// actor_not_found, ambiguous_actor, has_children.

#include "MCP/MCPHandler.h"
#include "MCP/ActorIdentity.h"

#include "Dom/JsonObject.h"
#include "Editor.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"

static FString JoinFNames(const TArray<FString>& In)
{
    FString Out;
    for (int32 i = 0; i < In.Num(); ++i) { if (i > 0) Out += TEXT(", "); Out += In[i]; }
    return Out;
}

class FHandler_DeleteActor : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("delete_actor"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("delete_actor: missing_params: request had no params object");
            return nullptr;
        }

        FString Name;
        if (!Params->TryGetStringField(TEXT("name"), Name) || Name.IsEmpty())
        {
            OutError = TEXT("delete_actor: missing_required_field: 'name' is required");
            return nullptr;
        }

        bool bForce = false;
        Params->TryGetBoolField(TEXT("force"), bForce);

        AActor* Actor = nullptr;
        TArray<FString> Ambiguous;
        const auto Res = UCMCP::ActorIdentity::Resolve(Name, Actor, Ambiguous);
        if (Res == UCMCP::ActorIdentity::EResolveResult::NotFound)
        {
            OutError = FString::Printf(TEXT("delete_actor: actor_not_found: no actor matches '%s'"), *Name);
            return nullptr;
        }
        if (Res == UCMCP::ActorIdentity::EResolveResult::Ambiguous)
        {
            OutError = FString::Printf(
                TEXT("delete_actor: ambiguous_actor: '%s' matched %d actors: [%s]"),
                *Name, Ambiguous.Num(), *JoinFNames(Ambiguous));
            return nullptr;
        }

        if (!bForce)
        {
            TArray<AActor*> Children;
            Actor->GetAttachedActors(Children);
            if (Children.Num() > 0)
            {
                OutError = FString::Printf(
                    TEXT("delete_actor: has_children: actor has %d attached children; pass force=true to delete anyway"),
                    Children.Num());
                return nullptr;
            }
        }

        const FString FNameStr = Actor->GetFName().ToString();

        UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
        if (World)
        {
            World->DestroyActor(Actor);
        }

        TSharedPtr<FJsonObject> Out = MakeShared<FJsonObject>();
        Out->SetBoolField(TEXT("ok"), true);
        Out->SetStringField(TEXT("name"), FNameStr);
        Out->SetBoolField(TEXT("deleted"), true);
        return Out;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_DeleteActor()
{
    return MakeShared<FHandler_DeleteActor>();
}
```

- [ ] **Step 2: Register in module** (same pattern)

- [ ] **Step 3: pytest** + **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_DeleteActor.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "feat(unreal): add delete_actor handler

Removes an actor from the editor world by name (label or FName via
ActorIdentity). Children are detached, not destroyed (UE default).
The 'force' flag overrides the has_children safety check.

Stable error codes: missing_params, missing_required_field,
actor_not_found, ambiguous_actor, has_children.

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 8 — Handler 5: set_actor_property

### Task 8: Create Handler_SetActorProperty

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorProperty.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`

- [ ] **Step 1: Create the handler**

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// set_actor_property - mutate any UPROPERTY on an actor.
// Supports v0.3.0 type list per PropertyCoercion module.
//
// Error format: "set_actor_property: <error_code>: <human-readable detail>".
// Stable error codes: missing_params, missing_required_field,
// actor_not_found, ambiguous_actor, property_not_found,
// unsupported_property_type, value_coercion_failed.

#include "MCP/MCPHandler.h"
#include "MCP/ActorIdentity.h"
#include "MCP/PropertyCoercion.h"

#include "Dom/JsonObject.h"
#include "GameFramework/Actor.h"
#include "UObject/UnrealType.h"

static FString JoinFNamesList(const TArray<FString>& In)
{
    FString Out;
    for (int32 i = 0; i < In.Num(); ++i) { if (i > 0) Out += TEXT(", "); Out += In[i]; }
    return Out;
}

class FHandler_SetActorProperty : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("set_actor_property"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("set_actor_property: missing_params: request had no params object");
            return nullptr;
        }

        FString Name;
        if (!Params->TryGetStringField(TEXT("name"), Name) || Name.IsEmpty())
        {
            OutError = TEXT("set_actor_property: missing_required_field: 'name' is required");
            return nullptr;
        }

        FString PropertyName;
        if (!Params->TryGetStringField(TEXT("property"), PropertyName) || PropertyName.IsEmpty())
        {
            OutError = TEXT("set_actor_property: missing_required_field: 'property' is required");
            return nullptr;
        }

        TSharedPtr<FJsonValue> Value = Params->TryGetField(TEXT("value"));
        if (!Value.IsValid())
        {
            OutError = TEXT("set_actor_property: missing_required_field: 'value' is required");
            return nullptr;
        }

        AActor* Actor = nullptr;
        TArray<FString> Ambiguous;
        const auto Res = UCMCP::ActorIdentity::Resolve(Name, Actor, Ambiguous);
        if (Res == UCMCP::ActorIdentity::EResolveResult::NotFound)
        {
            OutError = FString::Printf(TEXT("set_actor_property: actor_not_found: no actor matches '%s'"), *Name);
            return nullptr;
        }
        if (Res == UCMCP::ActorIdentity::EResolveResult::Ambiguous)
        {
            OutError = FString::Printf(
                TEXT("set_actor_property: ambiguous_actor: '%s' matched %d actors: [%s]"),
                *Name, Ambiguous.Num(), *JoinFNamesList(Ambiguous));
            return nullptr;
        }

        FProperty* Prop = UCMCP::PropertyCoercion::FindProperty(Actor, PropertyName);
        if (!Prop)
        {
            OutError = FString::Printf(
                TEXT("set_actor_property: property_not_found: '%s' not on class '%s'"),
                *PropertyName, *Actor->GetClass()->GetName());
            return nullptr;
        }

        // Capture old value before mutation
        TSharedPtr<FJsonValue> OldValue = UCMCP::PropertyCoercion::GetProperty(Actor, Prop);

        UCMCP::PropertyCoercion::FCoerceOutcome Outcome =
            UCMCP::PropertyCoercion::SetProperty(Actor, Prop, Value);

        if (Outcome.Result == UCMCP::PropertyCoercion::ECoerceResult::Unsupported)
        {
            OutError = FString::Printf(
                TEXT("set_actor_property: unsupported_property_type: %s — %s"),
                *Outcome.FPropertyClass, *Outcome.Detail);
            return nullptr;
        }
        if (Outcome.Result != UCMCP::PropertyCoercion::ECoerceResult::Success)
        {
            OutError = FString::Printf(
                TEXT("set_actor_property: value_coercion_failed: %s"),
                *Outcome.Detail);
            return nullptr;
        }

        // Fire UE's edit cascade for property mutations
        FPropertyChangedEvent ChangedEvent(Prop);
        Actor->PostEditChangeProperty(ChangedEvent);

        TSharedPtr<FJsonValue> NewValue = UCMCP::PropertyCoercion::GetProperty(Actor, Prop);

        TSharedPtr<FJsonObject> Out = MakeShared<FJsonObject>();
        Out->SetBoolField(TEXT("ok"), true);
        Out->SetStringField(TEXT("name"), Actor->GetFName().ToString());
        Out->SetStringField(TEXT("property"), PropertyName);
        Out->SetField(TEXT("old_value"), OldValue);
        Out->SetField(TEXT("new_value"), NewValue);
        return Out;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_SetActorProperty()
{
    return MakeShared<FHandler_SetActorProperty>();
}
```

- [ ] **Step 2: Register in module**

- [ ] **Step 3: pytest** + **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorProperty.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "feat(unreal): add set_actor_property handler with PropertyCoercion

Mutate any UPROPERTY on an actor via the JSON-to-FProperty coercion
layer. v0.3.0 supports primitives, FName/FText/FString, FVector/
Vector2D/Rotator/LinearColor/Color, enums, and TSoftObjectPtr.
USTRUCT/TArray/TMap deferred to v0.4.0.

Returns old_value and new_value (both JSON-encoded via the same
coercion) for verification of the change. Fires PostEditChangeProperty
to trigger UE's edit cascade.

Stable error codes: missing_params, missing_required_field,
actor_not_found, ambiguous_actor, property_not_found,
unsupported_property_type, value_coercion_failed.

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 9 — Handler 6: add_component

### Task 9: Create Handler_AddComponent

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_AddComponent.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`

- [ ] **Step 1: Create the handler**

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// add_component - attach a component to an existing actor at runtime.
//
// Error format: "add_component: <error_code>: <human-readable detail>".
// Stable error codes: missing_params, missing_required_field,
// actor_not_found, ambiguous_actor, invalid_component_class,
// attach_target_not_found, socket_not_found.

#include "MCP/MCPHandler.h"
#include "MCP/ActorIdentity.h"

#include "Dom/JsonObject.h"
#include "Components/ActorComponent.h"
#include "Components/SceneComponent.h"
#include "GameFramework/Actor.h"

static FString JoinFNamesAC(const TArray<FString>& In)
{
    FString Out;
    for (int32 i = 0; i < In.Num(); ++i) { if (i > 0) Out += TEXT(", "); Out += In[i]; }
    return Out;
}

class FHandler_AddComponent : public IUCMCPHandler
{
public:
    virtual FString GetMethodName() const override { return TEXT("add_component"); }

    virtual TSharedPtr<FJsonObject> Handle(const TSharedPtr<FJsonObject>& Params, FString& OutError) override
    {
        if (!Params.IsValid())
        {
            OutError = TEXT("add_component: missing_params: request had no params object");
            return nullptr;
        }

        FString ActorName;
        if (!Params->TryGetStringField(TEXT("actor_name"), ActorName) || ActorName.IsEmpty())
        {
            OutError = TEXT("add_component: missing_required_field: 'actor_name' is required");
            return nullptr;
        }
        FString ClassPath;
        if (!Params->TryGetStringField(TEXT("class_path"), ClassPath) || ClassPath.IsEmpty())
        {
            OutError = TEXT("add_component: missing_required_field: 'class_path' is required");
            return nullptr;
        }

        AActor* Actor = nullptr;
        TArray<FString> Ambiguous;
        const auto Res = UCMCP::ActorIdentity::Resolve(ActorName, Actor, Ambiguous);
        if (Res == UCMCP::ActorIdentity::EResolveResult::NotFound)
        {
            OutError = FString::Printf(TEXT("add_component: actor_not_found: no actor matches '%s'"), *ActorName);
            return nullptr;
        }
        if (Res == UCMCP::ActorIdentity::EResolveResult::Ambiguous)
        {
            OutError = FString::Printf(
                TEXT("add_component: ambiguous_actor: '%s' matched %d actors: [%s]"),
                *ActorName, Ambiguous.Num(), *JoinFNamesAC(Ambiguous));
            return nullptr;
        }

        UClass* CompClass = LoadClass<UActorComponent>(nullptr, *ClassPath);
        if (!CompClass)
        {
            OutError = FString::Printf(TEXT("add_component: invalid_component_class: '%s' did not resolve"), *ClassPath);
            return nullptr;
        }
        if (CompClass->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated))
        {
            OutError = FString::Printf(TEXT("add_component: invalid_component_class: '%s' is abstract or deprecated"), *ClassPath);
            return nullptr;
        }

        // Determine FName for the new component
        FString CompName;
        Params->TryGetStringField(TEXT("component_name"), CompName);
        FName CompFName = CompName.IsEmpty() ? NAME_None : FName(*CompName);

        UActorComponent* NewComp = NewObject<UActorComponent>(Actor, CompClass, CompFName);
        if (!NewComp)
        {
            OutError = FString::Printf(TEXT("add_component: invalid_component_class: NewObject returned null for '%s'"), *ClassPath);
            return nullptr;
        }

        Actor->AddInstanceComponent(NewComp);
        NewComp->RegisterComponent();

        FString AttachedToName = TEXT("");

        // If it's a scene component, attach to a parent
        if (USceneComponent* SceneComp = Cast<USceneComponent>(NewComp))
        {
            USceneComponent* Parent = Actor->GetRootComponent();
            FString AttachTo;
            if (Params->TryGetStringField(TEXT("attach_to"), AttachTo) && !AttachTo.IsEmpty())
            {
                // Find a component on the actor matching the name
                USceneComponent* Found = nullptr;
                TInlineComponentArray<USceneComponent*> SceneComps;
                Actor->GetComponents<USceneComponent>(SceneComps);
                for (USceneComponent* SC : SceneComps)
                {
                    if (SC && SC != SceneComp && SC->GetFName().ToString() == AttachTo)
                    {
                        Found = SC;
                        break;
                    }
                }
                if (!Found)
                {
                    OutError = FString::Printf(
                        TEXT("add_component: attach_target_not_found: no component '%s' on actor '%s'"),
                        *AttachTo, *Actor->GetFName().ToString());
                    NewComp->DestroyComponent();
                    return nullptr;
                }
                Parent = Found;
            }

            FString Socket;
            FName SocketFName = NAME_None;
            if (Params->TryGetStringField(TEXT("socket"), Socket) && !Socket.IsEmpty())
            {
                SocketFName = FName(*Socket);
                if (Parent && !Parent->DoesSocketExist(SocketFName))
                {
                    OutError = FString::Printf(
                        TEXT("add_component: socket_not_found: parent '%s' has no socket '%s'"),
                        *Parent->GetFName().ToString(), *Socket);
                    NewComp->DestroyComponent();
                    return nullptr;
                }
            }

            if (Parent)
            {
                SceneComp->AttachToComponent(Parent, FAttachmentTransformRules::KeepRelativeTransform, SocketFName);
                AttachedToName = Parent->GetFName().ToString();
            }

            // Apply optional relative transform
            const TSharedPtr<FJsonObject>* RelObj = nullptr;
            if (Params->TryGetObjectField(TEXT("relative_transform"), RelObj) && RelObj && (*RelObj).IsValid())
            {
                FVector Loc(0, 0, 0); FRotator Rot(0, 0, 0); FVector Scale(1, 1, 1);
                const TSharedPtr<FJsonObject>* L = nullptr;
                if ((*RelObj)->TryGetObjectField(TEXT("location"), L) && L && (*L).IsValid())
                {
                    double X = 0, Y = 0, Z = 0;
                    (*L)->TryGetNumberField(TEXT("x"), X); Loc.X = X;
                    (*L)->TryGetNumberField(TEXT("y"), Y); Loc.Y = Y;
                    (*L)->TryGetNumberField(TEXT("z"), Z); Loc.Z = Z;
                }
                const TSharedPtr<FJsonObject>* R = nullptr;
                if ((*RelObj)->TryGetObjectField(TEXT("rotation"), R) && R && (*R).IsValid())
                {
                    double P = 0, Y = 0, RR = 0;
                    (*R)->TryGetNumberField(TEXT("pitch"), P); Rot.Pitch = P;
                    (*R)->TryGetNumberField(TEXT("yaw"), Y); Rot.Yaw = Y;
                    (*R)->TryGetNumberField(TEXT("roll"), RR); Rot.Roll = RR;
                }
                const TSharedPtr<FJsonObject>* S = nullptr;
                if ((*RelObj)->TryGetObjectField(TEXT("scale"), S) && S && (*S).IsValid())
                {
                    double X = 1, Y = 1, Z = 1;
                    (*S)->TryGetNumberField(TEXT("x"), X); Scale.X = X;
                    (*S)->TryGetNumberField(TEXT("y"), Y); Scale.Y = Y;
                    (*S)->TryGetNumberField(TEXT("z"), Z); Scale.Z = Z;
                }
                SceneComp->SetRelativeTransform(FTransform(Rot, Loc, Scale));
            }
        }

        Actor->Modify();
        Actor->RerunConstructionScripts();

        TSharedPtr<FJsonObject> Out = MakeShared<FJsonObject>();
        Out->SetBoolField(TEXT("ok"), true);
        Out->SetStringField(TEXT("actor"), Actor->GetFName().ToString());
        Out->SetStringField(TEXT("component"), NewComp->GetFName().ToString());
        Out->SetStringField(TEXT("class"), NewComp->GetClass()->GetName());
        Out->SetStringField(TEXT("attached_to"), AttachedToName);
        return Out;
    }
};

TSharedRef<IUCMCPHandler> Make_Handler_AddComponent()
{
    return MakeShared<FHandler_AddComponent>();
}
```

- [ ] **Step 2: Register in module**

- [ ] **Step 3: pytest** + **Step 4: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_AddComponent.cpp UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp && git commit -m "feat(unreal): add add_component handler

Attach a UActorComponent (or USceneComponent subclass) to an existing
actor at runtime. For scene components, supports attaching to a named
parent component, optional socket, and optional relative transform.

Stable error codes: missing_params, missing_required_field,
actor_not_found, ambiguous_actor, invalid_component_class,
attach_target_not_found, socket_not_found.

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 10 — Live smoke test

### Task 10: Add build-a-level round-trip smoke step

**Files:**
- Modify: `examples/smoke_test.py`

- [ ] **Step 1: Add the new step in `main()` after `step("texture_pipeline", t_texture)`**

Insert this block:

```python
    header("8. build-a-level round-trip (find_assets + spawn + transform + property + component + delete)")
    def t_buildalevel():
        # 1. Discover what's available to spawn (basic shapes from /Engine/)
        find = call("find_assets", {
            "class_path": "/Script/Engine.StaticMesh",
            "path_under": "/Engine/BasicShapes/",
            "limit": 10,
        })
        find_res = assert_ok(find, "find_assets")
        if find_res.get("returned", 0) == 0:
            raise SmokeFailure(f"[find_assets] no basic shapes found: {find_res}")

        # 2. Spawn two StaticMeshActors with distinct labels
        s1 = call("spawn_actor", {
            "class_path": "/Script/Engine.StaticMeshActor",
            "location": {"x": 0, "y": 0, "z": 0},
            "label": "SmokeBuildCube1",
        })
        s1_res = assert_ok(s1, "spawn_actor.cube1")
        cube1_fname = s1_res["name"]

        s2 = call("spawn_actor", {
            "class_path": "/Script/Engine.StaticMeshActor",
            "location": {"x": 200, "y": 0, "z": 0},
            "label": "SmokeBuildCube2",
        })
        s2_res = assert_ok(s2, "spawn_actor.cube2")

        # 3. Move + rotate cube2
        t = call("set_actor_transform", {
            "name": "SmokeBuildCube2",
            "location": {"x": 200, "y": 200, "z": 50},
            "rotation": {"pitch": 0, "yaw": 45, "roll": 0},
        })
        t_res = assert_ok(t, "set_actor_transform")
        if "location" not in (t_res.get("applied") or {}):
            raise SmokeFailure(f"[set_actor_transform] location not in applied: {t_res}")

        # 4. Set a UPROPERTY on cube1 (bHidden)
        p = call("set_actor_property", {
            "name": "SmokeBuildCube1",
            "property": "bHidden",
            "value": False,
        })
        assert_ok(p, "set_actor_property")

        # 5. Add a PointLightComponent to cube1
        c = call("add_component", {
            "actor_name": "SmokeBuildCube1",
            "class_path": "/Script/Engine.PointLightComponent",
        })
        c_res = assert_ok(c, "add_component")
        if c_res.get("class") != "PointLightComponent":
            raise SmokeFailure(f"[add_component] unexpected class: {c_res}")

        # 6. Delete both actors (cube1 first with force=true since it has the new component)
        d1 = call("delete_actor", {"name": "SmokeBuildCube1", "force": True})
        assert_ok(d1, "delete_actor.cube1")
        d2 = call("delete_actor", {"name": "SmokeBuildCube2"})
        assert_ok(d2, "delete_actor.cube2")

        # 7. Verify world is back to baseline (no SmokeBuild* actors remain)
        actors = call("get_actors_in_level", {"name_contains": "SmokeBuild"})
        a_res = assert_ok(actors, "get_actors_in_level.cleanup_check")
        if a_res.get("returned", 0) != 0:
            raise SmokeFailure(f"[cleanup] residual actors found: {a_res}")
    step("buildalevel", t_buildalevel)
```

Renumber subsequent headers if any (the existing inspect_blueprint / widget / level steps were 8/9a-9e/10 in v0.2.0; they become 9/10a-10e/11 now).

- [ ] **Step 2: pytest regression** (smoke change is Python only)

```bash
py -m pytest tests/ -v
```

Expected: 65 passing.

- [ ] **Step 3: Commit**

```bash
git add examples/smoke_test.py && git commit -m "test: add build-a-level live smoke step

New end-to-end smoke step (#8) that exercises the full v0.3.0
build-a-level surface against a running UE editor:

  1. find_assets discovers /Engine/BasicShapes/* StaticMeshes
  2. spawn 2 StaticMeshActors with distinct labels
  3. set_actor_transform on one (location + rotation)
  4. set_actor_property bHidden=false on the other
  5. add_component PointLightComponent
  6. delete_actor (with force on the one with attached component)
  7. verify cleanup via get_actors_in_level name_contains=SmokeBuild

Live smoke remains opt-in / not part of CI (existing convention --
requires running UE editor on 127.0.0.1:18888).

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Phase 11 — Documentation

### Task 11: Update TOOLS.md, README.md, ARCHITECTURE.md

**Files:**
- Modify: `docs/TOOLS.md` (add 6 sections)
- Modify: `README.md` (table + counts)
- Modify: `docs/ARCHITECTURE.md` (shared modules section + scar entries)

- [ ] **Step 1: Append 6 new sections to `docs/TOOLS.md`**

Each section follows the existing pattern: prose intro → **Params** → **Result** → **Example** → **Errors** (table). Reference the spec at `docs/superpowers/specs/2026-05-08-v0.3.0-build-a-level-design.md` for the canonical content. Each new section ~30-50 lines.

Specifically write sections for: `find_assets`, `spawn_actor`, `set_actor_transform`, `delete_actor`, `set_actor_property`, `add_component`. Match the format of the existing `import_texture`/`configure_texture` sections from v0.2.0.

- [ ] **Step 2: Update `README.md`**

In the tools table (currently 13 rows after v0.2.0), append 6 rows:

```markdown
| `find_assets` | Query the asset registry by class + path + name. Returns matching assets so the LLM can discover what's available to spawn. |
| `spawn_actor` | Create an actor at a location with optional rotation, label, and initial properties. Class path supports built-ins and Blueprints. |
| `set_actor_transform` | Move / rotate / scale an existing actor by name. Absolute or relative mode. |
| `delete_actor` | Remove an actor by name. Force flag overrides children-attached safety check. |
| `set_actor_property` | Mutate any UPROPERTY on an actor. Supports primitives, FName/FText, vectors, rotators, colors, enums, and TSoftObjectPtr. |
| `add_component` | Attach a component (UActorComponent / USceneComponent subclass) to an existing actor at runtime, optionally socketed. |
```

Update count references:
- `Thirteen editor-automation tools.` → `Nineteen editor-automation tools.`
- `## Tools exposed (13)` → `## Tools exposed (19)`
- `... (13 lines)` → `... (19 lines)`
- `Adding a 14th tool` → `Adding a 20th tool`
- `eight sections of JSON output` → `nine sections of JSON output`
- Status block: `v0.2.0 — 2026-05-08` → `v0.3.0 — 2026-05-08`, `13 live, smoke-tested end-to-end` → `19 live, smoke-tested end-to-end`, `59 pytest cases` → `65 pytest cases`

- [ ] **Step 3: Update `docs/ARCHITECTURE.md`**

Add a new section (placement: after the existing "The four C++ files that matter" section) titled "Shared modules":

```markdown
## Shared modules (v0.3.0+)

Two helpers extract cross-cutting concerns from multiple handlers. Each lives in `Source/UnrealClaudeMCP/Private/MCP/` (alongside `MCPServer.cpp`/etc., not under `Handlers/`):

- **`ActorIdentity`** — hybrid label-or-FName actor lookup. Used by `set_actor_transform`, `delete_actor`, `set_actor_property`, `add_component`. Returns `EResolveResult::Ambiguous` with the candidate FNames listed when a label matches multiple actors, so handlers can produce actionable `ambiguous_actor` errors.
- **`PropertyCoercion`** — JSON ↔ FProperty value bridge. Supports the v0.3.0 type list (primitives, strings, vectors, rotators, colors, enums, soft references). Used by `spawn_actor.properties`, `set_actor_property`, and `add_component.relative_transform`. Returns `ECoerceResult::Unsupported` with the FProperty class name for types deferred to v0.4.0 (USTRUCT, TArray, TMap, etc.).

Each handler stays a leaf — these modules just lift cross-cutting concerns to where they can be tested once. The "leaf with bounded responsibility" pattern still holds.
```

Bump count references in the existing Mermaid diagram (`(13 handlers)` → `(19 handlers)`) and prose (`current 13 tools` → `current 19 tools`).

- [ ] **Step 4: pytest regression**

```bash
py -m pytest tests/ -v
```

Expected: 65 passing.

- [ ] **Step 5: Commit**

```bash
git add docs/TOOLS.md README.md docs/ARCHITECTURE.md && git commit -m "docs: document v0.3.0 build-a-level handlers; bump count to 19

- TOOLS.md: 6 new sections (find_assets, spawn_actor,
  set_actor_transform, delete_actor, set_actor_property,
  add_component) following the existing per-tool format
- README.md: 6 new rows in tools table; count and version
  bumps everywhere (13 -> 19, v0.2.0 -> v0.3.0, 59 tests -> 65)
- ARCHITECTURE.md: new 'Shared modules' section documenting
  ActorIdentity and PropertyCoercion; count bumps in diagram

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Final acceptance gate

Before opening the PR for v0.3.0, confirm:

- [ ] `py -m pytest tests/ -v` — all 65 pass
- [ ] UE editor build clean, no LNK errors, no warnings
- [ ] Editor Output Log shows **19** `Registered handler '...'` lines on startup
- [ ] `python examples/smoke_test.py` against running editor — all 9 numbered steps pass, including step 8 (build-a-level round-trip)
- [ ] `git log feat/v0.3.0-build-a-level ^main` — shows ~12 clean commits (1 spec + 1 plan + 1 bridge + 2 shared modules + 6 handlers + 1 smoke + 1 docs)
- [ ] No leftover `SmokeBuild*` actors in the test world (smoke cleans up via step 7's verification)
- [ ] PR description summarizes v0.3.0 surface, links the spec, lists the 19 tools with the 6 new ones marked

## Out of scope reminders (do NOT add to this PR)

- USTRUCT / TArray / TMap / TSet / FInstancedStruct property types — v0.4.0
- Tag-based asset registry queries — v0.4.0
- Bulk operations (`spawn_actors_batch` etc.) — speculative; YAGNI
- `remove_component` (companion to `add_component`) — v0.3.1 if needed
- Sublevels, level streaming — separate brainstorm
- Async / non-blocking long operations — needs framing protocol upgrade first
