diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs index a3ced1b71..0f9c6e112 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -25,33 +25,33 @@ public override DependencyStatus DetectPython() try { - // Try running python directly first - if (TryValidatePython("python3", out string version, out string fullPath) || - TryValidatePython("python", out version, out fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH"; - return status; - } - - // Fallback: try 'which' command + // 1. Try 'which' command with augmented PATH (prioritizing Homebrew) if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { - if (TryValidatePython(pathResult, out version, out fullPath)) + if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH"; + status.Details = $"Found Python {version} at {fullPath}"; return status; } } - status.ErrorMessage = "Python not found in PATH"; - status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + // 2. Fallback: Try running python directly from PATH + if (TryValidatePython("python3", out string v, out string p) || + TryValidatePython("python", out v, out p)) + { + status.IsAvailable = true; + status.Version = v; + status.Path = p; + status.Details = $"Found Python {v} in PATH"; + return status; + } + + status.ErrorMessage = "Python not found in PATH or standard locations"; + status.Details = "Install Python 3.10+ via Homebrew ('brew install python3') and ensure it's in your PATH."; } catch (Exception ex) { diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index e4d7b9251..f21d58ff2 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -50,6 +50,16 @@ public override DependencyStatus DetectPython() } } + // Fallback: try to find python via uv + if (TryFindPythonViaUv(out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} via uv"; + return status; + } + status.ErrorMessage = "Python not found in PATH"; status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } @@ -86,6 +96,64 @@ public override string GetInstallationRecommendations() 3. MCP Server: Will be installed automatically by MCP for Unity Bridge"; } + private bool TryFindPythonViaUv(out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "uv", // Assume uv is in path or user can't use this fallback + Arguments = "python list", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + // Look for installed python paths + // Format is typically: + // Skip lines with "" + if (line.Contains("")) continue; + + // The path is typically the last part of the line + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + string potentialPath = parts[parts.Length - 1]; + if (File.Exists(potentialPath) && + (potentialPath.EndsWith("python.exe") || potentialPath.EndsWith("python3.exe"))) + { + if (TryValidatePython(potentialPath, out version, out fullPath)) + { + return true; + } + } + } + } + } + } + catch + { + // Ignore errors if uv is not installed or fails + } + + return false; + } + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs index e8e80c5c9..de46fd8f2 100644 --- a/MCPForUnity/Editor/Helpers/PortManager.cs +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -119,17 +119,41 @@ private static int FindAvailablePort() /// True if port is available public static bool IsPortAvailable(int port) { + // Start with quick loopback check try { var testListener = new TcpListener(IPAddress.Loopback, port); testListener.Start(); testListener.Stop(); - return true; } catch (SocketException) { return false; } + +#if UNITY_EDITOR_OSX + // On macOS, the OS might report the port as available (SO_REUSEADDR) even if another process + // is using it, unless we also check active connections or try a stricter bind. + // Double check by trying to Connect to it. If we CAN connect, it's NOT available. + try + { + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + // If we connect successfully, someone is listening -> Not available + if (connectTask.Wait(50) && client.Connected) + { + if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} bind succeeded but connection also succeeded -> Not available (Conflict)."); + return false; + } + } + catch + { + // Connection failed -> likely available (or firewall blocked, but we assume available) + if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} connection failed -> likely available."); + } +#endif + + return true; } /// diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index ffecd2efd..31de7311e 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -306,26 +306,7 @@ public static void Start() { try { - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true - ); -#if UNITY_EDITOR_WIN - try - { - listener.ExclusiveAddressUse = false; - } - catch { } -#endif - try - { - listener.Server.LingerState = new LingerOption(true, 0); - } - catch (Exception) - { - } + listener = CreateConfiguredListener(currentUnityPort); listener.Start(); break; } @@ -355,7 +336,14 @@ public static void Start() } catch { } - currentUnityPort = PortManager.GetPortWithFallback(); + currentUnityPort = PortManager.DiscoverNewPort(); + + // Persist the new port so next time we start on this port + try + { + EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, currentUnityPort); + } + catch { } if (IsDebugEnabled()) { @@ -369,26 +357,7 @@ public static void Start() } } - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true - ); -#if UNITY_EDITOR_WIN - try - { - listener.ExclusiveAddressUse = false; - } - catch { } -#endif - try - { - listener.Server.LingerState = new LingerOption(true, 0); - } - catch (Exception) - { - } + listener = CreateConfiguredListener(currentUnityPort); listener.Start(); break; } @@ -416,6 +385,33 @@ public static void Start() } } + private static TcpListener CreateConfiguredListener(int port) + { + var newListener = new TcpListener(IPAddress.Loopback, port); +#if !UNITY_EDITOR_OSX + newListener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#endif +#if UNITY_EDITOR_WIN + try + { + newListener.ExclusiveAddressUse = false; + } + catch { } +#endif + try + { + newListener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + } + return newListener; + } + public static void Stop() { Task toWait = null; diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 6bc479929..9b2cc9335 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -173,6 +173,10 @@ public void UpdateConnectionStatus() statusIndicator.RemoveFromClassList("disconnected"); statusIndicator.AddToClassList("connected"); connectionToggleButton.text = "End Session"; + + // Force the UI to reflect the actual port being used + unityPortField.value = bridgeService.CurrentPort.ToString(); + unityPortField.SetEnabled(false); } else { @@ -180,17 +184,18 @@ public void UpdateConnectionStatus() statusIndicator.RemoveFromClassList("connected"); statusIndicator.AddToClassList("disconnected"); connectionToggleButton.text = "Start Session"; + + unityPortField.SetEnabled(true); healthStatusLabel.text = HealthStatusUnknown; healthIndicator.RemoveFromClassList("healthy"); healthIndicator.RemoveFromClassList("warning"); healthIndicator.AddToClassList("unknown"); - } - - int savedPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); - if (savedPort == 0) - { - unityPortField.value = bridgeService.CurrentPort.ToString(); + + int savedPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + unityPortField.value = (savedPort == 0 + ? bridgeService.CurrentPort + : savedPort).ToString(); } } diff --git a/Server/src/services/tools/debug_request_context.py b/Server/src/services/tools/debug_request_context.py index 16f4e4933..f6f2ae96b 100644 --- a/Server/src/services/tools/debug_request_context.py +++ b/Server/src/services/tools/debug_request_context.py @@ -40,6 +40,7 @@ def debug_request_context(ctx: Context) -> dict[str, Any]: active_instance = middleware.get_active_instance(ctx) # Debugging middleware internals + # NOTE: These fields expose internal implementation details and may change between versions. with middleware._lock: all_keys = list(middleware._active_by_key.keys()) diff --git a/Server/src/services/tools/manage_script.py b/Server/src/services/tools/manage_script.py index 9eeeed26b..41148682b 100644 --- a/Server/src/services/tools/manage_script.py +++ b/Server/src/services/tools/manage_script.py @@ -371,7 +371,7 @@ async def _flip_async(): async def create_script( ctx: Context, path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], - contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + contents: Annotated[str, "Contents of the script to create (plain text C# code). The server handles Base64 encoding."], script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: diff --git a/Server/src/transport/unity_instance_middleware.py b/Server/src/transport/unity_instance_middleware.py index fbc628533..8f3a0209e 100644 --- a/Server/src/transport/unity_instance_middleware.py +++ b/Server/src/transport/unity_instance_middleware.py @@ -103,7 +103,7 @@ async def on_call_tool(self, context: MiddlewareContext, call_next): # We only need session_id for HTTP transport routing. # For stdio, we just need the instance ID. session_id = await PluginHub._resolve_session_id(active_instance) - except Exception as exc: + except (ConnectionError, ValueError, KeyError, TimeoutError) as exc: # If resolution fails, it means the Unity instance is not reachable via HTTP/WS. # If we are in stdio mode, this might still be fine if the user is just setting state? # But usually if PluginHub is configured, we expect it to work. @@ -115,6 +115,16 @@ async def on_call_tool(self, context: MiddlewareContext, call_next): exc, exc_info=True, ) + except Exception as exc: + # Re-raise unexpected system exceptions to avoid swallowing critical failures + if isinstance(exc, (SystemExit, KeyboardInterrupt)): + raise + logger.error( + "Unexpected error during PluginHub session resolution for %s: %s", + active_instance, + exc, + exc_info=True + ) ctx.set_state("unity_instance", active_instance) if session_id is not None: