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
dsarno 2026-01-25 17:35:01 -08:00 committed by GitHub
parent 300a745bf2
commit ed11e30b47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 252 additions and 7 deletions

View File

@ -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;
} }

View File

@ -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)

View File

@ -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>

View File

@ -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)

View File

@ -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('\\', '/'));
} }
} }