fix: prefab stage dirty flag, root rename, test fix, and prefab resources (#627)
- Mark prefab stage scene as dirty when manage_components adds/removes/
modifies components, ensuring save_open_stage correctly detects changes
- When renaming the root GameObject of an open prefab stage, also rename
the prefab asset file to match, preventing Unity's "file name must
match" dialog from interrupting automated workflows
- Fix ManagePrefabsCrudTests cleanup order: delete NestedContainer.prefab
before ChildPrefab.prefab to avoid missing prefab reference errors
- Remove incorrect LogAssert.Expect that expected an error that doesn't
occur in the test scenario
- Add new prefab MCP resources for inspecting prefabs:
- mcpforunity://prefab-api: Documentation for prefab resources
- mcpforunity://prefab/{path}: Get prefab asset info
- mcpforunity://prefab/{path}/hierarchy: Get full prefab hierarchy
Addresses #97 (Prefab Editor Inspection & Modification Support)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
main
parent
300a745bf2
commit
ed11e30b47
|
|
@ -37,6 +37,42 @@ namespace MCPForUnity.Editor.Tools.GameObjects
|
|||
string name = @params["name"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(name) && targetGo.name != name)
|
||||
{
|
||||
// Check if we're renaming the root object of an open prefab stage
|
||||
var prefabStageForRename = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
bool isRenamingPrefabRoot = prefabStageForRename != null &&
|
||||
prefabStageForRename.prefabContentsRoot == targetGo;
|
||||
|
||||
if (isRenamingPrefabRoot)
|
||||
{
|
||||
// Rename the prefab asset file to match the new name (avoids Unity dialog)
|
||||
string assetPath = prefabStageForRename.assetPath;
|
||||
string directory = System.IO.Path.GetDirectoryName(assetPath);
|
||||
string newAssetPath = System.IO.Path.Combine(directory, name + ".prefab").Replace('\\', '/');
|
||||
|
||||
// Only rename if the path actually changes
|
||||
if (newAssetPath != assetPath)
|
||||
{
|
||||
// Check for collision using GUID comparison
|
||||
string currentGuid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
string existingGuid = AssetDatabase.AssetPathToGUID(newAssetPath);
|
||||
|
||||
// Collision only if there's a different asset at the new path
|
||||
if (!string.IsNullOrEmpty(existingGuid) && existingGuid != currentGuid)
|
||||
{
|
||||
return new ErrorResponse($"Cannot rename prefab root to '{name}': a prefab already exists at '{newAssetPath}'.");
|
||||
}
|
||||
|
||||
// Rename the asset file
|
||||
string renameError = AssetDatabase.RenameAsset(assetPath, name);
|
||||
if (!string.IsNullOrEmpty(renameError))
|
||||
{
|
||||
return new ErrorResponse($"Failed to rename prefab asset: {renameError}");
|
||||
}
|
||||
|
||||
McpLog.Info($"[GameObjectModify] Renamed prefab asset from '{assetPath}' to '{newAssetPath}'");
|
||||
}
|
||||
}
|
||||
|
||||
targetGo.name = name;
|
||||
modified = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Reflection;
|
|||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
|
|
@ -103,6 +104,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
MarkOwningSceneDirty(targetGo);
|
||||
|
||||
return new
|
||||
{
|
||||
|
|
@ -146,6 +148,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
MarkOwningSceneDirty(targetGo);
|
||||
|
||||
return new
|
||||
{
|
||||
|
|
@ -227,6 +230,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
EditorUtility.SetDirty(component);
|
||||
MarkOwningSceneDirty(targetGo);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
|
|
@ -262,6 +266,23 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Marks the appropriate scene as dirty for the given GameObject.
|
||||
/// Handles both regular scenes and prefab stages.
|
||||
/// </summary>
|
||||
private static void MarkOwningSceneDirty(GameObject targetGo)
|
||||
{
|
||||
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
{
|
||||
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorSceneManager.MarkSceneDirty(targetGo.scene);
|
||||
}
|
||||
}
|
||||
|
||||
private static GameObject FindTarget(JToken targetToken, string searchMethod)
|
||||
{
|
||||
if (targetToken == null)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ openupm add com.coplaydev.unity-mcp
|
|||
`manage_asset` • `manage_editor` • `manage_gameobject` • `manage_components` • `manage_material` • `manage_prefabs` • `manage_scene` • `manage_script` • `manage_scriptable_object` • `manage_shader` • `manage_vfx` • `manage_texture` • `batch_execute` • `find_gameobjects` • `find_in_file` • `read_console` • `refresh_unity` • `run_tests` • `get_test_job` • `execute_menu_item` • `apply_text_edits` • `script_apply_edits` • `validate_script` • `create_script` • `delete_script` • `get_sha`
|
||||
|
||||
### Available Resources
|
||||
`custom_tools` • `unity_instances` • `menu_items` • `get_tests` • `gameobject` • `gameobject_components` • `editor_state` • `editor_selection` • `editor_prefab_stage` • `project_info` • `project_tags` • `project_layers`
|
||||
`custom_tools` • `unity_instances` • `menu_items` • `get_tests` • `gameobject` • `gameobject_components` • `prefab_api` • `prefab_info` • `prefab_hierarchy` • `editor_state` • `editor_selection` • `editor_prefab_stage` • `project_info` • `project_tags` • `project_layers`
|
||||
|
||||
**Performance Tip:** Use `batch_execute` for multiple operations — it's 10-100x faster than individual calls!
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
"""
|
||||
MCP Resources for reading Prefab data from Unity.
|
||||
|
||||
These resources provide read-only access to:
|
||||
- Prefab info by asset path (mcpforunity://prefab/{path})
|
||||
- Prefab hierarchy by asset path (mcpforunity://prefab/{path}/hierarchy)
|
||||
- Currently open prefab stage (mcpforunity://editor/prefab-stage - see prefab_stage.py)
|
||||
"""
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
from pydantic import BaseModel
|
||||
from fastmcp import Context
|
||||
|
||||
from models import MCPResponse
|
||||
from services.registry import mcp_for_unity_resource
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
|
||||
|
||||
def _normalize_response(response: dict | MCPResponse | Any) -> MCPResponse:
|
||||
"""Normalize Unity transport response to MCPResponse."""
|
||||
if isinstance(response, dict):
|
||||
return MCPResponse(**response)
|
||||
if isinstance(response, MCPResponse):
|
||||
return response
|
||||
# Fallback: wrap unexpected types in an error response
|
||||
return MCPResponse(success=False, error=f"Unexpected response type: {type(response).__name__}")
|
||||
|
||||
|
||||
def _decode_prefab_path(encoded_path: str) -> str:
|
||||
"""
|
||||
Decode a URL-encoded prefab path.
|
||||
Handles paths like 'Assets%2FPrefabs%2FMyPrefab.prefab' -> 'Assets/Prefabs/MyPrefab.prefab'
|
||||
"""
|
||||
return unquote(encoded_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Static Helper Resource (shows in UI)
|
||||
# =============================================================================
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="mcpforunity://prefab-api",
|
||||
name="prefab_api",
|
||||
description="Documentation for Prefab resources. Use manage_asset action=search filterType=Prefab to find prefabs, then access resources below."
|
||||
)
|
||||
async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
|
||||
"""
|
||||
Returns documentation for the Prefab resource API.
|
||||
|
||||
This is a helper resource that explains how to use the parameterized
|
||||
Prefab resources which require an asset path.
|
||||
"""
|
||||
docs = {
|
||||
"overview": "Prefab resources provide read-only access to Unity prefab assets.",
|
||||
"workflow": [
|
||||
"1. Use manage_asset action=search filterType=Prefab to find prefabs",
|
||||
"2. Use the asset path to access detailed data via resources below",
|
||||
"3. Use manage_prefabs tool for prefab stage operations (open, save, close)"
|
||||
],
|
||||
"path_encoding": {
|
||||
"note": "Prefab paths must be URL-encoded when used in resource URIs",
|
||||
"example": "Assets/Prefabs/MyPrefab.prefab -> Assets%2FPrefabs%2FMyPrefab.prefab"
|
||||
},
|
||||
"resources": {
|
||||
"mcpforunity://prefab/{encoded_path}": {
|
||||
"description": "Get prefab asset info (type, root name, components, variant info)",
|
||||
"example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab",
|
||||
"returns": ["assetPath", "guid", "prefabType", "rootObjectName", "rootComponentTypes", "childCount", "isVariant", "parentPrefab"]
|
||||
},
|
||||
"mcpforunity://prefab/{encoded_path}/hierarchy": {
|
||||
"description": "Get full prefab hierarchy with nested prefab information",
|
||||
"example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab/hierarchy",
|
||||
"returns": ["prefabPath", "total", "items (with name, instanceId, path, componentTypes, prefab nesting info)"]
|
||||
},
|
||||
"mcpforunity://editor/prefab-stage": {
|
||||
"description": "Get info about the currently open prefab stage (if any)",
|
||||
"returns": ["isOpen", "assetPath", "prefabRootName", "mode", "isDirty"]
|
||||
}
|
||||
},
|
||||
"related_tools": {
|
||||
"manage_prefabs": "Open/close prefab stages, save changes, create prefabs from GameObjects",
|
||||
"manage_asset": "Search for prefab assets, get asset info",
|
||||
"manage_gameobject": "Modify GameObjects in open prefab stage",
|
||||
"manage_components": "Add/remove/modify components on prefab GameObjects"
|
||||
}
|
||||
}
|
||||
return MCPResponse(success=True, data=docs)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Prefab Info Resource
|
||||
# =============================================================================
|
||||
|
||||
# TODO: Use these typed response classes for better type safety once
|
||||
# we update the endpoints to validate response structure more strictly.
|
||||
|
||||
|
||||
class PrefabInfoData(BaseModel):
|
||||
"""Data for a prefab asset."""
|
||||
assetPath: str
|
||||
guid: str = ""
|
||||
prefabType: str = "Regular"
|
||||
rootObjectName: str = ""
|
||||
rootComponentTypes: list[str] = []
|
||||
childCount: int = 0
|
||||
isVariant: bool = False
|
||||
parentPrefab: str | None = None
|
||||
|
||||
|
||||
class PrefabInfoResponse(MCPResponse):
|
||||
"""Response containing prefab info data."""
|
||||
data: PrefabInfoData | None = None
|
||||
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="mcpforunity://prefab/{encoded_path}",
|
||||
name="prefab_info",
|
||||
description="Get detailed information about a prefab asset by URL-encoded path. Returns prefab type, root object name, component types, child count, and variant info."
|
||||
)
|
||||
async def get_prefab_info(ctx: Context, encoded_path: str) -> MCPResponse:
|
||||
"""Get prefab asset info by path."""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
# Decode the URL-encoded path
|
||||
decoded_path = _decode_prefab_path(encoded_path)
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"manage_prefabs",
|
||||
{
|
||||
"action": "get_info",
|
||||
"prefabPath": decoded_path
|
||||
}
|
||||
)
|
||||
|
||||
return _normalize_response(response)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Prefab Hierarchy Resource
|
||||
# =============================================================================
|
||||
|
||||
class PrefabHierarchyItem(BaseModel):
|
||||
"""Single item in prefab hierarchy."""
|
||||
name: str
|
||||
instanceId: int
|
||||
path: str
|
||||
activeSelf: bool = True
|
||||
childCount: int = 0
|
||||
componentTypes: list[str] = []
|
||||
prefab: dict[str, Any] = {}
|
||||
|
||||
|
||||
class PrefabHierarchyData(BaseModel):
|
||||
"""Data for prefab hierarchy."""
|
||||
prefabPath: str
|
||||
total: int = 0
|
||||
items: list[PrefabHierarchyItem] = []
|
||||
|
||||
|
||||
class PrefabHierarchyResponse(MCPResponse):
|
||||
"""Response containing prefab hierarchy data."""
|
||||
data: PrefabHierarchyData | None = None
|
||||
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="mcpforunity://prefab/{encoded_path}/hierarchy",
|
||||
name="prefab_hierarchy",
|
||||
description="Get the full hierarchy of a prefab with nested prefab information. Returns all GameObjects with their components and nesting depth."
|
||||
)
|
||||
async def get_prefab_hierarchy(ctx: Context, encoded_path: str) -> MCPResponse:
|
||||
"""Get prefab hierarchy by path."""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
# Decode the URL-encoded path
|
||||
decoded_path = _decode_prefab_path(encoded_path)
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"manage_prefabs",
|
||||
{
|
||||
"action": "get_hierarchy",
|
||||
"prefabPath": decoded_path
|
||||
}
|
||||
)
|
||||
|
||||
return _normalize_response(response)
|
||||
|
|
@ -423,9 +423,6 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// Expect the nested prefab warning due to test environment
|
||||
LogAssert.Expect(UnityEngine.LogType.Error, new Regex("Nested Prefab problem"));
|
||||
|
||||
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "get_hierarchy",
|
||||
|
|
@ -444,10 +441,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
finally
|
||||
{
|
||||
SafeDeleteAsset(parentPath);
|
||||
SafeDeleteAsset(Path.Combine(TempDirectory, "ParentPrefab.prefab").Replace('\\', '/'));
|
||||
SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/'));
|
||||
// Delete nested container first (before deleting prefabs it references)
|
||||
SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/'));
|
||||
SafeDeleteAsset(parentPath);
|
||||
SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue