From ac1e34c8b43a2256bde0ed73c515e59a0f52171a Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 1 Feb 2026 08:16:52 -0800 Subject: [PATCH 1/8] Fix Git URL in README for package installation Updated the Git URL for adding the package to include the branch name. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ff7667d74..5176b9e33 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ In Unity: `Window > Package Manager > + > Add package from git URL...` > [!TIP] > ```text -> https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity +> https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main > ``` **Want the latest beta?** Use the beta branch: From c98c0e09a7c9362acfa53212d079e883fe0e136e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 09:01:23 -0800 Subject: [PATCH 2/8] fix: Clean up Claude Code config from all scopes to prevent stale config conflicts (#664) - Add RemoveFromAllScopes helper to remove from local/user/project scopes - Use explicit --scope local when registering - Update manual snippets to show multi-scope cleanup - Handle legacy 'unityMCP' naming in all scopes Co-Authored-By: Claude Opus 4.5 --- .../Clients/McpClientConfiguratorBase.cs | 84 ++++++++++++------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 0d69cafbc..4ab995fed 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -627,27 +627,28 @@ private void RegisterWithCapturedValues( if (useHttpTransport) { // 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 (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey)) { string safeKey = SanitizeShellHeaderValue(apiKey); - args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; } else { - args = $"mcp add --transport http UnityMCP {httpUrl}"; + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else { // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty; - args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; + // 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}--from \"{gitUrl}\" {packageName}"; } - // Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy) - McpLog.Info("Removing any existing UnityMCP registrations before adding..."); - ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) + McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); // Now add the registration if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) @@ -670,10 +671,9 @@ private void UnregisterWithCapturedValues(string projectDir, string claudePath, throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } - // Remove both "UnityMCP" and "unityMCP" (legacy naming) - McpLog.Info("Removing all UnityMCP registrations..."); - ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + // Remove from ALL scopes to ensure complete cleanup (#664) + McpLog.Info("Removing all UnityMCP registrations from all scopes..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); McpLog.Info("MCP server successfully unregistered from Claude Code."); client.SetStatus(McpStatus.NotConfigured); @@ -696,22 +696,23 @@ private void Register() { string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); // 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()) { string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); if (!string.IsNullOrEmpty(apiKey)) { string safeKey = SanitizeShellHeaderValue(apiKey); - args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; } else { - args = $"mcp add --transport http UnityMCP {httpUrl}"; + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else { - args = $"mcp add --transport http UnityMCP {httpUrl}"; + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else @@ -720,7 +721,8 @@ private void Register() // Use central helper that checks both DevModeForceServerRefresh AND local path detection. // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; - args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; + // 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}--from \"{gitUrl}\" {packageName}"; } string projectDir = Path.GetDirectoryName(Application.dataPath); @@ -747,10 +749,9 @@ private void Register() } catch { } - // Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy) - McpLog.Info("Removing any existing UnityMCP registrations before adding..."); - ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) + McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); // Now add the registration with the current transport mode if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) @@ -787,10 +788,9 @@ private void Unregister() pathPrepend = "/usr/local/bin:/usr/bin:/bin"; } - // Remove both "UnityMCP" and "unityMCP" (legacy naming) - McpLog.Info("Removing all UnityMCP registrations..."); - ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + // Remove from ALL scopes to ensure complete cleanup (#664) + McpLog.Info("Removing all UnityMCP registrations from all scopes..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); McpLog.Info("MCP server successfully unregistered from Claude Code."); client.SetStatus(McpStatus.NotConfigured); @@ -813,9 +813,11 @@ public override string GetManualSnippet() headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : ""; } return "# Register the MCP server with Claude Code:\n" + - $"claude mcp add --transport http UnityMCP {httpUrl}{headerArg}\n\n" + - "# Unregister the MCP server:\n" + - "claude mcp remove UnityMCP\n\n" + + $"claude mcp add --scope local --transport http UnityMCP {httpUrl}{headerArg}\n\n" + + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + + "claude mcp remove --scope local UnityMCP\n" + + "claude mcp remove --scope user UnityMCP\n" + + "claude mcp remove --scope project UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list"; } @@ -831,9 +833,11 @@ public override string GetManualSnippet() 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 \"{packageSource}\" mcp-for-unity\n\n" + - "# Unregister the MCP server:\n" + - "claude mcp remove UnityMCP\n\n" + + $"claude mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{packageSource}\" mcp-for-unity\n\n" + + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + + "claude mcp remove --scope local UnityMCP\n" + + "claude mcp remove --scope user UnityMCP\n" + + "claude mcp remove --scope project UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list"; } @@ -845,6 +849,28 @@ public override string GetManualSnippet() "Restart Claude Code" }; + /// + /// Removes UnityMCP registration from all Claude Code configuration scopes (local, user, project). + /// This ensures no stale or conflicting configurations remain across different scopes. + /// Also handles legacy "unityMCP" naming convention. + /// + private static void RemoveFromAllScopes(string claudePath, string projectDir, string pathPrepend) + { + // Remove from all three scopes to prevent stale configs causing connection issues. + // See GitHub issue #664 - conflicting configs at different scopes can cause + // Claude Code to connect with outdated/incorrect configuration. + string[] scopes = { "local", "user", "project" }; + string[] names = { "UnityMCP", "unityMCP" }; // Include legacy naming + + foreach (var scope in scopes) + { + foreach (var name in names) + { + ExecPath.TryRun(claudePath, $"mcp remove --scope {scope} {name}", projectDir, out _, out _, 5000, pathPrepend); + } + } + } + /// /// Sanitizes a value for safe inclusion inside a double-quoted shell argument. /// Escapes characters that are special within double quotes (", \, `, $, !) From 6d8cb318633d227ec66ee74f8d8d709f50308ec0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 09:48:22 -0800 Subject: [PATCH 3/8] fix: Make Claude Code status check thread-safe (#664) The background thread status check was accessing main-thread-only Unity APIs (Application.platform, EditorPrefs via HttpEndpointUtility and AssetPathUtility), causing "GetString can only be called from main thread" errors. Now all main-thread-only values are captured before Task.Run() and passed as parameters to CheckStatusWithProjectDir(). Co-Authored-By: Claude Opus 4.5 --- .../Clients/McpClientConfiguratorBase.cs | 23 ++++++++++++------- .../ClientConfig/McpClientConfigSection.cs | 5 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 4ab995fed..2a201995d 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -409,18 +409,25 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; // Resolve claudePath on the main thread (EditorPrefs access) string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); - return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite); + RuntimePlatform platform = Application.platform; + bool isRemoteScope = HttpEndpointUtility.IsRemoteScope(); + string expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource(); + return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite); } /// /// Internal thread-safe version of CheckStatus. /// Can be called from background threads because all main-thread-only values are passed as parameters. - /// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time. + /// projectDir, useHttpTransport, claudePath, platform, isRemoteScope, and expectedPackageSource are REQUIRED + /// (non-nullable where applicable) to enforce thread safety at compile time. /// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread. /// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration /// on the main thread based on the returned status. /// - internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false) + internal McpStatus CheckStatusWithProjectDir( + string projectDir, bool useHttpTransport, string claudePath, RuntimePlatform platform, + bool isRemoteScope, string expectedPackageSource, + bool attemptAutoRewrite = false) { try { @@ -438,11 +445,11 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran } string pathPrepend = null; - if (Application.platform == RuntimePlatform.OSXEditor) + if (platform == RuntimePlatform.OSXEditor) { pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; } - else if (Application.platform == RuntimePlatform.LinuxEditor) + else if (platform == RuntimePlatform.LinuxEditor) { pathPrepend = "/usr/local/bin:/usr/bin:/bin"; } @@ -485,7 +492,7 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran // so infer from the current scope setting when HTTP is detected. if (registeredWithHttp) { - client.configuredTransport = HttpEndpointUtility.IsRemoteScope() + client.configuredTransport = isRemoteScope ? Models.ConfiguredTransport.HttpRemote : Models.ConfiguredTransport.Http; } @@ -504,10 +511,9 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran // For stdio transport, also check package version bool hasVersionMismatch = false; string configuredPackageSource = null; - string expectedPackageSource = null; if (registeredWithStdio) { - expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource(); + // expectedPackageSource was captured on main thread and passed as parameter configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout); hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) && !string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase); @@ -566,6 +572,7 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran } catch (Exception ex) { + McpLog.Warn($"[Claude Code] CheckStatus exception: {ex.GetType().Name}: {ex.Message}"); client.SetStatus(McpStatus.Error, ex.Message); client.configuredTransport = Models.ConfiguredTransport.Unknown; } diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 0433a824a..91e2e6883 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -458,6 +458,9 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm string projectDir = Path.GetDirectoryName(Application.dataPath); bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); + RuntimePlatform platform = Application.platform; + bool isRemoteScope = HttpEndpointUtility.IsRemoteScope(); + string expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource(); Task.Run(() => { @@ -466,7 +469,7 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm if (client is ClaudeCliMcpConfigurator claudeConfigurator) { // Use thread-safe version with captured main-thread values - claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite: false); + claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite: false); } }).ContinueWith(t => { From a4fffb73b3245b433aec27b3b7bbcbf7088fb61c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 10:00:58 -0800 Subject: [PATCH 4/8] fix: Persist client dropdown selection and remove dead IO code - Remember last selected client in EditorPrefs so it restores on window reopen (prevents hiding config issues by defaulting to first client) - Remove dead Outbound class, _outbox BlockingCollection, and writer thread that was never used (nothing ever enqueued to outbox) - Keep only failure IO logs, remove verbose success logging Co-Authored-By: Claude Opus 4.5 --- .../Editor/Constants/EditorPrefKeys.cs | 1 + .../Transport/Transports/StdioBridgeHost.cs | 39 ------------------- .../ClientConfig/McpClientConfigSection.cs | 22 ++++++++++- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 488bf39d9..5f99d3e5a 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -46,6 +46,7 @@ internal static class EditorPrefKeys internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled."; internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout."; internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel"; + internal const string LastSelectedClientId = "MCPForUnity.LastSelectedClientId"; internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index ab3af5db7..5759bbad1 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -21,13 +20,6 @@ namespace MCPForUnity.Editor.Services.Transport.Transports { - class Outbound - { - public byte[] Payload; - public string Tag; - public int? ReqId; - } - class QueuedCommand { public string CommandJson; @@ -44,7 +36,6 @@ public static class StdioBridgeHost private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly HashSet activeClients = new(); - private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); private static CancellationTokenSource cts; private static Task listenerTask; private static int processingCommands = 0; @@ -61,7 +52,6 @@ public static class StdioBridgeHost private const ulong MaxFrameBytes = 64UL * 1024 * 1024; private const int FrameIOTimeoutMs = 30000; - private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } private static bool IsDebugEnabled() @@ -123,30 +113,6 @@ public static bool FolderExists(string path) static StdioBridgeHost() { try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } - try - { - var writerThread = new Thread(() => - { - foreach (var item in _outbox.GetConsumingEnumerable()) - { - try - { - long seq = Interlocked.Increment(ref _ioSeq); - IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - sw.Stop(); - IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); - } - } - }) - { IsBackground = true, Name = "MCP-Writer" }; - writerThread.Start(); - } - catch { } if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { @@ -633,12 +599,10 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken { try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } } - long seq = Interlocked.Increment(ref _ioSeq); byte[] responseBytes; try { responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); } catch (Exception ex) { @@ -646,12 +610,9 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken throw; } - var swDirect = System.Diagnostics.Stopwatch.StartNew(); try { await WriteFrameAsync(stream, responseBytes); - swDirect.Stop(); - IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); } catch (Exception ex) { diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 91e2e6883..0e4d5fee2 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -95,7 +95,22 @@ private void InitializeUI() clientDropdown.choices = clientNames; if (clientNames.Count > 0) { - clientDropdown.index = 0; + // Restore last selected client from EditorPrefs + string lastClientId = EditorPrefs.GetString(EditorPrefKeys.LastSelectedClientId, string.Empty); + int restoredIndex = 0; + if (!string.IsNullOrEmpty(lastClientId)) + { + for (int i = 0; i < configurators.Count; i++) + { + if (string.Equals(configurators[i].Id, lastClientId, StringComparison.OrdinalIgnoreCase)) + { + restoredIndex = i; + break; + } + } + } + clientDropdown.index = restoredIndex; + selectedClientIndex = restoredIndex; } claudeCliPathRow.style.display = DisplayStyle.None; @@ -111,6 +126,11 @@ private void RegisterCallbacks() clientDropdown.RegisterValueChangedCallback(evt => { selectedClientIndex = clientDropdown.index; + // Persist the selected client so it's restored on next window open + if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) + { + EditorPrefs.SetString(EditorPrefKeys.LastSelectedClientId, configurators[selectedClientIndex].Id); + } UpdateClientStatus(); UpdateManualConfiguration(); UpdateClaudeCliPathVisibility(); From 3a4e90f624bf458a27944c51bbb37ab44219f2c3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 11:27:43 -0800 Subject: [PATCH 5/8] feat: Auto-detect beta package to enable UseBetaServer + workflow updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IsPreReleaseVersion() helper to detect beta/alpha/rc package versions - UseBetaServer now defaults to true only for prerelease package versions - Main branch users get false default, beta branch users get true default - Update beta-release.yml to set Unity package version with -beta.1 suffix - Update release.yml to merge beta → main and strip beta suffix - Fix CodexConfigHelperTests to explicitly set UseBetaServer for determinism - Use EditorConfigurationCache consistently for UseBetaServer access Co-Authored-By: Claude Opus 4.5 --- .github/workflows/beta-release.yml | 78 +++++++++++++++++++ .github/workflows/release.yml | 39 ++++++++++ .../Editor/Helpers/AssetPathUtility.cs | 30 ++++++- .../Services/EditorConfigurationCache.cs | 21 ++++- .../Components/Advanced/McpAdvancedSection.cs | 4 +- .../Editor/Windows/MCPForUnityEditorWindow.cs | 2 +- .../Helpers/CodexConfigHelperTests.cs | 17 ++++ 7 files changed, 184 insertions(+), 7 deletions(-) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index b316c3194..0616b2309 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -10,10 +10,87 @@ on: - beta paths: - "Server/**" + - "MCPForUnity/**" jobs: + update_unity_beta_version: + name: Update Unity package to beta version + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + unity_beta_version: ${{ steps.version.outputs.unity_beta_version }} + version_updated: ${{ steps.commit.outputs.updated }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: beta + + - name: Generate beta version for Unity package + id: version + shell: bash + run: | + set -euo pipefail + # Read current Unity package version + CURRENT_VERSION=$(jq -r '.version' MCPForUnity/package.json) + echo "Current Unity package version: $CURRENT_VERSION" + + # Strip any existing pre-release suffix for safe parsing + # e.g., "9.3.1-beta.1" -> "9.3.1" + BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + + # Check if already a beta version + if [[ "$CURRENT_VERSION" == *"-beta."* ]]; then + echo "Already a beta version, keeping: $CURRENT_VERSION" + echo "unity_beta_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "already_beta=true" >> "$GITHUB_OUTPUT" + else + # Create beta version with semver format (e.g., 9.4.0-beta.1) + # Bump minor version to ensure beta is "newer" than stable + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" + NEXT_MINOR=$((MINOR + 1)) + BETA_VERSION="${MAJOR}.${NEXT_MINOR}.0-beta.1" + echo "Generated Unity beta version: $BETA_VERSION" + echo "unity_beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT" + echo "already_beta=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update Unity package.json with beta version + if: steps.version.outputs.already_beta != 'true' + env: + BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }} + shell: bash + run: | + set -euo pipefail + # Update package.json version + jq --arg v "$BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json + mv tmp.json MCPForUnity/package.json + echo "Updated MCPForUnity/package.json:" + jq '.version' MCPForUnity/package.json + + - name: Commit and push beta version + id: commit + if: steps.version.outputs.already_beta != 'true' + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + if git diff --quiet MCPForUnity/package.json; then + echo "No changes to commit" + echo "updated=false" >> "$GITHUB_OUTPUT" + else + git add MCPForUnity/package.json + git commit -m "chore: update Unity package to beta version ${{ steps.version.outputs.unity_beta_version }}" + git push origin beta + echo "updated=true" >> "$GITHUB_OUTPUT" + fi + publish_pypi_prerelease: name: Publish beta to PyPI (pre-release) + needs: update_unity_beta_version runs-on: ubuntu-latest environment: name: pypi @@ -25,6 +102,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + ref: beta - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 198652e77..913dc6505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,45 @@ jobs: ref: main fetch-depth: 0 + - name: Merge beta into main + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Fetch beta branch + git fetch origin beta + + # Check if beta has changes not in main + if git merge-base --is-ancestor origin/beta HEAD; then + echo "beta is already merged into main. Nothing to merge." + else + echo "Merging beta into main..." + git merge origin/beta --no-edit -m "chore: merge beta into main for release" + echo "Beta merged successfully." + fi + + - name: Strip beta suffix from version if present + shell: bash + run: | + set -euo pipefail + CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") + echo "Current version: $CURRENT_VERSION" + + # Strip beta/alpha/rc suffix if present (e.g., "9.4.0-beta.1" -> "9.4.0") + if [[ "$CURRENT_VERSION" == *"-"* ]]; then + STABLE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + echo "Stripping prerelease suffix: $CURRENT_VERSION -> $STABLE_VERSION" + jq --arg v "$STABLE_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json + mv tmp.json MCPForUnity/package.json + + # Also update pyproject.toml + sed -i "s/^version = .*/version = \"${STABLE_VERSION}\"/" Server/pyproject.toml + else + echo "Version is already stable: $CURRENT_VERSION" + fi + - name: Compute new version id: compute shell: bash diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 69c017ef2..bc4e60f44 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -262,7 +262,7 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) } // Beta server mode: use prerelease from PyPI - bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; if (useBetaServer) { // Use --prerelease explicit with version specifier to only get prereleases of our package, @@ -300,7 +300,7 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList } // Beta server mode: use prerelease from PyPI - bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; if (useBetaServer) { args.Add("--prerelease"); @@ -426,5 +426,31 @@ public static string GetPackageVersion() return "unknown"; } } + + /// + /// Returns true if the installed package version is a prerelease (beta, alpha, rc, etc.). + /// Used to auto-enable beta server mode for beta package users. + /// + public static bool IsPreReleaseVersion() + { + try + { + string version = GetPackageVersion(); + if (string.IsNullOrEmpty(version) || version == "unknown") + return false; + + // Check for common prerelease indicators in semver format + // e.g., "9.3.0-beta.1", "9.3.0-alpha", "9.3.0-rc.2", "9.3.0-preview" + return version.Contains("-beta", StringComparison.OrdinalIgnoreCase) || + version.Contains("-alpha", StringComparison.OrdinalIgnoreCase) || + version.Contains("-rc", StringComparison.OrdinalIgnoreCase) || + version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || + version.Contains("-pre", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } } } diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs index 40dedd58a..3f8f04a9f 100644 --- a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -124,6 +124,23 @@ public static EditorConfigurationCache Instance /// public int UnitySocketPort => _unitySocketPort; + /// + /// Gets UseBetaServer value with dynamic default based on package version. + /// If the pref hasn't been explicitly set, defaults to true for prerelease packages + /// (beta, alpha, rc, etc.) and false for stable releases. + /// + private static bool GetUseBetaServerWithDynamicDefault() + { + // If user has explicitly set the pref, use that value + if (EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer)) + { + return EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, false); + } + + // Otherwise, default based on whether this is a prerelease package + return Helpers.AssetPathUtility.IsPreReleaseVersion(); + } + private EditorConfigurationCache() { Refresh(); @@ -137,7 +154,7 @@ public void Refresh() { _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); - _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + _useBetaServer = GetUseBetaServerWithDynamicDefault(); _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); @@ -312,7 +329,7 @@ public void InvalidateKey(string keyName) _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); break; case nameof(UseBetaServer): - _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + _useBetaServer = GetUseBetaServerWithDynamicDefault(); break; case nameof(DevModeForceServerRefresh): _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 8898fcf23..0fe1bab39 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -138,7 +138,7 @@ private void InitializeUI() McpLog.SetDebugLoggingEnabled(debugEnabled); devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); - useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + useBetaServerToggle.value = EditorConfigurationCache.Instance.UseBetaServer; UpdatePathOverrides(); UpdateDeploymentSection(); } @@ -292,7 +292,7 @@ public void UpdatePathOverrides() gitUrlOverride.value = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); debugLogsToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); - useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + useBetaServerToggle.value = EditorConfigurationCache.Instance.UseBetaServer; UpdateDeploymentSection(); } diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index a8f38fb50..a626a5278 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -196,7 +196,7 @@ public void CreateGUI() } // Initialize version label - UpdateVersionLabel(EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true)); + UpdateVersionLabel(EditorConfigurationCache.Instance.UseBetaServer); SetupTabs(); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs index d5de50a30..5e5bf3367 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs @@ -34,6 +34,8 @@ public MockPlatformService(bool isWindows, string systemRoot = "C:\\Windows") private bool _originalHttpTransport; private bool _hadDevForceRefresh; private bool _originalDevForceRefresh; + private bool _hadUseBetaServer; + private bool _originalUseBetaServer; private IPlatformService _originalPlatformService; [OneTimeSetUp] @@ -45,6 +47,8 @@ public void OneTimeSetUp() _originalHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); _hadDevForceRefresh = EditorPrefs.HasKey(EditorPrefKeys.DevModeForceServerRefresh); _originalDevForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + _hadUseBetaServer = EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer); + _originalUseBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); _originalPlatformService = MCPServiceLocator.Platform; } @@ -58,6 +62,10 @@ public void SetUp() // Ensure deterministic uvx args ordering for these tests regardless of editor settings // (dev-mode inserts --no-cache/--refresh, which changes the first args). EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + // Tests expect beta server mode (--prerelease explicit --from mcpforunityserver>=0.0.0a0) + EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, true); + // Refresh the cache so it picks up the test's pref values + EditorConfigurationCache.Instance.Refresh(); } [TearDown] @@ -108,6 +116,15 @@ public void OneTimeTearDown() { EditorPrefs.DeleteKey(EditorPrefKeys.DevModeForceServerRefresh); } + + if (_hadUseBetaServer) + { + EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, _originalUseBetaServer); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.UseBetaServer); + } } [Test] From 934ebd217c0647906b636b576d983f7aa8455ff4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 14:49:11 -0800 Subject: [PATCH 6/8] fix: Address code review feedback for thread-safety and validation - Add thread-safe overloads for GetBetaServerFromArgs/List that accept pre-captured useBetaServer and gitUrlOverride parameters - Use EditorConfigurationCache.SetUseBetaServer() in McpAdvancedSection for atomic cache + EditorPrefs update - Add semver validation guard in beta-release.yml before version arithmetic - Add semver validation guard in release.yml after stripping prerelease suffix Co-Authored-By: Claude Opus 4.5 --- .github/workflows/beta-release.yml | 6 +++ .github/workflows/release.yml | 5 ++ .../Editor/Helpers/AssetPathUtility.cs | 49 +++++++++++++++---- .../Components/Advanced/McpAdvancedSection.cs | 2 +- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 0616b2309..b149296db 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -40,6 +40,12 @@ jobs: # e.g., "9.3.1-beta.1" -> "9.3.1" BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + # Validate we have a proper X.Y.Z format before arithmetic + if ! [[ "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Could not parse version '$CURRENT_VERSION' -> '$BASE_VERSION'" >&2 + exit 1 + fi + # Check if already a beta version if [[ "$CURRENT_VERSION" == *"-beta."* ]]; then echo "Already a beta version, keeping: $CURRENT_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 913dc6505..a0b92a1e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,11 @@ jobs: # Strip beta/alpha/rc suffix if present (e.g., "9.4.0-beta.1" -> "9.4.0") if [[ "$CURRENT_VERSION" == *"-"* ]]; then STABLE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + # Validate we have a proper X.Y.Z format after stripping + if ! [[ "$STABLE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Could not parse version '$CURRENT_VERSION' -> '$STABLE_VERSION'" >&2 + exit 1 + fi echo "Stripping prerelease suffix: $CURRENT_VERSION -> $STABLE_VERSION" jq --arg v "$STABLE_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json mv tmp.json MCPForUnity/package.json diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index bc4e60f44..8527a79de 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -248,21 +248,35 @@ public static (string uvxPath, string fromUrl, string packageName) GetUvxCommand /// Handles beta server mode (prerelease from PyPI) vs standard mode (pinned version or override). /// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports. /// Priority: explicit fromUrl override > beta server mode > default package. + /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. + /// For background threads, use the overload that accepts useBetaServer and gitUrlOverride parameters. /// /// Whether to quote the --from path (needed for command-line strings, not for arg lists) /// The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0") public static string GetBetaServerFromArgs(bool quoteFromPath = false) + { + // Read values from cache/EditorPrefs on main thread + bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + return GetBetaServerFromArgs(useBetaServer, gitUrlOverride, quoteFromPath); + } + + /// + /// Thread-safe overload that accepts pre-captured values. + /// Use this when calling from background threads. + /// + /// Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer + /// Pre-captured value from EditorPrefs GitUrlOverride + /// Whether to quote the --from path + public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOverride, bool quoteFromPath = false) { // Explicit override (local path, git URL, etc.) always wins - string fromUrl = GetMcpServerPackageSource(); - string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); - if (!string.IsNullOrEmpty(overrideUrl)) + if (!string.IsNullOrEmpty(gitUrlOverride)) { - return $"--from {fromUrl}"; + return $"--from {gitUrlOverride}"; } // Beta server mode: use prerelease from PyPI - bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; if (useBetaServer) { // Use --prerelease explicit with version specifier to only get prereleases of our package, @@ -272,6 +286,7 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) } // Standard mode: use pinned version from package.json + string fromUrl = GetMcpServerPackageSource(); if (!string.IsNullOrEmpty(fromUrl)) { return $"--from {fromUrl}"; @@ -283,24 +298,37 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) /// /// Builds the uvx package source arguments as a list (for JSON config builders). /// Priority: explicit fromUrl override > beta server mode > default package. + /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. + /// For background threads, use the overload that accepts useBetaServer and gitUrlOverride parameters. /// /// List of arguments to add to uvx command public static System.Collections.Generic.IList GetBetaServerFromArgsList() + { + // Read values from cache/EditorPrefs on main thread + bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + return GetBetaServerFromArgsList(useBetaServer, gitUrlOverride); + } + + /// + /// Thread-safe overload that accepts pre-captured values. + /// Use this when calling from background threads. + /// + /// Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer + /// Pre-captured value from EditorPrefs GitUrlOverride + public static System.Collections.Generic.IList GetBetaServerFromArgsList(bool useBetaServer, string gitUrlOverride) { var args = new System.Collections.Generic.List(); // Explicit override (local path, git URL, etc.) always wins - string fromUrl = GetMcpServerPackageSource(); - string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); - if (!string.IsNullOrEmpty(overrideUrl)) + if (!string.IsNullOrEmpty(gitUrlOverride)) { args.Add("--from"); - args.Add(fromUrl); + args.Add(gitUrlOverride); return args; } // Beta server mode: use prerelease from PyPI - bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; if (useBetaServer) { args.Add("--prerelease"); @@ -311,6 +339,7 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList } // Standard mode: use pinned version from package.json + string fromUrl = GetMcpServerPackageSource(); if (!string.IsNullOrEmpty(fromUrl)) { args.Add("--from"); diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 0fe1bab39..4957e7da8 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -185,7 +185,7 @@ private void RegisterCallbacks() useBetaServerToggle.RegisterValueChangedCallback(evt => { - EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, evt.newValue); + EditorConfigurationCache.Instance.SetUseBetaServer(evt.newValue); OnHttpServerCommandUpdateRequested?.Invoke(); OnBetaModeChanged?.Invoke(evt.newValue); }); From 86f1e8b87916c0f9500bc699f4e0df6d9450a2af Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 15:00:27 -0800 Subject: [PATCH 7/8] fix: Complete thread-safety for GetBetaServerFromArgs overloads - Add packageSource parameter to thread-safe overloads to avoid calling GetMcpServerPackageSource() (which uses EditorPrefs) from background threads - Apply quoteFromPath logic to gitUrlOverride and packageSource paths to handle local paths with spaces correctly Co-Authored-By: Claude Opus 4.5 --- .../Editor/Helpers/AssetPathUtility.cs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 8527a79de..fc450bada 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -249,7 +249,7 @@ public static (string uvxPath, string fromUrl, string packageName) GetUvxCommand /// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports. /// Priority: explicit fromUrl override > beta server mode > default package. /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. - /// For background threads, use the overload that accepts useBetaServer and gitUrlOverride parameters. + /// For background threads, use the overload that accepts pre-captured parameters. /// /// Whether to quote the --from path (needed for command-line strings, not for arg lists) /// The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0") @@ -258,7 +258,8 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) // Read values from cache/EditorPrefs on main thread bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); - return GetBetaServerFromArgs(useBetaServer, gitUrlOverride, quoteFromPath); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgs(useBetaServer, gitUrlOverride, packageSource, quoteFromPath); } /// @@ -267,13 +268,15 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) /// /// Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer /// Pre-captured value from EditorPrefs GitUrlOverride + /// Pre-captured value from GetMcpServerPackageSource() /// Whether to quote the --from path - public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOverride, bool quoteFromPath = false) + public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOverride, string packageSource, bool quoteFromPath = false) { // Explicit override (local path, git URL, etc.) always wins if (!string.IsNullOrEmpty(gitUrlOverride)) { - return $"--from {gitUrlOverride}"; + string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride; + return $"--from {fromValue}"; } // Beta server mode: use prerelease from PyPI @@ -286,10 +289,10 @@ public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOver } // Standard mode: use pinned version from package.json - string fromUrl = GetMcpServerPackageSource(); - if (!string.IsNullOrEmpty(fromUrl)) + if (!string.IsNullOrEmpty(packageSource)) { - return $"--from {fromUrl}"; + string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource; + return $"--from {fromValue}"; } return string.Empty; @@ -299,7 +302,7 @@ public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOver /// Builds the uvx package source arguments as a list (for JSON config builders). /// Priority: explicit fromUrl override > beta server mode > default package. /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. - /// For background threads, use the overload that accepts useBetaServer and gitUrlOverride parameters. + /// For background threads, use the overload that accepts pre-captured parameters. /// /// List of arguments to add to uvx command public static System.Collections.Generic.IList GetBetaServerFromArgsList() @@ -307,7 +310,8 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList // Read values from cache/EditorPrefs on main thread bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer; string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); - return GetBetaServerFromArgsList(useBetaServer, gitUrlOverride); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgsList(useBetaServer, gitUrlOverride, packageSource); } /// @@ -316,7 +320,8 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList /// /// Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer /// Pre-captured value from EditorPrefs GitUrlOverride - public static System.Collections.Generic.IList GetBetaServerFromArgsList(bool useBetaServer, string gitUrlOverride) + /// Pre-captured value from GetMcpServerPackageSource() + public static System.Collections.Generic.IList GetBetaServerFromArgsList(bool useBetaServer, string gitUrlOverride, string packageSource) { var args = new System.Collections.Generic.List(); @@ -339,11 +344,10 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList } // Standard mode: use pinned version from package.json - string fromUrl = GetMcpServerPackageSource(); - if (!string.IsNullOrEmpty(fromUrl)) + if (!string.IsNullOrEmpty(packageSource)) { args.Add("--from"); - args.Add(fromUrl); + args.Add(packageSource); } return args; From 14715dc3052bf826df39a3393091267c68a19c11 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 2 Feb 2026 15:13:27 -0800 Subject: [PATCH 8/8] fix: Patch legacy connection pool in transport tests to prevent real Unity discovery The auto-select tests were failing because they only patched PluginHub but not the fallback legacy connection pool discovery. When PluginHub returns no results, the middleware falls back to discovering instances via get_unity_connection_pool(), which found the real running Unity. Co-Authored-By: Claude Opus 4.5 --- .../tests/test_transport_characterization.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Server/tests/test_transport_characterization.py b/Server/tests/test_transport_characterization.py index 5b9fd8f54..a0b617934 100644 --- a/Server/tests/test_transport_characterization.py +++ b/Server/tests/test_transport_characterization.py @@ -256,9 +256,10 @@ async def test_middleware_does_not_inject_when_no_instance(self, mock_context): async def mock_call_next(_ctx): return {"status": "ok"} - # Mock PluginHub as unavailable - this is sufficient for auto-select to fail + # Mock PluginHub as unavailable AND legacy connection pool to prevent fallback discovery with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=False): - await middleware.on_call_tool(middleware_ctx, mock_call_next) + with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None): + await middleware.on_call_tool(middleware_ctx, mock_call_next) # set_state should not be called for unity_instance if no instance found calls = [c for c in mock_context.set_state.call_args_list @@ -329,9 +330,10 @@ async def test_autoselect_fails_with_multiple_instances(self, mock_context): with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True): with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get: - mock_get.return_value = fake_sessions + with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None): + mock_get.return_value = fake_sessions - instance = await middleware._maybe_autoselect_instance(mock_context) + instance = await middleware._maybe_autoselect_instance(mock_context) assert instance is None @@ -345,10 +347,11 @@ async def test_autoselect_handles_plugin_hub_connection_error(self, mock_context with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True): with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get: - mock_get.side_effect = ConnectionError("Plugin hub unavailable") + with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None): + mock_get.side_effect = ConnectionError("Plugin hub unavailable") - # When PluginHub fails, auto-select returns None (graceful fallback) - instance = await middleware._maybe_autoselect_instance(mock_context) + # When PluginHub fails, auto-select returns None (graceful fallback) + instance = await middleware._maybe_autoselect_instance(mock_context) # Should return None since both PluginHub failed assert instance is None @@ -916,10 +919,11 @@ async def test_middleware_handles_exception_during_autoselect(self, mock_context with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True): with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get: - mock_get.side_effect = RuntimeError("Unexpected error") + with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None): + mock_get.side_effect = RuntimeError("Unexpected error") - # Should not raise, just return None - instance = await middleware._maybe_autoselect_instance(mock_context) + # Should not raise, just return None + instance = await middleware._maybe_autoselect_instance(mock_context) assert instance is None