chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,38 @@
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class AssetPathUtilityOfflineTests
{
private bool _originalForceRefresh;
[SetUp]
public void SetUp()
{
_originalForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
}
[TearDown]
public void TearDown()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalForceRefresh);
}
[Test]
public void ShouldUseUvxOffline_WhenForceRefreshEnabled_ReturnsFalse()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, true);
Assert.IsFalse(AssetPathUtility.ShouldUseUvxOffline());
}
[Test]
public void ShouldUseUvxOffline_DoesNotThrow()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
Assert.DoesNotThrow(() => AssetPathUtility.ShouldUseUvxOffline());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 39ea6f0fc573340d689bf01ef5510153
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,156 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
// Per-client MCP config-format coverage.
//
// Issue #1120: Kilo Code v7.0.33+ moved its MCP config from the VS Code extension's
// globalStorage/mcp_settings.json (mcpServers / type:"http" or "streamableHttp" / disabled)
// to a CLI-style kilo.jsonc under ~/.config/kilo whose schema (https://app.kilo.ai/config.json)
// uses an "mcp" container, type:"remote" for HTTP servers, and an "enabled" flag. Writing the
// old format left the server showing as "stdio" + disabled. These tests pin the new Kilo format
// while guarding that Cline keeps "streamableHttp" and generic clients keep plain "http".
public class ClientConfigFormatTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
[SetUp]
public void SetUp()
{
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
// Force HTTP transport so the remote/streamableHttp branch is exercised.
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
EditorConfigCache.Instance.Refresh();
}
[Test]
public void KiloCodeConfigurator_DeclaresNewKiloFormat()
{
var client = new KiloCodeConfigurator().Client;
Assert.AreEqual("mcp", client.ServerContainerKey,
"Kilo Code's new schema nests servers under an \"mcp\" container, not \"mcpServers\"");
Assert.AreEqual("remote", client.HttpTypeValue,
"Kilo Code's new schema uses type:\"remote\" for HTTP servers");
Assert.AreEqual("local", client.StdioTypeValue,
"Kilo Code's new schema uses type:\"local\" for stdio servers");
Assert.AreEqual("https://app.kilo.ai/config.json", client.SchemaUrl,
"Kilo Code config should declare the kilo.jsonc $schema");
Assert.IsTrue(client.DefaultUnityFields.ContainsKey("enabled"),
"Kilo Code uses an \"enabled\" flag instead of \"disabled\"");
Assert.AreEqual(true, client.DefaultUnityFields["enabled"],
"Kilo Code must default enabled:true so the server is active");
Assert.IsFalse(client.DefaultUnityFields.ContainsKey("disabled"),
"Kilo Code's new schema must not write the legacy \"disabled\" field");
foreach (var path in new[] { client.windowsConfigPath, client.macConfigPath, client.linuxConfigPath })
{
Assert.AreEqual("kilo.jsonc", System.IO.Path.GetFileName(path),
"Kilo Code config must target kilo.jsonc, not mcp_settings.json");
}
}
[Test]
public void BuildManualConfigJson_ForKiloCode_UsesMcpContainerRemoteTypeAndEnabled()
{
var client = new KiloCodeConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.AreEqual("https://app.kilo.ai/config.json", (string)root["$schema"],
"Kilo Code config should include the kilo.jsonc $schema at the root");
Assert.IsNull(root["mcpServers"], "Kilo Code must not use the legacy \"mcpServers\" container");
var unity = (JObject)root.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Kilo Code HTTP config must use type:remote, not type:http/streamableHttp");
Assert.AreEqual(true, (bool)unity["enabled"],
"Kilo Code config must set enabled:true");
Assert.IsNull(unity["disabled"], "Kilo Code must not write the legacy disabled field");
Assert.IsNotNull(unity["url"], "HTTP transport should set a url");
Assert.IsNull(unity["command"], "HTTP transport should not include a command");
}
[Test]
public void ApplyUnityServerToExistingConfig_ForKiloCode_RewritesStaleStdioToRemote()
{
var client = new KiloCodeConfigurator().Client;
// Simulate a stale local (stdio) entry that Kilo would have shown as the wrong transport.
var root = new JObject
{
["mcp"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = "uvx",
["args"] = new JArray("unity-mcp-server"),
["type"] = "local"
}
}
};
var result = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(root, uvPath: null, client);
Assert.AreEqual("https://app.kilo.ai/config.json", (string)result["$schema"],
"Rewrite should add the kilo.jsonc $schema when missing");
var unity = (JObject)result.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Existing config should be rewritten to type:remote for Kilo Code");
Assert.AreEqual(true, (bool)unity["enabled"], "Existing config should gain enabled:true");
Assert.IsNull(unity["command"], "HTTP transport should remove the stale stdio command");
}
[Test]
public void BuildManualConfigJson_ForCline_StillUsesStreamableHttpInMcpServers()
{
var client = new ClineConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.IsNull(root["$schema"], "Cline must not write a $schema");
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("streamableHttp", (string)unity["type"],
"Cline must keep type:streamableHttp after the name-check was replaced with a flag");
}
[Test]
public void BuildManualConfigJson_ForGenericHttpClient_UsesPlainHttpType()
{
// A client without a type override must continue to receive the generic type:http.
var client = new McpClient { name = "Cursor" };
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("http", (string)unity["type"],
"Clients without HttpTypeValue should keep the generic type:http");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e8c1a47f2d4e9b8a6c5d0f1e2a3b4c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,595 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.External.Tommy;
using MCPForUnity.Editor.Services;
using System.IO;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class CodexConfigHelperTests
{
/// <summary>
/// Validates that a TOML args array contains the expected uvx structure:
/// --from, a mcpforunityserver reference, mcp-for-unity package name,
/// and optionally --prerelease/explicit (only for prerelease builds).
/// </summary>
private static void AssertValidUvxArgs(TomlArray args)
{
var argValues = new List<string>();
foreach (TomlNode child in args.Children)
argValues.Add((child as TomlString).Value);
Assert.IsTrue(argValues.Contains("--from"), "Args should contain --from");
Assert.IsTrue(argValues.Any(a => a.Contains("mcpforunityserver")), "Args should contain PyPI package reference");
Assert.IsTrue(argValues.Contains("mcp-for-unity"), "Args should contain package name");
// Prerelease builds include --prerelease explicit before --from
int fromIndex = argValues.IndexOf("--from");
int prereleaseIndex = argValues.IndexOf("--prerelease");
if (prereleaseIndex >= 0)
{
Assert.IsTrue(prereleaseIndex < fromIndex, "--prerelease should come before --from");
Assert.AreEqual("explicit", argValues[prereleaseIndex + 1], "--prerelease should be followed by explicit");
}
}
/// <summary>
/// Mock platform service for testing
/// </summary>
private class MockPlatformService : IPlatformService
{
private readonly bool _isWindows;
private readonly string _systemRoot;
public MockPlatformService(bool isWindows, string systemRoot = "C:\\Windows")
{
_isWindows = isWindows;
_systemRoot = systemRoot;
}
public bool IsWindows() => _isWindows;
public string GetSystemRoot() => _isWindows ? _systemRoot : null;
}
private bool _hadGitOverride;
private string _originalGitOverride;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadDevForceRefresh;
private bool _originalDevForceRefresh;
private IPlatformService _originalPlatformService;
[OneTimeSetUp]
public void OneTimeSetUp()
{
_hadGitOverride = EditorPrefs.HasKey(EditorPrefKeys.GitUrlOverride);
_originalGitOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
_hadHttpTransport = EditorPrefs.HasKey(EditorPrefKeys.UseHttpTransport);
_originalHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_hadDevForceRefresh = EditorPrefs.HasKey(EditorPrefKeys.DevModeForceServerRefresh);
_originalDevForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_originalPlatformService = MCPServiceLocator.Platform;
}
[SetUp]
public void SetUp()
{
// Ensure per-test deterministic Git URL (ignore developer overrides)
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
// Default to stdio mode for existing tests unless specified otherwise
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Ensure deterministic uvx args ordering for these tests regardless of editor settings
// (dev-mode inserts --no-cache/--refresh, which changes the first args).
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
// Refresh the cache so it picks up the test's pref values
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// IMPORTANT:
// These tests can be executed while an MCP session is active (e.g., when running tests via MCP).
// MCPServiceLocator.Reset() disposes the bridge + transport manager, which can kill the MCP connection
// mid-run. Instead, restore only what this fixture mutates.
// To avoid leaking global state to other tests/fixtures, restore the original platform service
// instance captured before this fixture started running.
if (_originalPlatformService != null)
{
MCPServiceLocator.Register<IPlatformService>(_originalPlatformService);
}
else
{
MCPServiceLocator.Register<IPlatformService>(new PlatformService());
}
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
if (_hadGitOverride)
{
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, _originalGitOverride);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
}
if (_hadHttpTransport)
{
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _originalHttpTransport);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.UseHttpTransport);
}
if (_hadDevForceRefresh)
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalDevForceRefresh);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.DevModeForceServerRefresh);
}
}
[Test]
public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should detect server definition");
Assert.AreEqual("uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\",",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\", # package name",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should tolerate comments within the array block");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_HeaderWithComment_StillDetected()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP] # annotated header",
"command = \"uvx\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should recognize section headers even with inline comments");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = 'uvx'",
"args = ['mcp-for-unity']"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void BuildCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies that Windows-specific environment configuration is included in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true));
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void BuildCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void UpsertCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies the fix for https://github.com/CoplayDev/unity-mcp/issues/315
// Ensures that upsert operations also include Windows-specific env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true, systemRoot: "C:\\Windows"));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void UpsertCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that upsert operations on non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void BuildCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP transport mode generates url field instead of command/args
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
Assert.IsTrue(url.Contains("/mcp"), "URL should contain /mcp path");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
Assert.IsFalse(unityMcp.TryGetNode("env", out _), "HTTP mode should not contain env field");
}
[Test]
public void TryParseCodexServer_HttpMode_ParsesUrlSuccessfully()
{
// This test verifies HTTP mode parsing with url field
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"url = \"http://localhost:8080/mcp/v1/rpc\""
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args, out string url);
Assert.IsTrue(result, "Parser should accept HTTP mode with url field");
Assert.IsNull(command, "Command should be null in HTTP mode");
Assert.IsNull(args, "Args should be null in HTTP mode");
Assert.AreEqual("http://localhost:8080/mcp/v1/rpc", url, "URL should be parsed correctly");
}
[Test]
public void UpsertCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP mode upsert generates url field
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 013424dea29744a98b3dc01618f4e95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System.Linq;
using MCPForUnity.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for Matrix4x4Converter to ensure it safely serializes matrices
/// without accessing dangerous computed properties (lossyScale, rotation).
/// Regression test for https://github.com/CoplayDev/unity-mcp/issues/478
/// </summary>
public class Matrix4x4ConverterTests
{
private JsonSerializerSettings _settings;
[SetUp]
public void SetUp()
{
_settings = new JsonSerializerSettings
{
Converters = { new Matrix4x4Converter() }
};
}
[Test]
public void Serialize_IdentityMatrix_ReturnsCorrectJson()
{
var matrix = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(matrix, _settings);
Assert.That(json, Does.Contain("\"m00\":1"));
Assert.That(json, Does.Contain("\"m11\":1"));
Assert.That(json, Does.Contain("\"m22\":1"));
Assert.That(json, Does.Contain("\"m33\":1"));
Assert.That(json, Does.Contain("\"m01\":0"));
}
[Test]
public void Deserialize_IdentityMatrix_ReturnsIdentity()
{
var original = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(original, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result, Is.EqualTo(original));
}
[Test]
public void Serialize_TranslationMatrix_PreservesValues()
{
var matrix = Matrix4x4.Translate(new Vector3(10, 20, 30));
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result.m03, Is.EqualTo(10f));
Assert.That(result.m13, Is.EqualTo(20f));
Assert.That(result.m23, Is.EqualTo(30f));
}
[Test]
public void Serialize_DegenerateMatrix_DoesNotCrashAndRoundtrips()
{
// This is the key test - a degenerate matrix that would crash
// if we accessed lossyScale or rotation properties
var matrix = new Matrix4x4();
matrix.m00 = 0; matrix.m11 = 0; matrix.m22 = 0; // Degenerate - determinant = 0
// This should NOT throw or crash - the old code would fail here
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Verify JSON only contains raw mXY properties
var jo = JObject.Parse(json);
var expectedProps = new[]
{
"m00", "m01", "m02", "m03",
"m10", "m11", "m12", "m13",
"m20", "m21", "m22", "m23",
"m30", "m31", "m32", "m33"
};
CollectionAssert.AreEquivalent(expectedProps, jo.Properties().Select(p => p.Name).ToArray());
// Verify values roundtrip correctly (all zeros for degenerate matrix)
Assert.That(result.m00, Is.EqualTo(0f));
Assert.That(result.m11, Is.EqualTo(0f));
Assert.That(result.m22, Is.EqualTo(0f));
Assert.That(result, Is.EqualTo(matrix));
}
[Test]
public void Serialize_NonTRSMatrix_DoesNotCrash()
{
// Projection matrices are NOT valid TRS matrices
// Accessing lossyScale/rotation on them causes ValidTRS() assertion
var matrix = Matrix4x4.Perspective(60f, 1.77f, 0.1f, 1000f);
// Verify it's not a valid TRS matrix
Assert.That(matrix.ValidTRS(), Is.False, "Test requires non-TRS matrix");
// This should NOT throw - the fix ensures we never access computed properties
Assert.DoesNotThrow(() =>
{
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
});
}
[Test]
public void Deserialize_NullToken_ReturnsZeroMatrix()
{
var json = "null";
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Returns zero matrix (consistent with missing field defaults of 0f)
Assert.That(result, Is.EqualTo(new Matrix4x4()));
}
[Test]
public void Serialize_DoesNotContainDangerousProperties()
{
var matrix = Matrix4x4.TRS(Vector3.one, Quaternion.identity, Vector3.one);
var json = JsonConvert.SerializeObject(matrix, _settings);
// Ensure we're not serializing the dangerous computed properties
Assert.That(json, Does.Not.Contain("lossyScale"));
Assert.That(json, Does.Not.Contain("rotation"));
Assert.That(json, Does.Not.Contain("inverse"));
Assert.That(json, Does.Not.Contain("transpose"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d2c5dd202b4944675ae606581676a24a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,209 @@
using System.Collections.Generic;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the standard Pagination classes.
/// </summary>
public class PaginationTests
{
#region PaginationRequest Tests
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeSnakeCase()
{
var p = new JObject { ["page_size"] = 25 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(25, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeCamelCase()
{
var p = new JObject { ["pageSize"] = 30 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(30, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesCursor()
{
var p = new JObject { ["cursor"] = 50 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(50, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_ConvertsPageNumberToCursor()
{
// page_number is 1-based, should convert to 0-based cursor
var p = new JObject { ["page_number"] = 3, ["page_size"] = 10 };
var req = PaginationRequest.FromParams(p);
// Page 3 with page size 10 means items 20-29, so cursor should be 20
Assert.AreEqual(20, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_CursorTakesPrecedenceOverPageNumber()
{
// If both cursor and page_number are specified, cursor should win
var p = new JObject { ["cursor"] = 100, ["page_number"] = 1 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(100, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForNullParams()
{
var req = PaginationRequest.FromParams(null);
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForEmptyParams()
{
var req = PaginationRequest.FromParams(new JObject());
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_AcceptsCustomDefaultPageSize()
{
var req = PaginationRequest.FromParams(new JObject(), defaultPageSize: 100);
Assert.AreEqual(100, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_HandleStringValues()
{
// Some clients might send string values
var p = new JObject { ["page_size"] = "15", ["cursor"] = "5" };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(15, req.PageSize);
Assert.AreEqual(5, req.Cursor);
}
#endregion
#region PaginationResponse Tests
[Test]
public void PaginationResponse_Create_ReturnsCorrectPageOfItems()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 1, 2, 3 }, response.Items);
}
[Test]
public void PaginationResponse_Create_ReturnsCorrectMiddlePage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5, 6 }, response.Items);
}
[Test]
public void PaginationResponse_Create_HandlesLastPage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(2, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5 }, response.Items);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_HasMore_TrueWhenNextCursorSet()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsTrue(response.HasMore);
Assert.AreEqual(3, response.NextCursor);
}
[Test]
public void PaginationResponse_HasMore_FalseWhenNoMoreItems()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsFalse(response.HasMore);
Assert.IsNull(response.NextCursor);
}
[Test]
public void PaginationResponse_Create_SetsCorrectTotalCount()
{
var allItems = new List<string> { "a", "b", "c", "d", "e" };
var request = new PaginationRequest { PageSize = 2, Cursor = 0 };
var response = PaginationResponse<string>.Create(allItems, request);
Assert.AreEqual(5, response.TotalCount);
}
[Test]
public void PaginationResponse_Create_HandlesEmptyList()
{
var allItems = new List<int>();
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(0, response.TotalCount);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_Create_ClampsCursorToValidRange()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 100 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(3, response.Cursor); // Clamped to totalCount
}
[Test]
public void PaginationResponse_Create_HandlesNegativeCursor()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = -5 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Cursor); // Clamped to 0
Assert.AreEqual(3, response.Items.Count);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6d0177f4432b41c6bf7e0013cd5a2f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,469 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the ToolParams parameter validation wrapper.
/// </summary>
public class ToolParamsTests
{
#region Constructor Tests
[Test]
public void ToolParams_Constructor_ThrowsOnNullParams()
{
Assert.Throws<System.ArgumentNullException>(() => new ToolParams(null));
}
[Test]
public void ToolParams_Constructor_AcceptsEmptyJObject()
{
Assert.DoesNotThrow(() => new ToolParams(new JObject()));
}
#endregion
#region GetRequired Tests
[Test]
public void GetRequired_ExistingParameter_ReturnsSuccess()
{
var json = new JObject { ["action"] = "create" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("create", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void GetRequired_MissingParameter_ReturnsError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_EmptyStringParameter_ReturnsError()
{
var json = new JObject { ["action"] = "" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_CustomErrorMessage_ReturnsCustomError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action", "Custom error message");
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("Custom error message", result.ErrorMessage);
}
#endregion
#region Get Tests
[Test]
public void Get_ExistingParameter_ReturnsValue()
{
var json = new JObject { ["name"] = "TestObject" };
var p = new ToolParams(json);
var value = p.Get("name");
Assert.AreEqual("TestObject", value);
}
[Test]
public void Get_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name");
Assert.IsNull(value);
}
[Test]
public void Get_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name", "DefaultName");
Assert.AreEqual("DefaultName", value);
}
#endregion
#region Snake/Camel Case Fallback Tests
[Test]
public void Get_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_method"] = "by_name" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.Get("searchMethod");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchMethod"] = "by_name" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.Get("search_method");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_ExactMatchTakesPrecedence()
{
// If both snake_case and camelCase exist, exact match wins
var json = new JObject
{
["search_method"] = "snake",
["searchMethod"] = "camel"
};
var p = new ToolParams(json);
Assert.AreEqual("snake", p.Get("search_method"));
Assert.AreEqual("camel", p.Get("searchMethod"));
}
#endregion
#region GetInt Tests
[Test]
public void GetInt_ValidInteger_ReturnsValue()
{
var json = new JObject { ["count"] = "10" };
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.AreEqual(10, value);
}
[Test]
public void GetInt_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.IsNull(value);
}
[Test]
public void GetInt_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
[Test]
public void GetInt_InvalidInteger_ReturnsDefault()
{
var json = new JObject { ["count"] = "not_a_number" };
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
#endregion
#region GetFloat Tests
[Test]
public void GetFloat_ValidFloat_ReturnsValue()
{
var json = new JObject { ["scale"] = "2.5" };
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.AreEqual(2.5f, value);
}
[Test]
public void GetFloat_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.IsNull(value);
}
[Test]
public void GetFloat_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale", 1.0f);
Assert.AreEqual(1.0f, value);
}
#endregion
#region GetBool Tests
[Test]
public void GetBool_TrueBoolean_ReturnsTrue()
{
var json = new JObject { ["enabled"] = true };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_FalseBoolean_ReturnsFalse()
{
var json = new JObject { ["enabled"] = false };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsFalse(value);
}
[Test]
public void GetBool_MissingParameter_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetBool("enabled", true);
Assert.IsTrue(value);
}
[Test]
public void GetBool_StringTrue_ReturnsTrue()
{
var json = new JObject { ["enabled"] = "true" };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["include_inactive"] = true };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.GetBool("includeInactive");
Assert.IsTrue(value);
}
[Test]
public void GetBool_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["includeInactive"] = true };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.GetBool("include_inactive");
Assert.IsTrue(value);
}
#endregion
#region Has Tests
[Test]
public void Has_ExistingParameter_ReturnsTrue()
{
var json = new JObject { ["key"] = "value" };
var p = new ToolParams(json);
Assert.IsTrue(p.Has("key"));
}
[Test]
public void Has_MissingParameter_ReturnsFalse()
{
var json = new JObject();
var p = new ToolParams(json);
Assert.IsFalse(p.Has("key"));
}
[Test]
public void Has_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_term"] = "Player" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
Assert.IsTrue(p.Has("searchTerm"));
}
[Test]
public void Has_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchTerm"] = "Player" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
Assert.IsTrue(p.Has("search_term"));
}
#endregion
#region GetRaw Tests
[Test]
public void GetRaw_ComplexObject_ReturnsJToken()
{
var json = new JObject { ["data"] = new JObject { ["nested"] = "value" } };
var p = new ToolParams(json);
var raw = p.GetRaw("data");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual("value", raw["nested"]?.ToString());
}
[Test]
public void GetRaw_Array_ReturnsJToken()
{
var json = new JObject { ["items"] = new JArray { "a", "b", "c" } };
var p = new ToolParams(json);
var raw = p.GetRaw("items");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JArray>(raw);
Assert.AreEqual(3, ((JArray)raw).Count);
}
[Test]
public void GetRaw_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["component_properties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var raw = p.GetRaw("componentProperties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
[Test]
public void GetRaw_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["componentProperties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var raw = p.GetRaw("component_properties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
#endregion
#region Result<T> Tests
[Test]
public void Result_Success_IsSuccessTrue()
{
var result = Result<string>.Success("value");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("value", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void Result_Error_IsSuccessFalse()
{
var result = Result<string>.Error("error message");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.AreEqual("error message", result.ErrorMessage);
}
[Test]
public void Result_GetOrError_Success_ReturnsNull()
{
var result = Result<string>.Success("value");
var error = result.GetOrError(out var value);
Assert.IsNull(error);
Assert.AreEqual("value", value);
}
[Test]
public void Result_GetOrError_Error_ReturnsErrorResponse()
{
var result = Result<string>.Error("error message");
var error = result.GetOrError(out var value);
Assert.IsNotNull(error);
Assert.IsInstanceOf<ErrorResponse>(error);
Assert.IsNull(value);
var errorResponse = error as ErrorResponse;
Assert.AreEqual("error message", errorResponse.error);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8207a28487246e8a3c5f28481581354
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,475 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
public class WriteToConfigTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private const string HttpUrlPrefKey = EditorPrefKeys.HttpBaseUrl;
private string _tempRoot;
private string _fakeUvPath;
private string _serverSrcDir;
// Save/restore original pref values (must happen BEFORE Assert.Ignore since TearDown still runs)
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadHttpUrl;
private string _originalHttpUrl;
[SetUp]
public void SetUp()
{
// Save original pref values FIRST - TearDown runs even when test is ignored!
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
_hadHttpUrl = EditorPrefs.HasKey(HttpUrlPrefKey);
_originalHttpUrl = EditorPrefs.GetString(HttpUrlPrefKey, "");
// 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(EditorPrefKeys.ServerSrc, _serverSrcDir);
// Ensure no lock is enabled
EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool(EditorPrefKeys.AutoRegisterEnabled, false);
// Force HTTP transport defaults so expectations match current behavior
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetString(HttpUrlPrefKey, "http://localhost:8080");
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey(EditorPrefKeys.ServerSrc);
EditorPrefs.DeleteKey(EditorPrefKeys.LockCursorConfig);
EditorPrefs.DeleteKey(EditorPrefKeys.AutoRegisterEnabled);
// Restore original pref values (don't delete if user had them set!)
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
if (_hadHttpUrl)
EditorPrefs.SetString(HttpUrlPrefKey, _originalHttpUrl);
else
EditorPrefs.DeleteKey(HttpUrlPrefKey);
// Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }
}
// --- Tests ---
[Test]
public void AddsDisabledFalseAndServerUrl_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
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"], "Windsurf configs should not include an env block");
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing");
AssertTransportConfiguration(unity, client);
}
[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",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
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");
AssertTransportConfiguration(unity, client);
}
[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" };
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");
AssertTransportConfiguration(unity, client);
}
[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", IsVsCodeLayout = true };
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");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForTrae()
{
var configPath = Path.Combine(_tempRoot, "trae.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "Trae" };
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 Trae client");
Assert.IsNull(unity["disabled"], "disabled should not be added for Trae client");
AssertTransportConfiguration(unity, client);
}
[Test]
public void ClaudeDesktop_UsesAbsoluteUvPath_WhenOverrideProvided()
{
var configPath = Path.Combine(_tempRoot, "claude-desktop.json");
WriteInitialConfig(configPath, isVSCode: false, command: "uvx", directory: "/old/path");
WithTransportPreference(false, () =>
{
MCPServiceLocator.Paths.SetUvxPathOverride(_fakeUvPath);
try
{
var client = new McpClient
{
name = "Claude Desktop",
SupportsHttpTransport = false,
StripEnvWhenNotRequired = true
};
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(_fakeUvPath, (string)unity["command"], "Claude Desktop should use absolute uvx path");
Assert.IsNull(unity["env"], "Claude Desktop config should not include env block when not required");
AssertTransportConfiguration(unity, client);
}
finally
{
MCPServiceLocator.Paths.ClearUvxPathOverride();
}
});
}
[Test]
public void PreservesExistingEnvAndDisabled_ForKiro()
{
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 = "Kiro",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
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");
AssertTransportConfiguration(unity, client);
}
[Test]
public void RemovesEnvBlock_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf-env.json");
var json = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = _fakeUvPath,
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
["env"] = new JObject { ["SHOULD"] = "be removed" },
["disabled"] = true
}
}
};
File.WriteAllText(configPath, json.ToString());
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
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"], "Windsurf config should strip any existing env block");
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
AssertTransportConfiguration(unity, client);
}
[Test]
public void UsesStdioTransport_ForNonVSCodeClients_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-non-vscode.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
[Test]
public void UsesStdioTransport_ForVSCode_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-vscode.json");
WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient { name = "VSCode", IsVsCodeLayout = true };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("servers.unityMCP");
Assert.NotNull(unity, "Expected servers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
// --- 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 void InvokeWriteToConfig(string configPath, McpClient client)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success");
}
private static void AssertTransportConfiguration(JObject unity, McpClient client)
{
bool useHttp = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
bool isWindsurf = string.Equals(client.HttpUrlProperty, "serverUrl", StringComparison.OrdinalIgnoreCase);
if (useHttp)
{
string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
if (isWindsurf)
{
Assert.AreEqual(expectedUrl, (string)unity["serverUrl"],
"Windsurf should advertise HTTP using serverUrl");
Assert.IsNull(unity["url"], "Windsurf configs should not use the url property");
}
else
{
Assert.AreEqual(expectedUrl, (string)unity["url"],
"HTTP transport should set url to the MCP endpoint");
Assert.IsNull(unity["serverUrl"], "serverUrl should be reserved for Windsurf");
}
Assert.IsNull(unity["command"], "HTTP transport should remove command");
Assert.IsNull(unity["args"], "HTTP transport should remove args");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("http", (string)unity["type"],
"All entries should advertise HTTP transport type");
}
else
{
Assert.IsNull(unity["url"], "stdio transport should not include a url");
Assert.IsNull(unity["serverUrl"], "stdio transport should not include a serverUrl");
string command = (string)unity["command"];
Assert.False(string.IsNullOrEmpty(command), "stdio transport should include a command");
var args = (unity["args"] as JArray)?.ToObject<string[]>();
Assert.NotNull(args, "stdio transport should include args array");
int transportIndex = Array.IndexOf(args, "--transport");
Assert.GreaterOrEqual(transportIndex, 0, "args should include --transport flag");
Assert.Less(transportIndex + 1, args.Length,
"--transport flag should be followed by a mode value");
Assert.AreEqual("stdio", args[transportIndex + 1],
"--transport should be followed by stdio mode");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("stdio", (string)unity["type"],
"All entries should advertise stdio transport type");
}
}
private static void WithTransportPreference(bool useHttp, Action action)
{
bool original = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetBool(UseHttpTransportPrefKey, useHttp);
EditorConfigCache.Instance.Refresh();
try
{
action();
}
finally
{
EditorPrefs.SetBool(UseHttpTransportPrefKey, original);
EditorConfigCache.Instance.Refresh();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3fc4210e7cbef4479b2cb9498b1580a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: