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 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/ManualConfigJsonBuilderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs new file mode 100644 index 00000000..6ff7ff9d --- /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_HasServers_NoEnv_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.IsNull(unity["env"], "env should not be added for VSCode"); + 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_HasMcpServers_NoEnv_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.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/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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs new file mode 100644 index 00000000..c8f13b0c --- /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 DoesNotAddEnvOrDisabled_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.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 DoesNotAddEnvOrDisabled_ForVSCode() + { + 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.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"); + } + + [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/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs new file mode 100644 index 00000000..94ba5d97 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -0,0 +1,87 @@ +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(); + PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); + + 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(); + 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", directory, "server.py" }); + + if (isVSCode) + { + unity["type"] = "stdio"; + } + else + { + // Remove type if it somehow exists from previous clients + if (unity["type"] != null) unity.Remove("type"); + } + + 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; + } + } + } + + 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/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( diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 3091f371..e1aa073c 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; @@ -1095,25 +1096,18 @@ 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"; - } - 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); - } + JObject existingRoot; + if (existingConfig is JObject eo) + existingRoot = eo; + else + existingRoot = JObject.FromObject(existingConfig); + + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); - string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); + 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 @@ -1143,62 +1137,15 @@ 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; } + string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); }