diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index b316c3194..b149296db 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -10,10 +10,93 @@ 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]+$//') + + # 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" + 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 +108,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..a0b92a1e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,50 @@ 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]+$//') + # 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 + + # 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/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 0d69cafbc..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; } @@ -627,27 +634,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 +678,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 +703,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 +728,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 +756,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 +795,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 +820,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 +840,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 +856,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 (", \, `, $, !) 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/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 69c017ef2..fc450bada 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -248,21 +248,38 @@ 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 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") 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, ""); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgs(useBetaServer, gitUrlOverride, packageSource, 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 + /// Pre-captured value from GetMcpServerPackageSource() + /// Whether to quote the --from path + public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOverride, string packageSource, 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}"; + string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride; + return $"--from {fromValue}"; } // Beta server mode: use prerelease from PyPI - bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); if (useBetaServer) { // Use --prerelease explicit with version specifier to only get prereleases of our package, @@ -272,9 +289,10 @@ public static string GetBetaServerFromArgs(bool quoteFromPath = false) } // Standard mode: use pinned version from package.json - if (!string.IsNullOrEmpty(fromUrl)) + if (!string.IsNullOrEmpty(packageSource)) { - return $"--from {fromUrl}"; + string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource; + return $"--from {fromValue}"; } return string.Empty; @@ -283,24 +301,39 @@ 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 pre-captured 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, ""); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgsList(useBetaServer, gitUrlOverride, packageSource); + } + + /// + /// 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 + /// 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(); // 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 = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); if (useBetaServer) { args.Add("--prerelease"); @@ -311,10 +344,10 @@ public static System.Collections.Generic.IList GetBetaServerFromArgsList } // Standard mode: use pinned version from package.json - if (!string.IsNullOrEmpty(fromUrl)) + if (!string.IsNullOrEmpty(packageSource)) { args.Add("--from"); - args.Add(fromUrl); + args.Add(packageSource); } return args; @@ -426,5 +459,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/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/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 8898fcf23..4957e7da8 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(); } @@ -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); }); @@ -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/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 0433a824a..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(); @@ -458,6 +478,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 +489,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 => { 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/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: 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 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]