From c896abd9d19097c9fb9921f5a2fad3a08608c58d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 25 Jan 2026 16:49:39 -0800 Subject: [PATCH] fix: prefab stage dirty flag, root rename, test fix, and prefab resources - 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 --- .../Tools/GameObjects/GameObjectModify.cs | 36 ++++ MCPForUnity/Editor/Tools/ManageComponents.cs | 21 ++ README.md | 2 +- Server/src/services/resources/prefab.py | 191 ++++++++++++++++++ .../EditMode/Tools/ManagePrefabsCrudTests.cs | 7 +- 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 Server/src/services/resources/prefab.py diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index b817b90b0..ba0c08855 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -37,6 +37,42 @@ internal static object Handle(JObject @params, JToken targetToken, string search 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; } diff --git a/MCPForUnity/Editor/Tools/ManageComponents.cs b/MCPForUnity/Editor/Tools/ManageComponents.cs index 2564e674b..596e5b868 100644 --- a/MCPForUnity/Editor/Tools/ManageComponents.cs +++ b/MCPForUnity/Editor/Tools/ManageComponents.cs @@ -5,6 +5,7 @@ using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; +using UnityEditor.SceneManagement; using UnityEngine; namespace MCPForUnity.Editor.Tools @@ -103,6 +104,7 @@ private static object AddComponent(JObject @params, JToken targetToken, string s } EditorUtility.SetDirty(targetGo); + MarkOwningSceneDirty(targetGo); return new { @@ -146,6 +148,7 @@ private static object RemoveComponent(JObject @params, JToken targetToken, strin } EditorUtility.SetDirty(targetGo); + MarkOwningSceneDirty(targetGo); return new { @@ -227,6 +230,7 @@ private static object SetProperty(JObject @params, JToken targetToken, string se } EditorUtility.SetDirty(component); + MarkOwningSceneDirty(targetGo); if (errors.Count > 0) { @@ -262,6 +266,23 @@ private static object SetProperty(JObject @params, JToken targetToken, string se #region Helpers + /// + /// Marks the appropriate scene as dirty for the given GameObject. + /// Handles both regular scenes and prefab stages. + /// + 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) diff --git a/README.md b/README.md index 8de255858..be27b9484 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/Server/src/services/resources/prefab.py b/Server/src/services/resources/prefab.py new file mode 100644 index 000000000..5669cc474 --- /dev/null +++ b/Server/src/services/resources/prefab.py @@ -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) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs index a4d255b38..3323632f3 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -423,9 +423,6 @@ public void GetHierarchy_IncludesNestingInfo_ForNestedPrefabs() 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 @@ public void GetHierarchy_IncludesNestingInfo_ForNestedPrefabs() } finally { + // Delete nested container first (before deleting prefabs it references) + SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/')); SafeDeleteAsset(parentPath); - SafeDeleteAsset(Path.Combine(TempDirectory, "ParentPrefab.prefab").Replace('\\', '/')); SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/')); - SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/')); } }