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