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();
|
string name = @params["name"]?.ToString();
|
||||||
if (!string.IsNullOrEmpty(name) && targetGo.name != name)
|
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;
|
targetGo.name = name;
|
||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Reflection;
|
||||||
using MCPForUnity.Editor.Helpers;
|
using MCPForUnity.Editor.Helpers;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace MCPForUnity.Editor.Tools
|
namespace MCPForUnity.Editor.Tools
|
||||||
|
|
@ -103,6 +104,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorUtility.SetDirty(targetGo);
|
EditorUtility.SetDirty(targetGo);
|
||||||
|
MarkOwningSceneDirty(targetGo);
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
|
|
@ -146,6 +148,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorUtility.SetDirty(targetGo);
|
EditorUtility.SetDirty(targetGo);
|
||||||
|
MarkOwningSceneDirty(targetGo);
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
|
|
@ -227,6 +230,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorUtility.SetDirty(component);
|
EditorUtility.SetDirty(component);
|
||||||
|
MarkOwningSceneDirty(targetGo);
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -262,6 +266,23 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
#region Helpers
|
#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)
|
private static GameObject FindTarget(JToken targetToken, string searchMethod)
|
||||||
{
|
{
|
||||||
if (targetToken == null)
|
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`
|
`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
|
### 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!
|
**Performance Tip:** Use `batch_execute` for multiple operations — it's 10-100x faster than individual calls!
|
||||||
</details>
|
</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();
|
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
|
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
|
||||||
{
|
{
|
||||||
["action"] = "get_hierarchy",
|
["action"] = "get_hierarchy",
|
||||||
|
|
@ -444,10 +441,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
SafeDeleteAsset(parentPath);
|
// Delete nested container first (before deleting prefabs it references)
|
||||||
SafeDeleteAsset(Path.Combine(TempDirectory, "ParentPrefab.prefab").Replace('\\', '/'));
|
|
||||||
SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/'));
|
|
||||||
SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/'));
|
SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/'));
|
||||||
|
SafeDeleteAsset(parentPath);
|
||||||
|
SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue