From 00d7706364c34862b66f5f0c4786c1d5987fac98 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 18:36:40 -0400 Subject: [PATCH 01/19] Move the current test to a Tools folder --- .../UnityMCPTests/Assets/Tests/EditMode/Tools.meta | 8 ++++++++ .../Tests/EditMode/{ => Tools}/CommandRegistryTests.cs | 2 +- .../EditMode/{ => Tools}/CommandRegistryTests.cs.meta | 0 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta rename TestProjects/UnityMCPTests/Assets/Tests/EditMode/{ => Tools}/CommandRegistryTests.cs (96%) rename TestProjects/UnityMCPTests/Assets/Tests/EditMode/{ => Tools}/CommandRegistryTests.cs.meta (100%) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta new file mode 100644 index 00000000..97c801b2 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d2b20bbd70a544baf891f3caecd384cb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs similarity index 96% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs index 86d3c01a..c12d1fd1 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs @@ -3,7 +3,7 @@ using NUnit.Framework; using MCPForUnity.Editor.Tools; -namespace MCPForUnityTests.Editor +namespace MCPForUnityTests.Editor.Tools { public class CommandRegistryTests { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs.meta similarity index 100% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs.meta rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs.meta From 6ee2890f215edccfa74ea336a9058a5fa119c07a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 19:37:04 -0400 Subject: [PATCH 02/19] feat: add env object and disabled flag handling for MCP client configuration --- .../Assets/Tests/EditMode/Windows.meta | 8 + .../EditMode/Windows/WriteToConfigTests.cs | 240 ++++++++++++++++++ .../Windows/WriteToConfigTests.cs.meta | 11 + .../Editor/Windows/MCPForUnityEditorWindow.cs | 18 ++ 4 files changed, 277 insertions(+) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta new file mode 100644 index 00000000..d4ad8e5d --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 575ef6172fca24e4bbe5ecc1160691bb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs new file mode 100644 index 00000000..41088d8c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Windows; + +namespace MCPForUnityTests.Editor.Windows +{ + public class WriteToConfigTests + { + private string _tempRoot; + private string _fakeUvPath; + private string _serverSrcDir; + + [SetUp] + public void SetUp() + { + // Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo + // restrictions when UseShellExecute=false for .cmd/.bat scripts. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("WriteToConfig tests are skipped on Windows (CI runs linux).\n" + + "ValidateUvBinarySafe requires launching an actual exe on Windows."); + } + _tempRoot = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + + // Create a fake uv executable that prints a valid version string + _fakeUvPath = Path.Combine(_tempRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.cmd" : "uv"); + File.WriteAllText(_fakeUvPath, "#!/bin/sh\n\necho 'uv 9.9.9'\n"); + TryChmodX(_fakeUvPath); + + // Create a fake server directory with server.py + _serverSrcDir = Path.Combine(_tempRoot, "server-src"); + Directory.CreateDirectory(_serverSrcDir); + File.WriteAllText(Path.Combine(_serverSrcDir, "server.py"), "# dummy server\n"); + + // Point the editor to our server dir (so ResolveServerSrc() uses this) + EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir); + // Ensure no lock is enabled + EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); + } + + [TearDown] + public void TearDown() + { + // Clean up editor preferences set during SetUp + EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); + EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); + + // Remove temp files + try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } + } + + // --- Tests --- + + [Test] + public void AddsEnvAndDisabledFalse_ForWindsurf() + { + var configPath = Path.Combine(_tempRoot, "windsurf.json"); + WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + + var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + InvokeWriteToConfig(configPath, client); + + var root = JObject.Parse(File.ReadAllText(configPath)); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be present for all clients"); + Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object"); + Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing"); + } + + [Test] + public void AddsEnvAndDisabledFalse_ForKiro() + { + var configPath = Path.Combine(_tempRoot, "kiro.json"); + WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + + var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro }; + InvokeWriteToConfig(configPath, client); + + var root = JObject.Parse(File.ReadAllText(configPath)); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be present for all clients"); + Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object"); + Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing"); + } + + [Test] + public void AddsOnlyEnv_ForCursor() + { + var configPath = Path.Combine(_tempRoot, "cursor.json"); + WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + + var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; + InvokeWriteToConfig(configPath, client); + + var root = JObject.Parse(File.ReadAllText(configPath)); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be present for all clients"); + Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients"); + } + + [Test] + public void VSCode_AddsEnv_NoDisabled() + { + var configPath = Path.Combine(_tempRoot, "vscode.json"); + WriteInitialConfig(configPath, isVSCode:true, command:_fakeUvPath, directory:"/old/path"); + + var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; + InvokeWriteToConfig(configPath, client); + + var root = JObject.Parse(File.ReadAllText(configPath)); + var unity = (JObject)root.SelectToken("servers.unityMCP"); + Assert.NotNull(unity, "Expected servers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be present for VSCode as well"); + Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client"); + Assert.AreEqual("stdio", (string)unity["type"], "VSCode entry should include type=stdio"); + } + + [Test] + public void PreservesExistingEnvAndDisabled() + { + var configPath = Path.Combine(_tempRoot, "preserve.json"); + + // Existing config with env and disabled=true should be preserved + var json = new JObject + { + ["mcpServers"] = new JObject + { + ["unityMCP"] = new JObject + { + ["command"] = _fakeUvPath, + ["args"] = new JArray("run", "--directory", "/old/path", "server.py"), + ["env"] = new JObject { ["FOO"] = "bar" }, + ["disabled"] = true + } + } + }; + File.WriteAllText(configPath, json.ToString()); + + var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + InvokeWriteToConfig(configPath, client); + + var root = JObject.Parse(File.ReadAllText(configPath)); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved"); + Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved"); + } + + // --- Helpers --- + + private static void TryChmodX(string path) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "/bin/chmod", + Arguments = "+x \"" + path + "\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p?.WaitForExit(2000); + } + catch { /* best-effort on non-Unix */ } + } + + private static void WriteInitialConfig(string configPath, bool isVSCode, string command, string directory) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + JObject root; + if (isVSCode) + { + root = new JObject + { + ["servers"] = new JObject + { + ["unityMCP"] = new JObject + { + ["command"] = command, + ["args"] = new JArray("run", "--directory", directory, "server.py"), + ["type"] = "stdio" + } + } + }; + } + else + { + root = new JObject + { + ["mcpServers"] = new JObject + { + ["unityMCP"] = new JObject + { + ["command"] = command, + ["args"] = new JArray("run", "--directory", directory, "server.py") + } + } + }; + } + File.WriteAllText(configPath, root.ToString()); + } + + private static MCPForUnityEditorWindow CreateWindow() + { + return ScriptableObject.CreateInstance(); + } + + private static void InvokeWriteToConfig(string configPath, McpClient client) + { + var window = CreateWindow(); + var mi = typeof(MCPForUnityEditorWindow).GetMethod("WriteToConfig", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(mi, "Could not find WriteToConfig via reflection"); + + // pythonDir is unused by WriteToConfig, but pass server src to keep it consistent + var result = (string)mi!.Invoke(window, new object[] { + /* pythonDir */ string.Empty, + /* configPath */ configPath, + /* mcpClient */ client + }); + + Assert.AreEqual("Configured successfully", result, "WriteToConfig should return success"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta new file mode 100644 index 00000000..4d3029ad --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fc4210e7cbef4479b2cb9498b1580a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 3091f371..40bc0773 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1102,6 +1102,15 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingConfig.servers.unityMCP.command = uvPath; existingConfig.servers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); existingConfig.servers.unityMCP.type = "stdio"; + // Ensure env is present for all clients + if (existingConfig.servers.unityMCP.env == null) + existingConfig.servers.unityMCP.env = new Newtonsoft.Json.Linq.JObject(); + // Add disabled=false for Windsurf and Kiro (do not overwrite if already set) + if (mcpClient != null && (mcpClient.mcpType == McpTypes.Windsurf || mcpClient.mcpType == McpTypes.Kiro)) + { + if (existingConfig.servers.unityMCP.disabled == null) + existingConfig.servers.unityMCP.disabled = false; + } } else { @@ -1109,6 +1118,15 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC if (existingConfig.mcpServers.unityMCP == null) existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject(); existingConfig.mcpServers.unityMCP.command = uvPath; existingConfig.mcpServers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); + // Ensure env is present for all clients + if (existingConfig.mcpServers.unityMCP.env == null) + existingConfig.mcpServers.unityMCP.env = new Newtonsoft.Json.Linq.JObject(); + // Add disabled=false for Windsurf and Kiro (do not overwrite if already set) + if (mcpClient != null && (mcpClient.mcpType == McpTypes.Windsurf || mcpClient.mcpType == McpTypes.Kiro)) + { + if (existingConfig.mcpServers.unityMCP.disabled == null) + existingConfig.mcpServers.unityMCP.disabled = false; + } } string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); From 6220750423c922de3e129d5c5caf38d89662d417 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 20:11:39 -0400 Subject: [PATCH 03/19] Format manual config specially for Windsurf and Kiro --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 40bc0773..8ee00ae2 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Runtime.InteropServices; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Data; @@ -1217,9 +1218,49 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient break; } + // Ensure env is present for all and disabled:false for Windsurf/Kiro + manualConfigJson = ProcessManualConfigJson(manualConfigJson, mcpClient); + ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } + // Inject env and disabled into the manual JSON shape for display/copy + private static string ProcessManualConfigJson(string json, McpClient client) + { + try + { + var token = JToken.Parse(json); + + JObject unityNode = null; + if (token["servers"] is JObject servers && servers["unityMCP"] is JObject unityVs) + { + unityNode = unityVs; + } + else if (token["mcpServers"] is JObject mservers && mservers["unityMCP"] is JObject unity) + { + unityNode = unity; + } + + if (unityNode != null) + { + if (unityNode["env"] == null) + unityNode["env"] = new JObject(); + + if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + { + if (unityNode["disabled"] == null) + unityNode["disabled"] = false; + } + } + + return token.ToString(Formatting.Indented); + } + catch + { + return json; // fallback + } + } + private static string ResolveServerSrc() { try From 05011c42024a358ee171e15e3e8a68d76ed02496 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 20:23:24 -0400 Subject: [PATCH 04/19] refactor: extract config JSON building logic into dedicated ConfigJsonBuilder class --- .../Windows/ManualConfigJsonBuilderTests.cs | 54 +++++++ .../ManualConfigJsonBuilderTests.cs.meta | 11 ++ .../Editor/Helpers/ConfigJsonBuilder.cs | 92 ++++++++++++ .../Editor/Helpers/ConfigJsonBuilder.cs.meta | 11 ++ .../Editor/Windows/MCPForUnityEditorWindow.cs | 141 ++---------------- 5 files changed, 182 insertions(+), 127 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs.meta create mode 100644 UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs create mode 100644 UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs new file mode 100644 index 00000000..74015177 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs @@ -0,0 +1,54 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnityTests.Editor.Windows +{ + public class ManualConfigJsonBuilderTests + { + [Test] + public void VSCode_ManualJson_HasServersEnvType_NoDisabled() + { + var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; + string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); + + var root = JObject.Parse(json); + var unity = (JObject)root.SelectToken("servers.unityMCP"); + Assert.NotNull(unity, "Expected servers.unityMCP node"); + Assert.AreEqual("/usr/bin/uv", (string)unity["command"]); + CollectionAssert.AreEqual(new[] { "run", "--directory", "/path/to/server", "server.py" }, unity["args"].ToObject()); + Assert.AreEqual("stdio", (string)unity["type"], "VSCode should include type=stdio"); + Assert.NotNull(unity["env"], "env should be included"); + Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode"); + } + + [Test] + public void Windsurf_ManualJson_HasMcpServersEnv_DisabledFalse() + { + var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); + + var root = JObject.Parse(json); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be included"); + Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be added for Windsurf"); + Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients"); + } + + [Test] + public void Cursor_ManualJson_HasMcpServersEnv_NoDisabled() + { + var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; + string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); + + var root = JObject.Parse(json); + var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); + Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); + Assert.NotNull(unity["env"], "env should be included"); + Assert.IsNull(unity["disabled"], "disabled should not be added for Cursor"); + Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs.meta new file mode 100644 index 00000000..6e68950c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dbc50071a45a4adc8e7a91a25bd4fd8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs new file mode 100644 index 00000000..2c5c66f6 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -0,0 +1,92 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ConfigJsonBuilder + { + public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) + { + var root = new JObject(); + bool isVSCode = client?.mcpType == McpTypes.VSCode; + JObject container; + if (isVSCode) + { + container = EnsureObject(root, "servers"); + } + else + { + container = EnsureObject(root, "mcpServers"); + } + + var unity = new JObject + { + ["command"] = uvPath, + ["args"] = JArray.FromObject(new[] { "run", "--directory", pythonDir, "server.py" }) + }; + + // VSCode requires transport type + if (isVSCode) + { + unity["type"] = "stdio"; + } + + // Always include env {} + unity["env"] = new JObject(); + + // Only for Windsurf/Kiro (not for VSCode client) + if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + { + unity["disabled"] = false; + } + + container["unityMCP"] = unity; + + return root.ToString(Formatting.Indented); + } + + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) + { + if (root == null) root = new JObject(); + bool isVSCode = client?.mcpType == McpTypes.VSCode; + JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); + JObject unity = container["unityMCP"] as JObject ?? new JObject(); + + unity["command"] = uvPath; + unity["args"] = JArray.FromObject(new[] { "run", "--directory", serverSrc, "server.py" }); + + // VSCode transport type + if (isVSCode) + { + unity["type"] = "stdio"; + } + + // Ensure env exists + if (unity["env"] == null) + { + unity["env"] = new JObject(); + } + + // Only add disabled:false for Windsurf/Kiro + if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + { + if (unity["disabled"] == null) + { + unity["disabled"] = false; + } + } + + container["unityMCP"] = unity; + return root; + } + + private static JObject EnsureObject(JObject parent, string name) + { + if (parent[name] is JObject o) return o; + var created = new JObject(); + parent[name] = created; + return created; + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta new file mode 100644 index 00000000..f574fde7 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c07c3369f73943919d9e086a81d1dcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 8ee00ae2..faeb9f99 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1096,41 +1096,15 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } // 4) Ensure containers exist and write back minimal changes - if (isVSCode) - { - if (existingConfig.servers == null) existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); - if (existingConfig.servers.unityMCP == null) existingConfig.servers.unityMCP = new Newtonsoft.Json.Linq.JObject(); - existingConfig.servers.unityMCP.command = uvPath; - existingConfig.servers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); - existingConfig.servers.unityMCP.type = "stdio"; - // Ensure env is present for all clients - if (existingConfig.servers.unityMCP.env == null) - existingConfig.servers.unityMCP.env = new Newtonsoft.Json.Linq.JObject(); - // Add disabled=false for Windsurf and Kiro (do not overwrite if already set) - if (mcpClient != null && (mcpClient.mcpType == McpTypes.Windsurf || mcpClient.mcpType == McpTypes.Kiro)) - { - if (existingConfig.servers.unityMCP.disabled == null) - existingConfig.servers.unityMCP.disabled = false; - } - } - else - { - if (existingConfig.mcpServers == null) existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); - if (existingConfig.mcpServers.unityMCP == null) existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject(); - existingConfig.mcpServers.unityMCP.command = uvPath; - existingConfig.mcpServers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); - // Ensure env is present for all clients - if (existingConfig.mcpServers.unityMCP.env == null) - existingConfig.mcpServers.unityMCP.env = new Newtonsoft.Json.Linq.JObject(); - // Add disabled=false for Windsurf and Kiro (do not overwrite if already set) - if (mcpClient != null && (mcpClient.mcpType == McpTypes.Windsurf || mcpClient.mcpType == McpTypes.Kiro)) - { - if (existingConfig.mcpServers.unityMCP.disabled == null) - existingConfig.mcpServers.unityMCP.disabled = false; - } - } + JObject existingRoot; + if (existingConfig is JObject eo) + existingRoot = eo; + else + existingRoot = JObject.FromObject(existingConfig); - string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); + + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); string tmp = configPath + ".tmp"; System.IO.File.WriteAllText(tmp, mergedJson, System.Text.Encoding.UTF8); if (System.IO.File.Exists(configPath)) @@ -1162,105 +1136,18 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient { // Get the Python directory path using Package Manager API string pythonDir = FindPackagePythonDirectory(); - string manualConfigJson; - - // Create common JsonSerializerSettings - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - - // Use switch statement to handle different client types - switch (mcpClient.mcpType) + // Build manual JSON centrally using the shared builder + string uvPathForManual = FindUvPath(); + if (uvPathForManual == null) { - case McpTypes.VSCode: - // Resolve uv so VSCode launches the correct executable even if not on PATH - string uvPathManual = FindUvPath(); - if (uvPathManual == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); - return; - } - // Create VSCode-specific configuration with proper format - var vscodeConfig = new - { - servers = new - { - unityMCP = new - { - command = uvPathManual, - args = new[] { "run", "--directory", pythonDir, "server.py" }, - type = "stdio" - } - } - }; - manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); - break; - - default: - // Create standard MCP configuration for other clients - string uvPath = FindUvPath(); - if (uvPath == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot configure manual setup."); - return; - } - - McpConfig jsonConfig = new() - { - mcpServers = new McpConfigServers - { - unityMCP = new McpConfigServer - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" }, - }, - }, - }; - manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); - break; + UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); + return; } - // Ensure env is present for all and disabled:false for Windsurf/Kiro - manualConfigJson = ProcessManualConfigJson(manualConfigJson, mcpClient); - + string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } - // Inject env and disabled into the manual JSON shape for display/copy - private static string ProcessManualConfigJson(string json, McpClient client) - { - try - { - var token = JToken.Parse(json); - - JObject unityNode = null; - if (token["servers"] is JObject servers && servers["unityMCP"] is JObject unityVs) - { - unityNode = unityVs; - } - else if (token["mcpServers"] is JObject mservers && mservers["unityMCP"] is JObject unity) - { - unityNode = unity; - } - - if (unityNode != null) - { - if (unityNode["env"] == null) - unityNode["env"] = new JObject(); - - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) - { - if (unityNode["disabled"] == null) - unityNode["disabled"] = false; - } - } - - return token.ToString(Formatting.Indented); - } - catch - { - return json; // fallback - } - } - private static string ResolveServerSrc() { try From d28a4685dd56021476321fcf43ffd46d5f1d7b1c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 20:32:33 -0400 Subject: [PATCH 05/19] refactor: extract unity node population logic into centralized helper method --- .../Editor/Helpers/ConfigJsonBuilder.cs | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index 2c5c66f6..601e82d4 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -20,26 +20,8 @@ public static string BuildManualConfigJson(string uvPath, string pythonDir, McpC container = EnsureObject(root, "mcpServers"); } - var unity = new JObject - { - ["command"] = uvPath, - ["args"] = JArray.FromObject(new[] { "run", "--directory", pythonDir, "server.py" }) - }; - - // VSCode requires transport type - if (isVSCode) - { - unity["type"] = "stdio"; - } - - // Always include env {} - unity["env"] = new JObject(); - - // Only for Windsurf/Kiro (not for VSCode client) - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) - { - unity["disabled"] = false; - } + var unity = new JObject(); + PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); container["unityMCP"] = unity; @@ -52,23 +34,39 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); + PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); + container["unityMCP"] = unity; + return root; + } + + /// + /// Centralized builder that applies all caveats consistently. + /// - Sets command/args with provided directory + /// - Ensures env exists + /// - Adds type:"stdio" for VSCode + /// - Adds disabled:false for Windsurf/Kiro only when missing + /// + private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) + { unity["command"] = uvPath; - unity["args"] = JArray.FromObject(new[] { "run", "--directory", serverSrc, "server.py" }); + unity["args"] = JArray.FromObject(new[] { "run", "--directory", directory, "server.py" }); - // VSCode transport type if (isVSCode) { unity["type"] = "stdio"; } + else + { + // Remove type if it somehow exists from previous clients + if (unity["type"] != null) unity.Remove("type"); + } - // Ensure env exists if (unity["env"] == null) { unity["env"] = new JObject(); } - // Only add disabled:false for Windsurf/Kiro if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) { if (unity["disabled"] == null) @@ -76,9 +74,6 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa unity["disabled"] = false; } } - - container["unityMCP"] = unity; - return root; } private static JObject EnsureObject(JObject parent, string name) From 2c1630e44a887cdf15078a9add2b5b06b72c08a9 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 20 Aug 2025 20:37:35 -0400 Subject: [PATCH 06/19] refactor: only add env property to config for Windsurf and Kiro clients If it ain't broke with the other clients, don't fix... --- .../EditMode/Windows/ManualConfigJsonBuilderTests.cs | 8 ++++---- .../Tests/EditMode/Windows/WriteToConfigTests.cs | 8 ++++---- UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs index 74015177..6ff7ff9d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs @@ -8,7 +8,7 @@ namespace MCPForUnityTests.Editor.Windows public class ManualConfigJsonBuilderTests { [Test] - public void VSCode_ManualJson_HasServersEnvType_NoDisabled() + public void VSCode_ManualJson_HasServers_NoEnv_NoDisabled() { var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); @@ -19,7 +19,7 @@ public void VSCode_ManualJson_HasServersEnvType_NoDisabled() Assert.AreEqual("/usr/bin/uv", (string)unity["command"]); CollectionAssert.AreEqual(new[] { "run", "--directory", "/path/to/server", "server.py" }, unity["args"].ToObject()); Assert.AreEqual("stdio", (string)unity["type"], "VSCode should include type=stdio"); - Assert.NotNull(unity["env"], "env should be included"); + Assert.IsNull(unity["env"], "env should not be added for VSCode"); Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode"); } @@ -38,7 +38,7 @@ public void Windsurf_ManualJson_HasMcpServersEnv_DisabledFalse() } [Test] - public void Cursor_ManualJson_HasMcpServersEnv_NoDisabled() + public void Cursor_ManualJson_HasMcpServers_NoEnv_NoDisabled() { var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); @@ -46,7 +46,7 @@ public void Cursor_ManualJson_HasMcpServersEnv_NoDisabled() var root = JObject.Parse(json); var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); - Assert.NotNull(unity["env"], "env should be included"); + Assert.IsNull(unity["env"], "env should not be added for Cursor"); Assert.IsNull(unity["disabled"], "disabled should not be added for Cursor"); Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients"); } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs index 41088d8c..c8f13b0c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs @@ -96,7 +96,7 @@ public void AddsEnvAndDisabledFalse_ForKiro() } [Test] - public void AddsOnlyEnv_ForCursor() + public void DoesNotAddEnvOrDisabled_ForCursor() { var configPath = Path.Combine(_tempRoot, "cursor.json"); WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); @@ -107,12 +107,12 @@ public void AddsOnlyEnv_ForCursor() var root = JObject.Parse(File.ReadAllText(configPath)); var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); - Assert.NotNull(unity["env"], "env should be present for all clients"); + Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients"); Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients"); } [Test] - public void VSCode_AddsEnv_NoDisabled() + public void DoesNotAddEnvOrDisabled_ForVSCode() { var configPath = Path.Combine(_tempRoot, "vscode.json"); WriteInitialConfig(configPath, isVSCode:true, command:_fakeUvPath, directory:"/old/path"); @@ -123,7 +123,7 @@ public void VSCode_AddsEnv_NoDisabled() var root = JObject.Parse(File.ReadAllText(configPath)); var unity = (JObject)root.SelectToken("servers.unityMCP"); Assert.NotNull(unity, "Expected servers.unityMCP node"); - Assert.NotNull(unity["env"], "env should be present for VSCode as well"); + Assert.IsNull(unity["env"], "env should not be added for VSCode client"); Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client"); Assert.AreEqual("stdio", (string)unity["type"], "VSCode entry should include type=stdio"); } diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index 601e82d4..94ba5d97 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -62,13 +62,13 @@ private static void PopulateUnityNode(JObject unity, string uvPath, string direc if (unity["type"] != null) unity.Remove("type"); } - if (unity["env"] == null) - { - unity["env"] = new JObject(); - } - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) { + if (unity["env"] == null) + { + unity["env"] = new JObject(); + } + if (unity["disabled"] == null) { unity["disabled"] = false; From 317b099b3674befa9e237e354d05f783e75e78eb Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 21 Aug 2025 22:31:00 -0400 Subject: [PATCH 07/19] fix: write UTF-8 without BOM encoding for config files to avoid Windows compatibility issues --- UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index faeb9f99..e1aa073c 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1106,7 +1106,8 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); string tmp = configPath + ".tmp"; - System.IO.File.WriteAllText(tmp, mergedJson, System.Text.Encoding.UTF8); + // Write UTF-8 without BOM to avoid issues on Windows editors/tools + System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); if (System.IO.File.Exists(configPath)) System.IO.File.Replace(tmp, configPath, null); else From c53f5f1e8ed06227f17a68ad4f1c546bb5291c35 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 21 Aug 2025 23:20:45 -0400 Subject: [PATCH 08/19] fix: enforce UTF-8 encoding without BOM when writing files to disk --- UnityMcpBridge/Editor/Helpers/PortManager.cs | 4 ++-- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 2 +- UnityMcpBridge/Editor/Tools/ManageScript.cs | 4 ++-- UnityMcpBridge/Editor/Tools/ManageShader.cs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 20b63192..f041ac23 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -206,10 +206,10 @@ private static void SavePort(int port) string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); // Write to hashed, project-scoped file - File.WriteAllText(registryFile, json); + File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); // Also write to legacy stable filename to avoid hash/case drift across reloads string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - File.WriteAllText(legacy, json); + File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Saved port {port} to storage"); } diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index aa0c0889..82905cfe 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -719,7 +719,7 @@ private static void WriteHeartbeat(bool reloading, string reason = null) project_path = Application.dataPath, last_heartbeat = DateTime.UtcNow.ToString("O") }; - File.WriteAllText(filePath, JsonConvert.SerializeObject(payload)); + File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); } catch (Exception) { diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index c36097ef..274f84d1 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -217,7 +217,7 @@ string namespaceName try { - File.WriteAllText(fullPath, contents); + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); // Ensure Unity recognizes the new script return Response.Success( @@ -298,7 +298,7 @@ string contents try { - File.WriteAllText(fullPath, contents); + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes AssetDatabase.Refresh(); return Response.Success( diff --git a/UnityMcpBridge/Editor/Tools/ManageShader.cs b/UnityMcpBridge/Editor/Tools/ManageShader.cs index 17dffc32..c2dfbc2f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageShader.cs +++ b/UnityMcpBridge/Editor/Tools/ManageShader.cs @@ -171,7 +171,7 @@ string contents try { - File.WriteAllText(fullPath, contents); + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader return Response.Success( @@ -239,7 +239,7 @@ string contents try { - File.WriteAllText(fullPath, contents); + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); return Response.Success( From b45758566818a0c9a52d10e0c9e9ae8aaee2bcfc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 22 Aug 2025 00:46:54 -0400 Subject: [PATCH 09/19] refactor: replace execute_menu_item with enhanced manage_menu_item tool supporting list/exists/refresh --- README.md | 2 +- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 3 +- .../Editor/Tools/CommandRegistry.cs | 3 +- .../Editor/Tools/ExecuteMenuItem.cs | 140 ------------------ .../Editor/Tools/ExecuteMenuItem.cs.meta | 11 -- UnityMcpBridge/Editor/Tools/MenuItems.meta | 8 + .../Editor/Tools/MenuItems/ManageMenuItem.cs | 49 ++++++ .../Tools/MenuItems/ManageMenuItem.cs.meta | 2 + .../Tools/MenuItems/MenuItemExecutor.cs | 66 +++++++++ .../Tools/MenuItems/MenuItemExecutor.cs.meta | 2 + .../Editor/Tools/MenuItems/MenuItemsReader.cs | 105 +++++++++++++ .../Tools/MenuItems/MenuItemsReader.cs.meta | 2 + UnityMcpBridge/UnityMcpServer~/src/server.py | 25 ++-- .../UnityMcpServer~/src/tools/__init__.py | 4 +- .../src/tools/execute_menu_item.py | 49 ------ .../src/tools/manage_menu_item.py | 54 +++++++ 16 files changed, 308 insertions(+), 217 deletions(-) delete mode 100644 UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs delete mode 100644 UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems.meta create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs create mode 100644 UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta delete mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py diff --git a/README.md b/README.md index c3082f74..04723e19 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`: Execute and list Unity Editor menu items (e.g., execute "File/Save Project"). --- diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 82905cfe..08eeb8b2 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -13,6 +13,7 @@ using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.MenuItems; namespace MCPForUnity.Editor { @@ -631,7 +632,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 e51d773e..00000000 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ /dev/null @@ -1,140 +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().ToLower() ?? "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(); - - // 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 - { - // Attempt to execute the menu item on the main thread using delayCall for safety. - EditorApplication.delayCall += () => - { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - // Log potential failure inside the delayed call. - if (!executed) - { - Debug.LogError( - $"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent." - ); - } - } - catch (Exception delayEx) - { - Debug.LogError( - $"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}" - ); - } - }; - - // Report attempt immediately, as execution is delayed. - return Response.Success( - $"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors." - ); - } - catch (Exception e) - { - // Catch errors during setup phase. - Debug.LogError( - $"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}" - ); - return Response.Error( - $"Error setting up execution for 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/ExecuteMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta deleted file mode 100644 index d9520d98..00000000 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 896e8045986eb0d449ee68395479f1d6 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: 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..10aa7a63 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -0,0 +1,49 @@ +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); + case "refresh": + return MenuItemsReader.RefreshCommand(); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh."); + } + } + catch (Exception e) + { + Debug.LogError($"[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..6abac21b --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77808278b21a6474a90f3abb91483f71 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs new file mode 100644 index 00000000..cb8051d4 --- /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) + { + Debug.LogError($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); + } + } + catch (Exception delayEx) + { + Debug.LogError($"[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) + { + Debug.LogError($"[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..87a9c65d --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs new file mode 100644 index 00000000..57d7450e --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -0,0 +1,105 @@ +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. + /// + public 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) + .OrderBy(s => s) + .ToList(); + return _cached; + } + catch (Exception e) + { + Debug.LogError($"[MenuItemsReader] Failed to scan menu items: {e}"); + _cached = _cached ?? new List(); + return _cached; + } + } + + /// + /// Command wrapper that forces a refresh and returns the list. + /// + public static object RefreshCommand() + { + var list = Refresh(); + return Response.Success("Menu items refreshed.", list); + } + + /// + /// 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..3c122427 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37f212f83e8854ed7b5454d3733e4bfa \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 29c7b6a7..7fa0b5b3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -54,18 +54,19 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 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" - "- `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" - "- `manage_script`: Manages C# script files.\\n" - "- `manage_asset`: Manages prefabs and assets.\\n" - "- `manage_shader`: Manages shaders.\\n\\n" - "Tips:\\n" - "- Create prefabs for reusable GameObjects.\\n" - "- Always include a camera and main light in your scenes.\\n" + "Available MCP for Unity Server Tools:\n\n" + "- `manage_editor`: Controls editor state and queries info.\n" + "- `manage_menu_item`: Executes and lists 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" + "- `manage_script`: Manages C# script files.\n" + "- `manage_asset`: Manages prefabs and assets.\n" + "- `manage_shader`: Manages shaders.\n\n" + "Tips:\n" + "- Create prefabs for reusable GameObjects.\n" + "- Always include a camera and main light in your scenes.\n" + "- List menu items before using them if you are unsure of the menu path.\n" ) # Run the server diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 2bf711df..e3c8dbc1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -5,7 +5,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 def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" @@ -17,5 +17,5 @@ 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) + register_manage_menu_item_tools(mcp) print("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 a448465d..00000000 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py +++ /dev/null @@ -1,49 +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 - -def register_execute_menu_item_tools(mcp: FastMCP): - """Registers the execute_menu_item tool with the MCP server.""" - - @mcp.tool() - async def execute_menu_item( - ctx: Context, - 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..00b4d217 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -0,0 +1,54 @@ +""" +Defines the manage_menu_item tool for executing and reading Unity Editor menu items. +""" +import asyncio +from typing import Any +from mcp.server.fastmcp import FastMCP, Context +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() + async def manage_menu_item( + ctx: Context, + action: str, + menu_path: str | None = None, + search: str | None = None, + refresh: bool = False, + ) -> dict[str, Any]: + """Manage Unity menu items (execute/list/exists/refresh). + + Args: + ctx: The MCP context. + action: One of 'execute', 'list', 'exists', 'refresh'. + 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. + + Returns: + A dictionary with operation results ('success', 'data', 'error'). + """ + action = (action or "").lower() + if not action: + return {"success": False, "error": "action is required (execute|list|exists|refresh)"} + + # 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)} From c399938179743761426548c8f6002540f138e24a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 22 Aug 2025 00:49:27 -0400 Subject: [PATCH 10/19] Update meta files for older Unity versions --- .../Editor/Tools/MenuItems/ManageMenuItem.cs.meta | 11 ++++++++++- .../Editor/Tools/MenuItems/MenuItemExecutor.cs.meta | 11 ++++++++++- .../Editor/Tools/MenuItems/MenuItemsReader.cs.meta | 11 ++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta index 6abac21b..aba1f496 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 77808278b21a6474a90f3abb91483f71 \ No newline at end of file +guid: 77808278b21a6474a90f3abb91483f71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta index 87a9c65d..2e9f4223 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 \ No newline at end of file +guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta index 3c122427..78fd7ab4 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 37f212f83e8854ed7b5454d3733e4bfa \ No newline at end of file +guid: 37f212f83e8854ed7b5454d3733e4bfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 97d48d579f7c6e5bbd93a17af793788a6081a66b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 22 Aug 2025 23:19:38 -0400 Subject: [PATCH 11/19] test: add unit tests for menu item management and execution --- .../Tests/EditMode/Tools/MenuItems.meta | 8 ++ .../Tools/MenuItems/ManageMenuItemTests.cs | 47 +++++++++ .../MenuItems/ManageMenuItemTests.cs.meta | 11 +++ .../Tools/MenuItems/MenuItemExecutorTests.cs | 39 ++++++++ .../MenuItems/MenuItemExecutorTests.cs.meta | 11 +++ .../Tools/MenuItems/MenuItemsReaderTests.cs | 99 +++++++++++++++++++ .../MenuItems/MenuItemsReaderTests.cs.meta | 11 +++ .../Editor/Tools/MenuItems/MenuItemsReader.cs | 4 +- 8 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemExecutorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs.meta 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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta new file mode 100644 index 00000000..6f1a8c2b --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b36e5f577aa1481c8758831c49d8f9d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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..1fc1422a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs @@ -0,0 +1,99 @@ +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 Refresh_ReturnsSuccessAndListShape() + { + var res = MenuItemsReader.RefreshCommand(); + 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"); + StringAssert.Contains("refreshed", (string)jo["message"], "Expected refresh message"); + } + + [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/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs index 57d7450e..a03c4012 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -38,8 +38,8 @@ public static List Refresh() .OfType() .Select(attr => attr.menuItem)) .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(StringComparer.Ordinal) - .OrderBy(s => s) + .Distinct(StringComparer.Ordinal) // Ensure no duplicates + .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering .ToList(); return _cached; } From d5bfcdedf8bef687fd0436a01756aec56683b2e8 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 22 Aug 2025 23:33:48 -0400 Subject: [PATCH 12/19] feat: add tips for paths, script compilation, and menu item usage in asset creation strategy --- UnityMcpBridge/UnityMcpServer~/src/server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 7fa0b5b3..dad7d405 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -17,6 +17,7 @@ # Global connection state _unity_connection: UnityConnection = None + @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" @@ -50,6 +51,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: # Asset Creation Strategy + @mcp.prompt() def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" @@ -66,9 +68,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') From df990dcc743b17abd57e29d7457580abf5ce65c1 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 5 Sep 2025 17:49:13 -0400 Subject: [PATCH 13/19] Use McpLog functionality instead of Unity's Debug --- UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs | 2 +- UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs | 6 +++--- UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs index 10aa7a63..90b5e54f 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -41,7 +41,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - Debug.LogError($"[ManageMenuItem] Action '{action}' failed: {e}"); + McpLog.Error($"[ManageMenuItem] Action '{action}' failed: {e}"); return Response.Error($"Internal error: {e.Message}"); } } diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs index cb8051d4..fe6180f7 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -45,12 +45,12 @@ public static object Execute(JObject @params) bool executed = EditorApplication.ExecuteMenuItem(menuPath); if (!executed) { - Debug.LogError($"[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 via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); } } catch (Exception delayEx) { - Debug.LogError($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}"); + McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}"); } }; @@ -58,7 +58,7 @@ public static object Execute(JObject @params) } catch (Exception e) { - Debug.LogError($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {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/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs index a03c4012..f1ffb300 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -45,7 +45,7 @@ public static List Refresh() } catch (Exception e) { - Debug.LogError($"[MenuItemsReader] Failed to scan menu items: {e}"); + McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); _cached = _cached ?? new List(); return _cached; } From 7dc4a1d065e846b9055e8bd1ce3fa8b5ac16efeb Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 11 Sep 2025 21:09:15 -0400 Subject: [PATCH 14/19] Add telemetry --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 00b4d217..71da77f2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -5,14 +5,15 @@ from typing import Any from mcp.server.fastmcp import FastMCP, Context from unity_connection import get_unity_connection, async_send_command_with_retry - +from telemetry_decorator import telemetry_tool 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, + ctx: Any, action: str, menu_path: str | None = None, search: str | None = None, From 1a7d19e0eab9141bffebc5b1a541f6e7a06cc728 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 11 Sep 2025 23:05:11 -0400 Subject: [PATCH 15/19] Annotate parameters More info to LLMs + better validation --- .../src/tools/manage_menu_item.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 71da77f2..ba5601de 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -2,39 +2,41 @@ Defines the manage_menu_item tool for executing and reading Unity Editor menu items. """ import asyncio -from typing import Any +from typing import Annotated, Any, Literal + from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, async_send_command_with_retry 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: Any, - action: str, - menu_path: str | None = None, - search: str | None = None, - refresh: bool = False, + 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/refresh). + """Manage Unity menu items (execute/list/exists). Args: ctx: The MCP context. - action: One of 'execute', 'list', 'exists', 'refresh'. + 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. + refresh: Optional flag to force refresh of the menu cache when listing. Returns: A dictionary with operation results ('success', 'data', 'error'). """ - action = (action or "").lower() - if not action: - return {"success": False, "error": "action is required (execute|list|exists|refresh)"} - # Prepare parameters for the C# handler params_dict: dict[str, Any] = { "action": action, From d4224e9337bcceac84daf54c76c0087bd427d4df Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 11 Sep 2025 23:06:57 -0400 Subject: [PATCH 16/19] Remove the refresh command It's only ever useful in the context of listing menu items --- .../EditMode/Tools/MenuItems/MenuItemsReaderTests.cs | 11 ----------- .../Editor/Tools/MenuItems/ManageMenuItem.cs | 2 -- .../Editor/Tools/MenuItems/MenuItemsReader.cs | 11 +---------- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs index 1fc1422a..e13e1b90 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/MenuItemsReaderTests.cs @@ -10,17 +10,6 @@ public class MenuItemsReaderTests { private static JObject ToJO(object o) => JObject.FromObject(o); - [Test] - public void Refresh_ReturnsSuccessAndListShape() - { - var res = MenuItemsReader.RefreshCommand(); - 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"); - StringAssert.Contains("refreshed", (string)jo["message"], "Expected refresh message"); - } - [Test] public void List_NoSearch_ReturnsSuccessAndArray() { diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs index 90b5e54f..8cca35a6 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -33,8 +33,6 @@ public static object HandleCommand(JObject @params) return MenuItemsReader.List(@params); case "exists": return MenuItemsReader.Exists(@params); - case "refresh": - return MenuItemsReader.RefreshCommand(); default: return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh."); } diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs index f1ffb300..db91feb3 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -26,7 +26,7 @@ public static class MenuItemsReader /// /// Rebuilds the cached list from reflection. /// - public static List Refresh() + private static List Refresh() { try { @@ -51,15 +51,6 @@ public static List Refresh() } } - /// - /// Command wrapper that forces a refresh and returns the list. - /// - public static object RefreshCommand() - { - var list = Refresh(); - return Response.Success("Menu items refreshed.", list); - } - /// /// Returns a list of menu items. Optional 'search' param filters results. /// From e021eca7c72d6fddc232d08177e0325e677fa0a0 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 12 Sep 2025 11:06:55 -0400 Subject: [PATCH 17/19] Updated meta files since running in Unity 2021 --- .../Assets/Scripts/LongUnityScriptClaudeTest.cs.meta | 11 ++++++++++- .../UnityMCPTests/Assets/Scripts/TestAsmdef.meta | 8 ++++++++ .../EditMode/Tools/AIPropertyMatchingTests.cs.meta | 11 ++++++++++- .../EditMode/Tools/ComponentResolverTests.cs.meta | 11 ++++++++++- .../EditMode/Tools/ManageGameObjectTests.cs.meta | 11 ++++++++++- .../com.unity.testtools.codecoverage/Settings.json | 2 ++ UnityMcpBridge/Editor/AssemblyInfo.cs.meta | 11 ++++++++++- UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta | 11 ++++++++++- 8 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef.meta 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/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: From 4acbcfca39109abed248df95443aa8b8f9c2c7cc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 12 Sep 2025 11:09:06 -0400 Subject: [PATCH 18/19] Slightly better README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9dee2c8..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. - * `manage_menu_item`: Execute and list Unity Editor menu items (e.g., execute "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. From b01f60510aa7056fca97db222a88bd4f2eaf48ac Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 12 Sep 2025 11:17:46 -0400 Subject: [PATCH 19/19] fix: rename server-version.txt to server_version.txt and update menu item description --- UnityMcpBridge/UnityMcpServer~/src/server-version.txt | 1 - UnityMcpBridge/UnityMcpServer~/src/server.py | 4 ++-- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 UnityMcpBridge/UnityMcpServer~/src/server-version.txt 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 47e77fb7..db64e12f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -74,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" @@ -167,7 +167,7 @@ def asset_creation_strategy() -> str: return ( "Available MCP for Unity Server Tools:\n\n" "- `manage_editor`: Controls editor state and queries info.\n" - "- `manage_menu_item`: Executes and lists Unity Editor menu items.\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" 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