Fix: Python Detection, Port Conflicts, and Script Creation Reliability (#428)
* Fix macOS port conflict: Disable SO_REUSEADDR and improve UI port display - StdioBridgeHost.cs: Disable ReuseAddress on macOS to force AddressAlreadyInUse exception on conflict. - PortManager.cs: Add connection check safety net for macOS. - McpConnectionSection.cs: Ensure UI displays the actual live port instead of just the requested one. * Fix macOS port conflict: Disable SO_REUSEADDR and improve UI port display - StdioBridgeHost.cs: Disable ReuseAddress on macOS to force AddressAlreadyInUse exception on conflict. - PortManager.cs: Add connection check safety net for macOS. - McpConnectionSection.cs: Ensure UI displays the actual live port instead of just the requested one. * Address CodeRabbit feedback: UX improvements and code quality fixes - McpConnectionSection.cs: Disable port field when session is running to prevent editing conflicts - StdioBridgeHost.cs: Refactor listener creation into helper method and update EditorPrefs on port fallback - unity_instance_middleware.py: Narrow exception handling and preserve SystemExit/KeyboardInterrupt - debug_request_context.py: Document that debug fields expose internal implementation details * Fix: Harden Python detection on Windows to handle App Execution Aliases * Refactor: Pre-resolve conflicts for McpConnectionSection and middleware * Fix: Remove leftover merge conflict markers in StdioBridgeHost.cs * fix: clarify create_script tool description regarding base64 encoding * fix: improve python detection on macOS by checking specific Homebrew paths * Fix duplicate SetEnabled call and improve macOS Python detection * Fix port display not reverting to saved preference when session endsmain
parent
bf81319e4c
commit
49973db806
|
|
@ -25,33 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
|
|||
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
|
|||
}
|
||||
}
|
||||
|
||||
// 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 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
|
|||
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: <version> <path>
|
||||
// Skip lines with "<download available>"
|
||||
if (line.Contains("<download available>")) 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;
|
||||
|
|
|
|||
|
|
@ -119,17 +119,41 @@ namespace MCPForUnity.Editor.Helpers
|
|||
/// <returns>True if port is available</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -306,26 +306,7 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
|||
{
|
||||
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 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
|||
}
|
||||
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 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
|||
}
|
||||
}
|
||||
|
||||
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 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -173,6 +173,10 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
|||
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
|
||||
{
|
||||
|
|
@ -181,16 +185,17 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ async def apply_text_edits(
|
|||
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]:
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class UnityInstanceMiddleware(Middleware):
|
|||
# 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 @@ class UnityInstanceMiddleware(Middleware):
|
|||
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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue