diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 36688e513..c0ab8695b 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -467,8 +467,8 @@ private void Register() else { var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - bool devForceRefresh = GetDevModeForceRefresh(); - string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; } @@ -586,8 +586,8 @@ public override string GetManualSnippet() } string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); - bool devForceRefresh = GetDevModeForceRefresh(); - string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; return "# Register the MCP server with Claude Code:\n" + $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" mcp-for-unity\n\n" + @@ -597,12 +597,6 @@ public override string GetManualSnippet() "claude mcp list # Only works when claude is run in the project's directory"; } - private static bool GetDevModeForceRefresh() - { - try { return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } - catch { return false; } - } - public override IList GetInstallationSteps() => new List { "Ensure Claude CLI is installed", diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 832d44ab5..f7a6b4782 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -186,6 +186,88 @@ public static (string uvxPath, string fromUrl, string packageName) GetUvxCommand return (uvxPath, fromUrl, packageName); } + /// + /// Determines whether uvx should use --no-cache --refresh flags. + /// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path. + /// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache. + /// + public static bool ShouldForceUvxRefresh() + { + bool devForceRefresh = false; + try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } + + if (devForceRefresh) + return true; + + // Auto-enable force refresh when using a local path override. + return IsLocalServerPath(); + } + + /// + /// Returns true if the server URL is a local path (file:// or absolute path). + /// + public static bool IsLocalServerPath() + { + string fromUrl = GetMcpServerGitUrl(); + if (string.IsNullOrEmpty(fromUrl)) + return false; + + // Check for file:// protocol or absolute local path + return fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase) || + System.IO.Path.IsPathRooted(fromUrl); + } + + /// + /// Gets the local server path if GitUrlOverride points to a local directory. + /// Returns null if not using a local path. + /// + public static string GetLocalServerPath() + { + if (!IsLocalServerPath()) + return null; + + string fromUrl = GetMcpServerGitUrl(); + if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + // Strip file:// prefix + fromUrl = fromUrl.Substring(7); + } + + return fromUrl; + } + + /// + /// Cleans stale Python build artifacts from the local server path. + /// This is necessary because Python's build system doesn't remove deleted files from build/, + /// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools. + /// + /// True if cleaning was performed, false if not applicable or failed. + public static bool CleanLocalServerBuildArtifacts() + { + string localPath = GetLocalServerPath(); + if (string.IsNullOrEmpty(localPath)) + return false; + + // Clean the build/ directory which can contain stale .py files + string buildPath = System.IO.Path.Combine(localPath, "build"); + if (System.IO.Directory.Exists(buildPath)) + { + try + { + System.IO.Directory.Delete(buildPath, recursive: true); + McpLog.Info($"Cleaned stale build artifacts from: {buildPath}"); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"Failed to clean build artifacts: {ex.Message}"); + return false; + } + } + + return false; + } + /// /// Gets the package version from package.json /// diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs index 3a8a6cf65..22a78cdd2 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -17,22 +17,11 @@ namespace MCPForUnity.Editor.Helpers /// public static class CodexConfigHelper { - private static bool GetDevModeForceRefresh() - { - try - { - return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); - } - catch - { - return false; - } - } - - private static void AddDevModeArgs(TomlArray args, bool devForceRefresh) + private static void AddDevModeArgs(TomlArray args) { if (args == null) return; - if (!devForceRefresh) return; + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + if (!AssetPathUtility.ShouldForceUvxRefresh()) return; args.Add(new TomlString { Value = "--no-cache" }); args.Add(new TomlString { Value = "--refresh" }); } @@ -59,12 +48,11 @@ public static string BuildCodexServerBlock(string uvPath) { // Stdio mode: Use command and args var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - bool devForceRefresh = GetDevModeForceRefresh(); unityMCP["command"] = uvxPath; var args = new TomlArray(); - AddDevModeArgs(args, devForceRefresh); + AddDevModeArgs(args); if (!string.IsNullOrEmpty(fromUrl)) { args.Add(new TomlString { Value = "--from" }); @@ -209,12 +197,11 @@ private static TomlTable CreateUnityMcpTable(string uvPath) { // Stdio mode: Use command and args var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - bool devForceRefresh = GetDevModeForceRefresh(); unityMCP["command"] = new TomlString { Value = uvxPath }; var argsArray = new TomlArray(); - AddDevModeArgs(argsArray, devForceRefresh); + AddDevModeArgs(argsArray); if (!string.IsNullOrEmpty(fromUrl)) { argsArray.Add(new TomlString { Value = "--from" }); diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 067eed9c6..ebc82fe3f 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -51,7 +51,9 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { // Get transport preference (default to HTTP) - bool useHttpTransport = client?.SupportsHttpTransport != false && EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool prefValue = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool clientSupportsHttp = client?.SupportsHttpTransport != false; + bool useHttpTransport = clientSupportsHttp && prefValue; string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; var urlPropsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" }; urlPropsToRemove.Remove(httpProperty); @@ -81,10 +83,7 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl // Stdio mode: Use uvx command var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - bool devForceRefresh = false; - try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } - - var toolArgs = BuildUvxArgs(fromUrl, packageName, devForceRefresh); + var toolArgs = BuildUvxArgs(fromUrl, packageName); if (ShouldUseWindowsCmdShim(client)) { @@ -152,13 +151,15 @@ private static JObject EnsureObject(JObject parent, string name) return created; } - private static IList BuildUvxArgs(string fromUrl, string packageName, bool devForceRefresh) + private static IList BuildUvxArgs(string fromUrl, string packageName) { // Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating). // `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated. // Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name. var args = new List(); - if (devForceRefresh) + + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + if (AssetPathUtility.ShouldForceUvxRefresh()) { args.Add("--no-cache"); args.Add("--refresh"); diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 3c48abdf5..65f0e1d33 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Services @@ -22,11 +23,24 @@ public ClientConfigurationService() public void ConfigureClient(IMcpClientConfigurator configurator) { + // When using a local server path, clean stale build artifacts first. + // This prevents old deleted .py files from being picked up by Python's auto-discovery. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + configurator.Configure(); } public ClientConfigurationSummary ConfigureAllDetectedClients() { + // When using a local server path, clean stale build artifacts once before configuring all clients. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + var summary = new ClientConfigurationSummary(); foreach (var configurator in configurators) { diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index b40788c8e..b77bbd357 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -406,6 +406,9 @@ private string GetPlatformSpecificPathPrepend() /// public bool StartLocalHttpServer() { + /// Clean stale Python build artifacts when using a local dev server path + AssetPathUtility.CleanLocalServerBuildArtifacts(); + if (!TryGetLocalHttpServerCommandParts(out _, out _, out var displayCommand, out var error)) { EditorUtility.DisplayDialog( @@ -1236,10 +1239,8 @@ private bool TryGetLocalHttpServerCommandParts(out string fileName, out string a return false; } - bool devForceRefresh = false; - try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } - - string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; string args = string.IsNullOrEmpty(fromUrl) ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}" : $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; diff --git a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs index be9f692c4..dd2abaa45 100644 --- a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs +++ b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs @@ -27,8 +27,9 @@ private static void OnBeforeAssemblyReload() bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); // Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost // bypassing TransportManager state. - bool isRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio) - || StdioBridgeHost.IsRunning; + bool tmRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio); + bool hostRunning = StdioBridgeHost.IsRunning; + bool isRunning = tmRunning || hostRunning; bool shouldResume = !useHttp && isRunning; if (shouldResume) @@ -60,10 +61,12 @@ private static void OnAfterAssemblyReload() bool resume = false; try { - resume = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false); + bool resumeFlag = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false); bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - resume = resume && !useHttp; - if (resume) + resume = resumeFlag && !useHttp; + + // If we're not going to resume, clear the flag immediately to avoid stuck "Resuming..." state + if (!resume) { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } @@ -87,6 +90,13 @@ private static void TryStartBridgeImmediate() var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio); startTask.ContinueWith(t => { + // Clear the flag after attempting to start (success or failure). + // This prevents getting stuck in "Resuming..." state. + // We do this synchronously on the continuation thread - it's safe because + // EditorPrefs operations are thread-safe and any new reload will set the flag + // fresh in OnBeforeAssemblyReload before we get here. + try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { } + if (t.IsFaulted) { var baseEx = t.Exception?.GetBaseException(); diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 8fca7ecb6..c606460fb 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -84,6 +84,11 @@ private void InitializeUI() } claudeCliPathRow.style.display = DisplayStyle.None; + + // Initialize the configuration display for the first selected client + UpdateClientStatus(); + UpdateManualConfiguration(); + UpdateClaudeCliPathVisibility(); } private void RegisterCallbacks() diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 0e9d4a246..282280324 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -155,6 +155,10 @@ private void RegisterCallbacks() var selected = (TransportProtocol)evt.newValue; bool useHttp = selected != TransportProtocol.Stdio; EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, useHttp); + + // Clear any stale resume flags when user manually changes transport + try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { } if (useHttp) { @@ -274,7 +278,9 @@ public void UpdateConnectionStatus() bool isRunning = bridgeService.IsRunning; bool showLocalServerControls = IsHttpLocalSelected(); bool debugMode = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); - bool stdioSelected = transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.Stdio; + // Use EditorPrefs as source of truth for stdio selection - more reliable after domain reload + // than checking the dropdown which may not be initialized yet + bool stdioSelected = !EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); // Keep the Start/Stop Server button label in sync even when the session is not running // (e.g., orphaned server after a domain reload). @@ -327,6 +333,7 @@ public void UpdateConnectionStatus() statusIndicator.RemoveFromClassList("disconnected"); statusIndicator.AddToClassList("connected"); connectionToggleButton.text = "End Session"; + connectionToggleButton.SetEnabled(true); // Re-enable in case it was disabled during resumption // Force the UI to reflect the actual port being used unityPortField.value = bridgeService.CurrentPort.ToString(); @@ -334,12 +341,30 @@ public void UpdateConnectionStatus() } else { - connectionStatusLabel.text = "No Session"; - statusIndicator.RemoveFromClassList("connected"); - statusIndicator.AddToClassList("disconnected"); - connectionToggleButton.text = "Start Session"; + // Check if we're resuming the stdio bridge after a domain reload. + // During this brief window, show "Resuming..." instead of "No Session" to avoid UI flicker. + bool isStdioResuming = stdioSelected + && EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false); + + if (isStdioResuming) + { + connectionStatusLabel.text = "Resuming..."; + // Keep the indicator in a neutral/transitional state + statusIndicator.RemoveFromClassList("connected"); + statusIndicator.RemoveFromClassList("disconnected"); + connectionToggleButton.text = "Start Session"; + connectionToggleButton.SetEnabled(false); + } + else + { + connectionStatusLabel.text = "No Session"; + statusIndicator.RemoveFromClassList("connected"); + statusIndicator.AddToClassList("disconnected"); + connectionToggleButton.text = "Start Session"; + connectionToggleButton.SetEnabled(true); + } - unityPortField.SetEnabled(true); + unityPortField.SetEnabled(!isStdioResuming); healthStatusLabel.text = HealthStatusUnknown; healthIndicator.RemoveFromClassList("healthy"); diff --git a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs index edd5b2ebd..785e09600 100644 --- a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs +++ b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs @@ -39,6 +39,7 @@ public class EditorPrefsWindow : EditorWindow { EditorPrefKeys.SetupDismissed, EditorPrefType.Bool }, { EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool }, { EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool }, + { EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool }, // Integer prefs { EditorPrefKeys.UnitySocketPort, EditorPrefType.Int }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs index f168742f0..0b299d01b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs @@ -21,9 +21,21 @@ public class WriteToConfigTests private string _fakeUvPath; private string _serverSrcDir; + // Save/restore original pref values (must happen BEFORE Assert.Ignore since TearDown still runs) + private bool _hadHttpTransport; + private bool _originalHttpTransport; + private bool _hadHttpUrl; + private string _originalHttpUrl; + [SetUp] public void SetUp() { + // Save original pref values FIRST - TearDown runs even when test is ignored! + _hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey); + _originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true); + _hadHttpUrl = EditorPrefs.HasKey(HttpUrlPrefKey); + _originalHttpUrl = EditorPrefs.GetString(HttpUrlPrefKey, ""); + // Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo // restrictions when UseShellExecute=false for .cmd/.bat scripts. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -62,8 +74,17 @@ public void TearDown() EditorPrefs.DeleteKey(EditorPrefKeys.ServerSrc); EditorPrefs.DeleteKey(EditorPrefKeys.LockCursorConfig); EditorPrefs.DeleteKey(EditorPrefKeys.AutoRegisterEnabled); - EditorPrefs.DeleteKey(UseHttpTransportPrefKey); - EditorPrefs.DeleteKey(HttpUrlPrefKey); + + // Restore original pref values (don't delete if user had them set!) + if (_hadHttpTransport) + EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport); + else + EditorPrefs.DeleteKey(UseHttpTransportPrefKey); + + if (_hadHttpUrl) + EditorPrefs.SetString(HttpUrlPrefKey, _originalHttpUrl); + else + EditorPrefs.DeleteKey(HttpUrlPrefKey); // Remove temp files try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }