diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 87b38768..7ce2a99e 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -70,6 +70,9 @@ jobs: echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" + echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" + echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" + - name: Commit and push changes env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} @@ -78,7 +81,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" + git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs new file mode 100644 index 00000000..44288457 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -0,0 +1,255 @@ +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using MCPForUnity.Editor.Tools.Prefabs; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManagePrefabsTests + { + private const string TempDirectory = "Assets/Temp/ManagePrefabsTests"; + + [SetUp] + public void SetUp() + { + StageUtility.GoToMainStage(); + EnsureTempDirectoryExists(); + } + + [TearDown] + public void TearDown() + { + StageUtility.GoToMainStage(); + } + + [OneTimeTearDown] + public void CleanupAll() + { + StageUtility.GoToMainStage(); + if (AssetDatabase.IsValidFolder(TempDirectory)) + { + AssetDatabase.DeleteAsset(TempDirectory); + } + } + + [Test] + public void OpenStage_OpensPrefabInIsolation() + { + string prefabPath = CreateTestPrefab("OpenStageCube"); + + try + { + var openParams = new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }; + + var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams)); + + Assert.IsTrue(openResult.Value("success"), "open_stage should succeed for a valid prefab."); + + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + Assert.IsNotNull(stage, "Prefab stage should be open after open_stage."); + Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path."); + + var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" })); + Assert.IsTrue(stageInfo.Value("success"), "get_prefab_stage should succeed when stage is open."); + + var data = stageInfo["data"] as JObject; + Assert.IsNotNull(data, "Stage info should include data payload."); + Assert.IsTrue(data.Value("isOpen")); + Assert.AreEqual(prefabPath, data.Value("assetPath")); + } + finally + { + StageUtility.GoToMainStage(); + AssetDatabase.DeleteAsset(prefabPath); + } + } + + [Test] + public void CloseStage_ReturnsSuccess_WhenNoStageOpen() + { + StageUtility.GoToMainStage(); + var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "close_stage" + })); + + Assert.IsTrue(closeResult.Value("success"), "close_stage should succeed even if no stage is open."); + } + + [Test] + public void CloseStage_ClosesOpenPrefabStage() + { + string prefabPath = CreateTestPrefab("CloseStageCube"); + + try + { + ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }); + + var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "close_stage" + })); + + Assert.IsTrue(closeResult.Value("success"), "close_stage should succeed when stage is open."); + Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage."); + } + finally + { + StageUtility.GoToMainStage(); + AssetDatabase.DeleteAsset(prefabPath); + } + } + + [Test] + public void SaveOpenStage_SavesDirtyChanges() + { + string prefabPath = CreateTestPrefab("SaveStageCube"); + + try + { + ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }); + + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + Assert.IsNotNull(stage, "Stage should be open before modifying."); + + stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); + + var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage" + })); + + Assert.IsTrue(saveResult.Value("success"), "save_open_stage should succeed when stage is open."); + Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving."); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage."); + } + finally + { + StageUtility.GoToMainStage(); + AssetDatabase.DeleteAsset(prefabPath); + } + } + + [Test] + public void SaveOpenStage_ReturnsError_WhenNoStageOpen() + { + StageUtility.GoToMainStage(); + + var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage" + })); + + Assert.IsFalse(saveResult.Value("success"), "save_open_stage should fail when no stage is open."); + } + + [Test] + public void CreateFromGameObject_CreatesPrefabAndLinksInstance() + { + EnsureTempDirectoryExists(); + StageUtility.GoToMainStage(); + + string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/'); + GameObject sceneObject = new GameObject("ScenePrefabSource"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = sceneObject.name, + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "create_from_gameobject should succeed for a valid scene object."); + + var data = result["data"] as JObject; + Assert.IsNotNull(data, "Response data should include prefab information."); + + string savedPath = data.Value("prefabPath"); + Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(savedPath); + Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); + + int instanceId = data.Value("instanceId"); + var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId."); + Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab."); + + sceneObject = linkedInstance; + } + finally + { + if (AssetDatabase.LoadAssetAtPath(prefabPath) != null) + { + AssetDatabase.DeleteAsset(prefabPath); + } + + if (sceneObject != null) + { + if (PrefabUtility.IsPartOfPrefabInstance(sceneObject)) + { + PrefabUtility.UnpackPrefabInstance( + sceneObject, + PrefabUnpackMode.Completely, + InteractionMode.AutomatedAction + ); + } + UnityEngine.Object.DestroyImmediate(sceneObject, true); + } + } + } + + private static string CreateTestPrefab(string name) + { + EnsureTempDirectoryExists(); + + GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube); + temp.name = name; + + string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); + UnityEngine.Object.DestroyImmediate(temp); + + Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab."); + return path; + } + + private static void EnsureTempDirectoryExists() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + + if (!AssetDatabase.IsValidFolder(TempDirectory)) + { + AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests"); + } + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta new file mode 100644 index 00000000..8ef3fdb2 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e7a7e542325421ba6de4992ddb3f5db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs new file mode 100644 index 00000000..f03b66c7 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs @@ -0,0 +1,29 @@ +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity asset paths. + /// + public static class AssetPathUtility + { + /// + /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// + public static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + path = path.Replace('\\', '/'); + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + + return path; + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta new file mode 100644 index 00000000..bd6a0c70 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 8ad1f8fa..cd861658 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -15,6 +15,7 @@ using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.MenuItems; +using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor { @@ -1040,7 +1041,26 @@ private static string ExecuteCommand(Command command) // Use JObject for parameters as the new handlers likely expect this JObject paramsObject = command.@params ?? new JObject(); - object result = CommandRegistry.GetHandler(command.type)(paramsObject); + // Route command based on the new tool structure from the refactor plan + object result = command.type switch + { + // Maps the command type (tool name) to the corresponding handler's static HandleCommand method + // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters + "manage_script" => ManageScript.HandleCommand(paramsObject), + // Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag) + "manage_scene" => HandleManageScene(paramsObject) + ?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"), + "manage_editor" => ManageEditor.HandleCommand(paramsObject), + "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), + "manage_asset" => ManageAsset.HandleCommand(paramsObject), + "manage_shader" => ManageShader.HandleCommand(paramsObject), + "read_console" => ReadConsole.HandleCommand(paramsObject), + "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject), + "manage_prefabs" => ManagePrefabs.HandleCommand(paramsObject), + _ => throw new ArgumentException( + $"Unknown or unsupported command type: {command.type}" + ), + }; // Standard success response format var response = new { status = "success", result }; diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs index 70e3ff65..52a5bcac 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -115,7 +115,7 @@ private static object ReimportAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -154,7 +154,7 @@ private static object CreateAsset(JObject @params) if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); // Ensure directory exists @@ -280,7 +280,7 @@ private static object CreateFolder(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); @@ -338,7 +338,7 @@ private static object ModifyAsset(string path, JObject properties) if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -372,7 +372,7 @@ prop.Value is JObject componentProperties { targetComponent = gameObject.GetComponent(compType); } - + // Only warn about resolution failure if component also not found if (targetComponent == null && !resolved) { @@ -495,7 +495,7 @@ private static object DeleteAsset(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -526,7 +526,7 @@ private static object DuplicateAsset(string path, string destinationPath) if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate."); - string sourcePath = SanitizeAssetPath(path); + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); @@ -538,7 +538,7 @@ private static object DuplicateAsset(string path, string destinationPath) } else { - destPath = SanitizeAssetPath(destinationPath); + destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists @@ -576,8 +576,8 @@ private static object MoveOrRenameAsset(string path, string destinationPath) if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename."); - string sourcePath = SanitizeAssetPath(path); - string destPath = SanitizeAssetPath(destinationPath); + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); @@ -642,7 +642,7 @@ private static object SearchAssets(JObject @params) string[] folderScope = null; if (!string.IsNullOrEmpty(pathScope)) { - folderScope = new string[] { SanitizeAssetPath(pathScope) }; + folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; if (!AssetDatabase.IsValidFolder(folderScope[0])) { // Maybe the user provided a file path instead of a folder? @@ -732,7 +732,7 @@ private static object GetAssetInfo(string path, bool generatePreview) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -761,7 +761,7 @@ private static object GetComponentsFromAsset(string path) return Response.Error("'path' is required for get_components."); // 2. Sanitize and check existence - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -829,18 +829,6 @@ private static object GetComponentsFromAsset(string path) /// /// Ensures the asset path starts with "Assets/". /// - private static string SanitizeAssetPath(string path) - { - if (string.IsNullOrEmpty(path)) - return path; - path = path.Replace('\\', '/'); // Normalize separators - if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - return "Assets/" + path.TrimStart('/'); - } - return path; - } - /// /// Checks if an asset exists at the given path (file or folder). /// @@ -930,16 +918,18 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) ); } } - } else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py + } + else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py { - string propName = "_Color"; - try { + string propName = "_Color"; + try + { if (colorArr.Count >= 3) { Color newColor = new Color( colorArr[0].ToObject(), - colorArr[1].ToObject(), - colorArr[2].ToObject(), + colorArr[1].ToObject(), + colorArr[2].ToObject(), colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) @@ -948,8 +938,9 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) modified = true; } } - } - catch (Exception ex) { + } + catch (Exception ex) + { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); @@ -989,7 +980,7 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) if (!string.IsNullOrEmpty(texPath)) { Texture newTex = AssetDatabase.LoadAssetAtPath( - SanitizeAssetPath(texPath) + AssetPathUtility.SanitizeAssetPath(texPath) ); if ( newTex != null @@ -1217,7 +1208,7 @@ private static object ConvertJTokenToType(JToken token, Type targetType) && token.Type == JTokenType.String ) { - string assetPath = SanitizeAssetPath(token.ToString()); + string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( assetPath, targetType @@ -1337,4 +1328,3 @@ private static object GetAssetData(string path, bool generatePreview = false) } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs index 7ed6300b..f26502dd 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -5,8 +5,9 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management +using UnityEditor.SceneManagement; using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class +using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools { @@ -98,6 +99,8 @@ public static object HandleCommand(JObject @params) return GetActiveTool(); case "get_selection": return GetSelection(); + case "get_prefab_stage": + return GetPrefabStageInfo(); case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) @@ -140,7 +143,7 @@ public static object HandleCommand(JObject @params) default: return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." ); } } @@ -244,6 +247,35 @@ private static object GetEditorWindows() } } + private static object GetPrefabStageInfo() + { + try + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Success + ("No prefab stage is currently open.", new { isOpen = false }); + } + + return Response.Success( + "Prefab stage info retrieved.", + new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + } + ); + } + catch (Exception e) + { + return Response.Error($"Error getting prefab stage info: {e.Message}"); + } + } + private static object GetActiveTool() { try @@ -610,4 +642,3 @@ public static string GetActiveToolName() } } } - diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs index 8cca35a6..0f213c68 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -1,15 +1,9 @@ using System; using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems { - /// - /// Facade handler for managing Unity Editor menu items. - /// Routes actions to read or execute implementations. - /// public static class ManageMenuItem { /// diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs index fe6180f7..193a80f6 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using Newtonsoft.Json.Linq; using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems @@ -37,23 +36,12 @@ public static object Execute(JObject @params) try { - // Execute on main thread using delayCall - EditorApplication.delayCall += () => + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (!executed) { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - if (!executed) - { - McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); - } - } - catch (Exception delayEx) - { - McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}"); - } - }; - + McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + } return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); } catch (Exception e) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs index db91feb3..60c94125 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -3,7 +3,6 @@ using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems diff --git a/UnityMcpBridge/Editor/Tools/Prefabs.meta b/UnityMcpBridge/Editor/Tools/Prefabs.meta new file mode 100644 index 00000000..4fb95c50 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bd48a1b7555c46bba168078ce0291cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs new file mode 100644 index 00000000..aaf67b14 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -0,0 +1,274 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.Prefabs +{ + public static class ManagePrefabs + { + private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return Response.Error("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); + } + + try + { + switch (action) + { + case "open_stage": + return OpenStage(@params); + case "close_stage": + return CloseStage(@params); + case "save_open_stage": + return SaveOpenStage(); + case "create_from_gameobject": + return CreatePrefabFromGameObject(@params); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); + return Response.Error($"Internal error: {e.Message}"); + } + } + + private static object OpenStage(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return Response.Error("'prefabPath' parameter is required for open_stage."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (prefabAsset == null) + { + return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); + } + + string modeValue = @params["mode"]?.ToString(); + if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) + { + return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); + } + + PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); + if (stage == null) + { + return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); + } + + return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); + } + + private static object CloseStage(JObject @params) + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Success("No prefab stage was open."); + } + + bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; + if (saveBeforeClose && stage.scene.isDirty) + { + SaveStagePrefab(stage); + AssetDatabase.SaveAssets(); + } + + StageUtility.GoToMainStage(); + return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); + } + + private static object SaveOpenStage() + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Error("No prefab stage is currently open."); + } + + SaveStagePrefab(stage); + AssetDatabase.SaveAssets(); + return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + } + + private static void SaveStagePrefab(PrefabStage stage) + { + if (stage?.prefabContentsRoot == null) + { + throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); + } + + bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); + if (!saved) + { + throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); + } + } + + private static object CreatePrefabFromGameObject(JObject @params) + { + string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); + if (string.IsNullOrEmpty(targetName)) + { + return Response.Error("'target' parameter is required for create_from_gameobject."); + } + + bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; + GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); + if (sourceObject == null) + { + return Response.Error($"GameObject '{targetName}' not found in the active scene."); + } + + if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + { + return Response.Error( + $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." + ); + } + + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); + if (status != PrefabInstanceStatus.NotAPrefab) + { + return Response.Error( + $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." + ); + } + + string requestedPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + sanitizedPath += ".prefab"; + } + + bool allowOverwrite = @params["allowOverwrite"]?.ToObject() ?? false; + string finalPath = sanitizedPath; + + if (!allowOverwrite && AssetDatabase.LoadAssetAtPath(finalPath) != null) + { + finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); + } + + EnsureAssetDirectoryExists(finalPath); + + try + { + GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, + finalPath, + InteractionMode.AutomatedAction + ); + + if (connectedInstance == null) + { + return Response.Error($"Failed to save prefab asset at '{finalPath}'."); + } + + Selection.activeGameObject = connectedInstance; + + return Response.Success( + $"Prefab created at '{finalPath}' and instance linked.", + new + { + prefabPath = finalPath, + instanceId = connectedInstance.GetInstanceID() + } + ); + } + catch (Exception e) + { + return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); + } + } + + private static void EnsureAssetDirectoryExists(string assetPath) + { + string directory = Path.GetDirectoryName(assetPath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); + if (!Directory.Exists(fullDirectory)) + { + Directory.CreateDirectory(fullDirectory); + AssetDatabase.Refresh(); + } + } + + private static GameObject FindSceneObjectByName(string name, bool includeInactive) + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage?.prefabContentsRoot != null) + { + foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren(includeInactive)) + { + if (transform.name == name) + { + return transform.gameObject; + } + } + } + + Scene activeScene = SceneManager.GetActiveScene(); + foreach (GameObject root in activeScene.GetRootGameObjects()) + { + foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) + { + GameObject candidate = transform.gameObject; + if (candidate.name == name) + { + return candidate; + } + } + } + + return null; + } + + private static object SerializeStage(PrefabStage stage) + { + if (stage == null) + { + return new { isOpen = false }; + } + + return new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + }; + } + + } +} diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta new file mode 100644 index 00000000..27182e77 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c14e76b2aa7bb4570a88903b061e946e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index c14f6ceb..6845bcf7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -5,6 +5,7 @@ from .manage_editor import register_manage_editor_tools from .manage_gameobject import register_manage_gameobject_tools from .manage_asset import register_manage_asset_tools +from .manage_prefabs import register_manage_prefabs_tools from .manage_shader import register_manage_shader_tools from .read_console import register_read_console_tools from .manage_menu_item import register_manage_menu_item_tools @@ -22,6 +23,7 @@ def register_all_tools(mcp): register_manage_editor_tools(mcp) register_manage_gameobject_tools(mcp) register_manage_asset_tools(mcp) + register_manage_prefabs_tools(mcp) register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_manage_menu_item_tools(mcp) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index ba5601de..6ded5d02 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -13,7 +13,7 @@ def register_manage_menu_item_tools(mcp: FastMCP): """Registers the manage_menu_item tool with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.") @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, @@ -25,18 +25,6 @@ async def manage_menu_item( refresh: Annotated[bool | None, "Optional flag to force refresh of the menu cache when listing"] = None, ) -> dict[str, Any]: - """Manage Unity menu items (execute/list/exists). - - Args: - ctx: The MCP context. - action: One of 'execute', 'list', 'exists'. - menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project"). - search: Optional filter string for 'list'. - refresh: Optional flag to force refresh of the menu cache when listing. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ # Prepare parameters for the C# handler params_dict: dict[str, Any] = { "action": action, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py new file mode 100644 index 00000000..c9321b98 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -0,0 +1,59 @@ +from typing import Annotated, Any, Literal +from mcp.server.fastmcp import FastMCP, Context + +from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + +def register_manage_prefabs_tools(mcp: FastMCP) -> None: + """Register prefab management tools with the MCP server.""" + + @mcp.tool(description="Bridge for prefab management commands (stage control and creation).") + @telemetry_tool("manage_prefabs") + def manage_prefabs( + ctx: Context, + action: Annotated[Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "create_from_gameobject", + ], "One of open_stage, close_stage, save_open_stage, create_from_gameobject"], + prefab_path: Annotated[str | None, + "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] = None, + mode: Annotated[str | None, + "Optional prefab stage mode (only 'InIsolation' is currently supported)"] = None, + save_before_close: Annotated[bool | None, + "When true, `close_stage` will save the prefab before exiting the stage."] = None, + target: Annotated[str | None, + "Scene GameObject name required for create_from_gameobject"] = None, + allow_overwrite: Annotated[bool | None, + "Allow replacing an existing prefab at the same path"] = None, + search_inactive: Annotated[bool | None, + "Include inactive objects when resolving the target name"] = None, + ) -> dict[str, Any]: + try: + params: dict[str, Any] = {"action": action} + + if prefab_path: + params["prefabPath"] = prefab_path + if mode: + params["mode"] = mode + if save_before_close is not None: + params["saveBeforeClose"] = bool(save_before_close) + if target: + params["target"] = target + if allow_overwrite is not None: + params["allowOverwrite"] = bool(allow_overwrite) + if search_inactive is not None: + params["searchInactive"] = bool(search_inactive) + response = send_command_with_retry("manage_prefabs", params) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Prefab operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + except Exception as exc: + return {"success": False, "message": f"Python error managing prefabs: {exc}"}