Fix Claude Windows config and CLI status refresh (#412)

* Fix Claude Windows config and CLI status refresh

* Fix Claude uvx path resolution

* Address review feedback for Claude uvx

* Polish config cleanup and status errors

* Tidy Claude status refresh
main
dsarno 2025-12-01 18:01:14 -08:00 committed by GitHub
parent 839665b37c
commit 4cd6c071db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 316 additions and 46 deletions

View File

@ -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
})
{ }

View File

@ -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 @@ namespace MCPForUnity.Editor.Helpers
// Stdio mode: Use uvx command
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
unity["command"] = uvxPath;
var toolArgs = BuildUvxArgs(fromUrl, packageName);
var args = new List<string> { packageName };
if (!string.IsNullOrEmpty(fromUrl))
if (ShouldUseWindowsCmdShim(client))
{
args.Insert(0, fromUrl);
args.Insert(0, "--from");
unity["command"] = ResolveCmdPath();
var cmdArgs = new List<string> { "/c", uvxPath };
cmdArgs.AddRange(toolArgs);
unity["args"] = JArray.FromObject(cmdArgs.ToArray());
}
else
{
unity["command"] = uvxPath;
unity["args"] = JArray.FromObject(toolArgs.ToArray());
}
args.Add("--transport");
args.Add("stdio");
unity["args"] = JArray.FromObject(args.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 @@ namespace MCPForUnity.Editor.Helpers
parent[name] = created;
return created;
}
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{
var args = new List<string> { 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";
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@ -34,6 +35,12 @@ namespace MCPForUnity.Editor.Services
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 @@ namespace MCPForUnity.Editor.Services
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<string> 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))

View File

@ -4,6 +4,7 @@ using System.Diagnostics;
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 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
// Data
private readonly List<IMcpClientConfigurator> configurators;
private readonly Dictionary<IMcpClientConfigurator, DateTime> lastStatusChecks = new();
private readonly HashSet<IMcpClientConfigurator> statusRefreshInFlight = new();
private static readonly TimeSpan StatusRefreshInterval = TimeSpan.FromSeconds(45);
private int selectedClientIndex = 0;
public VisualElement Root { get; private set; }
@ -105,33 +109,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
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 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
try
{
MCPServiceLocator.Client.ConfigureClient(client);
UpdateClientStatus();
lastStatusChecks.Remove(client);
RefreshClientStatus(client, forceImmediate: true);
UpdateManualConfiguration();
}
catch (Exception ex)
@ -314,11 +293,140 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
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();
}
}
}

View File

@ -8,6 +8,7 @@ using UnityEditor;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
namespace MCPForUnityTests.Editor.Helpers
{
@ -167,6 +168,40 @@ namespace MCPForUnityTests.Editor.Helpers
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()
{