diff --git a/README.md b/README.md index c8aecf26..f7beb958 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. - * `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project"). + * `manage_menu_item`: List Unity Editor menu items; and check for their existence or execute them (e.g., execute "File/Save Project"). * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. * `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta index 3d95d986..1261b65c 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: dfbabf507ab1245178d1a8e745d8d283 \ No newline at end of file +guid: dfbabf507ab1245178d1a8e745d8d283 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef.meta b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef.meta new file mode 100644 index 00000000..6376d072 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e5441db2ad88a4bc3a8f0868c9471142 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta index 31bddd75..26284a21 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 9e4468da1a15349029e52570b84ec4b0 \ No newline at end of file +guid: 9e4468da1a15349029e52570b84ec4b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta index c4c339a8..9e5900a5 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: c15ba6502927e4901a43826c43debd7c \ No newline at end of file +guid: c15ba6502927e4901a43826c43debd7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta index cd9b0d92..8f11d54b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5931268353eab4ea5baa054e6231e824 \ No newline at end of file +guid: 5931268353eab4ea5baa054e6231e824 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta new file mode 100644 index 00000000..fd11c223 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c01321ff6339b4763807adb979c5c427 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs new file mode 100644 index 00000000..d4188040 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools.MenuItems; + +namespace MCPForUnityTests.Editor.Tools.MenuItems +{ + public class ManageMenuItemTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void HandleCommand_UnknownAction_ReturnsError() + { + var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "unknown_action" }); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false for unknown action"); + StringAssert.Contains("Unknown action", (string)jo["error"]); + } + + [Test] + public void HandleCommand_List_RoutesAndReturnsArray() + { + var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "list" }); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected success true"); + Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); + } + + [Test] + public void HandleCommand_Execute_Blacklisted_RoutesAndErrors() + { + var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "execute", ["menuPath"] = "File/Quit" }); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false"); + StringAssert.Contains("blocked for safety", (string)jo["error"], "Expected blacklist message"); + } + + [Test] + public void HandleCommand_Exists_MissingParam_ReturnsError() + { + var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "exists" }); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false when missing menuPath"); + StringAssert.Contains("Required parameter", (string)jo["error"]); + } + } +} diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta similarity index 83% rename from UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta index d9520d98..6f1a8c2b 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 896e8045986eb0d449ee68395479f1d6 +guid: 2b36e5f577aa1481c8758831c49d8f9d MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs new file mode 100644 index 00000000..495f429d --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools.MenuItems; + +namespace MCPForUnityTests.Editor.Tools.MenuItems +{ + public class MenuItemExecutorTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void Execute_MissingParam_ReturnsError() + { + var res = MenuItemExecutor.Execute(new JObject()); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false"); + StringAssert.Contains("Required parameter", (string)jo["error"]); + } + + [Test] + public void Execute_Blacklisted_ReturnsError() + { + var res = MenuItemExecutor.Execute(new JObject { ["menuPath"] = "File/Quit" }); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false for blacklisted menu"); + StringAssert.Contains("blocked for safety", (string)jo["error"], "Expected blacklist message"); + } + + [Test] + public void Execute_NonBlacklisted_ReturnsImmediateSuccess() + { + // We don't rely on the menu actually existing; execution is delayed and we only check the immediate response shape + var res = MenuItemExecutor.Execute(new JObject { ["menuPath"] = "File/Save Project" }); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected immediate success response"); + StringAssert.Contains("Attempted to execute menu item", (string)jo["message"], "Expected attempt message"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs.meta new file mode 100644 index 00000000..6c6db472 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae694b6ac48824768a319eb378e7fb63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs new file mode 100644 index 00000000..e13e1b90 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs @@ -0,0 +1,88 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools.MenuItems; +using System; +using System.Linq; + +namespace MCPForUnityTests.Editor.Tools.MenuItems +{ + public class MenuItemsReaderTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void List_NoSearch_ReturnsSuccessAndArray() + { + var res = MenuItemsReader.List(new JObject()); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected success true"); + Assert.IsNotNull(jo["data"], "Expected data field present"); + Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); + + // Validate list is sorted ascending when there are multiple items + var arr = (JArray)jo["data"]; + if (arr.Count >= 2) + { + var original = arr.Select(t => (string)t).ToList(); + var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList(); + CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending"); + } + } + + [Test] + public void List_SearchNoMatch_ReturnsEmpty() + { + var res = MenuItemsReader.List(new JObject { ["search"] = "___unlikely___term___" }); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected success true"); + Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); + Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term"); + } + + [Test] + public void List_SearchMatchesExistingItem_ReturnsContainingItem() + { + // Get the full list first + var listRes = MenuItemsReader.List(new JObject()); + var listJo = ToJO(listRes); + if (listJo["data"] is JArray arr && arr.Count > 0) + { + var first = (string)arr[0]; + // Use a mid-substring (case-insensitive) to avoid edge cases + var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first; + term = term.ToLowerInvariant(); + + var res = MenuItemsReader.List(new JObject { ["search"] = term }); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected success true"); + Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); + // Expect at least the original item to be present + var names = ((JArray)jo["data"]).Select(t => (string)t).ToList(); + CollectionAssert.Contains(names, first, "Expected search results to include the sampled item"); + } + else + { + Assert.Pass("No menu items available to perform a content-based search assertion."); + } + } + + [Test] + public void Exists_MissingParam_ReturnsError() + { + var res = MenuItemsReader.Exists(new JObject()); + var jo = ToJO(res); + Assert.IsFalse((bool)jo["success"], "Expected success false"); + StringAssert.Contains("Required parameter", (string)jo["error"]); + } + + [Test] + public void Exists_Bogus_ReturnsFalse() + { + var res = MenuItemsReader.Exists(new JObject { ["menuPath"] = "Nonexistent/Menu/___unlikely___" }); + var jo = ToJO(res); + Assert.IsTrue((bool)jo["success"], "Expected success true"); + Assert.IsNotNull(jo["data"], "Expected data field present"); + Assert.IsFalse((bool)jo["data"]["exists"], "Expected exists false for bogus menu path"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs.meta new file mode 100644 index 00000000..4c2a398e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dbae8d670978f4a2bb525d7da9ed9f34 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json index 3c7b4c18..ad11087f 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json +++ b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json @@ -1,4 +1,6 @@ { + "m_Name": "Settings", + "m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json", "m_Dictionary": { "m_DictionaryValues": [] } diff --git a/UnityMcpBridge/Editor/AssemblyInfo.cs.meta b/UnityMcpBridge/Editor/AssemblyInfo.cs.meta index 343ff10e..72bf5f72 100644 --- a/UnityMcpBridge/Editor/AssemblyInfo.cs.meta +++ b/UnityMcpBridge/Editor/AssemblyInfo.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: be61633e00d934610ac1ff8192ffbe3d \ No newline at end of file +guid: be61633e00d934610ac1ff8192ffbe3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta index af305308..f1a5dbe4 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b82eaef548d164ca095f17db64d15af8 \ No newline at end of file +guid: b82eaef548d164ca095f17db64d15af8 +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 2c92b9ce..88b30deb 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -14,6 +14,7 @@ using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.MenuItems; namespace MCPForUnity.Editor { @@ -1053,7 +1054,7 @@ private static string ExecuteCommand(Command command) "manage_asset" => ManageAsset.HandleCommand(paramsObject), "manage_shader" => ManageShader.HandleCommand(paramsObject), "read_console" => ReadConsole.HandleCommand(paramsObject), - "execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), + "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject), _ => throw new ArgumentException( $"Unknown or unsupported command type: {command.type}" ), diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index 55c7425b..912ddf59 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools.MenuItems; namespace MCPForUnity.Editor.Tools { @@ -19,7 +20,7 @@ public static class CommandRegistry { "HandleManageGameObject", ManageGameObject.HandleCommand }, { "HandleManageAsset", ManageAsset.HandleCommand }, { "HandleReadConsole", ReadConsole.HandleCommand }, - { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, + { "HandleManageMenuItem", ManageMenuItem.HandleCommand }, { "HandleManageShader", ManageShader.HandleCommand}, }; diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs deleted file mode 100644 index 306cf8d3..00000000 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Generic; // Added for HashSet -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles executing Unity Editor menu items by path. - /// - public static class ExecuteMenuItem - { - // Basic blacklist to prevent accidental execution of potentially disruptive menu items. - // This can be expanded based on needs. - private static readonly HashSet _menuPathBlacklist = new HashSet( - StringComparer.OrdinalIgnoreCase - ) - { - "File/Quit", - // Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed - }; - - /// - /// Main handler for executing menu items or getting available ones. - /// - public static object HandleCommand(JObject @params) - { - string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action - - try - { - switch (action) - { - case "execute": - return ExecuteItem(@params); - case "get_available_menus": - // Getting a comprehensive list of *all* menu items dynamically is very difficult - // and often requires complex reflection or maintaining a manual list. - // Returning a placeholder/acknowledgement for now. - Debug.LogWarning( - "[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex." - ); - // Returning an empty list as per the refactor plan's requirements. - return Response.Success( - "'get_available_menus' action is not fully implemented. Returning empty list.", - new List() - ); - // TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical. - default: - return Response.Error( - $"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'." - ); - } - } - catch (Exception e) - { - Debug.LogError($"[ExecuteMenuItem] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); - } - } - - /// - /// Executes a specific menu item. - /// - private static object ExecuteItem(JObject @params) - { - // Try both naming conventions: snake_case and camelCase - string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); - // Optional future param retained for API compatibility; not used in synchronous mode - // int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject() ?? 2000)); - - // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. - // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). - - if (string.IsNullOrWhiteSpace(menuPath)) - { - return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); - } - - // Validate against blacklist - if (_menuPathBlacklist.Contains(menuPath)) - { - return Response.Error( - $"Execution of menu item '{menuPath}' is blocked for safety reasons." - ); - } - - // TODO: Implement alias lookup here if needed (Map alias to actual menuPath). - // if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); } - - // TODO: Handle parameters ('parameters' object) if a viable method is found. - // This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly. - // It might require finding the underlying EditorWindow or command if parameters are needed. - - try - { - // Trace incoming execute requests (debug-gated) - McpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false); - - // Execute synchronously. This code runs on the Editor main thread in our bridge path. - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - if (executed) - { - // Success trace (debug-gated) - McpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false); - return Response.Success( - $"Executed menu item: '{menuPath}'", - new { executed = true, menuPath } - ); - } - Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'"); - return Response.Error( - $"Failed to execute menu item (not found or disabled): '{menuPath}'", - new { executed = false, menuPath } - ); - } - catch (Exception e) - { - Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}"); - return Response.Error($"Error executing menu item '{menuPath}': {e.Message}"); - } - } - - // TODO: Add helper for alias lookup if implementing aliases. - // private static string LookupAlias(string alias) { ... return actualMenuPath or null ... } - } -} - diff --git a/UnityMcpBridge/Editor/Tools/MenuItems.meta b/UnityMcpBridge/Editor/Tools/MenuItems.meta new file mode 100644 index 00000000..ffbda8e7 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2df8f144c6e684ec3bfd53e4a48f06ee +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs new file mode 100644 index 00000000..8cca35a6 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -0,0 +1,47 @@ +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 + { + /// + /// Routes actions: execute, list, exists, refresh + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required. Valid actions are: execute, list, exists, refresh."); + } + + try + { + switch (action) + { + case "execute": + return MenuItemExecutor.Execute(@params); + case "list": + return MenuItemsReader.List(@params); + case "exists": + return MenuItemsReader.Exists(@params); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageMenuItem] Action '{action}' failed: {e}"); + return Response.Error($"Internal error: {e.Message}"); + } + } + } +} diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta new file mode 100644 index 00000000..aba1f496 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77808278b21a6474a90f3abb91483f71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs new file mode 100644 index 00000000..fe6180f7 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.MenuItems +{ + /// + /// Executes Unity Editor menu items by path with safety checks. + /// + public static class MenuItemExecutor + { + // Basic blacklist to prevent execution of disruptive menu items. + private static readonly HashSet _menuPathBlacklist = new HashSet( + StringComparer.OrdinalIgnoreCase) + { + "File/Quit", + }; + + /// + /// Execute a specific menu item. Expects 'menu_path' or 'menuPath' in params. + /// + public static object Execute(JObject @params) + { + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + if (_menuPathBlacklist.Contains(menuPath)) + { + return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + } + + try + { + // Execute on main thread using delayCall + EditorApplication.delayCall += () => + { + 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}"); + } + }; + + return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + } + catch (Exception e) + { + McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); + return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + } + } + } +} diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta new file mode 100644 index 00000000..2e9f4223 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs new file mode 100644 index 00000000..db91feb3 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.MenuItems +{ + /// + /// Provides read/list/exists capabilities for Unity menu items with caching. + /// + public static class MenuItemsReader + { + private static List _cached; + + [InitializeOnLoadMethod] + private static void Build() => Refresh(); + + /// + /// Returns the cached list, refreshing if necessary. + /// + public static IReadOnlyList AllMenuItems() => _cached ??= Refresh(); + + /// + /// Rebuilds the cached list from reflection. + /// + private static List Refresh() + { + try + { + var methods = TypeCache.GetMethodsWithAttribute(); + _cached = methods + // Methods can have multiple [MenuItem] attributes; collect them all + .SelectMany(m => m + .GetCustomAttributes(typeof(MenuItem), false) + .OfType() + .Select(attr => attr.menuItem)) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.Ordinal) // Ensure no duplicates + .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering + .ToList(); + return _cached; + } + catch (Exception e) + { + McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); + _cached = _cached ?? new List(); + return _cached; + } + } + + /// + /// Returns a list of menu items. Optional 'search' param filters results. + /// + public static object List(JObject @params) + { + string search = @params["search"]?.ToString(); + bool doRefresh = @params["refresh"]?.ToObject() ?? false; + if (doRefresh || _cached == null) + { + Refresh(); + } + + IEnumerable result = _cached ?? Enumerable.Empty(); + if (!string.IsNullOrEmpty(search)) + { + result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0); + } + + return Response.Success("Menu items retrieved.", result.ToList()); + } + + /// + /// Checks if a given menu path exists in the cache. + /// + public static object Exists(JObject @params) + { + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + bool doRefresh = @params["refresh"]?.ToObject() ?? false; + if (doRefresh || _cached == null) + { + Refresh(); + } + + bool exists = (_cached ?? new List()).Contains(menuPath); + return Response.Success($"Exists check completed for '{menuPath}'.", new { exists }); + } + } +} diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta new file mode 100644 index 00000000..78fd7ab4 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37f212f83e8854ed7b5454d3733e4bfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer~/src/server-version.txt b/UnityMcpBridge/UnityMcpServer~/src/server-version.txt deleted file mode 100644 index 15a27998..00000000 --- a/UnityMcpBridge/UnityMcpServer~/src/server-version.txt +++ /dev/null @@ -1 +0,0 @@ -3.3.0 diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index b381a0dd..db64e12f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -62,6 +62,7 @@ # Global connection state _unity_connection: UnityConnection = None + @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" @@ -73,7 +74,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: start_clk = time.perf_counter() try: from pathlib import Path - ver_path = Path(__file__).parent / "server-version.txt" + ver_path = Path(__file__).parent / "server_version.txt" server_version = ver_path.read_text(encoding="utf-8").strip() except Exception: server_version = "unknown" @@ -159,13 +160,14 @@ def _emit_startup(): # Asset Creation Strategy + @mcp.prompt() def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" return ( "Available MCP for Unity Server Tools:\n\n" "- `manage_editor`: Controls editor state and queries info.\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path.\n" + "- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" "- `manage_scene`: Manages scenes.\n" "- `manage_gameobject`: Manages GameObjects in the scene.\n" @@ -175,8 +177,14 @@ def asset_creation_strategy() -> str: "Tips:\n" "- Create prefabs for reusable GameObjects.\n" "- Always include a camera and main light in your scenes.\n" + "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" + "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" + "- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n" + "- List menu items before using them if you are unsure of the menu path.\n" + "- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n" ) + # Run the server if __name__ == "__main__": mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index 47725433..18091983 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.3.2 +3.4.0 diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 43b53096..c14f6ceb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -7,7 +7,7 @@ from .manage_asset import register_manage_asset_tools from .manage_shader import register_manage_shader_tools from .read_console import register_read_console_tools -from .execute_menu_item import register_execute_menu_item_tools +from .manage_menu_item import register_manage_menu_item_tools from .resource_tools import register_resource_tools logger = logging.getLogger("mcp-for-unity-server") @@ -24,7 +24,6 @@ def register_all_tools(mcp): register_manage_asset_tools(mcp) register_manage_shader_tools(mcp) register_read_console_tools(mcp) - register_execute_menu_item_tools(mcp) - # Expose resource wrappers as normal tools so IDEs without resources primitive can use them + register_manage_menu_item_tools(mcp) register_resource_tools(mcp) logger.info("MCP for Unity Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py deleted file mode 100644 index c21ecb89..00000000 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Defines the execute_menu_item tool for running Unity Editor menu commands. -""" -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper -from config import config -import time - -from telemetry_decorator import telemetry_tool - -def register_execute_menu_item_tools(mcp: FastMCP): - """Registers the execute_menu_item tool with the MCP server.""" - - @mcp.tool() - @telemetry_tool("execute_menu_item") - def execute_menu_item( - ctx: Any, - menu_path: str, - action: str = 'execute', - parameters: Dict[str, Any] = None, - ) -> Dict[str, Any]: - """Executes a Unity Editor menu item via its path (e.g., "File/Save Project"). - - Args: - ctx: The MCP context. - menu_path: The full path of the menu item to execute. - action: The operation to perform (default: 'execute'). - parameters: Optional parameters for the menu item (rarely used). - - Returns: - A dictionary indicating success or failure, with optional message/error. - """ - - action = action.lower() if action else 'execute' - - # Prepare parameters for the C# handler - params_dict = { - "action": action, - "menuPath": menu_path, - "parameters": parameters if parameters else {}, - } - - # Remove None values - params_dict = {k: v for k, v in params_dict.items() if v is not None} - - if "parameters" not in params_dict: - params_dict["parameters"] = {} # Ensure parameters dict exists - - # Use centralized retry helper - resp = send_command_with_retry("execute_menu_item", params_dict) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py new file mode 100644 index 00000000..ba5601de --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -0,0 +1,57 @@ +""" +Defines the manage_menu_item tool for executing and reading Unity Editor menu items. +""" +import asyncio +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import FastMCP, Context +from telemetry_decorator import telemetry_tool + +from unity_connection import get_unity_connection, async_send_command_with_retry + + +def register_manage_menu_item_tools(mcp: FastMCP): + """Registers the manage_menu_item tool with the MCP server.""" + + @mcp.tool() + @telemetry_tool("manage_menu_item") + async def manage_menu_item( + ctx: Context, + action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"], + menu_path: Annotated[str | None, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, + search: Annotated[str | None, + "Optional filter string for 'list' (e.g., 'Save')"] = None, + 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, + "menuPath": menu_path, + "search": search, + "refresh": refresh, + } + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Get the current asyncio event loop + loop = asyncio.get_running_loop() + # Touch the connection to ensure availability (mirrors other tools' pattern) + _ = get_unity_connection() + + # Use centralized async retry helper + result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) + return result if isinstance(result, dict) else {"success": False, "message": str(result)}