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]