Skip to content

Commit 22fe9df

Browse files
authored
Improve Windsurf MCP Config (#231)
* Move the current test to a Tools folder * feat: add env object and disabled flag handling for MCP client configuration * Format manual config specially for Windsurf and Kiro * refactor: extract config JSON building logic into dedicated ConfigJsonBuilder class * refactor: extract unity node population logic into centralized helper method * refactor: only add env property to config for Windsurf and Kiro clients If it ain't broke with the other clients, don't fix... * fix: write UTF-8 without BOM encoding for config files to avoid Windows compatibility issues * fix: enforce UTF-8 encoding without BOM when writing files to disk
1 parent 97fb4a7 commit 22fe9df

File tree

15 files changed

+455
-78
lines changed

15 files changed

+455
-78
lines changed

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/CommandRegistryTests.cs renamed to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
using NUnit.Framework;
44
using MCPForUnity.Editor.Tools;
55

6-
namespace MCPForUnityTests.Editor
6+
namespace MCPForUnityTests.Editor.Tools
77
{
88
public class CommandRegistryTests
99
{

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Newtonsoft.Json.Linq;
2+
using NUnit.Framework;
3+
using MCPForUnity.Editor.Helpers;
4+
using MCPForUnity.Editor.Models;
5+
6+
namespace MCPForUnityTests.Editor.Windows
7+
{
8+
public class ManualConfigJsonBuilderTests
9+
{
10+
[Test]
11+
public void VSCode_ManualJson_HasServers_NoEnv_NoDisabled()
12+
{
13+
var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode };
14+
string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client);
15+
16+
var root = JObject.Parse(json);
17+
var unity = (JObject)root.SelectToken("servers.unityMCP");
18+
Assert.NotNull(unity, "Expected servers.unityMCP node");
19+
Assert.AreEqual("/usr/bin/uv", (string)unity["command"]);
20+
CollectionAssert.AreEqual(new[] { "run", "--directory", "/path/to/server", "server.py" }, unity["args"].ToObject<string[]>());
21+
Assert.AreEqual("stdio", (string)unity["type"], "VSCode should include type=stdio");
22+
Assert.IsNull(unity["env"], "env should not be added for VSCode");
23+
Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode");
24+
}
25+
26+
[Test]
27+
public void Windsurf_ManualJson_HasMcpServersEnv_DisabledFalse()
28+
{
29+
var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf };
30+
string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client);
31+
32+
var root = JObject.Parse(json);
33+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
34+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
35+
Assert.NotNull(unity["env"], "env should be included");
36+
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be added for Windsurf");
37+
Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients");
38+
}
39+
40+
[Test]
41+
public void Cursor_ManualJson_HasMcpServers_NoEnv_NoDisabled()
42+
{
43+
var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor };
44+
string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client);
45+
46+
var root = JObject.Parse(json);
47+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
48+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
49+
Assert.IsNull(unity["env"], "env should not be added for Cursor");
50+
Assert.IsNull(unity["disabled"], "disabled should not be added for Cursor");
51+
Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients");
52+
}
53+
}
54+
}

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using System.Reflection;
5+
using System.Runtime.InteropServices;
6+
using Newtonsoft.Json.Linq;
7+
using NUnit.Framework;
8+
using UnityEditor;
9+
using UnityEngine;
10+
using MCPForUnity.Editor.Data;
11+
using MCPForUnity.Editor.Models;
12+
using MCPForUnity.Editor.Windows;
13+
14+
namespace MCPForUnityTests.Editor.Windows
15+
{
16+
public class WriteToConfigTests
17+
{
18+
private string _tempRoot;
19+
private string _fakeUvPath;
20+
private string _serverSrcDir;
21+
22+
[SetUp]
23+
public void SetUp()
24+
{
25+
// Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo
26+
// restrictions when UseShellExecute=false for .cmd/.bat scripts.
27+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
28+
{
29+
Assert.Ignore("WriteToConfig tests are skipped on Windows (CI runs linux).\n" +
30+
"ValidateUvBinarySafe requires launching an actual exe on Windows.");
31+
}
32+
_tempRoot = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
33+
Directory.CreateDirectory(_tempRoot);
34+
35+
// Create a fake uv executable that prints a valid version string
36+
_fakeUvPath = Path.Combine(_tempRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.cmd" : "uv");
37+
File.WriteAllText(_fakeUvPath, "#!/bin/sh\n\necho 'uv 9.9.9'\n");
38+
TryChmodX(_fakeUvPath);
39+
40+
// Create a fake server directory with server.py
41+
_serverSrcDir = Path.Combine(_tempRoot, "server-src");
42+
Directory.CreateDirectory(_serverSrcDir);
43+
File.WriteAllText(Path.Combine(_serverSrcDir, "server.py"), "# dummy server\n");
44+
45+
// Point the editor to our server dir (so ResolveServerSrc() uses this)
46+
EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir);
47+
// Ensure no lock is enabled
48+
EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false);
49+
}
50+
51+
[TearDown]
52+
public void TearDown()
53+
{
54+
// Clean up editor preferences set during SetUp
55+
EditorPrefs.DeleteKey("MCPForUnity.ServerSrc");
56+
EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig");
57+
58+
// Remove temp files
59+
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }
60+
}
61+
62+
// --- Tests ---
63+
64+
[Test]
65+
public void AddsEnvAndDisabledFalse_ForWindsurf()
66+
{
67+
var configPath = Path.Combine(_tempRoot, "windsurf.json");
68+
WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path");
69+
70+
var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf };
71+
InvokeWriteToConfig(configPath, client);
72+
73+
var root = JObject.Parse(File.ReadAllText(configPath));
74+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
75+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
76+
Assert.NotNull(unity["env"], "env should be present for all clients");
77+
Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object");
78+
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing");
79+
}
80+
81+
[Test]
82+
public void AddsEnvAndDisabledFalse_ForKiro()
83+
{
84+
var configPath = Path.Combine(_tempRoot, "kiro.json");
85+
WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path");
86+
87+
var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro };
88+
InvokeWriteToConfig(configPath, client);
89+
90+
var root = JObject.Parse(File.ReadAllText(configPath));
91+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
92+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
93+
Assert.NotNull(unity["env"], "env should be present for all clients");
94+
Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object");
95+
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing");
96+
}
97+
98+
[Test]
99+
public void DoesNotAddEnvOrDisabled_ForCursor()
100+
{
101+
var configPath = Path.Combine(_tempRoot, "cursor.json");
102+
WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path");
103+
104+
var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor };
105+
InvokeWriteToConfig(configPath, client);
106+
107+
var root = JObject.Parse(File.ReadAllText(configPath));
108+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
109+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
110+
Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients");
111+
Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients");
112+
}
113+
114+
[Test]
115+
public void DoesNotAddEnvOrDisabled_ForVSCode()
116+
{
117+
var configPath = Path.Combine(_tempRoot, "vscode.json");
118+
WriteInitialConfig(configPath, isVSCode:true, command:_fakeUvPath, directory:"/old/path");
119+
120+
var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode };
121+
InvokeWriteToConfig(configPath, client);
122+
123+
var root = JObject.Parse(File.ReadAllText(configPath));
124+
var unity = (JObject)root.SelectToken("servers.unityMCP");
125+
Assert.NotNull(unity, "Expected servers.unityMCP node");
126+
Assert.IsNull(unity["env"], "env should not be added for VSCode client");
127+
Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client");
128+
Assert.AreEqual("stdio", (string)unity["type"], "VSCode entry should include type=stdio");
129+
}
130+
131+
[Test]
132+
public void PreservesExistingEnvAndDisabled()
133+
{
134+
var configPath = Path.Combine(_tempRoot, "preserve.json");
135+
136+
// Existing config with env and disabled=true should be preserved
137+
var json = new JObject
138+
{
139+
["mcpServers"] = new JObject
140+
{
141+
["unityMCP"] = new JObject
142+
{
143+
["command"] = _fakeUvPath,
144+
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
145+
["env"] = new JObject { ["FOO"] = "bar" },
146+
["disabled"] = true
147+
}
148+
}
149+
};
150+
File.WriteAllText(configPath, json.ToString());
151+
152+
var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf };
153+
InvokeWriteToConfig(configPath, client);
154+
155+
var root = JObject.Parse(File.ReadAllText(configPath));
156+
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
157+
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
158+
Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved");
159+
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
160+
}
161+
162+
// --- Helpers ---
163+
164+
private static void TryChmodX(string path)
165+
{
166+
try
167+
{
168+
var psi = new ProcessStartInfo
169+
{
170+
FileName = "/bin/chmod",
171+
Arguments = "+x \"" + path + "\"",
172+
UseShellExecute = false,
173+
RedirectStandardOutput = true,
174+
RedirectStandardError = true,
175+
CreateNoWindow = true
176+
};
177+
using var p = Process.Start(psi);
178+
p?.WaitForExit(2000);
179+
}
180+
catch { /* best-effort on non-Unix */ }
181+
}
182+
183+
private static void WriteInitialConfig(string configPath, bool isVSCode, string command, string directory)
184+
{
185+
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
186+
JObject root;
187+
if (isVSCode)
188+
{
189+
root = new JObject
190+
{
191+
["servers"] = new JObject
192+
{
193+
["unityMCP"] = new JObject
194+
{
195+
["command"] = command,
196+
["args"] = new JArray("run", "--directory", directory, "server.py"),
197+
["type"] = "stdio"
198+
}
199+
}
200+
};
201+
}
202+
else
203+
{
204+
root = new JObject
205+
{
206+
["mcpServers"] = new JObject
207+
{
208+
["unityMCP"] = new JObject
209+
{
210+
["command"] = command,
211+
["args"] = new JArray("run", "--directory", directory, "server.py")
212+
}
213+
}
214+
};
215+
}
216+
File.WriteAllText(configPath, root.ToString());
217+
}
218+
219+
private static MCPForUnityEditorWindow CreateWindow()
220+
{
221+
return ScriptableObject.CreateInstance<MCPForUnityEditorWindow>();
222+
}
223+
224+
private static void InvokeWriteToConfig(string configPath, McpClient client)
225+
{
226+
var window = CreateWindow();
227+
var mi = typeof(MCPForUnityEditorWindow).GetMethod("WriteToConfig", BindingFlags.Instance | BindingFlags.NonPublic);
228+
Assert.NotNull(mi, "Could not find WriteToConfig via reflection");
229+
230+
// pythonDir is unused by WriteToConfig, but pass server src to keep it consistent
231+
var result = (string)mi!.Invoke(window, new object[] {
232+
/* pythonDir */ string.Empty,
233+
/* configPath */ configPath,
234+
/* mcpClient */ client
235+
});
236+
237+
Assert.AreEqual("Configured successfully", result, "WriteToConfig should return success");
238+
}
239+
}
240+
}

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)