From 6b6172f62434b811238beba1de0dd00c653597f0 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 16 Sep 2025 21:19:31 -0400 Subject: [PATCH 01/13] refactor: remove unused UnityEngine references from menu item classes --- UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs | 6 ------ UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs | 1 - UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs | 1 - 3 files changed, 8 deletions(-) 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..6ab49daf 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 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 From 08bb831f5d4c5cfef0b19e740fdaf8ae85a2db73 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Mon, 22 Sep 2025 19:02:55 -0400 Subject: [PATCH 02/13] Add new tools to manage a prefab, particularly, making them staged. This might be enough, but it's possible we may have to extract some logic from ManageGameObject --- .../EditMode/Tools/ManagePrefabsTests.cs | 257 ++++++++++++++++ .../EditMode/Tools/ManagePrefabsTests.cs.meta | 11 + UnityMcpBridge/Editor/Tools/ManageEditor.cs | 37 ++- UnityMcpBridge/Editor/Tools/Prefabs.meta | 8 + .../Editor/Tools/Prefabs/ManagePrefabs.cs | 276 ++++++++++++++++++ .../Tools/Prefabs/ManagePrefabs.cs.meta | 11 + 6 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta create mode 100644 UnityMcpBridge/Editor/Tools/Prefabs.meta create mode 100644 UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs create mode 100644 UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta 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..059db71b --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -0,0 +1,257 @@ +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", + ["path"] = 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", + ["path"] = 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", + ["path"] = 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 ApplyInstanceOverrides_UpdatesPrefabAsset() + { + string prefabPath = CreateTestPrefab("ApplyOverridesCube"); + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + + GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset); + instance.name = "ApplyOverridesInstance"; + + try + { + instance.transform.localScale = new Vector3(3f, 3f, 3f); + + var applyResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "apply_instance_overrides", + ["instanceId"] = instance.GetInstanceID() + })); + + Assert.IsTrue(applyResult.Value("success"), "apply_instance_overrides should succeed for prefab instance."); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(new Vector3(3f, 3f, 3f), reloaded.transform.localScale, "Prefab asset should reflect applied overrides."); + } + finally + { + UnityEngine.Object.DestroyImmediate(instance); + AssetDatabase.DeleteAsset(prefabPath); + } + } + + [Test] + public void RevertInstanceOverrides_RevertsToPrefabDefaults() + { + string prefabPath = CreateTestPrefab("RevertOverridesCube"); + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + + GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset); + instance.name = "RevertOverridesInstance"; + + try + { + instance.transform.localScale = new Vector3(4f, 4f, 4f); + + var revertResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "revert_instance_overrides", + ["instanceId"] = instance.GetInstanceID() + })); + + Assert.IsTrue(revertResult.Value("success"), "revert_instance_overrides should succeed for prefab instance."); + Assert.AreEqual(Vector3.one, instance.transform.localScale, "Prefab instance should revert to default scale."); + } + finally + { + UnityEngine.Object.DestroyImmediate(instance); + AssetDatabase.DeleteAsset(prefabPath); + } + } + + 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/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/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..8f3f7b95 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -0,0 +1,276 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Prefabs +{ + public static class ManagePrefabs + { + private const string SupportedActions = "open_stage, close_stage, save_open_stage, apply_instance_overrides, revert_instance_overrides"; + + 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 "apply_instance_overrides": + return ApplyInstanceOverrides(@params); + case "revert_instance_overrides": + return RevertInstanceOverrides(@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 path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + { + return Response.Error("'path' parameter is required for open_stage."); + } + + string sanitizedPath = SanitizeAssetPath(path); + 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 ApplyInstanceOverrides(JObject @params) + { + if (!TryGetPrefabInstance(@params, out GameObject instanceRoot, out string error)) + { + return Response.Error(error); + } + + PrefabUtility.ApplyPrefabInstance(instanceRoot, InteractionMode.AutomatedAction); + string prefabAssetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(instanceRoot); + + return Response.Success( + $"Applied overrides on prefab instance '{instanceRoot.name}'.", + new + { + prefabAssetPath, + instanceId = instanceRoot.GetInstanceID() + } + ); + } + + private static object RevertInstanceOverrides(JObject @params) + { + if (!TryGetPrefabInstance(@params, out GameObject instanceRoot, out string error)) + { + return Response.Error(error); + } + + PrefabUtility.RevertPrefabInstance(instanceRoot, InteractionMode.AutomatedAction); + + return Response.Success( + $"Reverted overrides on prefab instance '{instanceRoot.name}'.", + new + { + instanceId = instanceRoot.GetInstanceID() + } + ); + } + + private static bool TryGetPrefabInstance(JObject @params, out GameObject instanceRoot, out string error) + { + instanceRoot = null; + error = null; + + JToken instanceIdToken = @params["instanceId"] ?? @params["instanceID"]; + if (instanceIdToken != null && instanceIdToken.Type == JTokenType.Integer) + { + int instanceId = instanceIdToken.Value(); + if (!TryResolveInstance(instanceId, out instanceRoot, out error)) + { + return false; + } + return true; + } + + string targetName = @params["target"]?.ToString(); + if (!string.IsNullOrEmpty(targetName)) + { + GameObject target = GameObject.Find(targetName); + if (target == null) + { + error = $"GameObject '{targetName}' not found in the current scene."; + return false; + } + + instanceRoot = GetPrefabInstanceRoot(target, out error); + return instanceRoot != null; + } + + error = "Parameter 'instanceId' (or 'target') is required for this action."; + return false; + } + + private static bool TryResolveInstance(int instanceId, out GameObject instanceRoot, out string error) + { + instanceRoot = null; + error = null; + + GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + if (obj == null) + { + error = $"No GameObject found for instanceId {instanceId}."; + return false; + } + + instanceRoot = GetPrefabInstanceRoot(obj, out error); + return instanceRoot != null; + } + + private static GameObject GetPrefabInstanceRoot(GameObject obj, out string error) + { + error = null; + + if (!PrefabUtility.IsPartOfPrefabInstance(obj)) + { + error = $"GameObject '{obj.name}' is not part of a prefab instance."; + return null; + } + + GameObject root = PrefabUtility.GetOutermostPrefabInstanceRoot(obj); + if (root == null) + { + error = $"Failed to resolve prefab instance root for '{obj.name}'."; + return null; + } + + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(root); + if (status == PrefabInstanceStatus.NotAPrefab) + { + error = $"GameObject '{obj.name}' is not recognised as a prefab instance."; + return null; + } + + return root; + } + + 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 + }; + } + + private 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/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: From 7f940db653dd70051d5d9a0b1c0a03637754fccf Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Mon, 22 Sep 2025 19:10:36 -0400 Subject: [PATCH 03/13] feat: add AssetPathUtility for asset path normalization and update references in ManageAsset and ManagePrefabs --- .../Editor/Helpers/AssetPathUtility.cs | 29 +++++++++ .../Editor/Helpers/AssetPathUtility.cs.meta | 11 ++++ UnityMcpBridge/Editor/Tools/ManageAsset.cs | 60 ++++++++----------- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 17 +----- 4 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs create mode 100644 UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta 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/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/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs index 8f3f7b95..6d58c891 100644 --- a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -57,7 +57,7 @@ private static object OpenStage(JObject @params) return Response.Error("'path' parameter is required for open_stage."); } - string sanitizedPath = SanitizeAssetPath(path); + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(path); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { @@ -257,20 +257,5 @@ private static object SerializeStage(PrefabStage stage) }; } - private 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; - } } } From 863984c0debc47217d8785052a332de0785ba749 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 23 Sep 2025 13:50:09 -0400 Subject: [PATCH 04/13] feat: add prefab management tools and register them with the MCP server --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 2 + .../UnityMcpServer~/src/tools/__init__.py | 2 + .../src/tools/manage_prefabs.py | 62 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 88b30deb..66632e24 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 { @@ -1055,6 +1056,7 @@ private static string ExecuteCommand(Command command) "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}" ), 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_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py new file mode 100644 index 00000000..6bbb73cb --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -0,0 +1,62 @@ +from typing import 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() + @telemetry_tool("manage_prefabs") + def manage_prefabs( + ctx: Context, + action: Literal["open_stage", "close_stage", "save_open_stage", "apply_instance_overrides", "revert_instance_overrides"], + path: str | None = None, + mode: str | None = None, + save_before_close: bool | None = None, + instance_id: int | None = None, + target: str | None = None, + ) -> dict[str, Any]: + """Bridge for prefab management commands (stage control, instance overrides). + + Args: + action: One of the supported prefab actions ("open_stage", "close_stage", "save_open_stage", + "apply_instance_overrides", "revert_instance_overrides"). + path: Prefab asset path (used by "open_stage"). + mode: Optional prefab stage mode (currently only "InIsolation" is supported by the C# side). + save_before_close: When true, `close_stage` will save the prefab before exiting the stage. + instance_id: Prefab instance ID for apply/revert overrides. Accepts int-like values. + target: Scene GameObject name/path to resolve prefab instance when `instance_id` isn't provided. + Returns: + Dictionary mirroring the Unity bridge response. + """ + try: + params: dict[str, str] = {"action": action} + + if path: + params["path"] = path + if mode: + params["mode"] = mode + if save_before_close is not None: + params["saveBeforeClose"] = bool(save_before_close) + + coerced_instance_id = int(instance_id) + if coerced_instance_id is not None: + params["instanceId"] = coerced_instance_id + + if target: + params["target"] = target + + 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}"} From dfa32722d4d10b9f71f48127fc87e67ea3334487 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 23 Sep 2025 17:19:19 -0400 Subject: [PATCH 05/13] feat: update prefab management commands to use 'prefabPath' and add 'create_from_gameobject' action --- .../EditMode/Tools/ManagePrefabsTests.cs | 84 ++++----- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 175 +++++++++--------- .../src/tools/manage_prefabs.py | 59 +++--- 3 files changed, 158 insertions(+), 160 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs index 059db71b..44288457 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -46,7 +46,7 @@ public void OpenStage_OpensPrefabInIsolation() var openParams = new JObject { ["action"] = "open_stage", - ["path"] = prefabPath + ["prefabPath"] = prefabPath }; var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams)); @@ -94,7 +94,7 @@ public void CloseStage_ClosesOpenPrefabStage() ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_stage", - ["path"] = prefabPath + ["prefabPath"] = prefabPath }); var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject @@ -122,7 +122,7 @@ public void SaveOpenStage_SavesDirtyChanges() ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_stage", - ["path"] = prefabPath + ["prefabPath"] = prefabPath }); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); @@ -162,62 +162,60 @@ public void SaveOpenStage_ReturnsError_WhenNoStageOpen() } [Test] - public void ApplyInstanceOverrides_UpdatesPrefabAsset() + public void CreateFromGameObject_CreatesPrefabAndLinksInstance() { - string prefabPath = CreateTestPrefab("ApplyOverridesCube"); - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + EnsureTempDirectoryExists(); + StageUtility.GoToMainStage(); - GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset); - instance.name = "ApplyOverridesInstance"; + string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/'); + GameObject sceneObject = new GameObject("ScenePrefabSource"); try { - instance.transform.localScale = new Vector3(3f, 3f, 3f); - - var applyResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { - ["action"] = "apply_instance_overrides", - ["instanceId"] = instance.GetInstanceID() + ["action"] = "create_from_gameobject", + ["target"] = sceneObject.name, + ["prefabPath"] = prefabPath })); - Assert.IsTrue(applyResult.Value("success"), "apply_instance_overrides should succeed for prefab instance."); + Assert.IsTrue(result.Value("success"), "create_from_gameobject should succeed for a valid scene object."); - GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); - Assert.AreEqual(new Vector3(3f, 3f, 3f), reloaded.transform.localScale, "Prefab asset should reflect applied overrides."); - } - finally - { - UnityEngine.Object.DestroyImmediate(instance); - AssetDatabase.DeleteAsset(prefabPath); - } - } + var data = result["data"] as JObject; + Assert.IsNotNull(data, "Response data should include prefab information."); - [Test] - public void RevertInstanceOverrides_RevertsToPrefabDefaults() - { - string prefabPath = CreateTestPrefab("RevertOverridesCube"); - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + string savedPath = data.Value("prefabPath"); + Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); - GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset); - instance.name = "RevertOverridesInstance"; + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(savedPath); + Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); - try - { - instance.transform.localScale = new Vector3(4f, 4f, 4f); - - var revertResult = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "revert_instance_overrides", - ["instanceId"] = instance.GetInstanceID() - })); + 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."); - Assert.IsTrue(revertResult.Value("success"), "revert_instance_overrides should succeed for prefab instance."); - Assert.AreEqual(Vector3.one, instance.transform.localScale, "Prefab instance should revert to default scale."); + sceneObject = linkedInstance; } finally { - UnityEngine.Object.DestroyImmediate(instance); - AssetDatabase.DeleteAsset(prefabPath); + 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); + } } } diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs index 6d58c891..88c83dea 100644 --- a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -1,15 +1,17 @@ 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, apply_instance_overrides, revert_instance_overrides"; + private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; public static object HandleCommand(JObject @params) { @@ -34,10 +36,8 @@ public static object HandleCommand(JObject @params) return CloseStage(@params); case "save_open_stage": return SaveOpenStage(); - case "apply_instance_overrides": - return ApplyInstanceOverrides(@params); - case "revert_instance_overrides": - return RevertInstanceOverrides(@params); + case "create_from_gameobject": + return CreatePrefabFromGameObject(@params); default: return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } @@ -51,13 +51,13 @@ public static object HandleCommand(JObject @params) private static object OpenStage(JObject @params) { - string path = @params["path"]?.ToString(); - if (string.IsNullOrEmpty(path)) + string prefabPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) { return Response.Error("'path' parameter is required for open_stage."); } - string sanitizedPath = AssetPathUtility.SanitizeAssetPath(path); + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { @@ -125,119 +125,120 @@ private static void SaveStagePrefab(PrefabStage stage) } } - private static object ApplyInstanceOverrides(JObject @params) + private static object CreatePrefabFromGameObject(JObject @params) { - if (!TryGetPrefabInstance(@params, out GameObject instanceRoot, out string error)) + string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); + if (string.IsNullOrEmpty(targetName)) { - return Response.Error(error); + return Response.Error("'target' parameter is required for create_from_gameobject."); } - PrefabUtility.ApplyPrefabInstance(instanceRoot, InteractionMode.AutomatedAction); - string prefabAssetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(instanceRoot); + 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."); + } - return Response.Success( - $"Applied overrides on prefab instance '{instanceRoot.name}'.", - new - { - prefabAssetPath, - instanceId = instanceRoot.GetInstanceID() - } - ); - } + if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + { + return Response.Error( + $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." + ); + } - private static object RevertInstanceOverrides(JObject @params) - { - if (!TryGetPrefabInstance(@params, out GameObject instanceRoot, out string error)) + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); + if (status != PrefabInstanceStatus.NotAPrefab) { - return Response.Error(error); + return Response.Error( + $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." + ); } - PrefabUtility.RevertPrefabInstance(instanceRoot, InteractionMode.AutomatedAction); + string requestedPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return Response.Error("'prefabPath' (or 'path') parameter is required for create_from_gameobject."); + } - return Response.Success( - $"Reverted overrides on prefab instance '{instanceRoot.name}'.", - new - { - instanceId = instanceRoot.GetInstanceID() - } - ); - } + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + sanitizedPath += ".prefab"; + } - private static bool TryGetPrefabInstance(JObject @params, out GameObject instanceRoot, out string error) - { - instanceRoot = null; - error = null; + bool allowOverwrite = @params["allowOverwrite"]?.ToObject() ?? false; + string finalPath = sanitizedPath; - JToken instanceIdToken = @params["instanceId"] ?? @params["instanceID"]; - if (instanceIdToken != null && instanceIdToken.Type == JTokenType.Integer) + if (!allowOverwrite && AssetDatabase.LoadAssetAtPath(finalPath) != null) { - int instanceId = instanceIdToken.Value(); - if (!TryResolveInstance(instanceId, out instanceRoot, out error)) - { - return false; - } - return true; + finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); } - string targetName = @params["target"]?.ToString(); - if (!string.IsNullOrEmpty(targetName)) + EnsureAssetDirectoryExists(finalPath); + + try { - GameObject target = GameObject.Find(targetName); - if (target == null) + GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, + finalPath, + InteractionMode.AutomatedAction + ); + + if (connectedInstance == null) { - error = $"GameObject '{targetName}' not found in the current scene."; - return false; + return Response.Error($"Failed to save prefab asset at '{finalPath}'."); } - instanceRoot = GetPrefabInstanceRoot(target, out error); - return instanceRoot != null; - } - - error = "Parameter 'instanceId' (or 'target') is required for this action."; - return false; - } - - private static bool TryResolveInstance(int instanceId, out GameObject instanceRoot, out string error) - { - instanceRoot = null; - error = null; + Selection.activeGameObject = connectedInstance; - GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; - if (obj == null) + return Response.Success( + $"Prefab created at '{finalPath}' and instance linked.", + new + { + prefabPath = finalPath, + instanceId = connectedInstance.GetInstanceID() + } + ); + } + catch (Exception e) { - error = $"No GameObject found for instanceId {instanceId}."; - return false; + return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); } - - instanceRoot = GetPrefabInstanceRoot(obj, out error); - return instanceRoot != null; } - private static GameObject GetPrefabInstanceRoot(GameObject obj, out string error) + private static void EnsureAssetDirectoryExists(string assetPath) { - error = null; - - if (!PrefabUtility.IsPartOfPrefabInstance(obj)) + string directory = Path.GetDirectoryName(assetPath); + if (string.IsNullOrEmpty(directory)) { - error = $"GameObject '{obj.name}' is not part of a prefab instance."; - return null; + return; } - GameObject root = PrefabUtility.GetOutermostPrefabInstanceRoot(obj); - if (root == null) + string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); + if (!Directory.Exists(fullDirectory)) { - error = $"Failed to resolve prefab instance root for '{obj.name}'."; - return null; + Directory.CreateDirectory(fullDirectory); + AssetDatabase.Refresh(); } + } - PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(root); - if (status == PrefabInstanceStatus.NotAPrefab) + private static GameObject FindSceneObjectByName(string name, bool includeInactive) + { + Scene activeScene = SceneManager.GetActiveScene(); + foreach (GameObject root in activeScene.GetRootGameObjects()) { - error = $"GameObject '{obj.name}' is not recognised as a prefab instance."; - return null; + foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) + { + GameObject candidate = transform.gameObject; + if (candidate.name == name) + { + return candidate; + } + } } - return root; + return null; } private static object SerializeStage(PrefabStage stage) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 6bbb73cb..2530ef62 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -8,47 +8,46 @@ def register_manage_prefabs_tools(mcp: FastMCP) -> None: """Register prefab management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Bridge for prefab management commands (stage control and creation).") @telemetry_tool("manage_prefabs") def manage_prefabs( ctx: Context, - action: Literal["open_stage", "close_stage", "save_open_stage", "apply_instance_overrides", "revert_instance_overrides"], - path: str | None = None, - mode: str | None = None, - save_before_close: bool | None = None, - instance_id: int | None = None, - target: str | None = None, + action: Annotated[Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "apply_instance_overrides", + "revert_instance_overrides", + "create_from_gameobject", + ], "One of open_stage, close_stage, save_open_stage, apply_instance_overrides, revert_instance_overrides, 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]: - """Bridge for prefab management commands (stage control, instance overrides). - - Args: - action: One of the supported prefab actions ("open_stage", "close_stage", "save_open_stage", - "apply_instance_overrides", "revert_instance_overrides"). - path: Prefab asset path (used by "open_stage"). - mode: Optional prefab stage mode (currently only "InIsolation" is supported by the C# side). - save_before_close: When true, `close_stage` will save the prefab before exiting the stage. - instance_id: Prefab instance ID for apply/revert overrides. Accepts int-like values. - target: Scene GameObject name/path to resolve prefab instance when `instance_id` isn't provided. - Returns: - Dictionary mirroring the Unity bridge response. - """ try: - params: dict[str, str] = {"action": action} + params: dict[str, Any] = {"action": action} - if path: - params["path"] = path + 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) - - coerced_instance_id = int(instance_id) - if coerced_instance_id is not None: - params["instanceId"] = coerced_instance_id - 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"): From 9002957fd6a05f64c5b0b368b2bfe445a01fe567 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 23 Sep 2025 17:57:11 -0400 Subject: [PATCH 06/13] fix: update parameter references to 'prefabPath' in ManagePrefabs and manage_prefabs tools --- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 16 ++++++++++++++-- .../UnityMcpServer~/src/tools/manage_prefabs.py | 4 +--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs index 88c83dea..c4c41748 100644 --- a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -54,7 +54,7 @@ private static object OpenStage(JObject @params) string prefabPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { - return Response.Error("'path' parameter is required for open_stage."); + return Response.Error("'prefabPath' parameter is required for open_stage."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); @@ -155,7 +155,7 @@ private static object CreatePrefabFromGameObject(JObject @params) ); } - string requestedPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + string requestedPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrWhiteSpace(requestedPath)) { return Response.Error("'prefabPath' (or 'path') parameter is required for create_from_gameobject."); @@ -225,6 +225,18 @@ private static void EnsureAssetDirectoryExists(string assetPath) 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()) { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 2530ef62..c9321b98 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -16,10 +16,8 @@ def manage_prefabs( "open_stage", "close_stage", "save_open_stage", - "apply_instance_overrides", - "revert_instance_overrides", "create_from_gameobject", - ], "One of open_stage, close_stage, save_open_stage, apply_instance_overrides, revert_instance_overrides, 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, From fdd1262e20633485170ef224ba8ac88c6b6c2ff2 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 23 Sep 2025 18:07:26 -0400 Subject: [PATCH 07/13] fix: clarify error message for missing 'prefabPath' in create_from_gameobject command --- UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs index c4c41748..aaf67b14 100644 --- a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -158,7 +158,7 @@ private static object CreatePrefabFromGameObject(JObject @params) string requestedPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrWhiteSpace(requestedPath)) { - return Response.Error("'prefabPath' (or 'path') parameter is required for create_from_gameobject."); + return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); From 10bfe54b5b7f3c449852b1bf1bb72f498289a1a0 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 13:17:00 -0400 Subject: [PATCH 08/13] fix: ensure pull request triggers for unity tests workflow --- .github/workflows/unity-tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index e1dea5a2..33e22fcb 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -7,6 +7,13 @@ on: - TestProjects/UnityMCPTests/** - UnityMcpBridge/Editor/** - .github/workflows/unity-tests.yml + pull_request: + types: [opened, synchronize, reopened, edited] + branches: [ main ] + paths: + - TestProjects/UnityMCPTests/** + - UnityMcpBridge/Editor/** + - .github/workflows/unity-tests.yml jobs: testAllModes: From 63b1a4267b0a86875dca5b5de78e9d4ac2ccf41f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 13:56:09 -0400 Subject: [PATCH 09/13] Revert "fix: ensure pull request triggers for unity tests workflow" This reverts commit 10bfe54b5b7f3c449852b1bf1bb72f498289a1a0. --- .github/workflows/unity-tests.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 33e22fcb..e1dea5a2 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -7,13 +7,6 @@ on: - TestProjects/UnityMCPTests/** - UnityMcpBridge/Editor/** - .github/workflows/unity-tests.yml - pull_request: - types: [opened, synchronize, reopened, edited] - branches: [ main ] - paths: - - TestProjects/UnityMCPTests/** - - UnityMcpBridge/Editor/** - - .github/workflows/unity-tests.yml jobs: testAllModes: From 745aa32c886d50102eed751e9fd283f8adca9d8e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 14:56:34 -0400 Subject: [PATCH 10/13] Remove delayed execution of executing menu item, fixing #279 This brings the Unity window into focus but that seems to be a better UX for devs. Also streamline manage_menu_item tool info, as FastMCP recommends --- .../Tools/MenuItems/MenuItemExecutor.cs | 21 +++++-------------- .../src/tools/manage_menu_item.py | 14 +------------ 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs index 6ab49daf..a72149af 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -36,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 via delayCall: '{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/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index ba5601de..b3fa1b89 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)") @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, From 7972faadb9b5748ad3c5d5ed29b14520bee05249 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 15:26:55 -0400 Subject: [PATCH 11/13] docs: clarify menu item tool description with guidance to use list action first --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index b3fa1b89..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(description="Manage Unity menu items (execute/list/exists)") + @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, From 72c8983cf75c86ef1cd51cf984449822e212db12 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 15:30:26 -0400 Subject: [PATCH 12/13] feat: add version update for server_version.txt in bump-version workflow --- .github/workflows/bump-version.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From f685e26f7a4a9607093dd6951659f50776a23816 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 16:37:25 -0400 Subject: [PATCH 13/13] fix: simplify error message for failed menu item execution --- UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs index a72149af..193a80f6 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -39,7 +39,7 @@ public static object Execute(JObject @params) 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."); + 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.");