diff --git a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs
index bf14559f3e..222be173e0 100644
--- a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs
+++ b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs
@@ -56,7 +56,7 @@ public override void Configure()
public override string GetManualSnippet()
{
- bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool useHttp = UseHttpTransport;
if (useHttp)
{
diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs
index 1d800332e9..134ca2f2b1 100644
--- a/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs
+++ b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs
@@ -127,7 +127,7 @@ public override void Configure()
McpConfigurationHelper.WriteAtomicFile(path, root.ToString(Formatting.Indented));
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
public override string GetManualSnippet()
@@ -269,23 +269,23 @@ private static JObject NormalizeServers(JToken serversToken)
return normalized;
}
- private static JObject BuildUnityServerEntry()
+ private JObject BuildUnityServerEntry()
{
- ConfiguredTransport transport = HttpEndpointUtility.GetCurrentServerTransport();
+ ConfiguredTransport transport = CurrentTransport;
if (transport == ConfiguredTransport.Stdio)
{
- var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
+ var (uvxPath, _, packageName) = UvxCommandParts;
if (string.IsNullOrWhiteSpace(uvxPath))
{
throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings.");
}
var args = new JArray();
- foreach (string value in AssetPathUtility.GetUvxDevFlagsList())
+ foreach (string value in UvxDevFlagList)
{
args.Add(value);
}
- foreach (string value in AssetPathUtility.GetBetaServerFromArgsList())
+ foreach (string value in UvxFromArgs)
{
args.Add(value);
}
@@ -309,7 +309,7 @@ private static JObject BuildUnityServerEntry()
return new JObject
{
["enabled"] = true,
- ["url"] = HttpEndpointUtility.GetMcpRpcUrl(),
+ ["url"] = RpcUrl,
["transport"] = HttpTransportName,
["toolPrefix"] = ServerName,
["requestTimeoutMs"] = 30000
@@ -323,7 +323,7 @@ private bool ServerMatchesCurrentEndpoint(JObject server)
return false;
}
- ConfiguredTransport expectedTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ ConfiguredTransport expectedTransport = CurrentTransport;
ConfiguredTransport configuredTransport = ResolveTransport(server);
if (configuredTransport != expectedTransport)
{
@@ -353,8 +353,8 @@ private bool ServerMatchesCurrentEndpoint(JObject server)
{
string configuredUrl = server["url"]?.ToString();
if (string.IsNullOrWhiteSpace(configuredUrl) ||
- (!UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl()) &&
- !UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl())))
+ (!UrlsEqual(configuredUrl, LocalRpcUrl) &&
+ !UrlsEqual(configuredUrl, RemoteRpcUrl)))
{
return false;
}
@@ -382,12 +382,12 @@ private ConfiguredTransport ResolveTransport(JObject server)
return ConfiguredTransport.Stdio;
}
- if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl()))
+ if (UrlsEqual(configuredUrl, RemoteRpcUrl))
{
return ConfiguredTransport.HttpRemote;
}
- if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl()))
+ if (UrlsEqual(configuredUrl, LocalRpcUrl))
{
return ConfiguredTransport.Http;
}
diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs
index f496bd7f1d..60ec4ea1df 100644
--- a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs
+++ b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs
@@ -167,22 +167,22 @@ public override string GetManualSnippet()
"The Unity MCP server should be detected automatically"
};
- private static JObject BuildServerEntry()
+ private JObject BuildServerEntry()
{
- if (HttpEndpointUtility.GetCurrentServerTransport() == ConfiguredTransport.Stdio)
+ if (CurrentTransport == ConfiguredTransport.Stdio)
{
- var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
+ var (uvxPath, _, packageName) = UvxCommandParts;
if (string.IsNullOrWhiteSpace(uvxPath))
{
throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings.");
}
var command = new JArray { uvxPath };
- foreach (string value in AssetPathUtility.GetUvxDevFlagsList())
+ foreach (string value in UvxDevFlagList)
{
command.Add(value);
}
- foreach (string value in AssetPathUtility.GetBetaServerFromArgsList())
+ foreach (string value in UvxFromArgs)
{
command.Add(value);
}
@@ -201,7 +201,7 @@ private static JObject BuildServerEntry()
return new JObject
{
["type"] = RemoteType,
- ["url"] = HttpEndpointUtility.GetMcpRpcUrl(),
+ ["url"] = RpcUrl,
["enabled"] = true
};
}
@@ -209,7 +209,7 @@ private static JObject BuildServerEntry()
private bool EntryMatchesCurrentTransport(JObject entry)
{
string entryType = entry["type"]?.ToString();
- ConfiguredTransport expected = HttpEndpointUtility.GetCurrentServerTransport();
+ ConfiguredTransport expected = CurrentTransport;
if (expected == ConfiguredTransport.Stdio)
{
@@ -218,7 +218,7 @@ private bool EntryMatchesCurrentTransport(JObject entry)
}
return string.Equals(entryType, RemoteType, StringComparison.OrdinalIgnoreCase)
- && UrlsEqual(entry["url"]?.ToString(), HttpEndpointUtility.GetMcpRpcUrl());
+ && UrlsEqual(entry["url"]?.ToString(), RpcUrl);
}
}
}
diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
index c4f021b2cc..a2c3092219 100644
--- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
+++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
@@ -16,7 +16,54 @@ namespace MCPForUnity.Editor.Clients
/// Shared base class for MCP configurators.
public abstract class McpClientConfiguratorBase : IMcpClientConfigurator
{
- protected readonly McpClient client;
+ protected McpClient client;
+ internal WindowRefreshContext RefreshContext { get; private set; }
+
+ internal Func CaptureRefresh(WindowRefreshContext context)
+ {
+ // CheckStatus implementations use their own model. No background result can
+ // modify the live client before the section checks its request generation.
+ var copy = (McpClientConfiguratorBase)MemberwiseClone();
+ copy.client = client.CopyForStatusRefresh();
+ copy.RefreshContext = context;
+ return () =>
+ {
+ context.Resolve();
+ copy.CheckStatus(attemptAutoRewrite: false);
+ string snippet;
+ try { snippet = copy.GetManualSnippet(); }
+ catch { snippet = "# Configuration not available - check paths in Advanced Settings"; }
+ return new ClientRefreshResult
+ {
+ Status = copy.client.status, Message = copy.client.configStatus,
+ Transport = copy.client.configuredTransport, ConfigPath = copy.GetConfigPath(),
+ Snippet = snippet, Steps = copy.GetInstallationSteps(), Context = context
+ };
+ };
+ }
+
+ internal sealed class ClientRefreshResult
+ {
+ internal McpStatus Status;
+ internal string Message, ConfigPath, Snippet;
+ internal ConfiguredTransport Transport;
+ internal IList Steps;
+ internal WindowRefreshContext Context;
+ }
+
+ protected bool UseHttpTransport => RefreshContext?.UseHttp ?? EditorConfigurationCache.Instance.UseHttpTransport;
+ protected bool RemoteScope => RefreshContext?.Remote ?? HttpEndpointUtility.IsRemoteScope();
+ protected string RpcUrl => RefreshContext?.RpcUrl ?? HttpEndpointUtility.GetMcpRpcUrl();
+ protected string LocalRpcUrl => RefreshContext?.LocalRpcUrl ?? HttpEndpointUtility.GetLocalMcpRpcUrl();
+ protected string RemoteRpcUrl => RefreshContext?.RemoteRpcUrl ?? HttpEndpointUtility.GetRemoteMcpRpcUrl();
+ protected ConfiguredTransport CurrentTransport => RefreshContext?.Transport ?? HttpEndpointUtility.GetCurrentServerTransport();
+ protected string ApiKey => RefreshContext?.ApiKey ?? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
+ protected string UvxDevFlags => RefreshContext?.DevFlags ?? AssetPathUtility.GetUvxDevFlags();
+ protected IReadOnlyList UvxDevFlagList => RefreshContext?.DevFlagList ?? AssetPathUtility.GetUvxDevFlagsList();
+ protected IList UvxFromArgs => RefreshContext?.FromArgs ?? AssetPathUtility.GetBetaServerFromArgsList();
+ protected string QuotedUvxFromArgs => RefreshContext?.QuotedFromArgs ?? AssetPathUtility.GetBetaServerFromArgs(true);
+ protected (string uvxPath, string fromUrl, string packageName) UvxCommandParts => RefreshContext == null
+ ? AssetPathUtility.GetUvxCommandParts() : (RefreshContext.Paths.UvxPath, RefreshContext.PackageSource, "mcp-for-unity");
protected McpClientConfiguratorBase(McpClient client)
{
@@ -55,7 +102,7 @@ public virtual void Unregister() { }
protected string GetUvxPathOrError()
{
- string uvx = MCPServiceLocator.Paths.GetUvxPath();
+ string uvx = RefreshContext == null ? MCPServiceLocator.Paths.GetUvxPath() : RefreshContext.Paths.UvxPath;
if (string.IsNullOrEmpty(uvx))
{
throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings.");
@@ -110,10 +157,10 @@ protected bool UrlsEqual(string a, string b)
/// This should match what Configure() would actually use for the --from argument.
/// MUST be called from the main thread due to EditorPrefs access.
///
- protected static string GetExpectedPackageSourceForValidation()
+ protected string GetExpectedPackageSourceForValidation()
{
// Includes explicit override, stable pin, or prerelease range depending on package version.
- return AssetPathUtility.GetMcpServerPackageSource();
+ return RefreshContext?.PackageSource ?? AssetPathUtility.GetMcpServerPackageSource();
}
///
@@ -223,8 +270,8 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
else if (!string.IsNullOrEmpty(configuredUrl))
{
// Distinguish HTTP Local from HTTP Remote by matching against both URLs
- string localRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl();
- string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl();
+ string localRpcUrl = LocalRpcUrl;
+ string remoteRpcUrl = RemoteRpcUrl;
if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(configuredUrl, remoteRpcUrl))
{
client.configuredTransport = Models.ConfiguredTransport.HttpRemote;
@@ -282,7 +329,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
else if (!string.IsNullOrEmpty(configuredUrl))
{
// Match against the active scope's URL
- string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ string expectedUrl = RpcUrl;
matches = UrlsEqual(configuredUrl, expectedUrl);
}
@@ -300,7 +347,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
else
{
@@ -318,7 +365,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
else
{
@@ -350,7 +397,7 @@ public override void Configure()
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
else
{
@@ -419,7 +466,7 @@ public override string GetManualSnippet()
try
{
string uvx = GetUvxPathOrError();
- return ConfigJsonBuilder.BuildManualConfigJson(uvx, client);
+ return ConfigJsonBuilder.BuildManualConfigJson(uvx, client, RefreshContext);
}
catch (Exception ex)
{
@@ -459,7 +506,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (!string.IsNullOrEmpty(url))
{
// Distinguish HTTP Local from HTTP Remote
- string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl();
+ string remoteRpcUrl = RemoteRpcUrl;
if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(url, remoteRpcUrl))
{
client.configuredTransport = Models.ConfiguredTransport.HttpRemote;
@@ -485,7 +532,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (!string.IsNullOrEmpty(url))
{
// Match against the active scope's URL
- matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl());
+ matches = UrlsEqual(url, RpcUrl);
}
else if (args != null && args.Length > 0)
{
@@ -538,7 +585,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
return client.status;
}
}
@@ -557,7 +604,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
else
{
@@ -586,7 +633,7 @@ public override void Configure()
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
else
{
@@ -599,7 +646,7 @@ public override string GetManualSnippet()
try
{
string uvx = GetUvxPathOrError();
- return CodexConfigHelper.BuildCodexServerBlock(uvx);
+ return CodexConfigHelper.BuildCodexServerBlock(uvx, RefreshContext);
}
catch (Exception ex)
{
@@ -659,13 +706,19 @@ internal static bool HasClientProjectDirOverride
///
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
+ if (RefreshContext != null)
+ {
+ return CheckStatusWithProjectDir(RefreshContext.ProjectDir, RefreshContext.UseHttp,
+ RefreshContext.Paths.ClaudePath, RefreshContext.Platform, RefreshContext.Remote,
+ RefreshContext.PackageSource, false, RefreshContext.HasProjectDirOverride);
+ }
// Capture main-thread-only values before delegating to thread-safe method
string projectDir = GetClientProjectDir();
- bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool useHttpTransport = UseHttpTransport;
// Resolve claudePath on the main thread (EditorPrefs access)
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
RuntimePlatform platform = Application.platform;
- bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
+ bool isRemoteScope = RemoteScope;
// Get expected package source for the installed package version (matches what Register() would use)
string expectedPackageSource = GetExpectedPackageSourceForValidation();
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite, HasClientProjectDirOverride);
@@ -953,17 +1006,17 @@ private void Register()
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
- bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool useHttpTransport = UseHttpTransport;
string args;
if (useHttpTransport)
{
- string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ string httpUrl = RpcUrl;
// Only include API key header for remote-hosted mode
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
- if (HttpEndpointUtility.IsRemoteScope())
+ if (RemoteScope)
{
- string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
+ string apiKey = ApiKey;
if (!string.IsNullOrEmpty(apiKey))
{
string safeKey = SanitizeShellHeaderValue(apiKey);
@@ -981,9 +1034,9 @@ private void Register()
}
else
{
- var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
- string devFlags = AssetPathUtility.GetUvxDevFlags();
- string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
+ var (uvxPath, _, packageName) = UvxCommandParts;
+ string devFlags = UvxDevFlags;
+ string fromArgs = QuotedUvxFromArgs;
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}";
}
@@ -1027,7 +1080,7 @@ private void Register()
// Set status to Configured immediately after successful registration
// The UI will trigger an async verification check separately to avoid blocking
client.SetStatus(McpStatus.Configured);
- client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
+ client.configuredTransport = CurrentTransport;
}
public override void Unregister()
@@ -1062,17 +1115,16 @@ public override void Unregister()
public override string GetManualSnippet()
{
- string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
- bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool useHttpTransport = UseHttpTransport;
if (useHttpTransport)
{
- string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ string httpUrl = RpcUrl;
// Only include API key header for remote-hosted mode
string headerArg = "";
- if (HttpEndpointUtility.IsRemoteScope())
+ if (RemoteScope)
{
- string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
+ string apiKey = ApiKey;
headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : "";
}
return "# Register the MCP server with Claude Code:\n" +
@@ -1085,13 +1137,14 @@ public override string GetManualSnippet()
"claude mcp list";
}
+ string uvxPath = RefreshContext == null ? MCPServiceLocator.Paths.GetUvxPath() : RefreshContext.Paths.UvxPath;
if (string.IsNullOrEmpty(uvxPath))
{
return "# Error: Configuration not available - check paths in Advanced Settings";
}
- string devFlags = AssetPathUtility.GetUvxDevFlags();
- string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
+ string devFlags = UvxDevFlags;
+ string fromArgs = QuotedUvxFromArgs;
return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} mcp-for-unity\n\n" +
diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
index 6daeab970c..20b838033e 100644
--- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
+++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
@@ -244,6 +244,14 @@ public static string GetMcpServerPackageSource()
return $"mcpforunityserver=={version}";
}
+ /// Pure refresh variant: resolves a captured override without writing preferences.
+ internal static string ResolveMcpServerPackageSource(string sourceOverride, string version)
+ {
+ if (!string.IsNullOrEmpty(sourceOverride)) return ResolveLocalServerPath(sourceOverride);
+ if (string.IsNullOrEmpty(version) || version == "unknown") return "mcpforunityserver";
+ return IsSemVerPreRelease(version) ? "mcpforunityserver>=0.0.0a0" : $"mcpforunityserver=={version}";
+ }
+
///
/// Validates and auto-corrects a local server source path to ensure it points to the
/// directory containing pyproject.toml. If the path points to a parent directory
diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
index 45ae5a39e1..1dedf2c203 100644
--- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
+++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
@@ -17,26 +17,29 @@ namespace MCPForUnity.Editor.Helpers
///
public static class CodexConfigHelper
{
- private static void AddUvxModeFlags(TomlArray args)
+ private static void AddUvxModeFlags(TomlArray args, WindowRefreshContext context = null)
{
if (args == null) return;
- foreach (var flag in AssetPathUtility.GetUvxDevFlagsList())
+ foreach (var flag in context?.DevFlagList ?? AssetPathUtility.GetUvxDevFlagsList())
args.Add(new TomlString { Value = flag });
}
public static string BuildCodexServerBlock(string uvPath)
+ => BuildCodexServerBlock(uvPath, null);
+
+ internal static string BuildCodexServerBlock(string uvPath, WindowRefreshContext context)
{
var table = new TomlTable();
var mcpServers = new TomlTable();
var unityMCP = new TomlTable();
// Check transport preference
- bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
+ bool useHttpTransport = context?.UseHttp ?? EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
if (useHttpTransport)
{
// HTTP mode: Use url field
- string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ string httpUrl = context?.RpcUrl ?? HttpEndpointUtility.GetMcpRpcUrl();
unityMCP["url"] = new TomlString { Value = httpUrl };
// Enable Codex's Rust MCP client for HTTP/SSE transport
@@ -45,14 +48,15 @@ public static string BuildCodexServerBlock(string uvPath)
else
{
// Stdio mode: Use command and args
- var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
+ string uvxPath = uvPath;
+ string packageName = "mcp-for-unity";
unityMCP["command"] = uvxPath;
var args = new TomlArray();
- AddUvxModeFlags(args);
+ AddUvxModeFlags(args, context);
// Use centralized helper for beta server / prerelease args
- foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
+ foreach (var arg in context?.FromArgs ?? AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(new TomlString { Value = arg });
}
@@ -63,11 +67,10 @@ public static string BuildCodexServerBlock(string uvPath)
unityMCP["args"] = args;
// Add Windows-specific environment configuration for stdio mode
- var platformService = MCPServiceLocator.Platform;
- if (platformService.IsWindows())
+ if (context != null ? context.Platform == RuntimePlatform.WindowsEditor : MCPServiceLocator.Platform.IsWindows())
{
var envTable = new TomlTable { IsInline = true };
- envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
+ envTable["SystemRoot"] = new TomlString { Value = context?.SystemRoot ?? MCPServiceLocator.Platform.GetSystemRoot() };
unityMCP["env"] = envTable;
}
diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
index 52f36496c6..4fb7c14bb1 100644
--- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
+++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
@@ -14,6 +14,9 @@ namespace MCPForUnity.Editor.Helpers
public static class ConfigJsonBuilder
{
public static string BuildManualConfigJson(string uvPath, McpClient client)
+ => BuildManualConfigJson(uvPath, client, null);
+
+ internal static string BuildManualConfigJson(string uvPath, McpClient client, WindowRefreshContext context)
{
var root = new JObject();
bool isVSCode = client?.IsVsCodeLayout == true;
@@ -21,7 +24,7 @@ public static string BuildManualConfigJson(string uvPath, McpClient client)
JObject container = EnsureObject(root, GetContainerKey(client, isVSCode));
var unity = new JObject();
- PopulateUnityNode(unity, uvPath, client, isVSCode);
+ PopulateUnityNode(unity, uvPath, client, isVSCode, context);
container["unityMCP"] = unity;
@@ -48,10 +51,10 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa
/// - Adds transport configuration (HTTP or stdio)
/// - Adds disabled:false for Windsurf/Kiro only when missing
///
- private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode)
+ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode, WindowRefreshContext context = null)
{
// Get transport preference (default to HTTP)
- bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool prefValue = context?.UseHttp ?? EditorConfigurationCache.Instance.UseHttpTransport;
bool clientSupportsHttp = client?.SupportsHttpTransport != false;
bool useHttpTransport = clientSupportsHttp && prefValue;
string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty;
@@ -61,7 +64,7 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl
if (useHttpTransport)
{
// HTTP mode: Use URL, no command
- string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ string httpUrl = context?.RpcUrl ?? HttpEndpointUtility.GetMcpRpcUrl();
unity[httpProperty] = httpUrl;
foreach (var prop in urlPropsToRemove)
@@ -74,9 +77,9 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl
if (unity["args"] != null) unity.Remove("args");
// Only include API key header for remote-hosted mode
- if (HttpEndpointUtility.IsRemoteScope())
+ if (context?.Remote ?? HttpEndpointUtility.IsRemoteScope())
{
- string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
+ string apiKey = context?.ApiKey ?? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
if (!string.IsNullOrEmpty(apiKey))
{
var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey };
@@ -102,9 +105,9 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl
else
{
// Stdio mode: Use uvx command
- var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
-
- var toolArgs = BuildUvxArgs(fromUrl, packageName);
+ string uvxPath = uvPath;
+ string fromUrl = context?.PackageSource ?? AssetPathUtility.GetMcpServerPackageSource();
+ var toolArgs = BuildUvxArgs(fromUrl, "mcp-for-unity", context);
unity["command"] = uvxPath;
unity["args"] = JArray.FromObject(toolArgs.ToArray());
@@ -159,7 +162,7 @@ private static JObject EnsureObject(JObject parent, string name)
return created;
}
- private static IList BuildUvxArgs(string fromUrl, string packageName)
+ private static IList BuildUvxArgs(string fromUrl, string packageName, WindowRefreshContext context)
{
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
@@ -167,11 +170,11 @@ private static IList BuildUvxArgs(string fromUrl, string packageName)
// Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name.
var args = new List();
- foreach (var flag in AssetPathUtility.GetUvxDevFlagsList())
+ foreach (var flag in context?.DevFlagList ?? AssetPathUtility.GetUvxDevFlagsList())
args.Add(flag);
// Use centralized helper for beta server / prerelease args
- foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
+ foreach (var arg in context?.FromArgs ?? AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(arg);
}
diff --git a/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs b/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs
new file mode 100644
index 0000000000..2c8165ca36
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Threading.Tasks;
+using UnityEditor;
+using UnityEngine.UIElements;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ /// Runs bounded read-only work off-thread and applies current results on editor update.
+ internal sealed class EditorAsyncRefresh
+ {
+ private readonly double ttlSeconds;
+ private Task task;
+ private Func pendingWork;
+ private Action apply;
+ private Action onFailure;
+ private Func isCurrent;
+ private string requestedKey, runningKey, cachedKey;
+ private int generation, runningGeneration;
+ private double completedAt;
+ private T cached;
+ private bool attached = true;
+
+ internal EditorAsyncRefresh(VisualElement root, double ttlSeconds)
+ {
+ this.ttlSeconds = ttlSeconds;
+ root.RegisterCallback(_ =>
+ {
+ attached = false;
+ Invalidate();
+ pendingWork = null;
+ EditorApplication.update -= Poll;
+ });
+ root.RegisterCallback(_ => attached = true);
+ }
+
+ internal void Invalidate()
+ {
+ generation++;
+ cachedKey = null;
+ }
+
+ internal void Request(string key, Func work, Action onResult,
+ Func stillCurrent, bool force = false, Action onFailure = null)
+ {
+ if (!attached) return;
+ if (requestedKey != key || force) Invalidate();
+ requestedKey = key;
+ apply = onResult;
+ isCurrent = stillCurrent;
+ this.onFailure = onFailure;
+ if (!force && cachedKey == key && EditorApplication.timeSinceStartup - completedAt < ttlSeconds)
+ {
+ if (stillCurrent()) onResult(cached, false);
+ return;
+ }
+ pendingWork = work;
+ if (task == null) Start();
+ else
+ {
+ // A focus event with the same inputs joins the existing request.
+ if (runningKey == key && runningGeneration == generation) pendingWork = null;
+ EditorApplication.update -= Poll;
+ EditorApplication.update += Poll;
+ }
+ }
+
+ private void Start()
+ {
+ var work = pendingWork;
+ pendingWork = null;
+ runningKey = requestedKey;
+ runningGeneration = generation;
+ task = Task.Run(work);
+ EditorApplication.update -= Poll;
+ EditorApplication.update += Poll;
+ }
+
+ private void Poll()
+ {
+ if (task == null || !task.IsCompleted) return;
+ EditorApplication.update -= Poll;
+ var completed = task;
+ task = null;
+ bool current = attached && runningGeneration == generation && isCurrent();
+ try
+ {
+ T result = completed.GetAwaiter().GetResult();
+ if (current)
+ {
+ cached = result;
+ cachedKey = runningKey;
+ completedAt = EditorApplication.timeSinceStartup;
+ apply(result, true);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Avoid including private file contents, paths or credentials in refresh logs.
+ if (current) McpLog.Warn("Window refresh failed; retry the refresh after checking your settings.");
+ if (current && completed.IsFaulted && onFailure != null)
+ {
+ try { onFailure(ex); }
+ catch (Exception)
+ {
+ McpLog.Warn("Window refresh failure handler failed; retry the refresh after checking your settings.");
+ }
+ }
+ }
+ if (attached && pendingWork != null) Start();
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs.meta b/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs.meta
new file mode 100644
index 0000000000..e1cf2d6de5
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/EditorAsyncRefresh.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 412615ed36764e719bdc602660fd104f
diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs
index f69f85f26c..412c1980a3 100644
--- a/MCPForUnity/Editor/Helpers/ExecPath.cs
+++ b/MCPForUnity/Editor/Helpers/ExecPath.cs
@@ -16,12 +16,13 @@ internal static class ExecPath
// Resolve Claude CLI absolute path. Pref → env → common locations → PATH.
internal static string ResolveClaude()
{
- try
- {
- string pref = EditorPrefs.GetString(PrefClaude, string.Empty);
- if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref;
- }
- catch { }
+ return ResolveClaude(EditorPrefs.GetString(PrefClaude, string.Empty));
+ }
+
+ // Worker-safe overload: caller captures EditorPrefs before starting discovery.
+ internal static string ResolveClaude(string pref)
+ {
+ if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref;
string env = Environment.GetEnvironmentVariable("CLAUDE_CLI");
if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env;
diff --git a/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs b/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs
new file mode 100644
index 0000000000..d79652cc0b
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Models;
+using MCPForUnity.Editor.Services;
+using Newtonsoft.Json.Linq;
+using UnityEditor;
+using UnityEngine;
+using PackageInfo = UnityEditor.PackageManager.PackageInfo;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ /// Editor-owned inputs captured before filesystem and executable discovery.
+ internal sealed class WindowRefreshContext
+ {
+ internal bool UseHttp, Remote, ForceRefresh, ProjectScopedTools, LocalLaunchAllowed;
+ internal string LocalUrl, LocalRpcUrl, RemoteRpcUrl, RpcUrl, ApiKey, SystemRoot;
+ internal string SourceOverride, PackageSource, PackageRoot, PackageVersion, ProjectDirOverride, ProjectDir;
+ internal string DeploySource, BackupPath, DeployTarget, LocalLaunchError;
+ internal RuntimePlatform Platform;
+ internal WindowPathResolution Paths;
+ internal string DevFlags;
+ internal IReadOnlyList DevFlagList;
+ internal bool HasProjectDirOverride, HasBackup;
+ private Func resolvePaths;
+ private static readonly PathResolverService DefaultWindowPaths = new PathResolverService();
+ internal string Key { get; private set; }
+ internal ConfiguredTransport Transport => !UseHttp ? ConfiguredTransport.Stdio :
+ Remote ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http;
+ internal IList FromArgs => AssetPathUtility.GetBetaServerFromArgsList(
+ string.IsNullOrEmpty(SourceOverride) ? string.Empty : PackageSource, PackageSource);
+ internal string QuotedFromArgs => AssetPathUtility.GetBetaServerFromArgs(
+ string.IsNullOrEmpty(SourceOverride) ? string.Empty : PackageSource, PackageSource, true);
+
+ internal static WindowRefreshContext Capture()
+ {
+ var package = PackageInfo.FindForAssembly(typeof(WindowRefreshContext).Assembly);
+ var context = new WindowRefreshContext
+ {
+ UseHttp = EditorConfigurationCache.Instance.UseHttpTransport,
+ Remote = HttpEndpointUtility.IsRemoteScope(),
+ LocalUrl = HttpEndpointUtility.GetLocalBaseUrl(),
+ LocalRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl(),
+ RemoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(),
+ RpcUrl = HttpEndpointUtility.GetMcpRpcUrl(),
+ ApiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty),
+ SourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty),
+ ProjectDirOverride = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty),
+ ProjectDir = Path.GetDirectoryName(Application.dataPath),
+ Platform = Application.platform,
+ SystemRoot = MCPServiceLocator.Platform.GetSystemRoot(),
+ ForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false),
+ ProjectScopedTools = EditorPrefs.GetBool(EditorPrefKeys.ProjectScopedToolsLocalHttp, true),
+ DeploySource = EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty),
+ BackupPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty),
+ PackageVersion = package?.version,
+ PackageRoot = package?.resolvedPath
+ };
+ if (string.IsNullOrEmpty(context.PackageRoot))
+ {
+ string assetPath = AssetPathUtility.GetMcpPackageRootPath();
+ context.PackageRoot = string.IsNullOrEmpty(assetPath) ? null :
+ Path.GetFullPath(Path.Combine(context.ProjectDir, assetPath));
+ }
+ context.LocalLaunchAllowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(
+ context.LocalUrl, out context.LocalLaunchError);
+ // Legacy implementations have no worker-safety contract. Display captured
+ // preference/system paths without calling their synchronous Unity APIs.
+ var windowPaths = MCPServiceLocator.Paths as IWindowPathResolver ?? DefaultWindowPaths;
+ context.resolvePaths = windowPaths.CaptureWindowResolution();
+ // Keys stay in memory. They can contain credentials and must never be logged.
+ context.Key = string.Join("\n", context.UseHttp, context.Remote, context.LocalUrl,
+ context.RemoteRpcUrl, context.ApiKey, context.SourceOverride, context.ProjectDirOverride,
+ context.ProjectDir, context.PackageVersion, context.PackageRoot, context.ForceRefresh,
+ context.ProjectScopedTools, context.DeploySource, context.BackupPath, context.LocalLaunchAllowed,
+ EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false),
+ EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false),
+ EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty),
+ EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty),
+ Environment.GetEnvironmentVariable("PATH"), Environment.GetEnvironmentVariable("CLAUDE_CLI"));
+ return context;
+ }
+
+ /// Worker-only: never reads EditorPrefs, Unity objects or package APIs.
+ internal WindowRefreshContext Resolve()
+ {
+ Paths = resolvePaths();
+ if (string.IsNullOrEmpty(PackageVersion))
+ {
+ string jsonPath = Path.Combine(PackageRoot ?? string.Empty, "package.json");
+ PackageVersion = File.Exists(jsonPath) ? JObject.Parse(File.ReadAllText(jsonPath))["version"]?.ToString() : "unknown";
+ }
+ PackageSource = AssetPathUtility.ResolveMcpServerPackageSource(SourceOverride, PackageVersion);
+ HasProjectDirOverride = !string.IsNullOrEmpty(ProjectDirOverride) && Directory.Exists(ProjectDirOverride);
+ if (HasProjectDirOverride) ProjectDir = ProjectDirOverride;
+ HasBackup = !string.IsNullOrEmpty(BackupPath) && Directory.Exists(BackupPath);
+ DeployTarget = !string.IsNullOrEmpty(PackageRoot) && Directory.Exists(PackageRoot)
+ ? PackageRoot.Replace('\\', '/') : "Not found (check Packages/manifest.json)";
+ bool localSource = PackageSource.StartsWith("file://", StringComparison.OrdinalIgnoreCase) || Path.IsPathRooted(PackageSource);
+ bool force = ForceRefresh || localSource;
+ bool offline = !force && ProbeOfflineCache(Paths.UvxPath, QuotedFromArgs);
+ DevFlags = AssetPathUtility.GetUvxDevFlags(force, offline);
+ DevFlagList = DevFlags.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ return this;
+ }
+
+ private static readonly object OfflineLock = new object();
+ private static string offlineKey;
+ private static DateTime offlineChecked;
+ private static bool offlineResult;
+ private static bool ProbeOfflineCache(string uvx, string fromArgs)
+ {
+ if (string.IsNullOrEmpty(uvx)) return false;
+ // Shared by the three sections; the lock is only acquired on workers.
+ lock (OfflineLock)
+ {
+ string key = uvx + "\n" + fromArgs;
+ if (key == offlineKey && DateTime.UtcNow - offlineChecked < TimeSpan.FromSeconds(30)) return offlineResult;
+ offlineResult = ExecPath.TryRun(uvx, $"--offline {fromArgs} mcp-for-unity --help", null,
+ out _, out _, 3000);
+ offlineKey = key;
+ offlineChecked = DateTime.UtcNow;
+ return offlineResult;
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs.meta b/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs.meta
new file mode 100644
index 0000000000..ba74fd7338
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/WindowRefreshContext.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 851771d56a2349358749785b34f158ca
diff --git a/MCPForUnity/Editor/Models/McpClient.cs b/MCPForUnity/Editor/Models/McpClient.cs
index 85b30c2ce2..2d8ff8d62c 100644
--- a/MCPForUnity/Editor/Models/McpClient.cs
+++ b/MCPForUnity/Editor/Models/McpClient.cs
@@ -24,6 +24,13 @@ public class McpClient
public string HttpUrlProperty = "url"; // The property name for the HTTP URL in the config
public Dictionary DefaultUnityFields = new();
+ internal McpClient CopyForStatusRefresh()
+ {
+ var copy = (McpClient)MemberwiseClone();
+ copy.DefaultUnityFields = new Dictionary(DefaultUnityFields);
+ return copy;
+ }
+
// Helper method to convert the enum to a display string
public string GetStatusDisplayString()
{
diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs
index c233d8680b..e58da4146e 100644
--- a/MCPForUnity/Editor/Services/PathResolverService.cs
+++ b/MCPForUnity/Editor/Services/PathResolverService.cs
@@ -14,9 +14,44 @@ namespace MCPForUnity.Editor.Services
///
/// Implementation of path resolver service with override support
///
- public class PathResolverService : IPathResolverService
+ public class PathResolverService : IPathResolverService, IWindowPathResolver
{
private bool _hasUvxPathFallback;
+ private readonly object windowResolutionLock = new object();
+ private string windowResolutionKey;
+ private DateTime windowResolvedAt;
+ private WindowPathResolution windowResolution;
+
+ public Func CaptureWindowResolution()
+ {
+ string uvxOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
+ string claudeOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
+ string key = string.Join("\n", uvxOverride, claudeOverride,
+ Environment.GetEnvironmentVariable("PATH"), Environment.GetEnvironmentVariable("CLAUDE_CLI"));
+ return () =>
+ {
+ lock (windowResolutionLock)
+ {
+ if (key == windowResolutionKey && DateTime.UtcNow - windowResolvedAt < TimeSpan.FromSeconds(30))
+ return windowResolution;
+ bool hasOverride = !string.IsNullOrEmpty(uvxOverride);
+ bool validOverride = hasOverride && TryValidateUvxExecutable(uvxOverride, out _);
+ string uvx = validOverride ? uvxOverride : ResolveUvxFromSystem();
+ if (!hasOverride && string.IsNullOrEmpty(uvx)) uvx = "uvx";
+ windowResolution = new WindowPathResolution
+ {
+ HasOverride = hasOverride, OverridePath = uvxOverride, UvxPath = uvx,
+ HasFallback = hasOverride && !validOverride && !string.IsNullOrEmpty(uvx),
+ UvxValid = validOverride || (!string.IsNullOrEmpty(uvx) && TryValidateUvxExecutable(uvx, out _)),
+ ClaudePath = !string.IsNullOrEmpty(claudeOverride)
+ ? (File.Exists(claudeOverride) ? claudeOverride : null) : ExecPath.ResolveClaude(string.Empty)
+ };
+ windowResolutionKey = key;
+ windowResolvedAt = DateTime.UtcNow;
+ return windowResolution;
+ }
+ };
+ }
public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null));
public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null));
diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
index 3c3207d5e5..d89007870e 100644
--- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
+++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
@@ -17,21 +17,25 @@ public class ServerCommandBuilder : IServerCommandBuilder
{
///
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
+ => TryBuildCommand(null, out fileName, out arguments, out displayCommand, out error);
+
+ internal bool TryBuildCommand(WindowRefreshContext context, out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = null;
arguments = null;
displayCommand = null;
error = null;
- bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
+ bool useHttpTransport = context?.UseHttp ?? EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttpTransport)
{
error = $"HTTP transport is disabled. Enable it in the {ProductInfo.ProductName} window first.";
return false;
}
- string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
- if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out string localUrlError))
+ string httpUrl = context?.LocalUrl ?? HttpEndpointUtility.GetLocalBaseUrl();
+ string localUrlError = context?.LocalLaunchError;
+ if (!(context?.LocalLaunchAllowed ?? HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out localUrlError)))
{
error = string.IsNullOrEmpty(localUrlError)
? $"The configured URL ({httpUrl}) is not allowed for HTTP Local launch."
@@ -39,22 +43,23 @@ public bool TryBuildCommand(out string fileName, out string arguments, out strin
return false;
}
- var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
+ var (uvxPath, fromUrl, packageName) = context == null ? AssetPathUtility.GetUvxCommandParts()
+ : (context.Paths.UvxPath, context.PackageSource, "mcp-for-unity");
if (string.IsNullOrEmpty(uvxPath))
{
error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings.";
return false;
}
- string devFlags = AssetPathUtility.GetUvxDevFlags();
- bool projectScopedTools = EditorPrefs.GetBool(
+ string devFlags = context?.DevFlags ?? AssetPathUtility.GetUvxDevFlags();
+ bool projectScopedTools = context?.ProjectScopedTools ?? EditorPrefs.GetBool(
EditorPrefKeys.ProjectScopedToolsLocalHttp,
true
);
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
// Use centralized helper for beta server / prerelease args
- string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
+ string fromArgs = context?.QuotedFromArgs ?? AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
string args = string.IsNullOrEmpty(fromArgs)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs
index 7a2c546cc3..f9d746b64f 100644
--- a/MCPForUnity/Editor/Services/ServerManagementService.cs
+++ b/MCPForUnity/Editor/Services/ServerManagementService.cs
@@ -478,10 +478,12 @@ public bool IsLocalHttpServerRunning()
}
public bool IsLocalHttpServerReachable()
+ => IsLocalHttpServerReachable(HttpEndpointUtility.GetLocalBaseUrl());
+
+ internal static bool IsLocalHttpServerReachable(string httpUrl)
{
try
{
- string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(httpUrl))
{
return false;
diff --git a/MCPForUnity/Editor/Services/WindowPathResolution.cs b/MCPForUnity/Editor/Services/WindowPathResolution.cs
new file mode 100644
index 0000000000..0ae8e8967e
--- /dev/null
+++ b/MCPForUnity/Editor/Services/WindowPathResolution.cs
@@ -0,0 +1,20 @@
+namespace MCPForUnity.Editor.Services
+{
+ /// Optional window discovery contract. Existing path-service implementations remain source-compatible.
+ public interface IWindowPathResolver
+ {
+ /// Capture preferences on main; invoke the returned read-only operation on a worker.
+ System.Func CaptureWindowResolution();
+ }
+
+ /// Read-only path discovery result for editor-window refreshes.
+ public sealed class WindowPathResolution
+ {
+ public string UvxPath { get; set; }
+ public string ClaudePath { get; set; }
+ public string OverridePath { get; set; }
+ public bool HasOverride { get; set; }
+ public bool HasFallback { get; set; }
+ public bool UvxValid { get; set; }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/WindowPathResolution.cs.meta b/MCPForUnity/Editor/Services/WindowPathResolution.cs.meta
new file mode 100644
index 0000000000..1e93180279
--- /dev/null
+++ b/MCPForUnity/Editor/Services/WindowPathResolution.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b8acc9a435f34a1785e798407d8a1f61
diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
index 8a4c98c168..5dcb727a7d 100644
--- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
@@ -45,6 +45,8 @@ public class McpAdvancedSection
private Label healthStatus;
private Button testConnectionButton;
+ private EditorAsyncRefresh pathRefresh;
+
// Events
public event Action OnGitUrlChanged;
public event Action OnHttpServerCommandUpdateRequested;
@@ -327,93 +329,38 @@ private void RegisterCallbacks()
public void UpdatePathOverrides()
{
- var pathService = MCPServiceLocator.Paths;
-
- bool hasOverride = pathService.HasUvxPathOverride;
- bool hasFallback = pathService.HasUvxPathFallback;
- string uvxPath = hasOverride ? pathService.GetUvxPath() : null;
-
- // Determine display text based on override and fallback status
- if (hasOverride)
- {
- if (hasFallback)
- {
- // Override path invalid, using system fallback
- string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
- uvxPathOverride.value = $"Invalid override path: {overridePath} (fallback to uvx path) {uvxPath}";
- }
- else if (!string.IsNullOrEmpty(uvxPath))
- {
- // Override path valid
- uvxPathOverride.value = uvxPath;
- }
- else
- {
- // Override set but invalid, no fallback available
- string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
- uvxPathOverride.value = $"Invalid override path: {overridePath}, no uv found";
- }
- }
- else
- {
- uvxPathOverride.value = "uvx (uses PATH)";
- }
+ // Displaying preferences must not invoke their save/configuration callbacks.
+ gitUrlOverride.SetValueWithoutNotify(EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""));
+ autoStartOnLoadToggle?.SetValueWithoutNotify(EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false));
+ debugLogsToggle.SetValueWithoutNotify(EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false));
+ logRecordToggle?.SetValueWithoutNotify(McpLogRecord.IsEnabled);
+ devModeForceRefreshToggle.SetValueWithoutNotify(EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false));
+ allowLanHttpBindToggle?.SetValueWithoutNotify(EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false));
+ allowInsecureRemoteHttpToggle?.SetValueWithoutNotify(EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false));
+
+ var context = WindowRefreshContext.Capture();
+ pathRefresh ??= new EditorAsyncRefresh(Root, 30);
+ pathRefresh.Request(context.Key, context.Resolve, (result, _) => ApplyPathRefresh(result),
+ () => WindowRefreshContext.Capture().Key == context.Key);
+ }
+ private void ApplyPathRefresh(WindowRefreshContext context)
+ {
+ var paths = context.Paths;
+ string display = !paths.HasOverride ? "uvx (uses PATH)" : paths.HasFallback
+ ? $"Invalid override path: {paths.OverridePath} (fallback to uvx path) {paths.UvxPath}"
+ : !string.IsNullOrEmpty(paths.UvxPath) ? paths.UvxPath
+ : $"Invalid override path: {paths.OverridePath}, no uv found";
+ uvxPathOverride.SetValueWithoutNotify(display);
uvxPathStatus.RemoveFromClassList("valid");
uvxPathStatus.RemoveFromClassList("invalid");
uvxPathStatus.RemoveFromClassList("warning");
-
- if (hasOverride)
- {
- if (hasFallback)
- {
- // Using fallback - show as warning (yellow)
- uvxPathStatus.AddToClassList("warning");
- }
- else
- {
- // Override mode: validate the override path
- string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
- if (pathService.TryValidateUvxExecutable(overridePath, out _))
- {
- uvxPathStatus.AddToClassList("valid");
- }
- else
- {
- uvxPathStatus.AddToClassList("invalid");
- }
- }
- }
- else
- {
- // PATH mode: validate system uvx
- string systemUvxPath = pathService.GetUvxPath();
- if (!string.IsNullOrEmpty(systemUvxPath) && pathService.TryValidateUvxExecutable(systemUvxPath, out _))
- {
- uvxPathStatus.AddToClassList("valid");
- }
- else
- {
- uvxPathStatus.AddToClassList("invalid");
- }
- }
-
- gitUrlOverride.value = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
- if (autoStartOnLoadToggle != null)
- autoStartOnLoadToggle.value = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
- debugLogsToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
- if (logRecordToggle != null)
- logRecordToggle.value = McpLogRecord.IsEnabled;
- devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
- if (allowLanHttpBindToggle != null)
- {
- allowLanHttpBindToggle.value = EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
- }
- if (allowInsecureRemoteHttpToggle != null)
- {
- allowInsecureRemoteHttpToggle.value = EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
- }
- UpdateDeploymentSection();
+ uvxPathStatus.AddToClassList(paths.HasFallback ? "warning" : paths.UvxValid ? "valid" : "invalid");
+ deploySourcePath.SetValueWithoutNotify(context.DeploySource ?? string.Empty);
+ deployTargetLabel.text = $"Target: {context.DeployTarget}";
+ deployBackupLabel.text = context.HasBackup
+ ? $"Last backup: {context.BackupPath.Replace('\\', '/')}" : "Last backup: none";
+ deployRestoreButton?.SetEnabled(context.HasBackup);
}
private void OnBrowseUvxClicked()
@@ -508,25 +455,7 @@ private static string ResolveServerPath(string path)
private void UpdateDeploymentSection()
{
- var deployService = MCPServiceLocator.Deployment;
-
- string sourcePath = deployService.GetStoredSourcePath();
- deploySourcePath.value = sourcePath ?? string.Empty;
-
- deployTargetLabel.text = $"Target: {deployService.GetTargetDisplayPath()}";
-
- string backupPath = deployService.GetLastBackupPath();
- if (deployService.HasBackup())
- {
- // Use forward slashes to avoid backslash escape sequence issues in UI text
- deployBackupLabel.text = $"Last backup: {backupPath?.Replace('\\', '/')}";
- }
- else
- {
- deployBackupLabel.text = "Last backup: none";
- }
-
- deployRestoreButton?.SetEnabled(deployService.HasBackup());
+ UpdatePathOverrides();
}
private void OnBrowseScreenshotsFolderClicked()
diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs
index 6f2001c538..8f19c76108 100644
--- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs
@@ -48,9 +48,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 EditorAsyncRefresh clientRefresh;
+ private WindowRefreshContext displayedContext;
private int selectedClientIndex = 0;
private bool isSkillSyncInProgress;
@@ -68,6 +68,7 @@ public class McpClientConfigSection
public event Action OnClientConfigMismatch;
public VisualElement Root { get; private set; }
+ public bool HasClientProjectDirOverride => displayedContext?.HasProjectDirOverride == true;
public McpClientConfigSection(VisualElement root)
{
@@ -219,84 +220,32 @@ private string GetStatusDisplayString(McpStatus status)
public void UpdateManualConfiguration()
{
- if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
- return;
-
- var client = configurators[selectedClientIndex];
-
- string configPath = client.GetConfigPath();
- configPathField.value = configPath;
-
- string configJson = client.GetManualSnippet();
- configJsonField.value = configJson;
-
- var steps = client.GetInstallationSteps();
- if (steps != null && steps.Count > 0)
- {
- var numbered = steps.Select((s, i) => $"{i + 1}. {s}");
- installationStepsLabel.text = string.Join("\n", numbered);
- }
- else
- {
- installationStepsLabel.text = "Configuration steps not available for this client.";
- }
+ RefreshSelectedClient();
}
private void UpdateClaudeCliPathVisibility()
{
- if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
- return;
-
- var client = configurators[selectedClientIndex];
-
- if (client is ClaudeCliMcpConfigurator)
- {
- string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
- if (string.IsNullOrEmpty(claudePath))
- {
- claudeCliPathRow.style.display = DisplayStyle.Flex;
- claudeCliPath.value = "Not found - click Browse to select";
- }
- else
- {
- claudeCliPathRow.style.display = DisplayStyle.Flex;
- claudeCliPath.value = claudePath;
- }
- }
- else
- {
- claudeCliPathRow.style.display = DisplayStyle.None;
- }
+ bool isClaude = selectedClientIndex >= 0 && selectedClientIndex < configurators.Count
+ && configurators[selectedClientIndex] is ClaudeCliMcpConfigurator;
+ claudeCliPathRow.style.display = isClaude ? DisplayStyle.Flex : DisplayStyle.None;
+ if (isClaude)
+ claudeCliPath.SetValueWithoutNotify(displayedContext == null ? "Checking..."
+ : displayedContext.Paths.ClaudePath ?? "Not found - click Browse to select");
}
private void UpdateClientProjectDirVisibility()
{
- if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
- return;
-
- var client = configurators[selectedClientIndex];
-
- if (client is ClaudeCliMcpConfigurator)
- {
- clientProjectDirRow.style.display = DisplayStyle.Flex;
- string projectDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
- if (ClaudeCliMcpConfigurator.HasClientProjectDirOverride)
- {
- clientProjectDirField.value = projectDir + " (override)";
- }
- else
- {
- clientProjectDirField.value = projectDir;
- }
- }
- else
- {
- clientProjectDirRow.style.display = DisplayStyle.None;
- }
+ bool isClaude = selectedClientIndex >= 0 && selectedClientIndex < configurators.Count
+ && configurators[selectedClientIndex] is ClaudeCliMcpConfigurator;
+ clientProjectDirRow.style.display = isClaude ? DisplayStyle.Flex : DisplayStyle.None;
+ if (isClaude)
+ clientProjectDirField.SetValueWithoutNotify(displayedContext == null ? "Checking..."
+ : displayedContext.ProjectDir + (displayedContext.HasProjectDirOverride ? " (override)" : ""));
}
private void OnConfigureAllClientsClicked()
{
+ clientRefresh?.Invalidate();
try
{
var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients();
@@ -312,12 +261,8 @@ private void OnConfigureAllClientsClicked()
EditorUtility.DisplayDialog("Configure Detected Clients", message, "OK");
- // The bulk path mutated state for every detected client. Clear the per-client
- // status cache so any subsequent dropdown switch (or the currently-selected
- // client refresh below) reads fresh status from disk instead of the pre-bulk
- // value — otherwise every other client in the dropdown looks like it still
- // has Missing/IncorrectPath status until 45 s elapses on the throttle.
- lastStatusChecks.Clear();
+ // Pending display results were invalidated before bulk configuration.
+ // Recheck the selected client's files using the new configuration.
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
{
@@ -333,6 +278,7 @@ private void OnConfigureAllClientsClicked()
private void OnConfigureClicked()
{
+ clientRefresh?.Invalidate();
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
return;
@@ -359,7 +305,6 @@ private void OnConfigureClicked()
{
MCPServiceLocator.Client.ConfigureClient(client);
}
- lastStatusChecks.Remove(client);
RefreshClientStatus(client, forceImmediate: true);
UpdateManualConfiguration();
}
@@ -377,6 +322,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
if (statusRefreshInFlight.Contains(client))
return;
+ clientRefresh?.Invalidate();
statusRefreshInFlight.Add(client);
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Configuring...");
@@ -439,7 +385,6 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
EditorApplication.delayCall += () =>
{
statusRefreshInFlight.Remove(client);
- lastStatusChecks.Remove(client);
if (errorMessage != null)
{
@@ -452,9 +397,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
}
else
{
- // Registration succeeded - trust the status set by RegisterWithCapturedValues
- // and update UI without re-verifying (which could fail due to CLI timing/scope issues)
- lastStatusChecks[client] = DateTime.UtcNow;
+ // Show the explicit operation's result before the read-only file refresh.
ApplyStatusToUi(client);
}
UpdateManualConfiguration();
@@ -606,108 +549,52 @@ private void OnCopyJsonClicked()
public void RefreshSelectedClient(bool forceImmediate = false)
{
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
- {
- var client = configurators[selectedClientIndex];
- // Force immediate for non-Claude CLI, or when explicitly requested
- bool shouldForceImmediate = forceImmediate || client is not ClaudeCliMcpConfigurator;
- RefreshClientStatus(client, shouldForceImmediate);
- UpdateManualConfiguration();
- UpdateClaudeCliPathVisibility();
- }
+ RefreshClientStatus(configurators[selectedClientIndex], forceImmediate);
}
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)
- {
- bool hasStatus = lastStatusChecks.ContainsKey(client);
- bool needsRefresh = !hasStatus || ShouldRefreshClient(client);
-
- if (!hasStatus)
- {
- ApplyStatusToUi(client, showChecking: true);
- }
- else
- {
+ // An explicitly requested configuration operation owns the client until it completes.
+ if (statusRefreshInFlight.Contains(client) || client is not McpClientConfiguratorBase baseClient) return;
+ var context = WindowRefreshContext.Capture();
+ var work = baseClient.CaptureRefresh(context);
+ string key = context.Key + "\n" + client.Id;
+ clientRefresh ??= new EditorAsyncRefresh(Root, 45);
+ ApplyStatusToUi(client, showChecking: true);
+ copyJsonButton?.SetEnabled(false);
+ clientRefresh.Request(key, work, (result, _) =>
+ {
+ baseClient.Client.status = result.Status;
+ baseClient.Client.configStatus = result.Message;
+ baseClient.Client.configuredTransport = result.Transport;
+ displayedContext = result.Context;
+ configPathField.SetValueWithoutNotify(result.ConfigPath);
+ configJsonField.SetValueWithoutNotify(result.Snippet);
+ installationStepsLabel.text = result.Steps != null && result.Steps.Count > 0
+ ? string.Join("\n", result.Steps.Select((step, i) => $"{i + 1}. {step}"))
+ : "Configuration steps not available for this client.";
+ copyJsonButton?.SetEnabled(true);
+ UpdateClaudeCliPathVisibility();
+ UpdateClientProjectDirVisibility();
ApplyStatusToUi(client);
- }
-
- if ((forceImmediate || needsRefresh) && !statusRefreshInFlight.Contains(client))
- {
- statusRefreshInFlight.Add(client);
- ApplyStatusToUi(client, showChecking: true);
-
- // Capture main-thread-only values before async task
- string projectDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
- bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
- string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
- RuntimePlatform platform = Application.platform;
- bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
- // Get expected package source based on installed package version and overrides.
- string expectedPackageSource = GetExpectedPackageSourceForCurrentPackage();
- bool hasProjectDirOverride = ClaudeCliMcpConfigurator.HasClientProjectDirOverride;
-
- Task.Run(() =>
+ }, () => selectedClientIndex >= 0 && selectedClientIndex < configurators.Count
+ && ReferenceEquals(configurators[selectedClientIndex], client)
+ && !statusRefreshInFlight.Contains(client) && WindowRefreshContext.Capture().Key == context.Key,
+ forceImmediate, onFailure: ex =>
{
- // Defensive: RefreshClientStatus routes Claude CLI clients here, but avoid hard-cast
- // so accidental future call sites can't crash the UI.
- if (client is ClaudeCliMcpConfigurator claudeConfigurator)
- {
- // Use thread-safe version with captured main-thread values
- claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite: false, hasProjectDirOverride: hasProjectDirOverride);
- }
- }).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);
- };
+ baseClient.Client.SetStatus(McpStatus.Error, $"Status check failed: {ex.Message}");
+ baseClient.Client.configuredTransport = ConfiguredTransport.Unknown;
+ // These fields still describe whichever client was shown last. Clear them and leave
+ // Copy JSON disabled so a failed check never copies another client's configuration.
+ configPathField.SetValueWithoutNotify(string.Empty);
+ configJsonField.SetValueWithoutNotify(string.Empty);
+ installationStepsLabel.text = "Configuration details are unavailable because the status check failed.";
+ // The earlier context belongs to settings that may no longer apply.
+ displayedContext = null;
+ claudeCliPath.SetValueWithoutNotify("Unavailable");
+ clientProjectDirField.SetValueWithoutNotify("Unavailable");
+ 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, string customMessage = null)
@@ -736,7 +623,7 @@ private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking =
// in the overridden project may legitimately differ from the local server.
bool hasTransportMismatch = false;
if (client.ConfiguredTransport != ConfiguredTransport.Unknown
- && !ClaudeCliMcpConfigurator.HasClientProjectDirOverride)
+ && displayedContext?.HasProjectDirOverride != true)
{
ConfiguredTransport serverTransport = HttpEndpointUtility.GetCurrentServerTransport();
hasTransportMismatch = client.ConfiguredTransport != serverTransport;
@@ -792,16 +679,6 @@ private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking =
}
}
- ///
- /// Gets the expected package source for validation based on installed package version.
- /// Uses the same logic as registration to ensure validation matches what was registered.
- /// MUST be called from the main thread due to EditorPrefs access.
- ///
- private static string GetExpectedPackageSourceForCurrentPackage()
- {
- return AssetPathUtility.GetMcpServerPackageSource();
- }
-
private int FindConfiguratorIndex(string persistedClientValue)
{
if (string.IsNullOrWhiteSpace(persistedClientValue))
diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs
index 0ff5c2fd27..8c8f4488c8 100644
--- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs
@@ -6,6 +6,7 @@
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
+using MCPForUnity.Editor.Services.Server;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
@@ -60,7 +61,12 @@ private enum TransportProtocol
private Task verificationTask;
private string lastHealthStatus;
private int consecutiveVerifyFailures;
- private double lastLocalServerRunningPollTime;
+ private EditorAsyncRefresh localServerRefresh;
+ private EditorAsyncRefresh> commandRefresh;
+ internal Func LocalServerProbe = ServerManagementService.IsLocalHttpServerReachable;
+ private string localProbeKey;
+ private bool hasLocalProbeResult;
+ private int connectionEpoch;
private bool lastLocalServerRunning;
private int consecutiveServerDownPolls;
private bool sessionWasRunning;
@@ -82,6 +88,7 @@ public void SetHealthStatusUpdateCallback(Action callback)
public McpConnectionSection(VisualElement root)
{
Root = root;
+ Root.RegisterCallback(_ => connectionEpoch++);
CacheUIElements();
InitializeUI();
RegisterCallbacks();
@@ -185,6 +192,7 @@ private void RegisterCallbacks()
{
transportDropdown.RegisterValueChangedCallback(evt =>
{
+ InvalidateConnectionRefresh();
var previous = (TransportProtocol)evt.previousValue;
var selected = (TransportProtocol)evt.newValue;
bool useHttp = selected != TransportProtocol.Stdio;
@@ -503,6 +511,7 @@ public void UpdateHttpServerCommandDisplay()
// Only show the local-server helper UI when HTTP Local is selected.
if (!useHttp || !httpLocalSelected)
{
+ commandRefresh?.Invalidate();
httpServerCommandSection.style.display = DisplayStyle.None;
httpServerCommandField.value = string.Empty;
httpServerCommandField.tooltip = string.Empty;
@@ -522,6 +531,7 @@ public void UpdateHttpServerCommandDisplay()
if (!isLocalHttpUrlAllowed)
{
+ commandRefresh?.Invalidate();
httpServerCommandField.value = string.Empty;
httpServerCommandField.tooltip = string.Empty;
httpServerCommandField.SetEnabled(false);
@@ -543,7 +553,21 @@ public void UpdateHttpServerCommandDisplay()
httpServerCommandHint.RemoveFromClassList("http-local-url-error");
}
- if (MCPServiceLocator.Server.TryGetLocalHttpServerCommand(out var command, out var error))
+ var context = WindowRefreshContext.Capture();
+ commandRefresh ??= new EditorAsyncRefresh>(Root, 30);
+ commandRefresh.Request(context.Key, () =>
+ {
+ context.Resolve();
+ new ServerCommandBuilder().TryBuildCommand(context, out _, out _, out string command, out string error);
+ return Tuple.Create(command, error);
+ }, (result, _) => ApplyHttpServerCommand(result.Item1, result.Item2),
+ () => IsHttpLocalSelected() && TryGetLocalHttpLaunchPolicy(out _, out _)
+ && WindowRefreshContext.Capture().Key == context.Key);
+ }
+
+ private void ApplyHttpServerCommand(string command, string error)
+ {
+ if (!string.IsNullOrEmpty(command))
{
httpServerCommandField.value = command;
httpServerCommandField.tooltip = command;
@@ -626,26 +650,30 @@ private void UpdateStartHttpButtonState()
bool canStartLocalServer = httpLocalSelected && localUrlAllowedForLaunch;
bool localServerRunning = false;
- // Avoid running expensive port/PID checks every UI tick; use a fast socket probe for UI state.
if (httpLocalSelected)
{
- double now = EditorApplication.timeSinceStartup;
- if ((now - lastLocalServerRunningPollTime) > 0.75f || httpServerToggleInProgress)
+ string key = GetConnectionRefreshKey();
+ if (key != localProbeKey)
{
- lastLocalServerRunningPollTime = now;
- lastLocalServerRunning = MCPServiceLocator.Server.IsLocalHttpServerReachable();
-
- // Probe results taken while the editor is busy are unreliable (main-thread
- // stalls starve the connect wait), so they don't count toward teardown.
- if (EditorApplication.isCompiling || EditorApplication.isUpdating)
- {
- consecutiveServerDownPolls = 0;
- }
- else
- {
- consecutiveServerDownPolls = lastLocalServerRunning ? 0 : consecutiveServerDownPolls + 1;
- }
+ localProbeKey = key;
+ consecutiveServerDownPolls = 0;
+ hasLocalProbeResult = false;
+ lastLocalServerRunning = false;
}
+ string url = HttpEndpointUtility.GetLocalBaseUrl();
+ var probe = LocalServerProbe;
+ localServerRefresh ??= new EditorAsyncRefresh(Root, 0.75);
+ localServerRefresh.Request(key, () => probe(url), (running, fresh) =>
+ {
+ lastLocalServerRunning = running;
+ hasLocalProbeResult = true;
+ if (!fresh) return;
+ // Only a newly completed probe counts, never cached false on focus.
+ consecutiveServerDownPolls = EditorApplication.isCompiling || EditorApplication.isUpdating
+ || httpServerToggleInProgress || connectionToggleInProgress ? 0
+ : running ? 0 : consecutiveServerDownPolls + 1;
+ UpdateConnectionStatus();
+ }, () => IsHttpLocalSelected() && GetConnectionRefreshKey() == key);
localServerRunning = lastLocalServerRunning;
}
@@ -659,13 +687,14 @@ private void UpdateStartHttpButtonState()
}
else
{
- startHttpServerButton.text = shouldShowStop ? "Stop Server" : "Start Server";
+ startHttpServerButton.text = httpLocalSelected && !hasLocalProbeResult ? "Checking..."
+ : shouldShowStop ? "Stop Server" : "Start Server";
}
// Note: Server logs may contain transient HTTP 400s on /mcp during startup probing and
// CancelledError stack traces on shutdown when streaming requests are cancelled; this is expected.
startHttpServerButton.EnableInClassList("server-running", localServerRunning);
startHttpServerButton.SetEnabled(
- !httpServerToggleInProgress && (shouldShowStop || canStartLocalServer));
+ !httpServerToggleInProgress && hasLocalProbeResult && (shouldShowStop || canStartLocalServer));
startHttpServerButton.tooltip = httpLocalSelected
? (canStartLocalServer
? string.Empty
@@ -686,6 +715,7 @@ private async void OnHttpServerToggleClicked()
return;
}
+ InvalidateConnectionRefresh();
var bridgeService = MCPServiceLocator.Bridge;
httpServerToggleInProgress = true;
startHttpServerButton?.SetEnabled(false);
@@ -841,6 +871,7 @@ private async void OnConnectionToggleClicked()
return;
}
+ InvalidateConnectionRefresh();
var bridgeService = MCPServiceLocator.Bridge;
connectionToggleInProgress = true;
connectionToggleButton?.SetEnabled(false);
@@ -1073,6 +1104,26 @@ private void OnClearApiKeyClicked()
McpLog.Info("API key cleared");
}
+ private string GetConnectionRefreshKey()
+ {
+ var bridge = MCPServiceLocator.Bridge;
+ var mode = bridge.ActiveMode;
+ string sessionId = mode.HasValue
+ ? MCPServiceLocator.TransportManager.GetClient(mode.Value)?.State?.SessionId : null;
+ return string.Join("\n", HttpEndpointUtility.GetCurrentServerTransport(),
+ HttpEndpointUtility.GetLocalBaseUrl(), HttpEndpointUtility.GetRemoteBaseUrl(),
+ bridge.IsRunning, bridge.CurrentPort, mode, sessionId, connectionEpoch);
+ }
+
+ private void InvalidateConnectionRefresh()
+ {
+ connectionEpoch++;
+ consecutiveServerDownPolls = 0;
+ consecutiveVerifyFailures = 0;
+ localServerRefresh?.Invalidate();
+ commandRefresh?.Invalidate();
+ }
+
public async Task VerifyBridgeConnectionAsync()
{
// Prevent concurrent verification calls
@@ -1101,7 +1152,10 @@ private async Task VerifyBridgeConnectionInternalAsync()
return;
}
+ string key = GetConnectionRefreshKey();
+ var panel = Root.panel;
var result = await bridgeService.VerifyAsync();
+ if (!ReferenceEquals(Root.panel, panel) || key != GetConnectionRefreshKey()) return;
string newStatus;
bool isHealthy;
@@ -1168,7 +1222,7 @@ private async Task VerifyBridgeConnectionInternalAsync()
///
/// The display name of the client being checked.
/// The transport the client is configured to use.
- public void UpdateTransportMismatchWarning(string clientName, ConfiguredTransport clientTransport)
+ public void UpdateTransportMismatchWarning(string clientName, ConfiguredTransport clientTransport, bool hasProjectDirOverride = false)
{
if (transportMismatchWarning == null || transportMismatchText == null)
return;
@@ -1183,7 +1237,7 @@ public void UpdateTransportMismatchWarning(string clientName, ConfiguredTranspor
// When a project dir override is active the registered transport belongs
// to a different project and may legitimately differ from the local server
// transport, so skip the mismatch check.
- if (ClaudeCliMcpConfigurator.HasClientProjectDirOverride)
+ if (hasProjectDirOverride)
{
transportMismatchWarning.RemoveFromClassList("visible");
return;
diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs
index 0855939055..25aeac98c1 100644
--- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs
+++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs
@@ -288,7 +288,7 @@ public void CreateGUI()
// Wire up transport mismatch detection: when client status is checked,
// update the connection section's warning banner if there's a mismatch
clientConfigSection.OnClientTransportDetected += (clientName, transport) =>
- connectionSection?.UpdateTransportMismatchWarning(clientName, transport);
+ connectionSection?.UpdateTransportMismatchWarning(clientName, transport, clientConfigSection.HasClientProjectDirOverride);
// Wire up version mismatch detection: when client status is checked,
// update the connection section's warning banner if there's a version mismatch
@@ -600,8 +600,8 @@ private void OnFocus()
private void OnEditorUpdate()
{
// Throttle to 2-second intervals instead of every frame.
- // This prevents the expensive IsLocalHttpServerReachable() socket checks from running
- // 60+ times per second, which caused main thread blocking and GC pressure.
+ // Socket probes run on a worker; this interval also limits status redraws.
+ // Focus refreshes share the section caches and any in-flight work.
double now = EditorApplication.timeSinceStartup;
if (now - _lastEditorUpdateTime < EditorUpdateIntervalSeconds)
{
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
index 4e395d8919..bb37c79af1 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
@@ -265,9 +265,9 @@ public void BuildCodexServerBlock_OnWindows_IncludesSystemRootEnv()
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
+ // The resolved executable was supplied by the caller; do not rediscover it.
var command = (commandNode as TomlString).Value;
- Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
+ Assert.AreEqual(uvPath, command, "Command should use the provided executable path");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
@@ -322,9 +322,9 @@ public void BuildCodexServerBlock_OnNonWindows_ExcludesEnv()
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
+ // The resolved executable was supplied by the caller; do not rediscover it.
var command = (commandNode as TomlString).Value;
- Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
+ Assert.AreEqual(uvPath, command, "Command should use the provided executable path");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs
new file mode 100644
index 0000000000..a0618084fc
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using System.Threading;
+using MCPForUnity.Editor.Helpers;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.TestTools;
+using UnityEngine.UIElements;
+
+namespace MCPForUnityTests.Editor.Windows
+{
+ public class EditorAsyncRefreshTests
+ {
+ private FocusRefreshTestWindow window;
+ private ManualResetEventSlim release;
+
+ [SetUp]
+ public void Setup()
+ {
+ window = ScriptableObject.CreateInstance();
+ window.Show();
+ release = new ManualResetEventSlim(false);
+ }
+
+ [TearDown]
+ public void Cleanup()
+ {
+ release.Set();
+ window.Close();
+ }
+
+ [UnityTest]
+ public IEnumerator FocusRequests_JoinOneWorker_KeepEditorUpdating_AndUseCache()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int started = 0, applied = 0, applyThread = 0;
+ int mainThread = Thread.CurrentThread.ManagedThreadId;
+ Func work = () => { Interlocked.Increment(ref started); release.Wait(5000); return 7; };
+ Action apply = (value, fresh) => { applied = value; applyThread = Thread.CurrentThread.ManagedThreadId; };
+ for (int i = 0; i < 20; i++) refresh.Request("same", work, apply, () => true);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ int updates = 0;
+ for (int i = 0; i < 5; i++) { yield return null; updates++; }
+ Assert.AreEqual(5, updates);
+ Assert.AreEqual(0, applied, "A result was applied before work finished.");
+ Assert.AreEqual(1, started);
+ release.Set();
+ yield return WaitFor(() => applied == 7);
+ Assert.AreEqual(mainThread, applyThread);
+ for (int i = 0; i < 20; i++) refresh.Request("same", work, apply, () => true);
+ Assert.AreEqual(1, started, "Focus bypassed the successful-result TTL.");
+ }
+
+ [UnityTest]
+ public IEnumerator InputChanges_DiscardOldResult_AndRunOnlyLatestQueuedRequest()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ var applied = new List();
+ int started = 0;
+ refresh.Request("old", () => { Interlocked.Increment(ref started); release.Wait(5000); return 1; },
+ (value, _) => applied.Add(value), () => true);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ refresh.Request("middle", () => { Interlocked.Increment(ref started); return 2; },
+ (value, _) => applied.Add(value), () => true);
+ refresh.Request("latest", () => { Interlocked.Increment(ref started); return 3; },
+ (value, _) => applied.Add(value), () => true);
+ release.Set();
+ yield return WaitFor(() => applied.Count > 0);
+ CollectionAssert.AreEqual(new[] { 3 }, applied);
+ Assert.AreEqual(2, started, "Superseded requests were not coalesced.");
+ }
+
+ [UnityTest]
+ public IEnumerator Detach_DiscardsCompletion_AndReattachCanRefresh()
+ {
+ var root = new VisualElement();
+ window.rootVisualElement.Add(root);
+ var refresh = new EditorAsyncRefresh(root, 30);
+ int started = 0, completed = 0, applied = 0;
+ refresh.Request("old", () =>
+ {
+ Interlocked.Increment(ref started);
+ release.Wait(5000);
+ Interlocked.Increment(ref completed);
+ return 1;
+ }, (value, _) => applied = value, () => true);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ root.RemoveFromHierarchy();
+ release.Set();
+ yield return WaitFor(() => Volatile.Read(ref completed) == 1);
+ for (int i = 0; i < 3; i++) yield return null;
+ Assert.AreEqual(0, applied);
+ window.rootVisualElement.Add(root);
+ refresh.Request("new", () => 2, (value, _) => applied = value, () => true);
+ yield return WaitFor(() => applied == 2);
+ }
+
+ [UnityTest]
+ public IEnumerator ExpiredCache_Refreshes_AndMarksOnlyNewCompletionsFresh()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 0.05);
+ int runs = 0, failures = 0;
+ Func work = () => { Interlocked.Increment(ref runs); return false; };
+ Action apply = (value, fresh) => { if (fresh && !value) failures++; };
+ refresh.Request("endpoint", work, apply, () => true);
+ yield return WaitFor(() => failures == 1);
+ for (int i = 0; i < 20; i++) refresh.Request("endpoint", work, apply, () => true);
+ Assert.AreEqual(1, failures, "Cached failures counted as new probes.");
+ double expires = EditorApplication.timeSinceStartup + 0.1;
+ while (EditorApplication.timeSinceStartup < expires) yield return null;
+ refresh.Request("endpoint", work, apply, () => true);
+ yield return WaitFor(() => failures == 2);
+ Assert.AreEqual(2, runs);
+ }
+
+ [UnityTest]
+ public IEnumerator SettingsChangedWithoutAnotherRequest_DoNotApplyStaleResult()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int started = 0, completed = 0, applied = 0;
+ bool sameSettings = true;
+ refresh.Request("old", () =>
+ {
+ Interlocked.Increment(ref started);
+ release.Wait(5000);
+ Interlocked.Increment(ref completed);
+ return 1;
+ }, (value, _) => applied = value, () => sameSettings);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ sameSettings = false;
+ release.Set();
+ yield return WaitFor(() => Volatile.Read(ref completed) == 1);
+ for (int i = 0; i < 3; i++) yield return null;
+ Assert.AreEqual(0, applied);
+ }
+
+ [UnityTest]
+ public IEnumerator FaultedWork_ReportsOriginalExceptionExactlyOnce()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ var expected = new InvalidOperationException("Status check failed.");
+ Exception reported = null;
+ int failures = 0, applied = 0, failureThread = 0;
+ int mainThread = Thread.CurrentThread.ManagedThreadId;
+ refresh.Request("fault", () => { throw expected; }, (value, _) => applied++, () => true,
+ onFailure: ex => { reported = ex; failures++; failureThread = Thread.CurrentThread.ManagedThreadId; });
+ yield return WaitFor(() => failures == 1);
+ for (int i = 0; i < 3; i++) yield return null;
+ Assert.AreSame(expected, reported);
+ Assert.AreEqual(1, failures);
+ Assert.AreEqual(0, applied);
+ Assert.AreEqual(mainThread, failureThread);
+ }
+
+ [UnityTest]
+ public IEnumerator SupersededFault_DoesNotNotifyEitherFailureHandler()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int started = 0, failures = 0, applied = 0;
+ refresh.Request("old", () =>
+ {
+ Interlocked.Increment(ref started);
+ release.Wait(5000);
+ throw new InvalidOperationException("Superseded status check.");
+ }, (value, _) => applied = value, () => true, onFailure: _ => failures++);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ refresh.Request("new", () => 2, (value, _) => applied = value, () => true,
+ onFailure: _ => failures++);
+ release.Set();
+ yield return WaitFor(() => applied == 2);
+ Assert.AreEqual(0, failures);
+ }
+
+ [UnityTest]
+ public IEnumerator SettingsChangedWithoutAnotherRequest_DoNotReportStaleFault()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int started = 0, failures = 0, currentChecks = 0;
+ bool sameSettings = true;
+ refresh.Request("old", () =>
+ {
+ Interlocked.Increment(ref started);
+ release.Wait(5000);
+ throw new InvalidOperationException("Stale status check.");
+ }, (value, _) => Assert.Fail("A fault must not apply a result."),
+ () => { currentChecks++; return sameSettings; }, onFailure: _ => failures++);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ sameSettings = false;
+ release.Set();
+ yield return WaitFor(() => currentChecks == 1);
+ Assert.AreEqual(0, failures);
+ }
+
+ [UnityTest]
+ public IEnumerator InvalidatedFault_DoesNotNotifyFailureHandler()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int started = 0, failures = 0, applied = 0;
+ refresh.Request("same", () =>
+ {
+ Interlocked.Increment(ref started);
+ release.Wait(5000);
+ throw new InvalidOperationException("Invalidated status check.");
+ }, (value, _) => applied = value, () => true, onFailure: _ => failures++);
+ yield return WaitFor(() => Volatile.Read(ref started) == 1);
+ refresh.Invalidate();
+ refresh.Request("same", () => 2, (value, _) => applied = value, () => true,
+ onFailure: _ => failures++);
+ release.Set();
+ yield return WaitFor(() => applied == 2);
+ Assert.AreEqual(0, failures);
+ }
+
+ [UnityTest]
+ public IEnumerator SuccessfulWorkAndCache_DoNotNotifyFailureHandler()
+ {
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int failures = 0, applied = 0;
+ refresh.Request("success", () => 7, (value, _) => applied = value, () => true,
+ onFailure: _ => failures++);
+ yield return WaitFor(() => applied == 7);
+ refresh.Request("success", () => 8, (value, _) => applied = value, () => true,
+ onFailure: _ => failures++);
+ Assert.AreEqual(7, applied);
+ Assert.AreEqual(0, failures);
+ }
+
+ [UnityTest]
+ public IEnumerator ThrowingFailureHandler_DoesNotPreventNextRequestCompleting()
+ {
+ // The guard must swallow the handler's exception and log this warning, not let it escape the poll loop.
+ LogAssert.Expect(LogType.Warning, new Regex("Window refresh failure handler failed"));
+ var refresh = new EditorAsyncRefresh(window.rootVisualElement, 30);
+ int failures = 0, applied = 0;
+ refresh.Request("fault", () => { throw new InvalidOperationException("Status check failed."); },
+ (value, _) => applied = value, () => true, onFailure: _ =>
+ {
+ failures++;
+ refresh.Request("next", () => 7, (value, fresh) => applied = value, () => true);
+ throw new InvalidOperationException("Failure handler failed.");
+ });
+ yield return WaitFor(() => applied == 7);
+ Assert.AreEqual(1, failures);
+ }
+
+ internal static IEnumerator WaitFor(Func condition)
+ {
+ double deadline = EditorApplication.timeSinceStartup + 10;
+ while (!condition() && EditorApplication.timeSinceStartup < deadline) yield return null;
+ Assert.IsTrue(condition(), "Asynchronous refresh did not reach the expected state.");
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs.meta
new file mode 100644
index 0000000000..b8414fa44d
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/EditorAsyncRefreshTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f31a43ec46cf4966955a3c8598134720
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WindowFocusRefreshTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WindowFocusRefreshTests.cs
new file mode 100644
index 0000000000..2fcfe174c9
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WindowFocusRefreshTests.cs
@@ -0,0 +1,406 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Runtime.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using MCPForUnity.Editor.Clients;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Models;
+using MCPForUnity.Editor.Services;
+using MCPForUnity.Editor.Services.Transport;
+using MCPForUnity.Editor.Windows.Components.Advanced;
+using MCPForUnity.Editor.Windows.Components.ClientConfig;
+using MCPForUnity.Editor.Windows.Components.Connection;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.TestTools;
+using UnityEngine.UIElements;
+
+namespace MCPForUnityTests.Editor.Windows
+{
+ public class FocusRefreshTestWindow : EditorWindow { }
+
+ public class WindowFocusRefreshTests : TransportPreferenceTestBase
+ {
+ private FocusRefreshTestWindow window;
+ private string fixtureDir;
+ private readonly Dictionary savedStrings = new();
+ private readonly HashSet existingPrefs = new();
+ private bool savedAllowLan, hadAllowLan;
+
+ [SetUp]
+ public void Setup()
+ {
+ hadAllowLan = EditorPrefs.HasKey(EditorPrefKeys.AllowLanHttpBind);
+ savedAllowLan = EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
+ string[] keys = { EditorPrefKeys.GitUrlOverride, EditorPrefKeys.UvxPathOverride,
+ EditorPrefKeys.ClaudeCliPathOverride, EditorPrefKeys.HttpBaseUrl, EditorPrefKeys.HttpTransportScope,
+ EditorPrefKeys.ClientProjectDirOverride };
+ foreach (string key in keys)
+ {
+ if (EditorPrefs.HasKey(key)) existingPrefs.Add(key);
+ savedStrings[key] = EditorPrefs.GetString(key, string.Empty);
+ }
+ fixtureDir = Path.Combine(Path.GetTempPath(), "mcp-window-refresh-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(fixtureDir, "Server"));
+ File.WriteAllText(Path.Combine(fixtureDir, "Server", "pyproject.toml"), "[project]\nname = 'refresh-fixture'\n");
+ EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, fixtureDir);
+ EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, "fixture-uvx");
+ EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, Path.Combine(fixtureDir, "missing-claude"));
+ EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.1:8080");
+ EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, "local");
+ EditorPrefs.SetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty);
+ EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
+ EditorConfigurationCache.Instance.Refresh();
+ }
+
+ [TearDown]
+ public void Cleanup()
+ {
+ if (window != null) window.Close();
+ MCPServiceLocator.Reset();
+ foreach (var pair in savedStrings)
+ {
+ if (existingPrefs.Contains(pair.Key)) EditorPrefs.SetString(pair.Key, pair.Value);
+ else EditorPrefs.DeleteKey(pair.Key);
+ }
+ savedStrings.Clear();
+ existingPrefs.Clear();
+ if (hadAllowLan) EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, savedAllowLan);
+ else EditorPrefs.DeleteKey(EditorPrefKeys.AllowLanHttpBind);
+ if (Directory.Exists(fixtureDir)) Directory.Delete(fixtureDir, true);
+ }
+
+ [UnityTest]
+ public IEnumerator AdvancedRefresh_ReturnsBeforeSlowExecutableValidation()
+ {
+ var paths = new SlowPathResolver();
+ MCPServiceLocator.Register(paths);
+ window = ScriptableObject.CreateInstance();
+ window.Show();
+ var asset = AssetDatabase.LoadAssetAtPath(
+ "Packages/com.coplaydev.unity-mcp/Editor/Windows/Components/Advanced/McpAdvancedSection.uxml");
+ Assert.IsNotNull(asset);
+ var root = asset.CloneTree();
+ window.rootVisualElement.Add(root);
+ // Build only the controls needed by refresh, without setup/configuration callbacks.
+ var section = (McpAdvancedSection)FormatterServices.GetUninitializedObject(typeof(McpAdvancedSection));
+ const BindingFlags Flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(McpAdvancedSection).GetField("k__BackingField", Flags).SetValue(section, root);
+ typeof(McpAdvancedSection).GetMethod("CacheUIElements", Flags).Invoke(section, null);
+ int mainThread = Thread.CurrentThread.ManagedThreadId;
+ var watch = Stopwatch.StartNew();
+ section.UpdatePathOverrides();
+ watch.Stop();
+ Assert.Less(watch.ElapsedMilliseconds, 200, "Focus refresh waited for an executable probe on the UI thread.");
+ double deadline = EditorApplication.timeSinceStartup + 5;
+ while (!paths.validationCompleted && EditorApplication.timeSinceStartup < deadline) yield return null;
+ Assert.IsTrue(paths.validationCompleted, "Asynchronous validation did not finish.");
+ Assert.AreNotEqual(mainThread, paths.validationThreadId, "Executable validation ran on the UI thread.");
+ }
+
+ [UnityTest]
+ public IEnumerator AdvancedRefresh_UsesSilentDisplayUpdates_AndDoesNotNormalizeStoredSource()
+ {
+ MCPServiceLocator.Register(new SlowPathResolver());
+ var root = CreateRoot("Advanced", "McpAdvancedSection");
+ var section = UninitializedSection(root);
+ int callbacks = 0;
+ root.Q("git-url-override").RegisterValueChangedCallback(_ => callbacks++);
+ root.Q("debug-logs-toggle").RegisterValueChangedCallback(_ => callbacks++);
+ root.Q("deploy-source-path").RegisterValueChangedCallback(_ => callbacks++);
+ section.UpdatePathOverrides();
+ yield return EditorAsyncRefreshTests.WaitFor(() => root.Q("uv-path-override").value == "fixture-uvx");
+ Assert.AreEqual(0, callbacks);
+ Assert.AreEqual(fixtureDir, EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride), "Read-only refresh normalized a preference.");
+ }
+
+ [UnityTest]
+ public IEnumerator ClientRefresh_IsReadOnly_AndLiveModelChangesOnlyAfterWorkerCompletes()
+ {
+ MCPServiceLocator.Register(new SlowPathResolver());
+ string path = Path.Combine(fixtureDir, "client.json");
+ const string original = "{\"mcpServers\":{\"unityMCP\":{\"url\":\"http://127.0.0.1:1/mcp\"}}}";
+ File.WriteAllText(path, original);
+ var model = new McpClient { name = "Refresh Fixture", windowsConfigPath = path, macConfigPath = path, linuxConfigPath = path };
+ var client = new FixtureJsonClient(model);
+ MCPServiceLocator.Register(new FixtureClientService(client));
+ var root = CreateRoot("ClientConfig", "McpClientConfigSection");
+ var watch = Stopwatch.StartNew();
+ var section = new McpClientConfigSection(root);
+ section.RefreshSelectedClient();
+ watch.Stop();
+ Assert.Less(watch.ElapsedMilliseconds, 200);
+ Assert.AreEqual(McpStatus.NotConfigured, model.status);
+ Assert.AreEqual(ConfiguredTransport.Unknown, model.configuredTransport);
+ yield return EditorAsyncRefreshTests.WaitFor(() => root.Q