diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs index 9bf16c704..2e0bf03bc 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs @@ -80,7 +80,7 @@ public static object HandleCommand(JObject @params) return new ErrorResponse( $"Target '{targetPath}' is a prefab asset. " + $"Use 'manage_asset' with action='modify' for prefab asset modifications, " + - $"or 'manage_prefabs' with action='modify_contents' to edit the prefab headlessly, or 'manage_editor' with action='close_prefab_stage' to exit prefab editing mode." + $"or 'manage_prefabs' with action='modify_contents' to edit the prefab headlessly, or 'manage_prefabs' with action='close_prefab_stage' to exit prefab editing mode." ); } // --- End Prefab Asset Check --- diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 824334f6c..6d2b27be7 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -3,7 +3,6 @@ using MCPForUnity.Editor.Services; using Newtonsoft.Json.Linq; using UnityEditor; -using UnityEditor.SceneManagement; using UnityEditorInternal; // Required for tag management using UnityEngine; @@ -47,8 +46,6 @@ 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) { @@ -137,14 +134,6 @@ public static object HandleCommand(JObject @params) // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); - // Prefab Stage - case "open_prefab_stage": - return OpenPrefabStage(prefabPath); - case "save_prefab_stage": - return SavePrefabStage(); - case "close_prefab_stage": - return ClosePrefabStage(); - // Package Deployment case "deploy_package": return DeployPackage(); @@ -182,7 +171,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, open_prefab_stage, save_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. 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, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } @@ -402,112 +391,6 @@ 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 SavePrefabStage() - { - try - { - var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - if (prefabStage == null) - { - return new ErrorResponse("Not currently in prefab editing mode. Open a prefab stage first with open_prefab_stage."); - } - - string prefabPath = prefabStage.assetPath; - EditorSceneManager.MarkSceneDirty(prefabStage.scene); - bool saved = EditorSceneManager.SaveScene(prefabStage.scene); - if (!saved) - { - return new ErrorResponse($"Failed to save prefab stage for '{prefabPath}'. The file may be read-only or the disk may be full."); - } - - return new SuccessResponse($"Saved prefab stage changes for '{prefabPath}'.", new { prefabPath, saved }); - } - catch (Exception e) - { - return new ErrorResponse($"Error saving prefab stage: {e.Message}"); - } - } - - private static object ClosePrefabStage() - { - try - { - var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - if (prefabStage == null) - { - return new SuccessResponse("Not currently in prefab editing mode."); - } - - string prefabPath = prefabStage.assetPath; - StageUtility.GoToMainStage(); - return new SuccessResponse($"Exited prefab stage for '{prefabPath}'.", new { prefabPath }); - } - catch (Exception e) - { - return new ErrorResponse($"Error closing prefab stage: {e.Message}"); - } - } - // --- Package Deployment Methods --- private static object DeployPackage() diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index bedd4a36d..812f86d57 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -13,8 +13,8 @@ namespace MCPForUnity.Editor.Tools.Prefabs { [McpForUnityTool("manage_prefabs", AutoRegister = false)] /// - /// Tool to manage Unity Prefabs: create, inspect, and modify prefab assets. - /// Uses headless editing (no UI, no dialogs) for reliable automated workflows. + /// Tool to manage Unity Prefabs: create, inspect, modify, and open/save/close prefab stage. + /// Supports both headless editing (modify_contents) and interactive prefab stage workflows. /// public static class ManagePrefabs { @@ -23,7 +23,10 @@ public static class ManagePrefabs private const string ACTION_GET_INFO = "get_info"; private const string ACTION_GET_HIERARCHY = "get_hierarchy"; private const string ACTION_MODIFY_CONTENTS = "modify_contents"; - private const string SupportedActions = ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY + ", " + ACTION_MODIFY_CONTENTS; + private const string ACTION_OPEN_PREFAB_STAGE = "open_prefab_stage"; + private const string ACTION_SAVE_PREFAB_STAGE = "save_prefab_stage"; + private const string ACTION_CLOSE_PREFAB_STAGE = "close_prefab_stage"; + private const string SupportedActions = ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY + ", " + ACTION_MODIFY_CONTENTS + ", " + ACTION_OPEN_PREFAB_STAGE + ", " + ACTION_SAVE_PREFAB_STAGE + ", " + ACTION_CLOSE_PREFAB_STAGE; public static object HandleCommand(JObject @params) { @@ -50,6 +53,18 @@ public static object HandleCommand(JObject @params) return GetHierarchy(@params); case ACTION_MODIFY_CONTENTS: return ModifyContents(@params); + case ACTION_OPEN_PREFAB_STAGE: + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + return OpenPrefabStage(prefabPath); + } + case ACTION_SAVE_PREFAB_STAGE: + return SavePrefabStage(); + case ACTION_CLOSE_PREFAB_STAGE: + { + bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; + return ClosePrefabStage(saveBeforeClose); + } default: return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } @@ -1261,5 +1276,122 @@ private static void BuildHierarchyItemsRecursive(Transform transform, Transform } #endregion + + #region Prefab Stage + + private static object OpenPrefabStage(string requestedPath) + { + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return new ErrorResponse("Either 'prefabPath' or 'path' 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 SavePrefabStage() + { + try + { + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage == null) + { + return new ErrorResponse("Not currently in prefab editing mode. Open a prefab stage first with open_prefab_stage."); + } + + string prefabPath = prefabStage.assetPath; + EditorSceneManager.MarkSceneDirty(prefabStage.scene); + bool saved = EditorSceneManager.SaveScene(prefabStage.scene); + if (!saved) + { + return new ErrorResponse($"Failed to save prefab stage for '{prefabPath}'. The file may be read-only or the disk may be full."); + } + + return new SuccessResponse($"Saved prefab stage changes for '{prefabPath}'.", new { prefabPath, saved }); + } + catch (Exception e) + { + return new ErrorResponse($"Error saving prefab stage: {e.Message}"); + } + } + + private static object ClosePrefabStage(bool saveBeforeClose = false) + { + try + { + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage == null) + { + return new SuccessResponse("Not currently in prefab editing mode."); + } + + if (saveBeforeClose) + { + var saveResult = SavePrefabStage(); + if (saveResult is ErrorResponse) + { + return saveResult; + } + } + + string prefabPath = prefabStage.assetPath; + StageUtility.GoToMainStage(); + return new SuccessResponse($"Exited prefab stage for '{prefabPath}'.", new { prefabPath }); + } + catch (Exception e) + { + return new ErrorResponse($"Error closing prefab stage: {e.Message}"); + } + } + + #endregion } } diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py index 8b492b094..aab9e033f 100644 --- a/Server/src/cli/commands/prefab.py +++ b/Server/src/cli/commands/prefab.py @@ -29,7 +29,7 @@ def open_stage(path: str): config = get_config() params: dict[str, Any] = { - "action": "open_stage", + "action": "open_prefab_stage", "prefabPath": path, } @@ -57,7 +57,7 @@ def close_stage(save: bool): config = get_config() params: dict[str, Any] = { - "action": "close_stage", + "action": "close_prefab_stage", } if save: params["saveBeforeClose"] = True @@ -83,7 +83,7 @@ def save_stage(): "action": "save_prefab_stage", } - result = run_command("manage_editor", params, config) + result = run_command("manage_prefabs", params, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success("Saved prefab stage") diff --git a/Server/src/services/resources/prefab.py b/Server/src/services/resources/prefab.py index 872e418f2..9c3b30336 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_editor action=open_prefab_stage / save_prefab_stage / close_prefab_stage for prefab editing UI transitions" + "3. Use manage_prefabs action=open_prefab_stage / save_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,8 +80,8 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse: } }, "related_tools": { - "manage_editor": "Open/save/close prefab stages in the Unity Editor UI", - "manage_prefabs": "Headless prefab inspection and modification without opening prefab stages", + "manage_editor": "Editor controls (play/pause/stop, active tool, tags/layers, package deploy/restore)", + "manage_prefabs": "Prefab stage lifecycle (open/save/close) and headless prefab inspection/modification", "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 a92bba70e..31142073b 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -10,24 +10,20 @@ 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, open_prefab_stage, save_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. save_prefab_stage saves changes in the currently open prefab stage back to the prefab asset. 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. undo/redo perform Unity editor undo/redo and return the affected group name.", + 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, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. 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. undo/redo perform Unity editor undo/redo and return the affected group name.", 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", "open_prefab_stage", "save_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; save_prefab_stage saves changes in the open prefab stage back to the asset; 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. undo/redo perform editor undo/redo."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs."], 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) @@ -41,12 +37,6 @@ async def manage_editor( 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, @@ -54,10 +44,6 @@ 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/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index e0a486d99..de7373c92 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -17,14 +17,17 @@ "get_hierarchy": ["prefab_path"], "create_from_gameobject": ["target", "prefab_path"], "modify_contents": ["prefab_path"], + "open_prefab_stage": ["prefab_path"], } @mcp_for_unity_tool( description=( - "Manages Unity Prefab assets via headless operations (no UI, no prefab stages). " - "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. " - "Use modify_contents for headless prefab editing - ideal for automated workflows. " + "Manages Unity Prefab assets. " + "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents, open_prefab_stage, save_prefab_stage, close_prefab_stage. " + "Two approaches to prefab editing: " + "(1) Headless: use modify_contents for automated/scripted edits without opening the prefab in the editor. " + "(2) Interactive: use open_prefab_stage to open a prefab, then manage_gameobject/manage_components to edit objects inside the prefab stage, then save_prefab_stage to save and close_prefab_stage to return to the main scene. " "Use create_child parameter with modify_contents to add child GameObjects or nested prefab instances to a prefab " "(single object or array for batch creation in one save). " "Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, " @@ -50,6 +53,9 @@ async def manage_prefabs( "get_info", "get_hierarchy", "modify_contents", + "open_prefab_stage", + "save_prefab_stage", + "close_prefab_stage", ], "Prefab operation to perform.", ], diff --git a/Server/tests/test_cli_commands_characterization.py b/Server/tests/test_cli_commands_characterization.py index 432694fb2..21cff3d74 100644 --- a/Server/tests/test_cli_commands_characterization.py +++ b/Server/tests/test_cli_commands_characterization.py @@ -106,7 +106,7 @@ def test_prefab_open_builds_action_and_path_params(self, runner, mock_config): args = mock_run.call_args assert args[0][0] == "manage_prefabs" params = args[0][1] - assert params["action"] == "open_stage" + assert params["action"] == "open_prefab_stage" assert params["prefabPath"] == "Assets/Prefabs/Test.prefab" def test_component_add_with_optional_properties(self, runner, mock_config): diff --git a/Server/tests/test_manage_editor.py b/Server/tests/test_manage_editor.py index 7dcc0d03b..bcfc2851b 100644 --- a/Server/tests/test_manage_editor.py +++ b/Server/tests/test_manage_editor.py @@ -55,7 +55,7 @@ def test_redo_forwards_to_unity(mock_unity): UNITY_FORWARDED_ACTIONS = [ "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", - "open_prefab_stage", "save_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", + "deploy_package", "restore_package", "undo", "redo", ] @@ -95,99 +95,3 @@ def test_undo_omits_none_params(mock_unity): assert "layerName" not in params -# ── open_prefab_stage ──────────────────────────────────────────────── - - -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 - - -def test_open_prefab_stage_forwards_prefab_path(mock_unity): - """prefab_path should map to Unity's prefabPath parameter.""" - result = asyncio.run( - manage_editor( - SimpleNamespace(), - action="open_prefab_stage", - prefab_path="Assets/Prefabs/Test.prefab", - ) - ) - assert result["success"] is True - assert mock_unity["params"]["action"] == "open_prefab_stage" - assert mock_unity["params"]["prefabPath"] == "Assets/Prefabs/Test.prefab" - assert "path" not in mock_unity["params"] - - -def test_open_prefab_stage_accepts_path_alias(mock_unity): - """path should remain available as a compatibility alias.""" - result = asyncio.run( - manage_editor( - SimpleNamespace(), - action="open_prefab_stage", - path="Assets/Prefabs/Alias.prefab", - ) - ) - assert result["success"] is True - assert mock_unity["params"]["action"] == "open_prefab_stage" - assert mock_unity["params"]["path"] == "Assets/Prefabs/Alias.prefab" - assert "prefabPath" not in mock_unity["params"] - - -def test_open_prefab_stage_rejects_conflicting_path_inputs(mock_unity): - """Conflicting aliases should fail fast before sending a Unity command.""" - result = asyncio.run( - manage_editor( - SimpleNamespace(), - action="open_prefab_stage", - prefab_path="Assets/Prefabs/Primary.prefab", - path="Assets/Prefabs/Alias.prefab", - ) - ) - assert result["success"] is False - assert "Provide only one of prefab_path or path" in result.get("message", "") - - -# ── save_prefab_stage ──────────────────────────────────────────────── - - -def test_manage_editor_description_mentions_save_prefab_stage(): - """The tool description should advertise the save_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 "save_prefab_stage" in desc - - -def test_save_prefab_stage_forwards_to_unity(mock_unity): - """save_prefab_stage should forward to Unity without extra parameters.""" - result = asyncio.run(manage_editor(SimpleNamespace(), action="save_prefab_stage")) - assert result["success"] is True - assert mock_unity["params"]["action"] == "save_prefab_stage" - assert mock_unity["tool_name"] == "manage_editor" - - -def test_save_prefab_stage_omits_none_params(mock_unity): - """save_prefab_stage should not include toolName, tagName, layerName, or path params.""" - asyncio.run(manage_editor(SimpleNamespace(), action="save_prefab_stage")) - params = mock_unity["params"] - assert "toolName" not in params - assert "tagName" not in params - assert "layerName" not in params - assert "prefabPath" not in params - assert "path" not in params diff --git a/Server/tests/test_manage_prefabs.py b/Server/tests/test_manage_prefabs.py index ace42957d..8ca7364b7 100644 --- a/Server/tests/test_manage_prefabs.py +++ b/Server/tests/test_manage_prefabs.py @@ -135,3 +135,77 @@ def test_delete_child_none_omitted_from_params(self, mock_unity): ) ) assert "deleteChild" not in mock_unity["params"] + + +# ── Prefab Stage Actions ──────────────────────────────────────────── + + +class TestManagePrefabsStageActions: + """Tests for open/save/close prefab stage actions on manage_prefabs.""" + + def test_description_mentions_open_prefab_stage(self): + """The tool description should mention open_prefab_stage.""" + prefab_tool = next( + (t for t in get_registered_tools() if t["name"] == "manage_prefabs"), None + ) + assert prefab_tool is not None + desc = prefab_tool.get("description") or prefab_tool.get("kwargs", {}).get("description", "") + assert "open_prefab_stage" in desc + + def test_description_mentions_save_prefab_stage(self): + """The tool description should mention save_prefab_stage.""" + prefab_tool = next( + (t for t in get_registered_tools() if t["name"] == "manage_prefabs"), None + ) + assert prefab_tool is not None + desc = prefab_tool.get("description") or prefab_tool.get("kwargs", {}).get("description", "") + assert "save_prefab_stage" in desc + + def test_open_prefab_stage_forwards_prefab_path(self, mock_unity): + """open_prefab_stage should forward prefab_path as prefabPath.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="open_prefab_stage", + prefab_path="Assets/Prefabs/Test.prefab", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "open_prefab_stage" + assert mock_unity["params"]["prefabPath"] == "Assets/Prefabs/Test.prefab" + assert mock_unity["tool_name"] == "manage_prefabs" + + def test_open_prefab_stage_requires_prefab_path(self, mock_unity): + """open_prefab_stage should fail without prefab_path.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="open_prefab_stage", + ) + ) + assert result["success"] is False + assert "prefab_path" in result["message"] + + def test_save_prefab_stage_forwards_to_unity(self, mock_unity): + """save_prefab_stage should forward to Unity.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="save_prefab_stage", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "save_prefab_stage" + assert mock_unity["tool_name"] == "manage_prefabs" + + def test_close_prefab_stage_forwards_to_unity(self, mock_unity): + """close_prefab_stage should forward to Unity.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="close_prefab_stage", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "close_prefab_stage" + assert mock_unity["tool_name"] == "manage_prefabs" diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta deleted file mode 100644 index 709168bff..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6e8096e84d654db1ab2d7074fd7f0e1a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsStageTests.cs similarity index 89% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsStageTests.cs index 5ef2c9f91..10a61589c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorPrefabStageTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsStageTests.cs @@ -1,5 +1,5 @@ using System.IO; -using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.Prefabs; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEditor; @@ -9,9 +9,9 @@ namespace MCPForUnityTests.Editor.Tools { - public class ManageEditorPrefabStageTests + public class ManagePrefabsStageTests { - private const string TempDirectory = "Assets/Temp/ManageEditorPrefabStageTests"; + private const string TempDirectory = "Assets/Temp/ManagePrefabsStageTests"; [SetUp] public void SetUp() @@ -36,7 +36,7 @@ public void TearDown() [Test] public void OpenPrefabStage_RequiresPrefabPath() { - var result = ToJObject(ManageEditor.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_prefab_stage" })); @@ -48,7 +48,7 @@ public void OpenPrefabStage_RequiresPrefabPath() [Test] public void OpenPrefabStage_RejectsNonPrefabPath() { - var result = ToJObject(ManageEditor.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_prefab_stage", ["prefabPath"] = "Assets/Temp/NotPrefab.txt" @@ -65,7 +65,7 @@ public void OpenPrefabStage_OpensPrefabStageAndReturnsStageData() try { - var result = ToJObject(ManageEditor.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_prefab_stage", ["prefabPath"] = prefabPath @@ -80,7 +80,7 @@ public void OpenPrefabStage_OpensPrefabStageAndReturnsStageData() Assert.IsNotNull(stage); Assert.AreEqual(prefabPath, stage.assetPath); - var closeResult = ToJObject(ManageEditor.HandleCommand(new JObject + var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "close_prefab_stage" })); @@ -101,7 +101,7 @@ public void OpenPrefabStage_AcceptsPathAlias() try { - var result = ToJObject(ManageEditor.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_prefab_stage", ["path"] = prefabPath @@ -126,7 +126,7 @@ public void OpenPrefabStage_PrefabPathTakesPrecedenceOverPath() try { - var result = ToJObject(ManageEditor.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_prefab_stage", ["prefabPath"] = prefabPath, diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 601e533a9..9bf1d41e5 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -730,9 +730,9 @@ 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="save_prefab_stage") # Save changes in the open prefab stage -manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene +manage_prefabs(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") +manage_prefabs(action="save_prefab_stage") # Save changes in the open prefab stage +manage_prefabs(action="close_prefab_stage") # Exit prefab editing mode back to main scene # Package deployment (no confirmation dialog — designed for LLM-driven iteration) manage_editor(action="deploy_package") # Copy configured MCPForUnity source into installed package