diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index c4f021b2cc..6cf4344992 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; @@ -179,12 +180,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) JToken unityToken = null; if (rootConfig != null) { - string containerKey = string.IsNullOrEmpty(client.ServerContainerKey) - ? "mcpServers" : client.ServerContainerKey; - unityToken = client.IsVsCodeLayout - ? rootConfig["servers"]?["unityMCP"] - ?? rootConfig["mcp"]?["servers"]?["unityMCP"] - : rootConfig[containerKey]?["unityMCP"]; + unityToken = McpConfigurationHelper.GetJsonUnityEntry(rootConfig, client); } if (unityToken is JObject unityObj) @@ -192,6 +188,13 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) configExists = true; var argsToken = unityObj["args"]; + if (argsToken != null && (!(argsToken is JArray argsArray) + || argsArray.Any(value => value.Type != JTokenType.String))) + { + client.SetStatus(McpStatus.Error, "The stdio args field must be an array of strings."); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } if (argsToken is JArray) { args = argsToken.ToObject(); @@ -200,10 +203,13 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) // Clients diverge on the HTTP URL property name: "url" (Cursor/VSCode/Claude), // "serverUrl" (Antigravity/Windsurf), "httpUrl" (Gemini CLI). Accept all three // so CheckStatus matches what Configure() actually wrote. - var urlToken = unityObj["url"] ?? unityObj["serverUrl"] ?? unityObj["httpUrl"]; - if (urlToken != null && urlToken.Type != JTokenType.Null) + configuredUrl = McpConfigurationHelper.GetConfiguredHttpUrl(unityObj); + + if (McpConfigurationHelper.HasCustomStdioLauncher(unityObj, client)) { - configuredUrl = urlToken.ToString(); + client.SetStatus(McpStatus.Configured); + client.configuredTransport = Models.ConfiguredTransport.Stdio; + return client.status; } } } @@ -339,6 +345,14 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) return client.status; } + internal bool HasCustomStdioConfiguration() + { + string path = GetConfigPath(); + if (!File.Exists(path)) return false; + var entry = McpConfigurationHelper.GetJsonUnityEntry(JObject.Parse(File.ReadAllText(path)), client); + return McpConfigurationHelper.HasCustomStdioLauncher(entry, client); + } + public override void Configure() { // Always idempotent-write. The per-client UI button routes through Unregister diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 61eccb13be..8a7a49f941 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -196,6 +196,84 @@ public static void EnsureConfigDirectoryExists(string configPath) Directory.CreateDirectory(Path.GetDirectoryName(configPath)); } + internal static JObject GetJsonUnityEntry(JObject root, McpClient client) + { + string container = string.IsNullOrEmpty(client.ServerContainerKey) + ? "mcpServers" : client.ServerContainerKey; + return (client.IsVsCodeLayout + ? root["servers"]?["unityMCP"] ?? root["mcp"]?["servers"]?["unityMCP"] + : root[container]?["unityMCP"]) as JObject; + } + + internal static string GetConfiguredHttpUrl(JObject entry) + { + foreach (string key in new[] { "url", "serverUrl", "httpUrl" }) + { + var value = entry[key]; + if (value != null && value.Type != JTokenType.Null + && !string.IsNullOrWhiteSpace(value.ToString())) + return value.ToString(); + } + return null; + } + + internal static bool HasCustomStdioLauncher(JObject entry, McpClient client) + { + var command = entry?["command"]; + if (command?.Type != JTokenType.String + || string.IsNullOrWhiteSpace(command.Value()) + || GetConfiguredHttpUrl(entry) != null) + return false; + + var argsToken = entry["args"]; + if (argsToken != null && (!(argsToken is JArray argsArray) + || argsArray.Any(value => value.Type != JTokenType.String))) + return false; + + var type = entry["type"]; + if (type != null && (type.Type != JTokenType.String + || !(string.Equals(type.Value(), "stdio", StringComparison.OrdinalIgnoreCase) + || string.Equals(type.Value(), client.StdioTypeValue, StringComparison.OrdinalIgnoreCase)))) + return false; + + string executable = Path.GetFileName(command.Value().Trim().Replace('\\', '/')); + if (string.Equals(executable, "uvx", StringComparison.OrdinalIgnoreCase) + || string.Equals(executable, "uvx.exe", StringComparison.OrdinalIgnoreCase)) + return false; + + bool uv = string.Equals(executable, "uv", StringComparison.OrdinalIgnoreCase) + || string.Equals(executable, "uv.exe", StringComparison.OrdinalIgnoreCase); + return !(uv && IsUvToolRun(argsToken as JArray)); + } + + private static bool IsUvToolRun(JArray args) + { + if (args == null) return false; + int index = SkipUvGlobalOptions(args, 0); + if (index >= args.Count || !string.Equals(args[index].Value(), "tool", StringComparison.OrdinalIgnoreCase)) + return false; + index = SkipUvGlobalOptions(args, index + 1); + return index < args.Count && string.Equals(args[index].Value(), "run", StringComparison.OrdinalIgnoreCase); + } + + private static int SkipUvGlobalOptions(JArray args, int index) + { + while (index < args.Count) + { + string option = args[index].Value(); + if (option == "--") return index + 1; + if (!option.StartsWith("-", StringComparison.Ordinal)) break; + index++; + // uv's global options with a separate value; --option=value needs no extra skip. + if (option == "--cache-dir" || option == "--color" || option == "--allow-insecure-host" + || option == "--trusted-host" || option == "--directory" || option == "--project" + || option == "--config-file" || option == "--python-preference" || option == "--python-fetch" + || option == "--preview-features" || option == "--preview-feature") + index++; + } + return index; + } + public static string ExtractUvxUrl(string[] args) { if (args == null) return null; diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs index 4fbeac856b..5b40652d36 100644 --- a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs @@ -1,6 +1,8 @@ using System; +using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; using UnityEditor; using UnityEngine; @@ -24,6 +26,48 @@ static LegacyServerSrcMigration() EditorApplication.delayCall += RunMigrationIfNeeded; } + internal static ClientConfigurationSummary MigrateDetectedClients(IClientConfigurationService clientService) + { + var summary = new ClientConfigurationSummary(); + foreach (var configurator in clientService.GetAllClients()) + { + if (!configurator.IsInstalled) + { + summary.SkippedCount++; + continue; + } + try + { + // Automatic migration must leave user-managed launchers untouched. + // Explicit Configure All continues to replace them. + if (configurator is JsonFileMcpConfigurator json && json.HasCustomStdioConfiguration()) + { + summary.SkippedCount++; + continue; + } + var status = configurator.CheckStatus(attemptAutoRewrite: false); + if (configurator is JsonFileMcpConfigurator && status == McpStatus.Error) + { + summary.FailureCount++; + summary.Messages.Add($"⚠ {configurator.DisplayName}: Configuration is invalid; update it manually before migrating."); + continue; + } + // Match the CLI's automatic re-registration path, without changing its explicit toggle. + if (configurator is ClaudeCliMcpConfigurator cli && status == McpStatus.Configured) + cli.Client.SetStatus(McpStatus.IncorrectPath); + clientService.ConfigureClient(configurator); + summary.SuccessCount++; + summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully"); + } + catch (Exception ex) + { + summary.FailureCount++; + summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}"); + } + } + return summary; + } + private static void RunMigrationIfNeeded() { EditorApplication.delayCall -= RunMigrationIfNeeded; @@ -40,7 +84,7 @@ private static void RunMigrationIfNeeded() { McpLog.Info("Detected legacy embedded MCP server configuration. Updating all client configs..."); - var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); + var summary = MigrateDetectedClients(MCPServiceLocator.Client); if (summary.FailureCount > 0) { diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs index ee1050958e..0d5d0d0b82 100644 --- a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -70,18 +70,8 @@ private static void RunMigrationIfNeeded() continue; } - // Handle JSON file-based configurators - if (!ConfigUsesStdIo(configurator.Client)) - continue; - - // Skip clients that don't support the current transport setting — - // Configure() would throw (e.g., Claude Desktop when HTTP is enabled). - bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; - if (useHttp && !configurator.Client.SupportsHttpTransport) - continue; - - MCPServiceLocator.Client.ConfigureClient(configurator); - touchedAny = true; + if (RefreshStdioConfiguration(configurator, MCPServiceLocator.Client)) + touchedAny = true; } catch (Exception ex) { @@ -112,9 +102,19 @@ private static void RunMigrationIfNeeded() McpLog.Info($"Updated stdio MCP configs to package version {currentVersion}."); } - private static bool ConfigUsesStdIo(McpClient client) + internal static bool RefreshStdioConfiguration( + McpClientConfiguratorBase configurator, IClientConfigurationService clientService) { - return JsonConfigUsesStdIo(client); + if (!configurator.SupportsAutoConfigure || !JsonConfigUsesStdIo(configurator.Client)) + return false; + + // Preserve the migration's existing transport guard. + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (useHttp && !configurator.Client.SupportsHttpTransport) + return false; + + clientService.ConfigureClient(configurator); + return true; } private static bool JsonConfigUsesStdIo(McpClient client) @@ -129,20 +129,13 @@ private static bool JsonConfigUsesStdIo(McpClient client) { var root = JObject.Parse(File.ReadAllText(configPath)); - JToken unityNode = null; - if (client.IsVsCodeLayout) - { - unityNode = root.SelectToken("servers.unityMCP") - ?? root.SelectToken("mcp.servers.unityMCP"); - } - else - { - unityNode = root.SelectToken("mcpServers.unityMCP"); - } - - if (unityNode == null) return false; - - return unityNode["command"] != null; + var unityNode = McpConfigurationHelper.GetJsonUnityEntry(root, client); + var argsToken = unityNode?["args"]; + if (argsToken != null && (!(argsToken is JArray argsArray) + || argsArray.Any(value => value.Type != JTokenType.String))) + return false; + return unityNode?["command"] != null + && !McpConfigurationHelper.HasCustomStdioLauncher(unityNode, client); } catch { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs new file mode 100644 index 0000000000..9b0febc5f2 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs @@ -0,0 +1,486 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Clients.Configurators; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Migrations; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnityTests.Editor.Clients +{ + [TestFixture] + public class JsonManualStdioConfigurationTests + { + private const string ExpectedSource = "mcpforunityserver==99.0.0"; + private readonly Dictionary _stringPrefs = new(); + private readonly Dictionary _boolPrefs = new(); + private IPathResolverService _originalPaths; + private bool _originalHttp; + private string _directory; + private string _configPath; + private string _uvxPath; + private McpClient _client; + private JsonFileMcpConfigurator _configurator; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetDirectoryName(Application.dataPath), "Temp", + "UnityMCPTests", "ManualStdio", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _configPath = Path.Combine(_directory, "client.json"); + _uvxPath = Path.Combine(_directory, "uvx"); + _originalPaths = MCPServiceLocator.Paths; + _originalHttp = EditorConfigurationCache.Instance.UseHttpTransport; + SaveBool(EditorPrefKeys.UseHttpTransport); + SaveBool(EditorPrefKeys.LockCursorConfig); + SaveBool(EditorPrefKeys.DevModeForceServerRefresh); + _stringPrefs[EditorPrefKeys.GitUrlOverride] = (EditorPrefs.HasKey(EditorPrefKeys.GitUrlOverride), + EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "")); + MCPServiceLocator.Register(new FakePaths(_uvxPath)); + EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, false); + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, true); + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, ExpectedSource); + EditorConfigurationCache.Instance.SetUseHttpTransport(true); + UseDesktop(); + } + + [TearDown] + public void TearDown() + { + MCPServiceLocator.Register(_originalPaths); + EditorConfigurationCache.Instance.SetUseHttpTransport(_originalHttp); + foreach (var pref in _boolPrefs) + if (pref.Value.present) EditorPrefs.SetBool(pref.Key, pref.Value.value); + else EditorPrefs.DeleteKey(pref.Key); + foreach (var pref in _stringPrefs) + if (pref.Value.present) EditorPrefs.SetString(pref.Key, pref.Value.value); + else EditorPrefs.DeleteKey(pref.Key); + _boolPrefs.Clear(); + _stringPrefs.Clear(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, true); + } + + private void SaveBool(string key) => _boolPrefs[key] = (EditorPrefs.HasKey(key), EditorPrefs.GetBool(key)); + + private void UseDesktop() + { + var desktop = new ClaudeDesktopConfigurator(); + _client = desktop.Client; + _client.windowsConfigPath = _client.macConfigPath = _client.linuxConfigPath = _configPath; + _configurator = desktop; + } + + [Test] + public void Status_DefaultRefresh_PreservesInstalledDesktopLauncher() + { + WriteEntry(new JObject { ["command"] = @"C:\Tools\mcp-for-unity.exe", + ["args"] = new JArray("--transport", "stdio"), ["type"] = "stdio" }); + AssertCustomPreserved(true); + } + + [TestCase("mcp-for-unity", "omitted", true)] + [TestCase("mcp-for-unity", "empty", false)] + [TestCase("/tools/mcp-for-unity", "empty", true)] + [TestCase("python3", "script", false)] + [TestCase(@"C:\Tools\python.exe", "script", true)] + [TestCase("wrapper", "from", true)] + [TestCase("uv", "run", true)] + public void Status_CustomLauncher_PreservesAllBytes(string command, string argumentKind, bool rewrite) + { + var entry = new JObject { ["command"] = command, ["type"] = "stdio" }; + if (argumentKind == "empty") entry["args"] = new JArray(); + if (argumentKind == "script") entry["args"] = new JArray("-m", "main", "--transport", "stdio"); + if (argumentKind == "from") entry["args"] = new JArray("--from", "custom-input"); + if (argumentKind == "run") entry["args"] = new JArray("run", "server.py", "--from", "custom-input"); + WriteEntry(entry); + AssertCustomPreserved(rewrite); + } + + [Test] + public void Status_CustomLauncherWithoutType_RemainsConfigured() + { + WriteEntry(new JObject { ["command"] = "custom-launcher" }); + AssertCustomPreserved(true); + } + + [Test] + public void Status_MismatchedUvxSource_StillAutoUpdates() + { + WriteEntry(new JObject { ["command"] = _uvxPath, + ["args"] = new JArray("--from", "mcpforunityserver==98.0.0", "mcp-for-unity"), ["type"] = "stdio" }); + Assert.AreEqual(McpStatus.Configured, _configurator.CheckStatus(true)); + var entry = JObject.Parse(File.ReadAllText(_configPath))["mcpServers"]["unityMCP"]; + Assert.AreEqual(ExpectedSource, McpConfigurationHelper.ExtractUvxUrl(entry["args"].ToObject())); + } + + [TestCase("servers", true)] + [TestCase("mcp", false)] + public void Status_CustomLauncher_UsesClientContainer(string container, bool vsCode) + { + _client.IsVsCodeLayout = vsCode; + _client.ServerContainerKey = container; + _client.StdioTypeValue = vsCode ? null : "local"; + WriteEntry(new JObject { ["command"] = "custom-launcher", ["args"] = new JArray(), + ["type"] = vsCode ? "stdio" : "local" }, container); + AssertCustomPreserved(true); + } + + [TestCase("number")] + [TestCase("boolean")] + [TestCase("null-item")] + [TestCase("object")] + [TestCase("string")] + [TestCase("matching-source-with-number")] + [TestCase("null-args")] + public void Status_InvalidArguments_AreNotAcceptedAsCustomStdio(string kind) + { + JToken args = InvalidArguments(kind); + WriteEntry(new JObject { ["command"] = "custom-launcher", ["args"] = args, ["type"] = "stdio" }); + var before = File.ReadAllBytes(_configPath); + Assert.AreEqual(McpStatus.Error, _configurator.CheckStatus(true)); + Assert.AreEqual(ConfiguredTransport.Unknown, _client.configuredTransport); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [TestCase("number")] + [TestCase("boolean")] + [TestCase("null-item")] + [TestCase("object")] + [TestCase("string")] + [TestCase("matching-source-with-number")] + [TestCase("null-args")] + public void Migrations_PreserveInvalidArguments(string kind) + { + EditorConfigurationCache.Instance.SetUseHttpTransport(false); + foreach (string command in new[] { "custom-launcher", _uvxPath }) + { + WriteEntry(new JObject { ["command"] = command, ["type"] = "stdio", + ["args"] = InvalidArguments(kind) }); + var before = File.ReadAllBytes(_configPath); + var service = new IsolatedClientService(_configurator); + Assert.IsFalse(StdIoVersionMigration.RefreshStdioConfiguration(_configurator, service)); + var summary = LegacyServerSrcMigration.MigrateDetectedClients(service); + Assert.AreEqual(1, summary.FailureCount); + Assert.AreEqual(0, summary.SuccessCount); + Assert.AreEqual(0, service.ConfigureCalls); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + } + + private static JToken InvalidArguments(string kind) => kind == "number" ? new JArray(42) + : kind == "boolean" ? new JArray(true) + : kind == "null-item" ? new JArray(JValue.CreateNull()) + : kind == "object" ? new JObject() + : kind == "matching-source-with-number" ? new JArray("--from", ExpectedSource, 42) + : kind == "null-args" ? JValue.CreateNull() : new JValue("--transport stdio"); + + [Test] + public void LegacyMigration_ReregistersConfiguredClaudeCode() + { + var configurator = new RecordingClaudeConfigurator(); + var summary = LegacyServerSrcMigration.MigrateDetectedClients(new IsolatedClientService(configurator)); + Assert.AreEqual(1, summary.SuccessCount); + Assert.AreEqual(0, summary.FailureCount); + Assert.AreEqual(McpStatus.IncorrectPath, configurator.StatusAtConfigure, + "Automatic registration must not dispatch the Configured state to the CLI's Unregister action."); + Assert.AreEqual(McpStatus.Configured, configurator.Status); + Assert.IsTrue(EditorConfigurationCache.Instance.UseHttpTransport); + } + + private sealed class RecordingClaudeConfigurator : ClaudeCodeConfigurator + { + public McpStatus StatusAtConfigure { get; private set; } + public override bool IsInstalled => true; + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + Assert.IsFalse(attemptAutoRewrite); + Client.SetStatus(McpStatus.Configured); + return Status; + } + public override void Configure() + { + StatusAtConfigure = Status; + Client.SetStatus(McpStatus.Configured); + } + } + + [TestCase("uvx")] + [TestCase(@"C:\Tools\UVX.EXE")] + [TestCase("/tools/uv")] + [TestCase(@"C:\Tools\uv.exe")] + public void Status_PackageRunner_RetainsVersionMismatchValidation(string command) + { + bool uv = command.EndsWith("uv", StringComparison.OrdinalIgnoreCase) + || command.EndsWith("uv.exe", StringComparison.OrdinalIgnoreCase); + var args = new JArray(); + if (uv) { args.Add("tool"); args.Add("run"); } + args.Add("--from"); args.Add("mcpforunityserver==98.0.0"); args.Add("mcp-for-unity"); + WriteEntry(new JObject { ["command"] = command, ["args"] = args, ["type"] = "stdio" }); + var before = File.ReadAllBytes(_configPath); + Assert.AreEqual(McpStatus.VersionMismatch, _configurator.CheckStatus(false)); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [TestCase("flag")] + [TestCase("value")] + [TestCase("equals")] + [TestCase("between")] + public void Status_UvToolRunWithGlobalOptions_RetainsVersionValidation(string options) + { + var args = options == "flag" ? new JArray("--offline", "-v", "tool", "run") + : options == "value" ? new JArray("--project", "run", "--color", "never", "tool", "run") + : options == "equals" ? new JArray("--project=run", "tool", "run") + : new JArray("tool", "--offline", "run"); + args.Add("--from"); args.Add("mcpforunityserver==98.0.0"); args.Add("mcp-for-unity"); + WriteEntry(new JObject { ["command"] = "uv", ["args"] = args, ["type"] = "stdio" }); + var before = File.ReadAllBytes(_configPath); + Assert.AreEqual(McpStatus.VersionMismatch, _configurator.CheckStatus(false)); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [TestCase("--trusted-host", "example.com")] + [TestCase("--python-preference", "only-managed")] + [TestCase("--python-fetch", "never")] + [TestCase("--preview-features", "python-upgrade")] + [TestCase("--preview-feature", "python-upgrade")] + public void StatusAndMigrations_UvGlobalValueOptions_RetainPackageValidation(string option, string value) + { + EditorConfigurationCache.Instance.SetUseHttpTransport(false); + foreach (string form in new[] { "before", "between", "equals" }) + { + var args = form == "before" ? new JArray(option, value, "tool", "run") + : form == "between" ? new JArray("tool", option, value, "run") + : new JArray(option + "=" + value, "tool", "run"); + args.Add("--from"); + args.Add("mcpforunityserver==98.0.0"); + args.Add("mcp-for-unity"); + var entry = new JObject { ["command"] = "uv", ["args"] = args, ["type"] = "stdio" }; + WriteEntry(entry); + var before = File.ReadAllBytes(_configPath); + Assert.AreEqual(McpStatus.VersionMismatch, _configurator.CheckStatus(false), option + ": " + form); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + + var service = new IsolatedClientService(_configurator); + Assert.IsTrue(StdIoVersionMigration.RefreshStdioConfiguration(_configurator, service)); + Assert.AreEqual(1, service.ConfigureCalls); + AssertGeneratedSource(); + + WriteEntry(entry); + service = new IsolatedClientService(_configurator); + var summary = LegacyServerSrcMigration.MigrateDetectedClients(service); + Assert.AreEqual(1, summary.SuccessCount); + Assert.AreEqual(0, summary.FailureCount); + Assert.AreEqual(0, summary.SkippedCount); + Assert.AreEqual(1, service.ConfigureCalls); + AssertGeneratedSource(); + + // The same global options must not turn a user-managed uv run script into a package runner. + args = form == "before" ? new JArray(option, value, "run", "server.py") + : form == "between" ? new JArray("run", option, value, "server.py") + : new JArray(option + "=" + value, "run", "server.py"); + args.Add("tool"); + args.Add("run"); + args.Add("--from"); + args.Add("custom-input"); + WriteEntry(new JObject { ["command"] = "uv", ["args"] = args, ["type"] = "stdio" }); + before = File.ReadAllBytes(_configPath); + AssertCustomPreserved(true); + service = new IsolatedClientService(_configurator); + Assert.IsFalse(StdIoVersionMigration.RefreshStdioConfiguration(_configurator, service)); + summary = LegacyServerSrcMigration.MigrateDetectedClients(service); + Assert.AreEqual(1, summary.SkippedCount); + Assert.AreEqual(0, service.ConfigureCalls); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + } + + [Test] + public void Status_UvRunScriptWithToolRunArguments_RemainsCustom() + { + WriteEntry(new JObject { ["command"] = "uv", ["type"] = "stdio", + ["args"] = new JArray("--offline", "run", "server.py", "tool", "run", "--from", "custom-input") }); + AssertCustomPreserved(true); + } + + [Test] + public void Status_MatchingUvxSource_RemainsConfigured() + { + WriteEntry(new JObject { ["command"] = _uvxPath, + ["args"] = new JArray("--from", ExpectedSource, "mcp-for-unity"), ["type"] = "stdio" }); + AssertCustomPreserved(true); + } + + [Test] + public void Status_HttpUrlWithLeftoverCommand_DoesNotBecomeCustomStdio() + { + WriteEntry(new JObject { ["command"] = "custom-launcher", ["args"] = new JArray(), + ["url"] = "http://127.0.0.1:65535/mcp", ["type"] = "http" }); + Assert.AreNotEqual(McpStatus.Configured, _configurator.CheckStatus(false)); + Assert.AreNotEqual(ConfiguredTransport.Stdio, _client.configuredTransport); + } + + [TestCase("null", "serverUrl")] + [TestCase("empty", "serverUrl")] + [TestCase("null", "httpUrl")] + [TestCase("empty", "httpUrl")] + public void Status_EmptyFirstUrl_DoesNotHideHttpAlias(string firstUrl, string alias) + { + var entry = new JObject { ["command"] = "custom-launcher", ["args"] = new JArray(), + ["url"] = firstUrl == "null" ? JValue.CreateNull() : new JValue(""), + [alias] = "http://127.0.0.1:65535/mcp" }; + if (alias == "httpUrl") entry["serverUrl"] = JValue.CreateNull(); + WriteEntry(entry); + var before = File.ReadAllBytes(_configPath); + Assert.AreNotEqual(McpStatus.Configured, _configurator.CheckStatus(false)); + Assert.AreEqual(ConfiguredTransport.Http, _client.configuredTransport); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [TestCase("mcpServers", false)] + [TestCase("servers", true)] + [TestCase("mcp", false)] + public void VersionMigration_PreservesCustomLauncher(string container, bool vsCode) + { + _client.IsVsCodeLayout = vsCode; + _client.ServerContainerKey = container; + EditorConfigurationCache.Instance.SetUseHttpTransport(false); + WriteEntry(new JObject { ["command"] = "mcp-for-unity", + ["args"] = new JArray("--transport", "stdio"), ["type"] = "stdio" }, container); + var before = File.ReadAllBytes(_configPath); + var service = new IsolatedClientService(_configurator); + Assert.IsFalse(StdIoVersionMigration.RefreshStdioConfiguration(_configurator, service)); + Assert.AreEqual(0, service.ConfigureCalls); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [Test] + public void VersionMigration_UpdatesGeneratedLauncher() + { + EditorConfigurationCache.Instance.SetUseHttpTransport(false); + WriteEntry(new JObject { ["command"] = _uvxPath, ["type"] = "stdio", + ["args"] = new JArray("--from", "mcpforunityserver==98.0.0", "mcp-for-unity") }); + var service = new IsolatedClientService(_configurator); + Assert.IsTrue(StdIoVersionMigration.RefreshStdioConfiguration(_configurator, service)); + Assert.AreEqual(1, service.ConfigureCalls); + AssertGeneratedSource(); + } + + [TestCase("mcp-for-unity")] + [TestCase("python3")] + public void LegacyMigration_PreservesCustomLauncher(string command) + { + WriteEntry(new JObject { ["command"] = command, ["type"] = "stdio", + ["args"] = new JArray("--transport", "stdio") }); + var before = File.ReadAllBytes(_configPath); + var service = new IsolatedClientService(_configurator); + var summary = LegacyServerSrcMigration.MigrateDetectedClients(service); + Assert.AreEqual(1, summary.SkippedCount); + Assert.AreEqual(0, summary.SuccessCount); + Assert.AreEqual(0, summary.FailureCount); + Assert.AreEqual(0, service.ConfigureCalls); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath)); + } + + [Test] + public void LegacyMigration_UpdatesGeneratedLauncherAndRestoresTransport() + { + WriteEntry(new JObject { ["command"] = _uvxPath, ["type"] = "stdio", + ["args"] = new JArray("--from", "mcpforunityserver==98.0.0", "mcp-for-unity") }); + var service = new IsolatedClientService(_configurator); + var summary = LegacyServerSrcMigration.MigrateDetectedClients(service); + Assert.AreEqual(1, summary.SuccessCount); + Assert.AreEqual(0, summary.FailureCount); + Assert.AreEqual(0, summary.SkippedCount); + Assert.AreEqual(1, service.ConfigureCalls); + AssertGeneratedSource(); + Assert.IsTrue(EditorConfigurationCache.Instance.UseHttpTransport); + } + + private void AssertGeneratedSource() + { + var entry = JObject.Parse(File.ReadAllText(_configPath))["mcpServers"]["unityMCP"]; + Assert.AreEqual(_uvxPath, entry.Value("command")); + Assert.AreEqual(ExpectedSource, McpConfigurationHelper.ExtractUvxUrl(entry["args"].ToObject())); + } + + [Test] + public void Configure_ExplicitlyReplacesCustomLauncher() + { + WriteEntry(new JObject { ["command"] = "custom-launcher", + ["args"] = new JArray("--transport", "stdio"), ["type"] = "stdio" }); + new ClientConfigurationService().ConfigureClient(_configurator); + var root = JObject.Parse(File.ReadAllText(_configPath)); + var entry = root["mcpServers"]["unityMCP"]; + Assert.AreEqual(_uvxPath, entry.Value("command")); + Assert.AreEqual(ExpectedSource, McpConfigurationHelper.ExtractUvxUrl(entry["args"].ToObject())); + Assert.AreEqual("other-launcher", root["mcpServers"]["otherServer"].Value("command")); + Assert.IsTrue(EditorConfigurationCache.Instance.UseHttpTransport); + } + + private void AssertCustomPreserved(bool rewrite) + { + var before = File.ReadAllBytes(_configPath); + var status = _configurator.CheckStatus(rewrite); + CollectionAssert.AreEqual(before, File.ReadAllBytes(_configPath), "Status checks must preserve the complete configuration file"); + Assert.AreEqual(McpStatus.Configured, status); + Assert.AreEqual(ConfiguredTransport.Stdio, _client.configuredTransport); + } + + private void WriteEntry(JObject entry, string container = "mcpServers") + { + entry["env"] = new JObject { ["TEST_LAUNCHER_MODE"] = "manual" }; + entry["cwd"] = "custom-working-directory"; + var root = new JObject { [container] = new JObject { ["unityMCP"] = entry, + ["otherServer"] = new JObject { ["command"] = "other-launcher" } }, + ["unrelatedSetting"] = "keep" }; + File.WriteAllText(_configPath, " \r\n" + root.ToString(Formatting.Indented) + "\r\n ", new UTF8Encoding(true)); + } + + private sealed class IsolatedClientService : IClientConfigurationService + { + private readonly IMcpClientConfigurator _configurator; + private readonly ClientConfigurationService _service = new(); + public int ConfigureCalls { get; private set; } + public IsolatedClientService(IMcpClientConfigurator configurator) => _configurator = configurator; + public IReadOnlyList GetAllClients() => new[] { _configurator }; + public void ConfigureClient(IMcpClientConfigurator configurator) + { + Assert.AreSame(_configurator, configurator); + ConfigureCalls++; + _service.ConfigureClient(configurator); + } + public ClientConfigurationSummary ConfigureAllDetectedClients() + => throw new InvalidOperationException("Tests must never configure the real client registry."); + public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true) + => _service.CheckClientStatus(configurator, attemptAutoRewrite); + } + + private sealed class FakePaths : IPathResolverService + { + private readonly string _path; + public FakePaths(string path) { _path = path; } + public string GetUvxPath() => _path; + public string GetClaudeCliPath() => null; + public bool IsPythonDetected() => false; + public bool IsClaudeCliDetected() => false; + public void SetUvxPathOverride(string path) { } + public void SetClaudeCliPathOverride(string path) { } + public void ClearUvxPathOverride() { } + public void ClearClaudeCliPathOverride() { } + public bool HasUvxPathOverride => false; + public bool HasClaudeCliPathOverride => false; + public bool HasUvxPathFallback => false; + public bool TryValidateUvxExecutable(string path, out string version) { version = null; return false; } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs.meta new file mode 100644 index 0000000000..34b94238d3 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/JsonManualStdioConfigurationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6cbd0361f0d34e548e06efbee98fb028 diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index 8cbdf101de..3f2cb24fe1 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -129,6 +129,21 @@ All of this logic is centralized in **`ConfigJsonBuilder`**, so most JSON-based --- +## Custom stdio launchers + +JSON-file clients can use a persistent `mcp-for-unity` executable, a Python +interpreter, or a wrapper as their `unityMCP` command. Status checks recognize +these entries as configured. Status checks and automatic package migrations +preserve the entire file, including custom +`env`, `cwd`, and other servers. Arguments may be omitted or an array of strings. +Use the client's stdio type (`stdio`, or its supported `local` value) and omit HTTP URLs. + +Package runner entries using `uvx` or `uv tool run` retain the existing package +source/version checks. A custom launcher's server version is not validated; +maintain its installation yourself. **Configure All** still replaces custom +entries with the generated configuration. For an individual configured client, +choose **Unregister** and then **Configure** to generate a new entry. + ## Special clients Some clients cannot be handled by the generic JSON configurator alone.