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 refreshmain
parent
839665b37c
commit
4cd6c071db
|
|
@ -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
|
||||
})
|
||||
{ }
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue