diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 5bfd1972d..1caa41127 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -5,6 +5,7 @@ using UnityEditor; using UnityEditor.SceneManagement; using UnityEditorInternal; // Required for tag management +using UnityEngine; namespace MCPForUnity.Editor.Tools { @@ -46,6 +47,7 @@ public static object HandleCommand(JObject @params) // Parameters for specific actions string tagName = p.Get("tagName"); string layerName = p.Get("layerName"); + string prefabPath = p.Get("prefabPath") ?? p.Get("path"); // Route action switch (action) @@ -136,6 +138,8 @@ public static object HandleCommand(JObject @params) // return SetQualityLevel(@params["qualityLevel"]); // Prefab Stage + case "open_prefab_stage": + return OpenPrefabStage(prefabPath); case "close_prefab_stage": return ClosePrefabStage(); @@ -147,7 +151,7 @@ public static object HandleCommand(JObject @params) default: return new ErrorResponse( - $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } @@ -369,6 +373,64 @@ private static object RemoveLayer(string layerName) // --- Prefab Stage Methods --- + private static object OpenPrefabStage(string requestedPath) + { + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for open_prefab_stage."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (sanitizedPath == null) + { + return new ErrorResponse($"Invalid prefab path (path traversal detected): '{requestedPath}'."); + } + + if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return new ErrorResponse($"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'."); + } + + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + return new ErrorResponse($"Prefab path must end with '.prefab'. Got: '{sanitizedPath}'."); + } + + try + { + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (prefabAsset == null) + { + return new ErrorResponse($"Prefab asset not found at '{sanitizedPath}'."); + } + + var prefabStage = PrefabStageUtility.OpenPrefab(sanitizedPath); + bool enteredStage = prefabStage != null + && string.Equals(prefabStage.assetPath, sanitizedPath, StringComparison.OrdinalIgnoreCase) + && prefabStage.prefabContentsRoot != null; + + if (!enteredStage) + { + return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'. PrefabStageUtility.OpenPrefab did not enter the requested prefab stage."); + } + + return new SuccessResponse( + $"Opened prefab stage for '{sanitizedPath}'.", + new + { + prefabPath = sanitizedPath, + openedPrefabPath = prefabStage.assetPath, + rootName = prefabStage.prefabContentsRoot.name, + enteredPrefabStage = enteredStage + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Error opening prefab stage: {e.Message}"); + } + } + private static object ClosePrefabStage() { try diff --git a/Server/src/services/resources/prefab.py b/Server/src/services/resources/prefab.py index b2a676c82..8e35bb82c 100644 --- a/Server/src/services/resources/prefab.py +++ b/Server/src/services/resources/prefab.py @@ -57,7 +57,7 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse: "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)" + "3. Use manage_editor action=open_prefab_stage / close_prefab_stage for prefab editing UI transitions" ], "path_encoding": { "note": "Prefab paths must be URL-encoded when used in resource URIs", @@ -80,7 +80,8 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse: } }, "related_tools": { - "manage_prefabs": "Open/close prefab stages, save changes, create prefabs from GameObjects", + "manage_editor": "Open/close prefab stages in the Unity Editor UI", + "manage_prefabs": "Headless prefab inspection and modification without opening prefab stages", "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" diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 8f44a0764..1da6a3d9c 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -10,20 +10,24 @@ from transport.legacy.unity_connection import async_send_command_with_retry @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup.", + description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup.", annotations=ToolAnnotations( title="Manage Editor", ), ) async def manage_editor( ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "close_prefab_stage", "deploy_package", "restore_package"], "Get and update the Unity Editor state. close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup."], tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, "Tag name when adding and removing tags"] | None = None, layer_name: Annotated[str, "Layer name when adding and removing layers"] | None = None, + prefab_path: Annotated[str, + "Prefab asset path when opening a prefab stage (e.g. Assets/Prefabs/MyPrefab.prefab)."] | None = None, + path: Annotated[str, + "Compatibility alias for prefab_path when opening a prefab stage."] | None = None, ) -> dict[str, Any]: # Get active instance from request state (injected by middleware) unity_instance = await get_unity_instance_from_context(ctx) @@ -36,6 +40,13 @@ async def manage_editor( if action == "telemetry_ping": record_tool_usage("diagnostic_ping", True, 1.0, None) return {"success": True, "message": "telemetry ping queued"} + + if prefab_path is not None and path is not None and prefab_path != path: + return { + "success": False, + "message": "Provide only one of prefab_path or path, or ensure both values match.", + } + # Prepare parameters, removing None values params = { "action": action, @@ -43,6 +54,10 @@ async def manage_editor( "tagName": tag_name, "layerName": layer_name, } + if prefab_path is not None: + params["prefabPath"] = prefab_path + elif path is not None: + params["path"] = path params = {k: v for k, v in params.items() if v is not None} # Send command using centralized retry helper with instance routing diff --git a/Server/tests/test_manage_editor.py b/Server/tests/test_manage_editor.py new file mode 100644 index 000000000..f61614151 --- /dev/null +++ b/Server/tests/test_manage_editor.py @@ -0,0 +1,108 @@ +"""Tests for the manage_editor tool surface.""" + +import inspect + +import pytest + +import services.tools.manage_editor as manage_editor_mod +from services.registry import get_registered_tools +from .integration.test_helpers import DummyContext + + +def test_manage_editor_prefab_path_parameters_exist(): + """open_prefab_stage should expose prefab_path plus path alias parameters.""" + sig = inspect.signature(manage_editor_mod.manage_editor) + assert "prefab_path" in sig.parameters + assert "path" in sig.parameters + assert sig.parameters["prefab_path"].default is None + assert sig.parameters["path"].default is None + + +def test_manage_editor_description_mentions_open_prefab_stage(): + """The tool description should advertise the new prefab stage action.""" + editor_tool = next( + (t for t in get_registered_tools() if t["name"] == "manage_editor"), None + ) + assert editor_tool is not None + desc = editor_tool.get("description") or editor_tool.get("kwargs", {}).get("description", "") + assert "open_prefab_stage" in desc + + +@pytest.mark.asyncio +async def test_manage_editor_open_prefab_stage_forwards_prefab_path(monkeypatch): + """prefab_path should map to Unity's prefabPath parameter.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True, "data": {"openedPrefabPath": params["prefabPath"]}} + + monkeypatch.setattr( + manage_editor_mod, + "async_send_command_with_retry", + fake_send, + ) + + resp = await manage_editor_mod.manage_editor( + ctx=DummyContext(), + action="open_prefab_stage", + prefab_path="Assets/Prefabs/Test.prefab", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "manage_editor" + assert captured["params"]["action"] == "open_prefab_stage" + assert captured["params"]["prefabPath"] == "Assets/Prefabs/Test.prefab" + assert "path" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_editor_open_prefab_stage_accepts_path_alias(monkeypatch): + """path should remain available as a compatibility alias.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True} + + monkeypatch.setattr( + manage_editor_mod, + "async_send_command_with_retry", + fake_send, + ) + + resp = await manage_editor_mod.manage_editor( + ctx=DummyContext(), + action="open_prefab_stage", + path="Assets/Prefabs/Alias.prefab", + ) + + assert resp.get("success") is True + assert captured["params"]["action"] == "open_prefab_stage" + assert captured["params"]["path"] == "Assets/Prefabs/Alias.prefab" + assert "prefabPath" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_editor_open_prefab_stage_rejects_conflicting_path_inputs(monkeypatch): + """Conflicting aliases should fail fast before sending a Unity command.""" + + async def fake_send(cmd, params, **kwargs): # pragma: no cover - should not be hit + raise AssertionError("send should not be called for conflicting path inputs") + + monkeypatch.setattr( + manage_editor_mod, + "async_send_command_with_retry", + fake_send, + ) + + resp = await manage_editor_mod.manage_editor( + ctx=DummyContext(), + action="open_prefab_stage", + prefab_path="Assets/Prefabs/Primary.prefab", + path="Assets/Prefabs/Alias.prefab", + ) + + assert resp.get("success") is False + assert "Provide only one of prefab_path or path" in resp.get("message", "") diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs new file mode 100644 index 000000000..5ef2c9f91 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs @@ -0,0 +1,171 @@ +using System.IO; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using static MCPForUnityTests.Editor.TestUtilities; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageEditorPrefabStageTests + { + private const string TempDirectory = "Assets/Temp/ManageEditorPrefabStageTests"; + + [SetUp] + public void SetUp() + { + StageUtility.GoToMainStage(); + EnsureFolder(TempDirectory); + } + + [TearDown] + public void TearDown() + { + StageUtility.GoToMainStage(); + + if (AssetDatabase.IsValidFolder(TempDirectory)) + { + AssetDatabase.DeleteAsset(TempDirectory); + } + + CleanupEmptyParentFolders(TempDirectory); + } + + [Test] + public void OpenPrefabStage_RequiresPrefabPath() + { + var result = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "open_prefab_stage" + })); + + Assert.IsFalse(result.Value("success")); + StringAssert.Contains("prefabPath", result.Value("error")); + } + + [Test] + public void OpenPrefabStage_RejectsNonPrefabPath() + { + var result = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "open_prefab_stage", + ["prefabPath"] = "Assets/Temp/NotPrefab.txt" + })); + + Assert.IsFalse(result.Value("success")); + StringAssert.Contains(".prefab", result.Value("error")); + } + + [Test] + public void OpenPrefabStage_OpensPrefabStageAndReturnsStageData() + { + string prefabPath = CreateTestPrefab("OpenStageRoot"); + + try + { + var result = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "open_prefab_stage", + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success")); + Assert.AreEqual(prefabPath, result["data"].Value("prefabPath")); + Assert.AreEqual(prefabPath, result["data"].Value("openedPrefabPath")); + Assert.AreEqual("OpenStageRoot", result["data"].Value("rootName")); + + var stage = PrefabStageUtility.GetCurrentPrefabStage(); + Assert.IsNotNull(stage); + Assert.AreEqual(prefabPath, stage.assetPath); + + var closeResult = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "close_prefab_stage" + })); + Assert.IsTrue(closeResult.Value("success")); + Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage()); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void OpenPrefabStage_AcceptsPathAlias() + { + string prefabPath = CreateTestPrefab("AliasRoot"); + + try + { + var result = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "open_prefab_stage", + ["path"] = prefabPath + })); + + Assert.IsTrue(result.Value("success")); + Assert.AreEqual(prefabPath, result["data"].Value("openedPrefabPath")); + Assert.IsNotNull(PrefabStageUtility.GetCurrentPrefabStage()); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void OpenPrefabStage_PrefabPathTakesPrecedenceOverPath() + { + string prefabPath = CreateTestPrefab("PrefabPathRoot"); + string aliasPath = CreateTestPrefab("AliasPathRoot"); + + try + { + var result = ToJObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "open_prefab_stage", + ["prefabPath"] = prefabPath, + ["path"] = aliasPath + })); + + Assert.IsTrue(result.Value("success")); + + var currentStage = PrefabStageUtility.GetCurrentPrefabStage(); + Assert.IsNotNull(currentStage, "Expected a prefab stage to be open."); + Assert.AreEqual( + prefabPath, + currentStage.assetPath, + "prefabPath should take precedence over path when both are provided." + ); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + SafeDeleteAsset(aliasPath); + } + } + + private static string CreateTestPrefab(string rootName) + { + string prefabPath = Path.Combine(TempDirectory, $"{rootName}.prefab").Replace('\\', '/'); + var root = new GameObject(rootName); + + try + { + PrefabUtility.SaveAsPrefabAsset(root, prefabPath); + } + finally + { + Object.DestroyImmediate(root, true); + } + + return prefabPath; + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta new file mode 100644 index 000000000..709168bff --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e8096e84d654db1ab2d7074fd7f0e1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 4a09511b7..acf61a8ad 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -706,6 +706,7 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") +manage_editor(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene # Package deployment (no confirmation dialog — designed for LLM-driven iteration)