diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs index e6cb41b1..f55f3231 100644 --- a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -9,13 +9,16 @@ namespace MCPForUnity.Editor.Clients.Configurators { public class ClaudeDesktopConfigurator : JsonFileMcpConfigurator { + public const string ClientName = "Claude Desktop"; + public ClaudeDesktopConfigurator() : base(new McpClient { - name = "Claude Desktop", + name = ClientName, windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"), macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"), linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json"), - SupportsHttpTransport = false + SupportsHttpTransport = false, + StripEnvWhenNotRequired = true }) { } diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 6df986ae..3c0ba705 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -1,11 +1,15 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Clients.Configurators; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; +using UnityEngine; namespace MCPForUnity.Editor.Helpers { @@ -77,27 +81,26 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl // Stdio mode: Use uvx command var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - unity["command"] = uvxPath; + var toolArgs = BuildUvxArgs(fromUrl, packageName); - var args = new List { packageName }; - if (!string.IsNullOrEmpty(fromUrl)) + if (ShouldUseWindowsCmdShim(client)) { - args.Insert(0, fromUrl); - args.Insert(0, "--from"); - } + unity["command"] = ResolveCmdPath(); - args.Add("--transport"); - args.Add("stdio"); + var cmdArgs = new List { "/c", uvxPath }; + cmdArgs.AddRange(toolArgs); - unity["args"] = JArray.FromObject(args.ToArray()); + unity["args"] = JArray.FromObject(cmdArgs.ToArray()); + } + else + { + unity["command"] = uvxPath; + unity["args"] = JArray.FromObject(toolArgs.ToArray()); + } // Remove url/serverUrl if they exist from previous config if (unity["url"] != null) unity.Remove("url"); if (unity["serverUrl"] != null) unity.Remove("serverUrl"); - foreach (var prop in urlPropsToRemove) - { - if (unity[prop] != null) unity.Remove(prop); - } if (isVSCode) { @@ -145,5 +148,44 @@ private static JObject EnsureObject(JObject parent, string name) parent[name] = created; return created; } + + private static IList BuildUvxArgs(string fromUrl, string packageName) + { + var args = new List { packageName }; + + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Insert(0, fromUrl); + args.Insert(0, "--from"); + } + + args.Add("--transport"); + args.Add("stdio"); + + return args; + } + + private static bool ShouldUseWindowsCmdShim(McpClient client) + { + if (client == null) + { + return false; + } + + return Application.platform == RuntimePlatform.WindowsEditor && + string.Equals(client.name, ClaudeDesktopConfigurator.ClientName, StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveCmdPath() + { + var comSpec = Environment.GetEnvironmentVariable("ComSpec"); + if (!string.IsNullOrEmpty(comSpec) && File.Exists(comSpec)) + { + return comSpec; + } + + string system32Cmd = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + return File.Exists(system32Cmd) ? system32Cmd : "cmd.exe"; + } } } diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 4b6b07fb..4947a16d 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -34,6 +35,12 @@ public string GetUvxPath() McpLog.Debug("No uvx path override found, falling back to default command"); } + string discovered = ResolveUvxFromSystem(); + if (!string.IsNullOrEmpty(discovered)) + { + return discovered; + } + return "uvx"; } @@ -123,6 +130,81 @@ public bool IsClaudeCliDetected() return !string.IsNullOrEmpty(GetClaudeCliPath()); } + private static string ResolveUvxFromSystem() + { + try + { + foreach (string candidate in EnumerateUvxCandidates()) + { + if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) + { + return candidate; + } + } + } + catch + { + // fall back to bare command + } + + return null; + } + + private static IEnumerable EnumerateUvxCandidates() + { + string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uvx.exe" : "uvx"; + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + yield return Path.Combine(home, ".local", "bin", exeName); + yield return Path.Combine(home, ".cargo", "bin", exeName); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + yield return "/opt/homebrew/bin/" + exeName; + yield return "/usr/local/bin/" + exeName; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + yield return "/usr/local/bin/" + exeName; + yield return "/usr/bin/" + exeName; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + if (!string.IsNullOrEmpty(localAppData)) + { + yield return Path.Combine(localAppData, "Programs", "uv", exeName); + } + + if (!string.IsNullOrEmpty(programFiles)) + { + yield return Path.Combine(programFiles, "uv", exeName); + } + } + + string pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathEnv)) + { + foreach (string rawDir in pathEnv.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(rawDir)) continue; + string dir = rawDir.Trim(); + yield return Path.Combine(dir, exeName); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Some PATH entries may already contain the file without extension + yield return Path.Combine(dir, "uvx"); + } + } + } + } + public void SetUvxPathOverride(string path) { if (string.IsNullOrEmpty(path)) diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 617347cb..781d1c0e 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; @@ -38,6 +39,9 @@ public class McpClientConfigSection // Data private readonly List configurators; + private readonly Dictionary lastStatusChecks = new(); + private readonly HashSet statusRefreshInFlight = new(); + private static readonly TimeSpan StatusRefreshInterval = TimeSpan.FromSeconds(45); private int selectedClientIndex = 0; public VisualElement Root { get; private set; } @@ -105,33 +109,7 @@ public void UpdateClientStatus() return; var client = configurators[selectedClientIndex]; - MCPServiceLocator.Client.CheckClientStatus(client); - - clientStatusLabel.text = GetStatusDisplayString(client.Status); - clientStatusLabel.style.color = StyleKeyword.Null; - - clientStatusIndicator.RemoveFromClassList("configured"); - clientStatusIndicator.RemoveFromClassList("not-configured"); - clientStatusIndicator.RemoveFromClassList("warning"); - - switch (client.Status) - { - case McpStatus.Configured: - case McpStatus.Running: - case McpStatus.Connected: - clientStatusIndicator.AddToClassList("configured"); - break; - case McpStatus.IncorrectPath: - case McpStatus.CommunicationError: - case McpStatus.NoResponse: - clientStatusIndicator.AddToClassList("warning"); - break; - default: - clientStatusIndicator.AddToClassList("not-configured"); - break; - } - - configureButton.text = client.GetConfigureActionLabel(); + RefreshClientStatus(client); } private string GetStatusDisplayString(McpStatus status) @@ -240,7 +218,8 @@ private void OnConfigureClicked() try { MCPServiceLocator.Client.ConfigureClient(client); - UpdateClientStatus(); + lastStatusChecks.Remove(client); + RefreshClientStatus(client, forceImmediate: true); UpdateManualConfiguration(); } catch (Exception ex) @@ -314,11 +293,140 @@ public void RefreshSelectedClient() if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { var client = configurators[selectedClientIndex]; - MCPServiceLocator.Client.CheckClientStatus(client); - UpdateClientStatus(); + RefreshClientStatus(client, forceImmediate: true); UpdateManualConfiguration(); UpdateClaudeCliPathVisibility(); } } + + private void RefreshClientStatus(IMcpClientConfigurator client, bool forceImmediate = false) + { + if (client is ClaudeCliMcpConfigurator) + { + RefreshClaudeCliStatus(client, forceImmediate); + return; + } + + if (forceImmediate || ShouldRefreshClient(client)) + { + MCPServiceLocator.Client.CheckClientStatus(client); + lastStatusChecks[client] = DateTime.UtcNow; + } + + ApplyStatusToUi(client); + } + + private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate) + { + if (forceImmediate) + { + MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); + lastStatusChecks[client] = DateTime.UtcNow; + ApplyStatusToUi(client); + return; + } + + bool hasStatus = lastStatusChecks.ContainsKey(client); + bool needsRefresh = !hasStatus || ShouldRefreshClient(client); + + if (!hasStatus) + { + ApplyStatusToUi(client, showChecking: true); + } + else + { + ApplyStatusToUi(client); + } + + if (needsRefresh && !statusRefreshInFlight.Contains(client)) + { + statusRefreshInFlight.Add(client); + ApplyStatusToUi(client, showChecking: true); + + Task.Run(() => + { + MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); + }).ContinueWith(t => + { + bool faulted = false; + string errorMessage = null; + if (t.IsFaulted && t.Exception != null) + { + var baseException = t.Exception.GetBaseException(); + errorMessage = baseException?.Message ?? "Status check failed"; + McpLog.Error($"Failed to refresh Claude CLI status: {errorMessage}"); + faulted = true; + } + + EditorApplication.delayCall += () => + { + statusRefreshInFlight.Remove(client); + lastStatusChecks[client] = DateTime.UtcNow; + if (faulted) + { + if (client is McpClientConfiguratorBase baseConfigurator) + { + baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage ?? "Status check failed"); + } + } + ApplyStatusToUi(client); + }; + }); + } + } + + private bool ShouldRefreshClient(IMcpClientConfigurator client) + { + if (!lastStatusChecks.TryGetValue(client, out var last)) + { + return true; + } + + return (DateTime.UtcNow - last) > StatusRefreshInterval; + } + + private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false) + { + if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) + return; + + if (!ReferenceEquals(configurators[selectedClientIndex], client)) + return; + + clientStatusIndicator.RemoveFromClassList("configured"); + clientStatusIndicator.RemoveFromClassList("not-configured"); + clientStatusIndicator.RemoveFromClassList("warning"); + + if (showChecking) + { + clientStatusLabel.text = "Checking..."; + clientStatusLabel.style.color = StyleKeyword.Null; + clientStatusIndicator.AddToClassList("warning"); + configureButton.text = client.GetConfigureActionLabel(); + return; + } + + clientStatusLabel.text = GetStatusDisplayString(client.Status); + clientStatusLabel.style.color = StyleKeyword.Null; + + switch (client.Status) + { + case McpStatus.Configured: + case McpStatus.Running: + case McpStatus.Connected: + clientStatusIndicator.AddToClassList("configured"); + break; + case McpStatus.IncorrectPath: + case McpStatus.CommunicationError: + case McpStatus.NoResponse: + clientStatusIndicator.AddToClassList("warning"); + break; + default: + clientStatusIndicator.AddToClassList("not-configured"); + break; + } + + configureButton.text = client.GetConfigureActionLabel(); + } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs index 65ebc879..f168742f 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs @@ -8,6 +8,7 @@ using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; namespace MCPForUnityTests.Editor.Helpers { @@ -167,6 +168,40 @@ public void DoesNotAddEnvOrDisabled_ForTrae() 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() {