# v0.7.0 Asset Registry 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:** Ship 4 new MCP handlers (`inspect_asset`, `move_asset`, `rename_asset`, `delete_asset`) and extend `find_assets` with tag-based queries — giving the LLM complete editor-side asset-management authority.

**Architecture:** Each new handler is a single `Handler_*.cpp` file under `Source/UnrealClaudeMCP/Private/MCP/Handlers/`, registered via `extern Make_Handler_*()` + `Reg.Register(...)` in `UnrealClaudeMCPModule.cpp`. Handlers use UE 5.7's canonical asset-tools APIs: `IAssetRegistry` (queries + dependency graph), `UEditorAssetLibrary::DoesAssetExist/RenameAsset/DeleteAsset` (mutations). One shared header (`AssetPathUtil.h`) holds path normalization helpers used by all four handlers.

**Tech Stack:** UE 5.7 C++, `EditorScriptingUtilities` plugin (already in Build.cs), `AssetRegistry` and `AssetTools` modules (already in Build.cs), Python 3.11+ for bridge, pytest for unit tests.

**Reference implementations:** `Handler_FindAssets.cpp` (existing — class structure to mirror), `Handler_GetLogLines.cpp` (v0.6.0 — error-code prefix pattern), `Handler_DeleteActor.cpp` (v0.4.0 — force-flag safety pattern).

**Spec:** `docs/superpowers/specs/2026-05-08-v0.7.0-asset-registry-design.md` — read this first.

---

## File structure

```
Source/UnrealClaudeMCP/Private/MCP/Handlers/
  AssetPathUtil.h               (NEW — shared path normalization helpers)
  Handler_FindAssets.cpp        (MODIFY — +tags param, +include_tags output)
  Handler_InspectAsset.cpp      (NEW — read class + tags + dep/ref graph + size)
  Handler_MoveAsset.cpp         (NEW — RenameAsset wrapper, dest_folder semantics)
  Handler_RenameAsset.cpp       (NEW — RenameAsset wrapper, new_name semantics)
  Handler_DeleteAsset.cpp       (NEW — referencer check + DeleteAsset)

Source/UnrealClaudeMCP/Private/
  UnrealClaudeMCPModule.cpp     (MODIFY — +4 externs, +4 Reg.Register)

UnrealClaudeMCP/
  UnrealClaudeMCP.uplugin       (MODIFY — VersionName 0.6.0 → 0.7.0)
  Resources/mcp_manifest.json   (MODIFY — version, +4 tools, find_assets schema)

bridge/
  unreal_claude_mcp_bridge.py   (MODIFY — version, docstring count, +4 TOOLS, find_assets schema)

tests/
  test_bridge.py                (MODIFY — count assertion + 4 new schema tests + find_assets schema test)
  test_manifest_sync.py         (MODIFY — count assertion)

examples/
  smoke_test.py                 (MODIFY — count assertion + 4 new steps + 1 modified step)

docs/
  TOOLS.md                      (MODIFY — +4 sections + find_assets section update)
  superpowers/specs/2026-05-08-v0.7.0-asset-registry-design.md  (already written)
  superpowers/plans/2026-05-08-v0.7.0-asset-registry.md         (this file)

README.md                       (MODIFY — count 21→25, version row, table rows)
```

Tasks are decomposed **vertically**: one task per handler, where each task delivers a working end-to-end vertical slice (C++ + module reg + bridge entry + manifest + TOOLS.md + smoke step + unit test) and ends with a single commit. This matches the v0.4.0/v0.5.0/v0.6.0 workflow and gives clean per-task commit boundaries.

---

## Task 1: Branch + AssetPathUtil shared helper

**Files:**
- Create branch: `feat/v0.7.0-asset-registry`
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/AssetPathUtil.h`

**Why first:** Path normalization is needed by all 4 handlers (`/Game/Folder/Name` vs `/Game/Folder/Name.Name` vs `Name.Name` — UE accepts variants in different APIs). Centralizing avoids subtle bugs.

- [x] **Step 1.1: Create branch from v0.6.0**

```bash
git checkout -b feat/v0.7.0-asset-registry feat/v0.6.0-observability
```

v0.7.0 stacks on top of v0.6.0 (PR #11). The v0.7.0 PR will show both bundles' diff against main until PR #11 merges, after which it auto-shrinks to just the v0.7.0 changes. **Already done** at HEAD `2082ac8`.

- [ ] **Step 1.2: Create AssetPathUtil.h**

Write the following file verbatim to `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/AssetPathUtil.h`:

```cpp
// Copyright (c) 2026 HD Media. MIT licensed - see LICENSE.
//
// Asset path normalization helpers shared by the v0.7.0 asset-registry handlers.
// Header-only inline functions — no .cpp counterpart.
//
// UE 5.7 has three common asset-path forms that flow through different APIs:
//   1. Package path:    /Game/Textures/T_Stone     (no asset suffix)
//   2. Object path:     /Game/Textures/T_Stone.T_Stone   (with .Name suffix)
//   3. Soft object path: same as object path, wrapped in FSoftObjectPath
//
// IAssetRegistry::GetReferencers takes a package name (FName).
// IAssetRegistry::GetAssetByObjectPath takes an FSoftObjectPath (object path).
// UEditorAssetLibrary::DeleteAsset/RenameAsset takes an object path string.

#pragma once

#include "CoreMinimal.h"

namespace UCMCPAssetPath
{
    /**
     * Strip a trailing ".Name" suffix to get the package path.
     * Input:  "/Game/Textures/T_Stone.T_Stone" -> "/Game/Textures/T_Stone"
     * Input:  "/Game/Textures/T_Stone"          -> "/Game/Textures/T_Stone" (unchanged)
     */
    inline FString ToPackagePath(const FString& InPath)
    {
        int32 DotIndex = INDEX_NONE;
        if (InPath.FindLastChar('.', DotIndex))
        {
            return InPath.Left(DotIndex);
        }
        return InPath;
    }

    /**
     * Ensure the path has a ".Name" suffix (UE's "object path" form).
     * Input:  "/Game/Textures/T_Stone"          -> "/Game/Textures/T_Stone.T_Stone"
     * Input:  "/Game/Textures/T_Stone.T_Stone"  -> "/Game/Textures/T_Stone.T_Stone" (unchanged)
     */
    inline FString ToObjectPath(const FString& InPath)
    {
        int32 DotIndex = INDEX_NONE;
        if (InPath.FindLastChar('.', DotIndex))
        {
            // Already has a dot somewhere — assume well-formed object path.
            return InPath;
        }
        // Compute leaf name (last segment after final '/').
        int32 SlashIndex = INDEX_NONE;
        if (!InPath.FindLastChar('/', SlashIndex))
        {
            return InPath;  // No slash — caller error; return unchanged.
        }
        const FString LeafName = InPath.Mid(SlashIndex + 1);
        return InPath + TEXT(".") + LeafName;
    }

    /**
     * Extract the leaf asset name (final segment after the last '/'), with the
     * ".Name" suffix stripped if present.
     * Input:  "/Game/Textures/T_Stone.T_Stone" -> "T_Stone"
     * Input:  "/Game/Textures/T_Stone"          -> "T_Stone"
     */
    inline FString ExtractLeafName(const FString& InPath)
    {
        const FString PackagePath = ToPackagePath(InPath);
        int32 SlashIndex = INDEX_NONE;
        if (PackagePath.FindLastChar('/', SlashIndex))
        {
            return PackagePath.Mid(SlashIndex + 1);
        }
        return PackagePath;
    }

    /**
     * Extract the containing folder (everything up to and including the last '/').
     * Input:  "/Game/Textures/T_Stone.T_Stone" -> "/Game/Textures"
     * Input:  "/Game/Textures/T_Stone"          -> "/Game/Textures"
     */
    inline FString ExtractFolder(const FString& InPath)
    {
        const FString PackagePath = ToPackagePath(InPath);
        int32 SlashIndex = INDEX_NONE;
        if (PackagePath.FindLastChar('/', SlashIndex))
        {
            return PackagePath.Left(SlashIndex);
        }
        return PackagePath;
    }

    /**
     * Validate a leaf asset name. Returns true if non-empty and contains no
     * '/' or '.' characters. Used by rename_asset to reject path-shaped inputs.
     */
    inline bool IsValidLeafName(const FString& InName)
    {
        if (InName.IsEmpty()) { return false; }
        if (InName.Contains(TEXT("/"))) { return false; }
        if (InName.Contains(TEXT("."))) { return false; }
        return true;
    }
}
```

- [ ] **Step 1.3: Stage and commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/AssetPathUtil.h docs/superpowers/specs/2026-05-08-v0.7.0-asset-registry-design.md docs/superpowers/plans/2026-05-08-v0.7.0-asset-registry.md
git commit -m "docs(v0.7.0): asset registry deep dive — spec + plan + path util"
```

Expected: clean commit on `feat/v0.7.0-asset-registry`. The branch now contains the spec doc, the plan doc, and the shared helper header. No build is required for header-only changes.

---

## Task 2: `find_assets` extension (tags + include_tags)

**Files:**
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_FindAssets.cpp`
- Modify: `bridge/unreal_claude_mcp_bridge.py` (TOOLS entry for find_assets)
- Modify: `UnrealClaudeMCP/Resources/mcp_manifest.json` (find_assets schema)
- Modify: `tests/test_bridge.py` (new schema test)
- Modify: `docs/TOOLS.md` (find_assets section)

- [ ] **Step 2.1: Modify Handler_FindAssets.cpp**

Open `Handler_FindAssets.cpp`. After the existing `int32 Limit = 100;` block (around line 53), add tag-filter parsing. After the existing PackagePaths/ClassPaths filter setup (around line 62), append `tags` map entries to `Filter.TagsAndValues`. After the existing JSON-building loop (around line 122), conditionally include per-asset tag values when `include_tags` is true.

Algorithm sketch (the implementing subagent fills in error message strings using the v0.6.0 prefix pattern `find_assets: <code>: <detail>`):

```cpp
// 1. Read include_tags optional bool
bool bIncludeTags = false;
Params->TryGetBoolField(TEXT("include_tags"), bIncludeTags);

// 2. Read tags optional object and append to FARFilter.TagsAndValues
const TSharedPtr<FJsonObject>* TagsObj = nullptr;
if (Params->TryGetObjectField(TEXT("tags"), TagsObj) && TagsObj && (*TagsObj).IsValid())
{
    for (const auto& Pair : (*TagsObj)->Values)
    {
        const FString& TagName = Pair.Key;
        const TSharedPtr<FJsonValue>& Val = Pair.Value;
        if (!Val.IsValid()) { continue; }

        const FName TagKey(*TagName);
        if (Val->Type == EJson::Null)
        {
            Filter.TagsAndValues.Add(TagKey, TOptional<FString>());
        }
        else if (Val->Type == EJson::String)
        {
            Filter.TagsAndValues.Add(TagKey, Val->AsString());
        }
        else
        {
            OutError = FString::Printf(
                TEXT("find_assets: invalid_tag_value: tag '%s' must be a string or null, got %d"),
                *TagName, (int32)Val->Type);
            return nullptr;
        }
    }
}

// 3. After the existing per-asset JSON-build loop, when bIncludeTags is true:
//    Read Data.TagsAndValues and serialize each (FName -> FAssetTagValueRef) entry
//    as a string-keyed JSON object on the asset record:
if (bIncludeTags)
{
    TSharedRef<FJsonObject> TagsJson = MakeShared<FJsonObject>();
    Data.TagsAndValues.ForEach([&TagsJson](const TPair<FName, FAssetTagValueRef>& TagPair) {
        TagsJson->SetStringField(TagPair.Key.ToString(), TagPair.Value.AsString());
    });
    A->SetObjectField(TEXT("tags"), TagsJson);
}
```

The `FAssetTagValueRef::AsString()` method exists in UE 5.7 (`AssetData.h`); it returns the underlying value coerced to FString regardless of the original type (FName / FString / int32 / object path).

- [ ] **Step 2.2: Update bridge TOOLS entry for find_assets**

In `bridge/unreal_claude_mcp_bridge.py`, locate the `find_assets` entry (the one with `class_path` required). Replace its `inputSchema.properties` block to include the new `tags` and `include_tags` fields. The full updated entry:

```python
    {
        "name": "find_assets",
        "description": "Query the asset registry by class + optional path + optional name substring + optional tag filters. Returns matching assets with structured records.",
        "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."},
                "tags": {"type": "object", "description": "Map of tag-name -> required-value (string) or null (any value). AND-combined."},
                "include_tags": {"type": "boolean", "description": "When true, each result asset includes a 'tags' map of all its registry tags. Default false."},
            },
            "required": ["class_path"],
        },
    },
```

- [ ] **Step 2.3: Update mcp_manifest.json find_assets entry**

Locate the find_assets block. Update its `description` and `params` to match the bridge:

```json
    {
      "name": "find_assets",
      "description": "Query the asset registry by class + optional path + optional name substring + optional tag filters. Returns matching assets with structured records.",
      "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)",
        "tags": "object (optional) - map of tag-name -> required-value (string) or null (any value); AND-combined",
        "include_tags": "bool (optional, default false) - when true, each result asset includes a 'tags' map"
      },
      "returns": { "ok": "bool", "matched": "int", "returned": "int", "assets": "array of {name, package_path, class[, tags]}" }
    },
```

- [ ] **Step 2.4: Add bridge schema test for find_assets tags**

Append to `tests/test_bridge.py`:

```python
def test_find_assets_schema_includes_tags_and_include_tags():
    """find_assets v0.7.0 adds optional tags + include_tags fields."""
    from bridge.unreal_claude_mcp_bridge import TOOLS
    find_assets = next(t for t in TOOLS if t["name"] == "find_assets")
    props = find_assets["inputSchema"]["properties"]
    assert "tags" in props, "find_assets schema must declare 'tags'"
    assert props["tags"]["type"] == "object"
    assert "include_tags" in props, "find_assets schema must declare 'include_tags'"
    assert props["include_tags"]["type"] == "boolean"
    # Required list unchanged — both new fields are optional.
    assert find_assets["inputSchema"]["required"] == ["class_path"]
```

- [ ] **Step 2.5: Update docs/TOOLS.md find_assets section**

Append a "v0.7.0 additions" sub-section to the existing find_assets entry:

```markdown
### find_assets — v0.7.0 additions

Two optional parameters extend the v0.4.0 contract:

- `tags`: map of tag-name → required-value (string) or `null` (any value). All entries AND together.
- `include_tags`: when `true`, each returned asset includes a `tags` map of all its registry tags.

Example — find all Texture2D assets in `/Game/Textures/` whose `LODGroup` registry tag is `TEXTUREGROUP_World`, returning the full tag map for each:

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

Tag values are coerced to strings via `FAssetTagValueRef::AsString()`, so numeric or object-path tags appear as their string representation.
```

- [ ] **Step 2.6: Run pytest**

```bash
py -3 -m pytest tests/test_bridge.py -q
```

Expected: all prior tests pass + 1 new test passes (`test_find_assets_schema_includes_tags_and_include_tags`).

- [ ] **Step 2.7: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_FindAssets.cpp \
        bridge/unreal_claude_mcp_bridge.py \
        UnrealClaudeMCP/Resources/mcp_manifest.json \
        tests/test_bridge.py \
        docs/TOOLS.md
git commit -m "feat(find_assets): tag-based filtering + include_tags output (v0.7.0 part 1/5)"
```

---

## Task 3: `inspect_asset` handler

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_InspectAsset.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp`
- Modify: `bridge/unreal_claude_mcp_bridge.py`
- Modify: `UnrealClaudeMCP/Resources/mcp_manifest.json`
- Modify: `tests/test_bridge.py`
- Modify: `docs/TOOLS.md`

- [ ] **Step 3.1: Create Handler_InspectAsset.cpp**

Mirror the structure of `Handler_FindAssets.cpp` and `Handler_GetLogLines.cpp`. The handler must:

1. Validate `path` param (non-empty string).
2. Normalize to package path via `UCMCPAssetPath::ToPackagePath`.
3. Call `FAssetRegistryModule::GetRegistry().GetAssetByObjectPath(FSoftObjectPath(ObjectPath))` (where `ObjectPath` is the dotted form). Test `FAssetData::IsValid()` — if false, emit `inspect_asset: asset_not_found: '<path>' is not in the asset registry`.
4. Pull `AssetName`, `PackageName`, `AssetClassPath`. Build the result JSON.
5. Loop `Data.TagsAndValues.ForEach(...)` and add to `tags` JSON sub-object (same idiom as Task 2 step 2.1).
6. Call `Registry.GetDependencies(PackageName, OutDeps)` with default options. Convert `TArray<FName>` to a JSON string array (drop the entry if it equals the asset's own package name to avoid self-reference noise).
7. Same for `Registry.GetReferencers(PackageName, OutRefs)`.
8. Compute file size: `IFileManager::Get().FileSize(*FPackageName::LongPackageNameToFilename(PackageName.ToString(), FPackageName::GetAssetPackageExtension()))`. If `< 0`, set the JSON field to JSON null.

Error code prefix: `inspect_asset: <code>: <detail>`. Stable codes: `missing_required_field`, `asset_not_found`.

The header includes needed:

```cpp
#include "MCP/MCPHandler.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetRegistry/AssetData.h"
#include "Modules/ModuleManager.h"
#include "Misc/PackageName.h"
#include "HAL/FileManager.h"
#include "UObject/SoftObjectPath.h"
#include "MCP/Handlers/AssetPathUtil.h"
```

End with the factory:

```cpp
TSharedRef<IUCMCPHandler> Make_Handler_InspectAsset()
{
    return MakeShared<FHandler_InspectAsset>();
}
```

- [ ] **Step 3.2: Register handler in UnrealClaudeMCPModule.cpp**

Add the extern declaration after the existing `Make_Handler_ExecuteConsoleCommand()` extern:

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

Add the registration call after the existing `Reg.Register(Make_Handler_ExecuteConsoleCommand());`:

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

- [ ] **Step 3.3: Add bridge TOOLS entry**

Append to the `TOOLS` array in `bridge/unreal_claude_mcp_bridge.py`:

```python
    {
        "name": "inspect_asset",
        "description": "Read everything the asset registry knows about a single asset: class, all registry tags, dependency packages, referencer packages, on-disk file size.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Asset path or package path (e.g. /Game/Textures/T_Stone or /Game/Textures/T_Stone.T_Stone)."},
            },
            "required": ["path"],
        },
    },
```

- [ ] **Step 3.4: Add manifest entry**

Append to the `tools` array in `UnrealClaudeMCP/Resources/mcp_manifest.json`:

```json
    {
      "name": "inspect_asset",
      "description": "Read everything the asset registry knows about a single asset: class, all registry tags, dependency packages, referencer packages, on-disk file size.",
      "params": {
        "path": "string (required) - asset path or package path"
      },
      "returns": { "ok": "bool", "name": "string", "package_path": "string", "asset_path": "string", "class": "string", "class_path": "string", "tags": "object", "dependencies": "array of string", "referencers": "array of string", "package_size_bytes": "int or null" }
    },
```

- [ ] **Step 3.5: Add bridge schema test**

Append to `tests/test_bridge.py`:

```python
def test_inspect_asset_in_tools_catalog():
    """inspect_asset is a v0.7.0 addition with required 'path' param."""
    from bridge.unreal_claude_mcp_bridge import TOOLS
    inspect = next((t for t in TOOLS if t["name"] == "inspect_asset"), None)
    assert inspect is not None, "inspect_asset must be in TOOLS catalog"
    assert "path" in inspect["inputSchema"]["properties"]
    assert inspect["inputSchema"]["required"] == ["path"]
```

- [ ] **Step 3.6: Add docs/TOOLS.md section**

Append to `docs/TOOLS.md` (after the find_assets section):

````markdown
### inspect_asset

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

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

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

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

**Example:**
```json
{
  "method": "inspect_asset",
  "params": {"path": "/Game/Textures/T_Stone"}
}
```
````

- [ ] **Step 3.7: Run pytest**

```bash
py -3 -m pytest tests/test_bridge.py -q
```

Expected: all prior tests + new `test_inspect_asset_in_tools_catalog` pass.

- [ ] **Step 3.8: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_InspectAsset.cpp \
        UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/UnrealClaudeMCPModule.cpp \
        bridge/unreal_claude_mcp_bridge.py \
        UnrealClaudeMCP/Resources/mcp_manifest.json \
        tests/test_bridge.py \
        docs/TOOLS.md
git commit -m "feat(inspect_asset): read class+tags+deps+refs+size (v0.7.0 part 2/5)"
```

---

## Task 4: `move_asset` handler

**Files:**
- Create: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_MoveAsset.cpp`
- Modify: same six files as Task 3 (module + bridge + manifest + tests + docs)

- [ ] **Step 4.1: Create Handler_MoveAsset.cpp**

Mirror the established handler structure. Key flow:

1. Validate `path` (required, non-empty) and `dest_folder` (required, non-empty).
2. Normalize `path` via `UCMCPAssetPath::ToObjectPath`.
3. Validate `dest_folder` starts with `/Game/` or `/Engine/`. Trim trailing `/`. Otherwise: `move_asset: invalid_dest_folder: '<value>' must start with /Game/ or /Engine/`.
4. Call `UEditorAssetLibrary::DoesAssetExist(SourceObjectPath)` — if false: `move_asset: asset_not_found: '<path>'`.
5. Compute `LeafName = UCMCPAssetPath::ExtractLeafName(SourceObjectPath)`.
6. Compute `DestObjectPath = dest_folder + "/" + LeafName + "." + LeafName`.
7. If `UEditorAssetLibrary::DoesAssetExist(DestObjectPath)` returns true: `move_asset: dest_exists: an asset already exists at '<DestObjectPath>'`.
8. Call `bool bOk = UEditorAssetLibrary::RenameAsset(SourceObjectPath, DestObjectPath)`. If false: `move_asset: rename_failed: UE RenameAsset returned false (likely SCC checkout failure)`.
9. Return `{ ok: true, old_path: SourceObjectPath, new_path: DestObjectPath }`.

Required header includes:

```cpp
#include "MCP/MCPHandler.h"
#include "Dom/JsonObject.h"
#include "EditorAssetLibrary.h"
#include "MCP/Handlers/AssetPathUtil.h"
```

Error codes: `missing_required_field`, `asset_not_found`, `invalid_dest_folder`, `dest_exists`, `rename_failed`.

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

Add extern + Reg.Register call mirroring Task 3.2 pattern.

- [ ] **Step 4.3: Add bridge TOOLS entry**

```python
    {
        "name": "move_asset",
        "description": "Move an asset to a different folder; leaf name unchanged. UE auto-creates a redirector at the source path.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Source asset path."},
                "dest_folder": {"type": "string", "description": "Destination folder under /Game/ or /Engine/."},
            },
            "required": ["path", "dest_folder"],
        },
    },
```

- [ ] **Step 4.4: Add manifest entry**

```json
    {
      "name": "move_asset",
      "description": "Move an asset to a different folder; leaf name unchanged. UE auto-creates a redirector at the source path.",
      "params": {
        "path": "string (required) - source asset path",
        "dest_folder": "string (required) - destination folder under /Game/ or /Engine/"
      },
      "returns": { "ok": "bool", "old_path": "string", "new_path": "string" }
    },
```

- [ ] **Step 4.5: Add bridge schema test**

```python
def test_move_asset_in_tools_catalog():
    from bridge.unreal_claude_mcp_bridge import TOOLS
    t = next((t for t in TOOLS if t["name"] == "move_asset"), None)
    assert t is not None
    assert set(t["inputSchema"]["required"]) == {"path", "dest_folder"}
```

- [ ] **Step 4.6: Add docs/TOOLS.md section**

```markdown
### move_asset

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

**Params:**
- `path` (string, required): source asset path.
- `dest_folder` (string, required): destination folder under `/Game/` or `/Engine/`.

**Returns:** `{ok, old_path, new_path}`.

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

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

- [ ] **Step 4.7: Run pytest + commit**

```bash
py -3 -m pytest tests/test_bridge.py -q
```

Expected: pass.

```bash
git add <same six files as Task 3>
git commit -m "feat(move_asset): move asset to a different folder (v0.7.0 part 3/5)"
```

---

## Task 5: `rename_asset` handler

**Files:** mirror Task 4 layout.

- [ ] **Step 5.1: Create Handler_RenameAsset.cpp**

Same structure as `Handler_MoveAsset.cpp`. Key differences:

1. Required params: `path`, `new_name`.
2. Validate `new_name` via `UCMCPAssetPath::IsValidLeafName`. If false: `rename_asset: invalid_asset_name: 'new_name' must be a non-empty string with no '/' or '.'`.
3. Compute `Folder = UCMCPAssetPath::ExtractFolder(SourceObjectPath)`.
4. Compute `DestObjectPath = Folder + "/" + new_name + "." + new_name`.
5. Else identical to move_asset (dest_exists check, RenameAsset call, return shape).

Error codes: `missing_required_field`, `asset_not_found`, `invalid_asset_name`, `dest_exists`, `rename_failed`.

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

Add extern + Reg.Register.

- [ ] **Step 5.3: Add bridge TOOLS entry**

```python
    {
        "name": "rename_asset",
        "description": "Rename an asset's leaf name; folder unchanged. UE auto-creates a redirector at the old name.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Source asset path."},
                "new_name": {"type": "string", "description": "New leaf name (no '/' or '.')."},
            },
            "required": ["path", "new_name"],
        },
    },
```

- [ ] **Step 5.4: Add manifest entry**

```json
    {
      "name": "rename_asset",
      "description": "Rename an asset's leaf name; folder unchanged. UE auto-creates a redirector at the old name.",
      "params": {
        "path": "string (required) - source asset path",
        "new_name": "string (required) - new leaf name (no '/' or '.')"
      },
      "returns": { "ok": "bool", "old_path": "string", "new_path": "string" }
    },
```

- [ ] **Step 5.5: Add bridge schema test**

```python
def test_rename_asset_in_tools_catalog():
    from bridge.unreal_claude_mcp_bridge import TOOLS
    t = next((t for t in TOOLS if t["name"] == "rename_asset"), None)
    assert t is not None
    assert set(t["inputSchema"]["required"]) == {"path", "new_name"}
```

- [ ] **Step 5.6: Add docs/TOOLS.md section**

```markdown
### rename_asset

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

**Params:**
- `path` (string, required): source asset path.
- `new_name` (string, required): new leaf name. No `/` or `.` characters allowed.

**Returns:** `{ok, old_path, new_path}`.

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

**Behavior note:** Same redirector behavior as `move_asset`.
```

- [ ] **Step 5.7: Run pytest + commit**

```bash
py -3 -m pytest tests/test_bridge.py -q
git add <same six files as Task 3>
git commit -m "feat(rename_asset): change leaf name in place (v0.7.0 part 4/5)"
```

---

## Task 6: `delete_asset` handler

**Files:** mirror Task 4 layout.

- [ ] **Step 6.1: Create Handler_DeleteAsset.cpp**

Critical: the spec mandates a referencer-check safety gate because `UEditorAssetLibrary::DeleteAsset` is documented as a force-delete with no built-in safety check. Algorithm:

1. Validate `path` (required, non-empty).
2. Read optional `bool force` param (default false).
3. Normalize `path` via `UCMCPAssetPath::ToObjectPath` for the DeleteAsset call.
4. Compute `PackagePath = UCMCPAssetPath::ToPackagePath(path)`. Convert to FName.
5. Call `UEditorAssetLibrary::DoesAssetExist(ObjectPath)` — if false: `delete_asset: asset_not_found: '<path>'`.
6. Get the AssetRegistry: `IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();`.
7. Call `Registry.GetReferencers(PackageName, OutReferencers)` — `OutReferencers` is `TArray<FName>`.
8. If `OutReferencers.Num() > 0` and `force == false`:
   - Build a comma-joined list of up to 5 referencer names (with "and N more" if there are more).
   - Emit error: `delete_asset: has_referencers: '<path>' is referenced by N package(s): <list>. Set force=true to delete anyway.`
9. Call `bool bOk = UEditorAssetLibrary::DeleteAsset(ObjectPath)`. If false: `delete_asset: delete_failed: UE DeleteAsset returned false`.
10. Return `{ ok: true, deleted_path: ObjectPath }`.

Required header includes:

```cpp
#include "MCP/MCPHandler.h"
#include "Dom/JsonObject.h"
#include "EditorAssetLibrary.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Modules/ModuleManager.h"
#include "MCP/Handlers/AssetPathUtil.h"
```

Error codes: `missing_required_field`, `asset_not_found`, `has_referencers`, `delete_failed`.

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

Add extern + Reg.Register.

- [ ] **Step 6.3: Add bridge TOOLS entry**

```python
    {
        "name": "delete_asset",
        "description": "Delete an asset. Refuses if referenced by other packages unless force=true. WARNING: deletion is permanent within the project; use force flag with caution.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Asset path to delete."},
                "force": {"type": "boolean", "description": "When true, delete even if referenced (default false)."},
            },
            "required": ["path"],
        },
    },
```

- [ ] **Step 6.4: Add manifest entry**

```json
    {
      "name": "delete_asset",
      "description": "Delete an asset. Refuses if referenced by other packages unless force=true.",
      "params": {
        "path": "string (required) - asset path to delete",
        "force": "bool (optional, default false) - delete even if referenced"
      },
      "returns": { "ok": "bool", "deleted_path": "string" }
    }
```

(Note: this is the **last** entry in the `tools` array — no trailing comma after the closing `}`.)

- [ ] **Step 6.5: Add bridge schema test**

```python
def test_delete_asset_in_tools_catalog():
    from bridge.unreal_claude_mcp_bridge import TOOLS
    t = next((t for t in TOOLS if t["name"] == "delete_asset"), None)
    assert t is not None
    assert t["inputSchema"]["required"] == ["path"]
    assert "force" in t["inputSchema"]["properties"]
```

- [ ] **Step 6.6: Add docs/TOOLS.md section**

```markdown
### delete_asset

Delete an asset from the project. Refuses by default if any other package references the asset.

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

**Returns:** `{ok, deleted_path}`.

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

**Safety:** UE's `EditorAssetLibrary::DeleteAsset` is documented as a force-delete that does not check referencers. The handler runs `IAssetRegistry::GetReferencers` first and refuses unless `force=true`. Force-delete still cannot recover deleted assets — make sure SCC has the asset before deleting.
```

- [ ] **Step 6.7: Run pytest + commit**

```bash
py -3 -m pytest tests/test_bridge.py -q
git add <same six files as Task 3>
git commit -m "feat(delete_asset): referencer-checked deletion + force flag (v0.7.0 part 5/5)"
```

---

## Task 7: Version bumps + smoke test + final wiring

**Files:**
- Modify: `UnrealClaudeMCP/UnrealClaudeMCP.uplugin`
- Modify: `UnrealClaudeMCP/Resources/mcp_manifest.json` (version + description count)
- Modify: `bridge/unreal_claude_mcp_bridge.py` (SERVER_VERSION + docstring count)
- Modify: `README.md`
- Modify: `tests/test_manifest_sync.py`
- Modify: `examples/smoke_test.py`

- [ ] **Step 7.1: Bump .uplugin version + description**

Replace `"VersionName": "0.6.0"` with `"VersionName": "0.7.0"`. Update the `Description` to say "Twenty-five tools" instead of "Twenty-one tools" (count the existing tool list to verify).

- [ ] **Step 7.2: Bump mcp_manifest.json**

Replace the `"version": "0.6.0"` line with `"version": "0.7.0"`. Update the description tool count from "Twenty-one" to "Twenty-five".

- [ ] **Step 7.3: Bump bridge SERVER_VERSION**

In `bridge/unreal_claude_mcp_bridge.py`:
- Change `SERVER_VERSION = "0.6.0"` to `SERVER_VERSION = "0.7.0"`.
- Update the `tools/list` docstring from "21 handlers" to "25 handlers".
- Update the inline comment `# v0.6.0: 21 tools (added get_log_lines, execute_console_command).` to `# v0.7.0: 25 tools (added inspect_asset, move_asset, rename_asset, delete_asset; find_assets gained tags+include_tags).`.

- [ ] **Step 7.4: Update README.md**

In the introductory paragraph, change "Twenty-one editor-automation tools" to "Twenty-five editor-automation tools".

In the **Status** table, update:

```markdown
| **Latest release** | v0.7.0 — 2026-05-08 |
| **Tools** | 25 live, smoke-tested end-to-end |
```

In the **Tools** table, append four new rows after the `execute_console_command` row:

```markdown
| `inspect_asset` | Read every fact the asset registry knows about an asset: class, tags, dependencies, referencers, package size. |
| `move_asset` | Move an asset to a different folder; UE creates a redirector at the source path. |
| `rename_asset` | Change an asset's leaf name in place; UE creates a redirector at the old name. |
| `delete_asset` | Delete an asset; refuses if referenced unless `force=true`. |
```

In the smoke-test description: change "ten sections" to "fourteen sections" and update the tail comment to mention asset-registry round-trip on the new last step.

Update the closing line near the top:
- Change `Adding a 22nd tool is one .cpp file plus one line of registration` to `Adding a 26th tool is one .cpp file plus one line of registration`.

- [ ] **Step 7.5: Update test_manifest_sync.py**

Find the count assertion (something like `assert len(manifest["tools"]) == 21`). Change to `== 25`.

- [ ] **Step 7.6: Update smoke_test.py — count assertion**

Locate the assertion that compares the live `list_tools` count (likely `assert len(tools) >= 21`). Change to `>= 25`.

- [ ] **Step 7.7: Add 4 new smoke steps + 1 modified step**

The smoke test currently has 10 steps. Add these as steps 11-14 (after step 10 "execute_console_command + get_log_lines round-trip"):

- **Step 11 — find_assets with tags and include_tags:**
  - Call `find_assets` with `class_path=/Script/Engine.Texture2D`, `path_under=/Game/`, `include_tags=true`. Expect at least one asset back with a `tags` map populated. (If the project has zero textures, log a warning and skip. The smoke test runs against the user's actual project.)

- **Step 12 — inspect_asset round-trip:**
  - Pick the first asset from step 11's result (or any known-existing asset). Call `inspect_asset` with its package path. Assert the result has `name`, `class`, `tags`, `dependencies`, `referencers`, `package_size_bytes` keys.

- **Step 13 — rename round-trip (write+revert):**
  - Find a fresh asset (or skip-with-warning if none available). Call `rename_asset` to add a `_smoketest` suffix; verify success. Then call `rename_asset` again to revert. Verify the original asset is back at its original path. This validates the write path without leaving litter in the project.

- **Step 14 — delete_asset safety check:**
  - Call `delete_asset` on a referenced asset (one with non-zero `referencers` from step 12). Assert the response contains `error` with code `has_referencers`. This validates the safety gate without actually deleting anything.

The smoke test should print "Smoke test complete." at the end after all 14 steps.

- [ ] **Step 7.8: Build the editor**

```bash
# Kill UE editor first if running (Live Coding holds the DLL):
taskkill /IM UnrealEditor.exe /F 2>/dev/null

# Build the editor module:
"F:/UE_5.7/Engine/Build/BatchFiles/Build.bat" UnrealClaudeMCPEditor Win64 Development -project="<full path to .uproject>"
```

Expected: build completes in ~10-30 seconds with a `BUILD SUCCESSFUL` line. If the build fails, copy the errors verbatim and read the relevant Handler_*.cpp file — common issues are missing includes (`AssetData.h`, `IFileManager`) or wrong API signatures.

- [ ] **Step 7.9: Launch UE editor + run smoke test**

Open the user's .uproject in UE Editor. Wait for the Output Log to show:
```
[LogUnrealClaudeMCP] Module started
LogUCMCPHandler: Registered handler 'execute_unreal_python'
... (25 lines)
[LogUCMCP] Listening on 127.0.0.1:18888
```

Run smoke test:
```bash
py -3 examples/smoke_test.py
```

Expected: 14 sections of JSON output, all `ok: true` (except step 14, which expects an `error` with code `has_referencers` — that's the success condition for the safety check). Final line: `Smoke test complete.`.

If any step fails: read the failing JSON output, check the corresponding `Handler_*.cpp`, fix, rebuild, re-run.

- [ ] **Step 7.10: Final commit + push + open PR**

```bash
git add UnrealClaudeMCP/UnrealClaudeMCP.uplugin \
        UnrealClaudeMCP/Resources/mcp_manifest.json \
        bridge/unreal_claude_mcp_bridge.py \
        README.md \
        tests/test_manifest_sync.py \
        examples/smoke_test.py
git commit -m "feat(v0.7.0): version bumps + smoke step coverage for asset registry"
git push -u origin feat/v0.7.0-asset-registry
```

Open the PR with this body:

````markdown
## v0.7.0 — Asset registry deep dive

Tool count: 21 → 25. Version: 0.6.0 → 0.7.0.

Five changes:
1. `find_assets` gains optional `tags` (filter) + `include_tags` (output) params.
2. `inspect_asset` (new) — full registry read: class, tags, dependencies, referencers, package size.
3. `move_asset` (new) — move to a different folder; UE creates a redirector at the source.
4. `rename_asset` (new) — change leaf name; same redirector behavior.
5. `delete_asset` (new) — referencer-checked deletion with `force=true` override.

### Spec
`docs/superpowers/specs/2026-05-08-v0.7.0-asset-registry-design.md`

### Verification
- 4 new bridge schema tests + manifest sync test all pass (locally + in CI matrix).
- Smoke test extended from 10 to 14 steps; all pass against a real UE project.
- All UE 5.7 API claims grounded in source paths cited in the spec's References section.

### Known limits (out of scope)
- No bulk operations (deferred to v0.7.x if perf requires).
- No `duplicate_asset` (deferred; `EditorAssetLibrary::DuplicateAsset` is the obvious follow-up).
- No folder-level operations.
- Redirectors created by `move_asset`/`rename_asset` persist until the user runs *Fix Up Redirectors* in the Content Browser. Documented in TOOLS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
````

Then run:

```bash
gh pr create --title "feat: v0.7.0 asset registry (4 new handlers + find_assets tags)" \
             --body-file <(cat <<'EOF'
[paste body above]
EOF
)
```

Expected: PR opens, GitHub Actions starts the 4-job pytest matrix, all green.

---

## Self-review

**Spec coverage check:** Walked through each section of the spec and matched to a task:

| Spec section | Task |
|---|---|
| `find_assets` extension | Task 2 |
| `inspect_asset` handler | Task 3 |
| `move_asset` handler | Task 4 |
| `rename_asset` handler | Task 5 |
| `delete_asset` handler | Task 6 |
| Shared module `AssetPathUtil` | Task 1 |
| Version bumps + manifest + tests + docs + smoke | Task 7 |
| Stable error codes | Distributed across handler tasks (3-6) |
| References (UE 5.7 source paths) | Already in spec; not a code task |

✓ Full coverage.

**Placeholder scan:** No "TBD", "TODO", "implement later". Code blocks are concrete. The C++ handler skeletons are described as algorithmic flows because printing 600+ lines of FProperty boilerplate would bloat the plan; the spec + reference-handler pattern (`Handler_FindAssets.cpp`, `Handler_GetLogLines.cpp`) gives the implementing subagent enough to fill in the boilerplate. This matches the v0.4.0/v0.5.0/v0.6.0 plan style that has shipped 21 handlers cleanly.

**Type consistency:** `UCMCPAssetPath::ToPackagePath`, `ToObjectPath`, `ExtractLeafName`, `ExtractFolder`, `IsValidLeafName` — all five helpers used consistently by name across Tasks 3-6. Module-registration extern names follow the `Make_Handler_<Name>()` pattern unchanged from v0.4.0/v0.6.0.
