fix: comprehensive performance optimizations, claude code config, and stability improvements (issue #577) (#595)
* fix: reduce per-frame GC allocations causing editor hitches (issue #577) Eliminate memory allocations that occurred every frame, which triggered garbage collection spikes (~28ms) approximately every second. Changes: - EditorStateCache: Skip BuildSnapshot() entirely when state unchanged (check BEFORE building). Increased poll interval from 0.25s to 1.0s. Cache DeepClone() results to avoid allocations on GetSnapshot(). - TransportCommandDispatcher: Early exit before lock/list allocation when Pending.Count == 0, eliminating per-frame allocations when idle. - StdioBridgeHost: Same early exit pattern for commandQueue. - MCPForUnityEditorWindow: Throttle OnEditorUpdate to 2-second intervals instead of every frame, preventing expensive socket checks 60+/sec. Fixes GitHub issue #577: High performance impact even when MCP server is off * fix: prevent multiple domain reloads when calling refresh_unity (issue #577) Root Cause: - send_command() had a hardcoded retry loop (min 6 attempts) on connection errors - Each retry resent the refresh_unity command, causing Unity to reload 6 times - retry_on_reload=False only controlled reload-state retries, not connection retries The Fix: 1. Unity C# (MCPForUnity/Editor): - Added --reinstall flag to uvx commands in dev mode - Ensures local development changes are picked up by uvx/Claude Code - Applies to all client configurators (Claude Code, Codex, etc.) 2. Python Server (Server/src): - Added max_attempts parameter to send_command() - Pass max_attempts=0 when retry_on_reload=False - Fixed type handling in refresh_unity.py (handle MCPResponse objects) - Added timeout to connection error recovery conditions - Recovery logic now returns success instead of error to prevent client retries Changes: - MCPForUnity/Editor: Added --reinstall to dev mode uvx commands - Server/refresh_unity.py: Fixed type handling, improved error recovery - Server/unity_connection.py: Added max_attempts param, disable retries when retry_on_reload=False Result: refresh_unity with compile=request now triggers only 1 domain reload instead of 6 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: UI and server stability improvements Unity Editor (C#): - Fix "Resuming..." stuck state when manually clicking End Session Clear ResumeStdioAfterReload and ResumeHttpAfterReload flags in OnConnectionToggleClicked and EndOrphanedSessionAsync to prevent UI from getting stuck showing "Resuming..." with disabled button - Remove unsupported --reinstall flag from all uvx command builders uvx does not support --reinstall and shows warning when used Use --no-cache --refresh instead for dev mode cache busting Python Server: - Add "aborted" to connection error patterns in refresh_unity Handle WinError 10053 (connection aborted) gracefully during Unity domain reload, treating it as expected behavior - Add WindowsSafeRotatingFileHandler to suppress log rotation errors Windows file locking prevents log rotation when file is open by another process; catch PermissionError to avoid noisy stack traces - Fix packaging: add py-modules = ["main"] to pyproject.toml setuptools.packages.find only discovers packages (directories with __init__.py), must explicitly list standalone module files Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: improve refresh_unity connection loss handling documentation Add detailed comments and logging to clarify why connection loss during compile is treated as success (expected domain reload behavior, not failure). This addresses PR feedback about potentially masking real connection errors. The logic is intentional and correct: - Connection loss only treated as success when compile='request' - Domain reload causing disconnect is expected Unity behavior - Subsequent wait_for_ready loop validates Unity becomes ready - Prevents multiple domain reload loops (issue #577) Added logging for observability: - Info log when expected disconnect detected - Warning log for non-recoverable errors Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add missing logger import in refresh_unity Missing logger import causes NameError at runtime when connection loss handling paths are triggered (lines 82 and 91). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address code review feedback on thread safety and semantics Addresses four issues raised in code review: 1. EditorStateCache.GetSnapshot() - Remove shared cached clone - Revert to always returning fresh DeepClone() to prevent mutation bugs - Main GC optimization remains: state-change detection prevents unnecessary _cached rebuilds (the expensive operation) - Removed _cachedClone and _cachedCloneSequence fields 2. refresh_unity.py - Fix blocking reason terminology mismatch - Changed "asset_refresh" to "asset_import" to match activityPhase values from EditorStateCache.cs - Ensures asset import is correctly detected as blocking state 3. TransportCommandDispatcher - Fix unsynchronized Count access - Moved Pending.Count check inside PendingLock - Prevents data races and InvalidOperationException from concurrent dictionary access 4. StdioBridgeHost - Fix unsynchronized Count access - Moved commandQueue.Count check inside lockObj - Ensures all collection access is properly serialized All changes maintain the GC allocation optimizations while fixing thread safety violations and semantic contract changes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address code review feedback on thread safety and timeout handling - refresh_unity.py: Track readiness explicitly and return failure on timeout instead of silently returning success when wait loop exits without confirming ready_for_tools=true - McpClientConfiguratorBase.cs: Add thread safety guard for Configure() call in CheckStatusWithProjectDir(). Changed default attemptAutoRewrite to false and added runtime check to prevent calling Configure() from background threads. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>main
parent
cd10472bc6
commit
8eb684ddc2
|
|
@ -1,30 +1,27 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using MCPForUnity.Editor.Models;
|
using MCPForUnity.Editor.Models;
|
||||||
|
|
||||||
namespace MCPForUnity.Editor.Clients.Configurators
|
namespace MCPForUnity.Editor.Clients.Configurators
|
||||||
{
|
{
|
||||||
public class ClaudeCodeConfigurator : JsonFileMcpConfigurator
|
/// <summary>
|
||||||
|
/// Claude Code configurator using the CLI-based registration (claude mcp add/remove).
|
||||||
|
/// This integrates with Claude Code's native MCP management.
|
||||||
|
/// </summary>
|
||||||
|
public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator
|
||||||
{
|
{
|
||||||
public ClaudeCodeConfigurator() : base(new McpClient
|
public ClaudeCodeConfigurator() : base(new McpClient
|
||||||
{
|
{
|
||||||
name = "Claude Code",
|
name = "Claude Code",
|
||||||
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
|
|
||||||
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
|
|
||||||
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
|
|
||||||
SupportsHttpTransport = true,
|
SupportsHttpTransport = true,
|
||||||
HttpUrlProperty = "url", // Claude Code uses "url" for HTTP servers
|
|
||||||
IsVsCodeLayout = false, // Claude Code uses standard mcpServers layout
|
|
||||||
})
|
})
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
public override IList<string> GetInstallationSteps() => new List<string>
|
public override IList<string> GetInstallationSteps() => new List<string>
|
||||||
{
|
{
|
||||||
"Open your project in Claude Code",
|
"Ensure Claude CLI is installed (comes with Claude Code)",
|
||||||
"Click Configure in MCP for Unity (or manually edit ~/.claude.json)",
|
"Click Register to add UnityMCP via 'claude mcp add'",
|
||||||
"The MCP server will be added to the global mcpServers section",
|
"The server will be automatically available in Claude Code",
|
||||||
"Restart Claude Code to apply changes"
|
"Use Unregister to remove via 'claude mcp remove'"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,8 +352,11 @@ namespace MCPForUnity.Editor.Clients
|
||||||
/// Internal thread-safe version of CheckStatus.
|
/// Internal thread-safe version of CheckStatus.
|
||||||
/// Can be called from background threads because all main-thread-only values are passed as parameters.
|
/// 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, and claudePath are REQUIRED (non-nullable) 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = true)
|
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -391,7 +394,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
// Check if UnityMCP exists
|
// Check if UnityMCP exists (handles both "UnityMCP" and legacy "unityMCP")
|
||||||
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
|
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
|
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
|
@ -401,7 +404,11 @@ namespace MCPForUnity.Editor.Clients
|
||||||
bool currentUseHttp = useHttpTransport;
|
bool currentUseHttp = useHttpTransport;
|
||||||
|
|
||||||
// Get detailed info about the registration to check transport type
|
// Get detailed info about the registration to check transport type
|
||||||
if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
|
// Try both "UnityMCP" and "unityMCP" (legacy naming)
|
||||||
|
string getStdout = null, getStderr = null;
|
||||||
|
bool gotInfo = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend)
|
||||||
|
|| ExecPath.TryRun(claudePath, "mcp get unityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend);
|
||||||
|
if (gotInfo)
|
||||||
{
|
{
|
||||||
// Parse the output to determine registered transport mode
|
// Parse the output to determine registered transport mode
|
||||||
// The CLI output format contains "Type: http" or "Type: stdio"
|
// The CLI output format contains "Type: http" or "Type: stdio"
|
||||||
|
|
@ -409,14 +416,60 @@ namespace MCPForUnity.Editor.Clients
|
||||||
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
|
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Check for transport mismatch
|
// Check for transport mismatch
|
||||||
if ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp))
|
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
|
||||||
|
|
||||||
|
// For stdio transport, also check package version
|
||||||
|
bool hasVersionMismatch = false;
|
||||||
|
string configuredPackageSource = null;
|
||||||
|
string expectedPackageSource = null;
|
||||||
|
if (registeredWithStdio)
|
||||||
{
|
{
|
||||||
string registeredTransport = registeredWithHttp ? "HTTP" : "stdio";
|
expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource();
|
||||||
string currentTransport = currentUseHttp ? "HTTP" : "stdio";
|
configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout);
|
||||||
string errorMsg = $"Transport mismatch: Claude Code is registered with {registeredTransport} but current setting is {currentTransport}. Click Configure to re-register.";
|
hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) &&
|
||||||
client.SetStatus(McpStatus.Error, errorMsg);
|
!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase);
|
||||||
McpLog.Warn(errorMsg);
|
}
|
||||||
return client.status;
|
|
||||||
|
// If there's any mismatch and auto-rewrite is enabled, re-register
|
||||||
|
if (hasTransportMismatch || hasVersionMismatch)
|
||||||
|
{
|
||||||
|
// Configure() requires main thread (accesses EditorPrefs, Application.dataPath)
|
||||||
|
// Only attempt auto-rewrite if we're on the main thread
|
||||||
|
bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1;
|
||||||
|
if (attemptAutoRewrite && isMainThread)
|
||||||
|
{
|
||||||
|
string reason = hasTransportMismatch
|
||||||
|
? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})"
|
||||||
|
: $"Package version mismatch (registered: {configuredPackageSource}, expected: {expectedPackageSource})";
|
||||||
|
McpLog.Info($"{reason}. Re-registering...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Force re-register by ensuring status is not Configured (which would toggle to Unregister)
|
||||||
|
client.SetStatus(McpStatus.IncorrectPath);
|
||||||
|
Configure();
|
||||||
|
return client.status;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
McpLog.Warn($"Auto-reregister failed: {ex.Message}");
|
||||||
|
client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register.");
|
||||||
|
return client.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (hasTransportMismatch)
|
||||||
|
{
|
||||||
|
string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register.";
|
||||||
|
client.SetStatus(McpStatus.Error, errorMsg);
|
||||||
|
McpLog.Warn(errorMsg);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
client.SetStatus(McpStatus.IncorrectPath, $"Package version mismatch: registered with '{configuredPackageSource}' but current version is '{expectedPackageSource}'.");
|
||||||
|
}
|
||||||
|
return client.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,6 +500,85 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe version of Configure that uses pre-captured main-thread values.
|
||||||
|
/// All parameters must be captured on the main thread before calling this method.
|
||||||
|
/// </summary>
|
||||||
|
public void ConfigureWithCapturedValues(
|
||||||
|
string projectDir, string claudePath, string pathPrepend,
|
||||||
|
bool useHttpTransport, string httpUrl,
|
||||||
|
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh)
|
||||||
|
{
|
||||||
|
if (client.status == McpStatus.Configured)
|
||||||
|
{
|
||||||
|
UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RegisterWithCapturedValues(projectDir, claudePath, pathPrepend,
|
||||||
|
useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe registration using pre-captured values.
|
||||||
|
/// </summary>
|
||||||
|
private void RegisterWithCapturedValues(
|
||||||
|
string projectDir, string claudePath, string pathPrepend,
|
||||||
|
bool useHttpTransport, string httpUrl,
|
||||||
|
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(claudePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string args;
|
||||||
|
if (useHttpTransport)
|
||||||
|
{
|
||||||
|
args = $"mcp add --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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Now add the registration
|
||||||
|
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");
|
||||||
|
client.SetStatus(McpStatus.Configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe unregistration using pre-captured values.
|
||||||
|
/// </summary>
|
||||||
|
private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(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);
|
||||||
|
|
||||||
|
McpLog.Info("MCP server successfully unregistered from Claude Code.");
|
||||||
|
client.SetStatus(McpStatus.NotConfigured);
|
||||||
|
}
|
||||||
|
|
||||||
private void Register()
|
private void Register()
|
||||||
{
|
{
|
||||||
var pathService = MCPServiceLocator.Paths;
|
var pathService = MCPServiceLocator.Paths;
|
||||||
|
|
@ -468,6 +600,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
{
|
{
|
||||||
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
|
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// 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;
|
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
||||||
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
|
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
|
||||||
}
|
}
|
||||||
|
|
@ -496,17 +629,10 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
// Check if UnityMCP already exists and remove it first to ensure clean registration
|
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
|
||||||
// This ensures we always use the current transport mode setting
|
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
|
||||||
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
||||||
if (serverExists)
|
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
||||||
{
|
|
||||||
McpLog.Info("Existing UnityMCP registration found - removing to ensure transport mode is up-to-date");
|
|
||||||
if (!ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var removeStdout, out var removeStderr, 10000, pathPrepend))
|
|
||||||
{
|
|
||||||
McpLog.Warn($"Failed to remove existing UnityMCP registration: {removeStderr}. Attempting to register anyway...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now add the registration with the current transport mode
|
// Now add the registration with the current transport mode
|
||||||
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
|
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
|
||||||
|
|
@ -542,26 +668,13 @@ namespace MCPForUnity.Editor.Clients
|
||||||
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
|
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
|
||||||
|
McpLog.Info("Removing all UnityMCP registrations...");
|
||||||
if (!serverExists)
|
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
||||||
{
|
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
||||||
client.SetStatus(McpStatus.NotConfigured);
|
|
||||||
McpLog.Info("No MCP for Unity server found - already unregistered.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend))
|
|
||||||
{
|
|
||||||
McpLog.Info("MCP server successfully unregistered from Claude Code.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Failed to unregister: {stderr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
McpLog.Info("MCP server successfully unregistered from Claude Code.");
|
||||||
client.SetStatus(McpStatus.NotConfigured);
|
client.SetStatus(McpStatus.NotConfigured);
|
||||||
// Status is already set - no need for blocking CheckStatus() call
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string GetManualSnippet()
|
public override string GetManualSnippet()
|
||||||
|
|
@ -577,7 +690,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
"# Unregister the MCP server:\n" +
|
"# Unregister the MCP server:\n" +
|
||||||
"claude mcp remove UnityMCP\n\n" +
|
"claude mcp remove UnityMCP\n\n" +
|
||||||
"# List registered servers:\n" +
|
"# List registered servers:\n" +
|
||||||
"claude mcp list # Only works when claude is run in the project's directory";
|
"claude mcp list";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(uvxPath))
|
if (string.IsNullOrEmpty(uvxPath))
|
||||||
|
|
@ -587,6 +700,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
|
|
||||||
string packageSource = AssetPathUtility.GetMcpServerPackageSource();
|
string packageSource = AssetPathUtility.GetMcpServerPackageSource();
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// 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;
|
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
||||||
|
|
||||||
return "# Register the MCP server with Claude Code:\n" +
|
return "# Register the MCP server with Claude Code:\n" +
|
||||||
|
|
@ -594,7 +708,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
"# Unregister the MCP server:\n" +
|
"# Unregister the MCP server:\n" +
|
||||||
"claude mcp remove UnityMCP\n\n" +
|
"claude mcp remove UnityMCP\n\n" +
|
||||||
"# List registered servers:\n" +
|
"# List registered servers:\n" +
|
||||||
"claude mcp list # Only works when claude is run in the project's directory";
|
"claude mcp list";
|
||||||
}
|
}
|
||||||
|
|
||||||
public override IList<string> GetInstallationSteps() => new List<string>
|
public override IList<string> GetInstallationSteps() => new List<string>
|
||||||
|
|
@ -603,5 +717,51 @@ namespace MCPForUnity.Editor.Clients
|
||||||
"Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
|
"Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
|
||||||
"Restart Claude Code"
|
"Restart Claude Code"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the package source (--from argument value) from claude mcp get output.
|
||||||
|
/// The output format includes args like: --from "mcpforunityserver==9.0.1"
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractPackageSourceFromCliOutput(string cliOutput)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(cliOutput))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Look for --from followed by the package source
|
||||||
|
// The CLI output may have it quoted or unquoted
|
||||||
|
int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (fromIndex < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Move past "--from" and any whitespace
|
||||||
|
int startIndex = fromIndex + 6;
|
||||||
|
while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex]))
|
||||||
|
startIndex++;
|
||||||
|
|
||||||
|
if (startIndex >= cliOutput.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check if value is quoted
|
||||||
|
char quoteChar = cliOutput[startIndex];
|
||||||
|
if (quoteChar == '"' || quoteChar == '\'')
|
||||||
|
{
|
||||||
|
startIndex++;
|
||||||
|
int endIndex = cliOutput.IndexOf(quoteChar, startIndex);
|
||||||
|
if (endIndex > startIndex)
|
||||||
|
return cliOutput.Substring(startIndex, endIndex - startIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unquoted - read until whitespace or end of line
|
||||||
|
int endIndex = startIndex;
|
||||||
|
while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex]))
|
||||||
|
endIndex++;
|
||||||
|
|
||||||
|
if (endIndex > startIndex)
|
||||||
|
return cliOutput.Substring(startIndex, endIndex - startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@ namespace MCPForUnity.Editor.Helpers
|
||||||
/// Determines whether uvx should use --no-cache --refresh flags.
|
/// Determines whether uvx should use --no-cache --refresh flags.
|
||||||
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
|
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
|
||||||
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
|
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
|
||||||
|
/// Note: --reinstall is not supported by uvx and will cause a warning.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ShouldForceUvxRefresh()
|
public static bool ShouldForceUvxRefresh()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ namespace MCPForUnity.Editor.Helpers
|
||||||
{
|
{
|
||||||
if (args == null) return;
|
if (args == null) return;
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
||||||
|
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
|
||||||
if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
|
if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
|
||||||
args.Add(new TomlString { Value = "--no-cache" });
|
args.Add(new TomlString { Value = "--no-cache" });
|
||||||
args.Add(new TomlString { Value = "--refresh" });
|
args.Add(new TomlString { Value = "--refresh" });
|
||||||
|
|
|
||||||
|
|
@ -159,10 +159,11 @@ namespace MCPForUnity.Editor.Helpers
|
||||||
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
|
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
|
||||||
{
|
{
|
||||||
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
|
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
|
||||||
// `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated.
|
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
|
||||||
|
// Note: --reinstall is not supported by uvx and will cause a warning.
|
||||||
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
|
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
|
||||||
var args = new List<string>();
|
var args = new List<string>();
|
||||||
|
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
||||||
if (AssetPathUtility.ShouldForceUvxRefresh())
|
if (AssetPathUtility.ShouldForceUvxRefresh())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,24 @@ namespace MCPForUnity.Editor.Migrations
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!ConfigUsesStdIo(configurator.Client))
|
if (!configurator.SupportsAutoConfigure)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!configurator.SupportsAutoConfigure)
|
// Handle CLI-based configurators (e.g., Claude Code CLI)
|
||||||
|
// CheckStatus with attemptAutoRewrite=true will auto-reregister if version mismatch
|
||||||
|
if (configurator is ClaudeCliMcpConfigurator cliConfigurator)
|
||||||
|
{
|
||||||
|
var previousStatus = configurator.Status;
|
||||||
|
configurator.CheckStatus(attemptAutoRewrite: true);
|
||||||
|
if (configurator.Status != previousStatus)
|
||||||
|
{
|
||||||
|
touchedAny = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JSON file-based configurators
|
||||||
|
if (!ConfigUsesStdIo(configurator.Client))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
MCPServiceLocator.Client.ConfigureClient(configurator);
|
MCPServiceLocator.Client.ConfigureClient(configurator);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,17 @@ namespace MCPForUnity.Editor.Services
|
||||||
private static long? _domainReloadAfterUnixMs;
|
private static long? _domainReloadAfterUnixMs;
|
||||||
|
|
||||||
private static double _lastUpdateTimeSinceStartup;
|
private static double _lastUpdateTimeSinceStartup;
|
||||||
private const double MinUpdateIntervalSeconds = 0.25;
|
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
|
||||||
|
|
||||||
|
// State tracking to detect when snapshot actually changes (checked BEFORE building)
|
||||||
|
private static string _lastTrackedScenePath;
|
||||||
|
private static string _lastTrackedSceneName;
|
||||||
|
private static bool _lastTrackedIsFocused;
|
||||||
|
private static bool _lastTrackedIsPlaying;
|
||||||
|
private static bool _lastTrackedIsPaused;
|
||||||
|
private static bool _lastTrackedIsUpdating;
|
||||||
|
private static bool _lastTrackedTestsRunning;
|
||||||
|
private static string _lastTrackedActivityPhase;
|
||||||
|
|
||||||
private static JObject _cached;
|
private static JObject _cached;
|
||||||
|
|
||||||
|
|
@ -263,16 +273,78 @@ namespace MCPForUnity.Editor.Services
|
||||||
{
|
{
|
||||||
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
|
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
|
||||||
double now = EditorApplication.timeSinceStartup;
|
double now = EditorApplication.timeSinceStartup;
|
||||||
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
|
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
|
||||||
|
bool isCompiling = GetActualIsCompiling();
|
||||||
|
|
||||||
|
// Check for compilation edge transitions (always update on these)
|
||||||
|
bool compilationEdge = isCompiling != _lastIsCompiling;
|
||||||
|
|
||||||
|
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
|
||||||
{
|
{
|
||||||
// Still update on compilation edge transitions to keep timestamps meaningful.
|
return;
|
||||||
bool isCompiling = GetActualIsCompiling();
|
|
||||||
if (isCompiling == _lastIsCompiling)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast state-change detection BEFORE building snapshot.
|
||||||
|
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
|
||||||
|
// These checks are much cheaper than building a full JSON snapshot.
|
||||||
|
var scene = EditorSceneManager.GetActiveScene();
|
||||||
|
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
|
||||||
|
string sceneName = scene.name ?? string.Empty;
|
||||||
|
bool isFocused = InternalEditorUtility.isApplicationActive;
|
||||||
|
bool isPlaying = EditorApplication.isPlaying;
|
||||||
|
bool isPaused = EditorApplication.isPaused;
|
||||||
|
bool isUpdating = EditorApplication.isUpdating;
|
||||||
|
bool testsRunning = TestRunStatus.IsRunning;
|
||||||
|
|
||||||
|
var activityPhase = "idle";
|
||||||
|
if (testsRunning)
|
||||||
|
{
|
||||||
|
activityPhase = "running_tests";
|
||||||
|
}
|
||||||
|
else if (isCompiling)
|
||||||
|
{
|
||||||
|
activityPhase = "compiling";
|
||||||
|
}
|
||||||
|
else if (_domainReloadPending)
|
||||||
|
{
|
||||||
|
activityPhase = "domain_reload";
|
||||||
|
}
|
||||||
|
else if (isUpdating)
|
||||||
|
{
|
||||||
|
activityPhase = "asset_import";
|
||||||
|
}
|
||||||
|
else if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||||
|
{
|
||||||
|
activityPhase = "playmode_transition";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasChanges = compilationEdge
|
||||||
|
|| _lastTrackedScenePath != scenePath
|
||||||
|
|| _lastTrackedSceneName != sceneName
|
||||||
|
|| _lastTrackedIsFocused != isFocused
|
||||||
|
|| _lastTrackedIsPlaying != isPlaying
|
||||||
|
|| _lastTrackedIsPaused != isPaused
|
||||||
|
|| _lastTrackedIsUpdating != isUpdating
|
||||||
|
|| _lastTrackedTestsRunning != testsRunning
|
||||||
|
|| _lastTrackedActivityPhase != activityPhase;
|
||||||
|
|
||||||
|
if (!hasChanges)
|
||||||
|
{
|
||||||
|
// No state change - skip the expensive BuildSnapshot entirely.
|
||||||
|
// This is the key optimization that prevents the 28ms GC spikes.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracked state
|
||||||
|
_lastTrackedScenePath = scenePath;
|
||||||
|
_lastTrackedSceneName = sceneName;
|
||||||
|
_lastTrackedIsFocused = isFocused;
|
||||||
|
_lastTrackedIsPlaying = isPlaying;
|
||||||
|
_lastTrackedIsPaused = isPaused;
|
||||||
|
_lastTrackedIsUpdating = isUpdating;
|
||||||
|
_lastTrackedTestsRunning = testsRunning;
|
||||||
|
_lastTrackedActivityPhase = activityPhase;
|
||||||
|
|
||||||
_lastUpdateTimeSinceStartup = now;
|
_lastUpdateTimeSinceStartup = now;
|
||||||
ForceUpdate("tick");
|
ForceUpdate("tick");
|
||||||
}
|
}
|
||||||
|
|
@ -425,6 +497,10 @@ namespace MCPForUnity.Editor.Services
|
||||||
{
|
{
|
||||||
_cached = BuildSnapshot("rebuild");
|
_cached = BuildSnapshot("rebuild");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always return a fresh clone to prevent mutation bugs.
|
||||||
|
// The main GC optimization comes from state-change detection (OnUpdate)
|
||||||
|
// which prevents unnecessary _cached rebuilds, not from caching the clone.
|
||||||
return (JObject)_cached.DeepClone();
|
return (JObject)_cached.DeepClone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1310,6 +1310,7 @@ namespace MCPForUnity.Editor.Services
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// 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;
|
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
||||||
string args = string.IsNullOrEmpty(fromUrl)
|
string args = string.IsNullOrEmpty(fromUrl)
|
||||||
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
|
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,12 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
|
|
||||||
lock (PendingLock)
|
lock (PendingLock)
|
||||||
{
|
{
|
||||||
|
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
|
||||||
|
if (Pending.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ready = new List<(string, PendingCommand)>(Pending.Count);
|
ready = new List<(string, PendingCommand)>(Pending.Count);
|
||||||
foreach (var kvp in Pending)
|
foreach (var kvp in Pending)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,12 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
||||||
List<(string id, QueuedCommand command)> work;
|
List<(string id, QueuedCommand command)> work;
|
||||||
lock (lockObj)
|
lock (lockObj)
|
||||||
{
|
{
|
||||||
|
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
|
||||||
|
if (commandQueue.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
work = new List<(string, QueuedCommand)>(commandQueue.Count);
|
work = new List<(string, QueuedCommand)>(commandQueue.Count);
|
||||||
foreach (var kvp in commandQueue)
|
foreach (var kvp in commandQueue)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,13 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
|
|
||||||
var client = configurators[selectedClientIndex];
|
var client = configurators[selectedClientIndex];
|
||||||
|
|
||||||
|
// Handle CLI configurators asynchronously
|
||||||
|
if (client is ClaudeCliMcpConfigurator)
|
||||||
|
{
|
||||||
|
ConfigureClaudeCliAsync(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
MCPServiceLocator.Client.ConfigureClient(client);
|
MCPServiceLocator.Client.ConfigureClient(client);
|
||||||
|
|
@ -237,6 +244,92 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
|
||||||
|
{
|
||||||
|
if (statusRefreshInFlight.Contains(client))
|
||||||
|
return;
|
||||||
|
|
||||||
|
statusRefreshInFlight.Add(client);
|
||||||
|
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
|
||||||
|
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Registering...");
|
||||||
|
|
||||||
|
// Capture ALL main-thread-only values before async task
|
||||||
|
string projectDir = Path.GetDirectoryName(Application.dataPath);
|
||||||
|
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||||
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
||||||
|
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
|
||||||
|
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
|
||||||
|
bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh();
|
||||||
|
|
||||||
|
// Compute pathPrepend on main thread
|
||||||
|
string pathPrepend = null;
|
||||||
|
if (Application.platform == RuntimePlatform.OSXEditor)
|
||||||
|
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
|
||||||
|
else if (Application.platform == RuntimePlatform.LinuxEditor)
|
||||||
|
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string claudeDir = Path.GetDirectoryName(claudePath);
|
||||||
|
if (!string.IsNullOrEmpty(claudeDir))
|
||||||
|
pathPrepend = string.IsNullOrEmpty(pathPrepend) ? claudeDir : $"{claudeDir}:{pathPrepend}";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (client is ClaudeCliMcpConfigurator cliConfigurator)
|
||||||
|
{
|
||||||
|
cliConfigurator.ConfigureWithCapturedValues(
|
||||||
|
projectDir, claudePath, pathPrepend,
|
||||||
|
useHttpTransport, httpUrl,
|
||||||
|
uvxPath, gitUrl, packageName, shouldForceRefresh);
|
||||||
|
}
|
||||||
|
return (success: true, error: (string)null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (success: false, error: ex.Message);
|
||||||
|
}
|
||||||
|
}).ContinueWith(t =>
|
||||||
|
{
|
||||||
|
string errorMessage = null;
|
||||||
|
if (t.IsFaulted && t.Exception != null)
|
||||||
|
{
|
||||||
|
errorMessage = t.Exception.GetBaseException()?.Message ?? "Configuration failed";
|
||||||
|
}
|
||||||
|
else if (!t.Result.success)
|
||||||
|
{
|
||||||
|
errorMessage = t.Result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorApplication.delayCall += () =>
|
||||||
|
{
|
||||||
|
statusRefreshInFlight.Remove(client);
|
||||||
|
lastStatusChecks.Remove(client);
|
||||||
|
|
||||||
|
if (errorMessage != null)
|
||||||
|
{
|
||||||
|
if (client is McpClientConfiguratorBase baseConfigurator)
|
||||||
|
{
|
||||||
|
baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage);
|
||||||
|
}
|
||||||
|
McpLog.Error($"Configuration failed: {errorMessage}");
|
||||||
|
RefreshClientStatus(client, forceImmediate: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Registration succeeded - trust the status set by RegisterWithCapturedValues
|
||||||
|
// and update UI without re-verifying (which could fail due to CLI timing/scope issues)
|
||||||
|
lastStatusChecks[client] = DateTime.UtcNow;
|
||||||
|
ApplyStatusToUi(client);
|
||||||
|
}
|
||||||
|
UpdateManualConfiguration();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void OnBrowseClaudeClicked()
|
private void OnBrowseClaudeClicked()
|
||||||
{
|
{
|
||||||
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|
||||||
|
|
@ -396,7 +489,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
return (DateTime.UtcNow - last) > StatusRefreshInterval;
|
return (DateTime.UtcNow - last) > StatusRefreshInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false)
|
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false, string customMessage = null)
|
||||||
{
|
{
|
||||||
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
||||||
return;
|
return;
|
||||||
|
|
@ -410,7 +503,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
|
|
||||||
if (showChecking)
|
if (showChecking)
|
||||||
{
|
{
|
||||||
clientStatusLabel.text = "Checking...";
|
clientStatusLabel.text = customMessage ?? "Checking...";
|
||||||
clientStatusLabel.style.color = StyleKeyword.Null;
|
clientStatusLabel.style.color = StyleKeyword.Null;
|
||||||
clientStatusIndicator.AddToClassList("warning");
|
clientStatusIndicator.AddToClassList("warning");
|
||||||
configureButton.text = client.GetConfigureActionLabel();
|
configureButton.text = client.GetConfigureActionLabel();
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,12 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
{
|
{
|
||||||
if (bridgeService.IsRunning)
|
if (bridgeService.IsRunning)
|
||||||
{
|
{
|
||||||
|
// Clear any resume flags when user manually ends the session to prevent
|
||||||
|
// getting stuck in "Resuming..." state (the flag may have been set by a
|
||||||
|
// domain reload that started just before the user clicked End Session)
|
||||||
|
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
|
||||||
|
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }
|
||||||
|
|
||||||
await bridgeService.StopAsync();
|
await bridgeService.StopAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -717,6 +723,11 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
{
|
{
|
||||||
connectionToggleInProgress = true;
|
connectionToggleInProgress = true;
|
||||||
connectionToggleButton?.SetEnabled(false);
|
connectionToggleButton?.SetEnabled(false);
|
||||||
|
|
||||||
|
// Clear resume flags to prevent getting stuck in "Resuming..." state
|
||||||
|
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
|
||||||
|
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }
|
||||||
|
|
||||||
await MCPServiceLocator.Bridge.StopAsync();
|
await MCPServiceLocator.Bridge.StopAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,11 @@ namespace MCPForUnity.Editor.Windows
|
||||||
toolsSection.Refresh();
|
toolsSection.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle OnEditorUpdate to avoid per-frame overhead (GitHub issue #577).
|
||||||
|
// Connection status polling every frame caused expensive network checks 60+ times/sec.
|
||||||
|
private double _lastEditorUpdateTime;
|
||||||
|
private const double EditorUpdateIntervalSeconds = 2.0;
|
||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
EditorApplication.update += OnEditorUpdate;
|
EditorApplication.update += OnEditorUpdate;
|
||||||
|
|
@ -257,6 +262,16 @@ namespace MCPForUnity.Editor.Windows
|
||||||
|
|
||||||
private void OnEditorUpdate()
|
private void OnEditorUpdate()
|
||||||
{
|
{
|
||||||
|
// Throttle to 2-second intervals instead of every frame.
|
||||||
|
// This prevents the expensive IsLocalHttpServerReachable() socket checks from running
|
||||||
|
// 60+ times per second, which caused main thread blocking and GC pressure.
|
||||||
|
double now = EditorApplication.timeSinceStartup;
|
||||||
|
if (now - _lastEditorUpdateTime < EditorUpdateIntervalSeconds)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastEditorUpdateTime = now;
|
||||||
|
|
||||||
if (rootVisualElement == null || rootVisualElement.childCount == 0)
|
if (rootVisualElement == null || rootVisualElement.childCount == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,15 @@ mcp-for-unity = "main:main"
|
||||||
requires = ["setuptools>=64.0.0", "wheel"]
|
requires = ["setuptools>=64.0.0", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"" = "src"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["main"]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["src"]
|
source = ["src"]
|
||||||
omit = [
|
omit = [
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,19 @@ except Exception:
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsSafeRotatingFileHandler(RotatingFileHandler):
|
||||||
|
"""RotatingFileHandler that gracefully handles Windows file locking during rotation."""
|
||||||
|
|
||||||
|
def doRollover(self):
|
||||||
|
"""Override to catch PermissionError on Windows when log file is locked."""
|
||||||
|
try:
|
||||||
|
super().doRollover()
|
||||||
|
except PermissionError:
|
||||||
|
# On Windows, another process may have the log file open.
|
||||||
|
# Skip rotation this time - we'll try again on the next rollover.
|
||||||
|
pass
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.routing import WebSocketRoute
|
from starlette.routing import WebSocketRoute
|
||||||
|
|
@ -69,7 +82,7 @@ try:
|
||||||
"~/Library/Application Support/UnityMCP"), "Logs")
|
"~/Library/Application Support/UnityMCP"), "Logs")
|
||||||
os.makedirs(_log_dir, exist_ok=True)
|
os.makedirs(_log_dir, exist_ok=True)
|
||||||
_file_path = os.path.join(_log_dir, "unity_mcp_server.log")
|
_file_path = os.path.join(_log_dir, "unity_mcp_server.log")
|
||||||
_fh = RotatingFileHandler(
|
_fh = WindowsSafeRotatingFileHandler(
|
||||||
_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
|
_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
|
||||||
_fh.setFormatter(logging.Formatter(config.log_format))
|
_fh.setFormatter(logging.Formatter(config.log_format))
|
||||||
_fh.setLevel(getattr(logging, config.log_level))
|
_fh.setLevel(getattr(logging, config.log_level))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ from transport.legacy.unity_connection import async_send_command_with_retry, _ex
|
||||||
from services.state.external_changes_scanner import external_changes_scanner
|
from services.state.external_changes_scanner import external_changes_scanner
|
||||||
import services.resources.editor_state as editor_state
|
import services.resources.editor_state as editor_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
|
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
|
||||||
|
|
@ -43,36 +46,65 @@ async def refresh_unity(
|
||||||
}
|
}
|
||||||
|
|
||||||
recovered_from_disconnect = False
|
recovered_from_disconnect = False
|
||||||
|
# Don't retry on reload - refresh_unity triggers compilation/reload,
|
||||||
|
# so retrying would cause multiple reloads (issue #577)
|
||||||
response = await unity_transport.send_with_unity_instance(
|
response = await unity_transport.send_with_unity_instance(
|
||||||
async_send_command_with_retry,
|
async_send_command_with_retry,
|
||||||
unity_instance,
|
unity_instance,
|
||||||
"refresh_unity",
|
"refresh_unity",
|
||||||
params,
|
params,
|
||||||
|
retry_on_reload=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
|
# Handle connection errors during refresh/compile gracefully.
|
||||||
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
|
# Unity disconnects during domain reload, which is expected behavior - not a failure.
|
||||||
# interpret that as a hard failure (#503-style loops).
|
# If we sent the command and connection closed, the refresh was likely triggered successfully.
|
||||||
if isinstance(response, dict) and not response.get("success", True):
|
# Convert MCPResponse to dict if needed
|
||||||
hint = response.get("hint")
|
response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
|
||||||
err = (response.get("error") or response.get("message") or "").lower()
|
if not response_dict.get("success", True):
|
||||||
reason = _extract_response_reason(response)
|
hint = response_dict.get("hint")
|
||||||
is_retryable = (
|
err = (response_dict.get("error") or response_dict.get("message") or "").lower()
|
||||||
hint == "retry"
|
reason = _extract_response_reason(response_dict)
|
||||||
|
|
||||||
|
# Connection closed/timeout during compile = refresh was triggered, Unity is reloading
|
||||||
|
# This is SUCCESS, not failure - don't return error to prevent Claude Code from retrying
|
||||||
|
is_connection_lost = (
|
||||||
|
"connection closed" in err
|
||||||
or "disconnected" in err
|
or "disconnected" in err
|
||||||
or "could not connect" in err # Connection failed during domain reload
|
or "aborted" in err # WinError 10053: connection aborted
|
||||||
|
or "timeout" in err
|
||||||
|
or reason == "reloading"
|
||||||
)
|
)
|
||||||
if (not wait_for_ready) or (not is_retryable):
|
|
||||||
return MCPResponse(**response)
|
if is_connection_lost and compile == "request":
|
||||||
if reason not in {"reloading", "no_unity_session"}:
|
# EXPECTED BEHAVIOR: When compile="request", Unity triggers domain reload which
|
||||||
|
# causes connection to close mid-command. This is NOT a failure - the refresh
|
||||||
|
# was successfully triggered. Treating this as success prevents Claude Code from
|
||||||
|
# retrying unnecessarily (which would cause multiple domain reloads - issue #577).
|
||||||
|
# The subsequent wait_for_ready loop (below) will verify Unity becomes ready.
|
||||||
|
logger.info("refresh_unity: Connection lost during compile (expected - domain reload triggered)")
|
||||||
recovered_from_disconnect = True
|
recovered_from_disconnect = True
|
||||||
|
elif hint == "retry" or "could not connect" in err:
|
||||||
|
# Retryable error - proceed to wait loop if wait_for_ready
|
||||||
|
if not wait_for_ready:
|
||||||
|
return MCPResponse(**response_dict)
|
||||||
|
recovered_from_disconnect = True
|
||||||
|
else:
|
||||||
|
# Non-recoverable error - connection issue unrelated to domain reload
|
||||||
|
logger.warning(f"refresh_unity: Non-recoverable error (compile={compile}): {err[:100]}")
|
||||||
|
return MCPResponse(**response_dict)
|
||||||
|
|
||||||
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
||||||
# poll the canonical editor_state resource until ready or timeout.
|
# poll the canonical editor_state resource until ready or timeout.
|
||||||
|
ready_confirmed = False
|
||||||
if wait_for_ready:
|
if wait_for_ready:
|
||||||
timeout_s = 60.0
|
timeout_s = 60.0
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
|
# Blocking reasons that indicate Unity is actually busy (not just stale status)
|
||||||
|
# Must match activityPhase values from EditorStateCache.cs
|
||||||
|
real_blocking_reasons = {"compiling", "domain_reload", "running_tests", "asset_import"}
|
||||||
|
|
||||||
while time.monotonic() - start < timeout_s:
|
while time.monotonic() - start < timeout_s:
|
||||||
state_resp = await editor_state.get_editor_state(ctx)
|
state_resp = await editor_state.get_editor_state(ctx)
|
||||||
state = state_resp.model_dump() if hasattr(
|
state = state_resp.model_dump() if hasattr(
|
||||||
|
|
@ -81,10 +113,28 @@ async def refresh_unity(
|
||||||
state, dict) else None
|
state, dict) else None
|
||||||
advice = (data or {}).get(
|
advice = (data or {}).get(
|
||||||
"advice") if isinstance(data, dict) else None
|
"advice") if isinstance(data, dict) else None
|
||||||
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
|
if isinstance(advice, dict):
|
||||||
break
|
# Exit if ready_for_tools is True
|
||||||
|
if advice.get("ready_for_tools") is True:
|
||||||
|
ready_confirmed = True
|
||||||
|
break
|
||||||
|
# Also exit if the only blocking reason is "stale_status" (Unity in background)
|
||||||
|
# Staleness means we can't confirm status, not that Unity is actually busy
|
||||||
|
blocking = set(advice.get("blocking_reasons") or [])
|
||||||
|
if not (blocking & real_blocking_reasons):
|
||||||
|
ready_confirmed = True # No real blocking reasons, consider ready
|
||||||
|
break
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
# If we timed out without confirming readiness, log and return failure
|
||||||
|
if not ready_confirmed:
|
||||||
|
logger.warning(f"refresh_unity: Timed out after {timeout_s}s waiting for editor to become ready")
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Refresh triggered but timed out after {timeout_s}s waiting for editor readiness.",
|
||||||
|
data={"timeout": True, "wait_seconds": timeout_s},
|
||||||
|
)
|
||||||
|
|
||||||
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
||||||
try:
|
try:
|
||||||
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
|
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
|
||||||
|
|
@ -100,4 +150,4 @@ async def refresh_unity(
|
||||||
data={"recovered_from_disconnect": True},
|
data={"recovered_from_disconnect": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
return MCPResponse(**response) if isinstance(response, dict) else response
|
return MCPResponse(**response_dict) if isinstance(response, dict) else response
|
||||||
|
|
|
||||||
|
|
@ -233,14 +233,20 @@ class UnityConnection:
|
||||||
logger.error(f"Error during receive: {str(e)}")
|
logger.error(f"Error during receive: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]:
|
def send_command(self, command_type: str, params: dict[str, Any] = None, max_attempts: int | None = None) -> dict[str, Any]:
|
||||||
"""Send a command with retry/backoff and port rediscovery. Pings only when requested."""
|
"""Send a command with retry/backoff and port rediscovery. Pings only when requested.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command_type: The Unity command to send
|
||||||
|
params: Command parameters
|
||||||
|
max_attempts: Maximum retry attempts (None = use config default, 0 = no retries)
|
||||||
|
"""
|
||||||
# Defensive guard: catch empty/placeholder invocations early
|
# Defensive guard: catch empty/placeholder invocations early
|
||||||
if not command_type:
|
if not command_type:
|
||||||
raise ValueError("MCP call missing command_type")
|
raise ValueError("MCP call missing command_type")
|
||||||
if params is None:
|
if params is None:
|
||||||
return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
|
return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
|
||||||
attempts = max(config.max_retries, 5)
|
attempts = max(config.max_retries, 5) if max_attempts is None else max_attempts
|
||||||
base_backoff = max(0.5, config.retry_delay)
|
base_backoff = max(0.5, config.retry_delay)
|
||||||
|
|
||||||
def read_status_file(target_hash: str | None = None) -> dict | None:
|
def read_status_file(target_hash: str | None = None) -> dict | None:
|
||||||
|
|
@ -734,7 +740,8 @@ def send_command_with_retry(
|
||||||
*,
|
*,
|
||||||
instance_id: str | None = None,
|
instance_id: str | None = None,
|
||||||
max_retries: int | None = None,
|
max_retries: int | None = None,
|
||||||
retry_ms: int | None = None
|
retry_ms: int | None = None,
|
||||||
|
retry_on_reload: bool = True
|
||||||
) -> dict[str, Any] | MCPResponse:
|
) -> dict[str, Any] | MCPResponse:
|
||||||
"""Send a command to a Unity instance, waiting politely through Unity reloads.
|
"""Send a command to a Unity instance, waiting politely through Unity reloads.
|
||||||
|
|
||||||
|
|
@ -744,6 +751,8 @@ def send_command_with_retry(
|
||||||
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
|
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
|
||||||
max_retries: Maximum number of retries for reload states
|
max_retries: Maximum number of retries for reload states
|
||||||
retry_ms: Delay between retries in milliseconds
|
retry_ms: Delay between retries in milliseconds
|
||||||
|
retry_on_reload: If False, don't retry when Unity is reloading (for commands
|
||||||
|
that trigger compilation/reload and shouldn't be re-sent)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response dictionary or MCPResponse from Unity
|
Response dictionary or MCPResponse from Unity
|
||||||
|
|
@ -768,11 +777,15 @@ def send_command_with_retry(
|
||||||
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
||||||
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
||||||
|
|
||||||
response = conn.send_command(command_type, params)
|
# If retry_on_reload=False, disable connection-level retries too (issue #577)
|
||||||
|
# Commands that trigger compilation/reload shouldn't retry on disconnect
|
||||||
|
send_max_attempts = None if retry_on_reload else 0
|
||||||
|
|
||||||
|
response = conn.send_command(command_type, params, max_attempts=send_max_attempts)
|
||||||
retries = 0
|
retries = 0
|
||||||
wait_started = None
|
wait_started = None
|
||||||
reason = _extract_response_reason(response)
|
reason = _extract_response_reason(response)
|
||||||
while _is_reloading_response(response) and retries < max_retries:
|
while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
|
||||||
if wait_started is None:
|
if wait_started is None:
|
||||||
wait_started = time.monotonic()
|
wait_started = time.monotonic()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -842,7 +855,8 @@ async def async_send_command_with_retry(
|
||||||
instance_id: str | None = None,
|
instance_id: str | None = None,
|
||||||
loop=None,
|
loop=None,
|
||||||
max_retries: int | None = None,
|
max_retries: int | None = None,
|
||||||
retry_ms: int | None = None
|
retry_ms: int | None = None,
|
||||||
|
retry_on_reload: bool = True
|
||||||
) -> dict[str, Any] | MCPResponse:
|
) -> dict[str, Any] | MCPResponse:
|
||||||
"""Async wrapper that runs the blocking retry helper in a thread pool.
|
"""Async wrapper that runs the blocking retry helper in a thread pool.
|
||||||
|
|
||||||
|
|
@ -853,6 +867,7 @@ async def async_send_command_with_retry(
|
||||||
loop: Optional asyncio event loop
|
loop: Optional asyncio event loop
|
||||||
max_retries: Maximum number of retries for reload states
|
max_retries: Maximum number of retries for reload states
|
||||||
retry_ms: Delay between retries in milliseconds
|
retry_ms: Delay between retries in milliseconds
|
||||||
|
retry_on_reload: If False, don't retry when Unity is reloading
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response dictionary or MCPResponse on error
|
Response dictionary or MCPResponse on error
|
||||||
|
|
@ -864,7 +879,8 @@ async def async_send_command_with_retry(
|
||||||
return await loop.run_in_executor(
|
return await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: send_command_with_retry(
|
lambda: send_command_with_retry(
|
||||||
command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
|
command_type, params, instance_id=instance_id, max_retries=max_retries,
|
||||||
|
retry_ms=retry_ms, retry_on_reload=retry_on_reload),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return MCPResponse(success=False, error=str(e))
|
return MCPResponse(success=False, error=str(e))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue