diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index ed8d6a69..2cff1ab6 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -42,7 +42,7 @@ protected string GetUvxPathOrError() string uvx = MCPServiceLocator.Paths.GetUvxPath(); if (string.IsNullOrEmpty(uvx)) { - throw new InvalidOperationException("uv not found. Install uv/uvx or set the override in Advanced Settings."); + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); } return uvx; } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs index 1c5bf458..ff883a7e 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -2,8 +2,10 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -92,38 +94,35 @@ public override string GetInstallationRecommendations() public override DependencyStatus DetectUv() { - var status = new DependencyStatus("uv Package Manager", isRequired: true) + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) { - InstallationHint = GetUvInstallUrl() - }; + return status; + } + + // If the user configured an override path, keep the base result (failure typically means the override path is invalid) + if (MCPServiceLocator.Paths.HasUvxPathOverride) + { + return status; + } try { - // Try running uv/uvx directly with augmented PATH - if (TryValidateUv("uv", out string version, out string fullPath) || - TryValidateUv("uvx", out version, out fullPath)) + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; return status; } - // Fallback: use which with augmented PATH - if (TryFindInPath("uv", out string pathResult) || - TryFindInPath("uvx", out pathResult)) - { - if (TryValidateUv(pathResult, out version, out fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found uv {version} in PATH"; - return status; - } - } - status.ErrorMessage = "uv not found in PATH"; status.Details = "Install uv package manager and ensure it's added to PATH."; } @@ -142,45 +141,29 @@ private bool TryValidatePython(string pythonPath, out string version, out string try { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + string augmentedPath = BuildAugmentedPath(); - // Set PATH to include common locations - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) { - "/usr/local/bin", - "/usr/bin", - "/bin", - "/snap/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; + commandToRun = resolvedPath; + } - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; + version = output.Substring(7); + fullPath = commandToRun; - // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { - return major > 3 || (major >= 3 && minor >= 10); + return major > 3 || (major == 3 && minor >= 10); } } } @@ -192,50 +175,13 @@ private bool TryValidatePython(string pythonPath, out string version, out string return false; } - private bool TryValidateUv(string uvPath, out string version, out string fullPath) + protected string BuildAugmentedPath() { - version = null; - fullPath = null; + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - psi.EnvironmentVariables["PATH"] = BuildAugmentedPath(); - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3).Trim(); - fullPath = uvPath; - return true; - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private string BuildAugmentedPath() - { - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - return string.Join(":", GetPathAdditions()) + ":" + currentPath; + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); } private string[] GetPathAdditions() @@ -251,54 +197,10 @@ private string[] GetPathAdditions() }; } - private bool TryFindInPath(string executable, out string fullPath) + protected override bool TryFindInPath(string executable, out string fullPath) { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Enhance PATH for Unity's GUI environment - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/usr/local/bin", - "/usr/bin", - "/bin", - "/snap/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - fullPath = output; - return true; - } - } - catch - { - // Ignore errors - } - - return false; + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); } } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs index 0f9c6e11..229e7f88 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -2,8 +2,10 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -90,38 +92,35 @@ public override string GetInstallationRecommendations() public override DependencyStatus DetectUv() { - var status = new DependencyStatus("uv Package Manager", isRequired: true) + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) { - InstallationHint = GetUvInstallUrl() - }; + return status; + } + + // If the user provided an override path, keep the base result (failure likely means the override is invalid) + if (MCPServiceLocator.Paths.HasUvxPathOverride) + { + return status; + } try { - // Try running uv/uvx directly with augmented PATH - if (TryValidateUv("uv", out string version, out string fullPath) || - TryValidateUv("uvx", out version, out fullPath)) + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; return status; } - // Fallback: use which with augmented PATH - if (TryFindInPath("uv", out string pathResult) || - TryFindInPath("uvx", out pathResult)) - { - if (TryValidateUv(pathResult, out version, out fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found uv {version} in PATH"; - return status; - } - } - status.ErrorMessage = "uv not found in PATH"; status.Details = "Install uv package manager and ensure it's added to PATH."; } @@ -140,44 +139,29 @@ private bool TryValidatePython(string pythonPath, out string version, out string try { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + string augmentedPath = BuildAugmentedPath(); - // Set PATH to include common locations - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) { - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; + commandToRun = resolvedPath; + } - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; + version = output.Substring(7); + fullPath = commandToRun; - // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { - return major > 3 || (major >= 3 && minor >= 10); + return major > 3 || (major == 3 && minor >= 10); } } } @@ -189,52 +173,13 @@ private bool TryValidatePython(string pythonPath, out string version, out string return false; } - private bool TryValidateUv(string uvPath, out string version, out string fullPath) + protected string BuildAugmentedPath() { - version = null; - fullPath = null; + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - var augmentedPath = BuildAugmentedPath(); - psi.EnvironmentVariables["PATH"] = augmentedPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3).Trim(); - fullPath = uvPath; - return true; - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private string BuildAugmentedPath() - { - var pathAdditions = GetPathAdditions(); - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - return string.Join(":", pathAdditions) + ":" + currentPath; + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); } private string[] GetPathAdditions() @@ -250,54 +195,10 @@ private string[] GetPathAdditions() }; } - private bool TryFindInPath(string executable, out string fullPath) + protected override bool TryFindInPath(string executable, out string fullPath) { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Enhance PATH for Unity's GUI environment - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - fullPath = output; - return true; - } - } - catch - { - // Ignore errors - } - - return false; + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); } } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index dd554aff..fcbb0457 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -1,6 +1,7 @@ using System; -using System.Diagnostics; using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -26,90 +27,100 @@ public virtual DependencyStatus DetectUv() try { - // Try to find uv/uvx in PATH - if (TryFindUvInPath(out string uvPath, out string version)) + // Get uv path from PathResolverService (respects override) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + + // Verify uv executable and get version + if (MCPServiceLocator.Paths.TryValidateUvxExecutable(uvxPath, out string version)) { status.IsAvailable = true; status.Version = version; - status.Path = uvPath; - status.Details = $"Found uv {version} in PATH"; + status.Path = uvxPath; + status.Details = MCPServiceLocator.Paths.HasUvxPathOverride + ? $"Found uv {version} (override path)" + : $"Found uv {version} in system path"; return status; } - status.ErrorMessage = "uv not found in PATH"; - status.Details = "Install uv package manager and ensure it's added to PATH."; + status.ErrorMessage = "uvx not found"; + status.Details = "Install uv package manager or configure path override in Advanced Settings."; } catch (Exception ex) { - status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + status.ErrorMessage = $"Error detecting uvx: {ex.Message}"; } return status; } - protected bool TryFindUvInPath(out string uvPath, out string version) + protected bool TryParseVersion(string version, out int major, out int minor) { - uvPath = null; - version = null; - - // Try common uv command names - var commands = new[] { "uvx", "uv" }; + major = 0; + minor = 0; - foreach (var cmd in commands) + try { - try - { - var psi = new ProcessStartInfo - { - FileName = cmd, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) continue; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3).Trim(); - uvPath = cmd; - return true; - } - } - catch + var parts = version.Split('.'); + if (parts.Length >= 2) { - // Try next command + return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); } } + catch + { + // Ignore parsing errors + } return false; } - - protected bool TryParseVersion(string version, out int major, out int minor) + // In PlatformDetectorBase.cs + protected bool TryValidateUvWithPath(string command, string augmentedPath, out string version, out string fullPath) { - major = 0; - minor = 0; + version = null; + fullPath = null; try { - var parts = version.Split('.'); - if (parts.Length >= 2) + string commandToRun = command; + if (TryFindInPath(command, out string resolvedPath)) { - return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + string output = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim(); + + if (output.StartsWith("uvx ") || output.StartsWith("uv ")) + { + int spaceIndex = output.IndexOf(' '); + if (spaceIndex >= 0) + { + var remainder = output.Substring(spaceIndex + 1).Trim(); + int nextSpace = remainder.IndexOf(' '); + int parenIndex = remainder.IndexOf('('); + int endIndex = Math.Min( + nextSpace >= 0 ? nextSpace : int.MaxValue, + parenIndex >= 0 ? parenIndex : int.MaxValue + ); + version = endIndex < int.MaxValue ? remainder.Substring(0, endIndex).Trim() : remainder; + fullPath = commandToRun; + return true; + } } } catch { - // Ignore parsing errors + // Ignore validation errors } return false; } + + + // Add abstract method for subclasses to implement + protected abstract bool TryFindInPath(string executable, out string fullPath); } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index f21d58ff..1ce4acf7 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -1,9 +1,13 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -96,51 +100,84 @@ 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) + public override DependencyStatus DetectUv() { - version = null; - fullPath = null; + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path, keep the base result (failure typically means the override path is invalid) + if (MCPServiceLocator.Paths.HasUvxPathOverride) + { + return status; + } try { - var psi = new ProcessStartInfo + string augmentedPath = BuildAugmentedPath(); + + // try to find uv + if (TryValidateUvWithPath("uv.exe", augmentedPath, out string uvVersion, out string uvPath)) + { + status.IsAvailable = true; + status.Version = uvVersion; + status.Path = uvPath; + status.Details = $"Found uv {uvVersion} at {uvPath}"; + return status; + } + + // try to find uvx + if (TryValidateUvWithPath("uvx.exe", augmentedPath, out string uvxVersion, out string uvxPath)) { - 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 - }; + status.IsAvailable = true; + status.Version = uvxVersion; + status.Path = uvxPath; + status.Details = $"Found uvx {uvxVersion} at {uvxPath} (fallback)"; + return status; + } - using var process = Process.Start(psi); - if (process == null) return false; + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } - string output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(5000); - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) + private bool TryFindPythonViaUv(out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + // Try to list installed python versions via uvx + if (!ExecPath.TryRun("uv", "python list", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; + + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) { - var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + if (line.Contains("")) continue; + + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) { - // 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"))) { - 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)) { - if (TryValidatePython(potentialPath, out version, out fullPath)) - { - return true; - } + return true; } } } @@ -161,31 +198,29 @@ private bool TryValidatePython(string pythonPath, out string version, out string try { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + string augmentedPath = BuildAugmentedPath(); - using var process = Process.Start(psi); - if (process == null) return false; + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + // Run 'python --version' to get the version + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; + version = output.Substring(7); + fullPath = commandToRun; - // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { - return major > 3 || (major >= 3 && minor >= 10); + return major > 3 || (major == 3 && minor >= 10); } } } @@ -197,45 +232,65 @@ private bool TryValidatePython(string pythonPath, out string version, out string return false; } - private bool TryFindInPath(string executable, out string fullPath) + protected override bool TryFindInPath(string executable, out string fullPath) { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } - using var process = Process.Start(psi); - if (process == null) return false; + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) + private string[] GetPathAdditions() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + var additions = new List(); + + // uv common installation paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "uv")); + if (!string.IsNullOrEmpty(programFiles)) + additions.Add(Path.Combine(programFiles, "uv")); + + // npm global paths + if (!string.IsNullOrEmpty(appData)) + additions.Add(Path.Combine(appData, "npm")); + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "npm")); + + // Python common paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "Python")); + // Instead of hardcoded versions, enumerate existing directories + if (!string.IsNullOrEmpty(programFiles)) + { + try { - // Take the first result - var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > 0) + var pythonDirs = Directory.GetDirectories(programFiles, "Python3*") + .OrderByDescending(d => d); // Newest first + foreach (var dir in pythonDirs) { - fullPath = lines[0].Trim(); - return File.Exists(fullPath); + additions.Add(dir); } } - } - catch - { - // Ignore errors + catch { /* Ignore if directory doesn't exist */ } } - return false; + // User scripts + if (!string.IsNullOrEmpty(homeDir)) + additions.Add(Path.Combine(homeDir, ".local", "bin")); + + return additions.ToArray(); } } } diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index ebc82fe3..676d2567 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -77,6 +77,11 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl { unity["type"] = "http"; } + // Also add type for Claude Code (uses mcpServers layout but needs type field) + else if (client?.name == "Claude Code") + { + unity["type"] = "http"; + } } else { @@ -110,8 +115,8 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl } } - // Remove type for non-VSCode clients - if (!isVSCode && unity["type"] != null) + // Remove type for non-VSCode clients (except Claude Code which needs it) + if (!isVSCode && client?.name != "Claude Code" && unity["type"] != null) { unity.Remove("type"); } diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 2224009e..5df0374d 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -62,7 +62,7 @@ internal static string ResolveClaude() Path.Combine(localAppData, "npm", "claude.ps1"), }; foreach (string c in candidates) { if (File.Exists(c)) return c; } - string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); + string fromWhere = FindInPathWindows("claude.exe") ?? FindInPathWindows("claude.cmd") ?? FindInPathWindows("claude.ps1") ?? FindInPathWindows("claude"); if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; #endif return null; @@ -197,9 +197,9 @@ internal static bool TryRun( using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; - var so = new StringBuilder(); + var sb = new StringBuilder(); var se = new StringBuilder(); - process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + process.OutputDataReceived += (_, e) => { if (e.Data != null) sb.AppendLine(e.Data); }; process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; if (!process.Start()) return false; @@ -216,7 +216,7 @@ internal static bool TryRun( // Ensure async buffers are flushed process.WaitForExit(); - stdout = so.ToString(); + stdout = sb.ToString(); stderr = se.ToString(); return process.ExitCode == 0; } @@ -226,6 +226,21 @@ internal static bool TryRun( } } + /// + /// Cross-platform path lookup. Uses 'where' on Windows, 'which' on macOS/Linux. + /// Returns the full path if found, null otherwise. + /// + internal static string FindInPath(string executable, string extraPathPrepend = null) + { +#if UNITY_EDITOR_WIN + return FindInPathWindows(executable); +#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which(executable, extraPathPrepend ?? string.Empty); +#else + return null; +#endif + } + #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX private static string Which(string exe, string prependPath) { @@ -239,9 +254,22 @@ private static string Which(string exe, string prependPath) }; string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + using var p = Process.Start(psi); - string output = p?.StandardOutput.ReadToEnd().Trim(); - p?.WaitForExit(1500); + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string output = so.ToString().Trim(); return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; } catch { return null; } @@ -249,7 +277,7 @@ private static string Which(string exe, string prependPath) #endif #if UNITY_EDITOR_WIN - private static string Where(string exe) + private static string FindInPathWindows(string exe) { try { @@ -260,10 +288,22 @@ private static string Where(string exe) CreateNoWindow = true, }; using var p = Process.Start(psi); - string first = p?.StandardOutput.ReadToEnd() + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string first = so.ToString() .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); - p?.WaitForExit(1500); return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; } catch { return null; } diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs index 104c3113..0087aafb 100644 --- a/MCPForUnity/Editor/Services/IPathResolverService.cs +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -60,5 +60,13 @@ public interface IPathResolverService /// Gets whether a Claude CLI path override is active /// bool HasClaudeCliPathOverride { get; } + + /// + /// Validates the provided uv executable by running "--version" and parsing the output. + /// + /// Absolute or relative path to the uv/uvx executable. + /// Parsed version string if successful. + /// True when the executable runs and returns a uv version string. + bool TryValidateUvxExecutable(string uvPath, out string version); } } diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 4947a16d..ea20136a 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -21,41 +21,80 @@ public class PathResolverService : IPathResolverService public string GetUvxPath() { - try + // Check override first - only validate if explicitly set + if (HasUvxPathOverride) { string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); - if (!string.IsNullOrEmpty(overridePath)) + // Validate the override - if invalid, don't fall back to discovery + if (TryValidateUvxExecutable(overridePath, out string version)) { return overridePath; } - } - catch - { - // ignore EditorPrefs read errors and fall back to default command - McpLog.Debug("No uvx path override found, falling back to default command"); + // Override is set but invalid - return null (no fallback) + return null; } + // No override set - try discovery (uvx first, then uv) string discovered = ResolveUvxFromSystem(); if (!string.IsNullOrEmpty(discovered)) { return discovered; } + // Fallback to bare command return "uvx"; } - public string GetClaudeCliPath() + /// + /// Resolves uv/uvx from system by trying both commands. + /// Returns the full path if found, null otherwise. + /// + private static string ResolveUvxFromSystem() { try + { + // Try uvx first, then uv + string[] commandNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "uvx.exe", "uv.exe" } + : new[] { "uvx", "uv" }; + + foreach (string commandName in commandNames) + { + foreach (string candidate in EnumerateCommandCandidates(commandName)) + { + if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) + { + return candidate; + } + } + } + } + catch (Exception ex) + { + McpLog.Debug($"PathResolver error: {ex.Message}"); + } + + return null; + } + + + + public string GetClaudeCliPath() + { + // Check override first - only validate if explicitly set + if (HasClaudeCliPathOverride) { string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + // Validate the override - if invalid, don't fall back to discovery + if (File.Exists(overridePath)) { return overridePath; } + // Override is set but invalid - return null (no fallback) + return null; } - catch { /* ignore */ } + // No override - use platform-specific discovery if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { string[] candidates = new[] @@ -76,7 +115,7 @@ public string GetClaudeCliPath() { "/opt/homebrew/bin/claude", "/usr/local/bin/claude", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") }; foreach (var c in candidates) @@ -90,7 +129,7 @@ public string GetClaudeCliPath() { "/usr/bin/claude", "/usr/local/bin/claude", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") }; foreach (var c in candidates) @@ -104,37 +143,130 @@ public string GetClaudeCliPath() public bool IsPythonDetected() { + return ExecPath.TryRun( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3", + "--version", + null, + out _, + out _, + 2000); + } + + public bool IsClaudeCliDetected() + { + return !string.IsNullOrEmpty(GetClaudeCliPath()); + } + + public void SetUvxPathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearUvxPathOverride(); + return; + } + + if (!File.Exists(path)) + { + throw new ArgumentException("The selected uvx executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path); + } + + public void SetClaudeCliPathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearClaudeCliPathOverride(); + return; + } + + if (!File.Exists(path)) + { + throw new ArgumentException("The selected Claude CLI executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path); + } + + public void ClearUvxPathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride); + } + + public void ClearClaudeCliPathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); + } + + /// + /// Validates the provided uv executable by running "--version" and parsing the output. + /// + /// Absolute or relative path to the uv/uvx executable. + /// Parsed version string if successful. + /// True when the executable runs and returns a uvx version string. + public bool TryValidateUvxExecutable(string uvxPath, out string version) + { + version = null; + + if (string.IsNullOrEmpty(uvxPath)) + return false; + try { - var psi = new ProcessStartInfo + // Check if the path is just a command name (no directory separator) + bool isBareCommand = !uvxPath.Contains('/') && !uvxPath.Contains('\\'); + + if (isBareCommand) { - FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3", - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - p.WaitForExit(2000); - return p.ExitCode == 0; + // For bare commands like "uvx" or "uv", use EnumerateCommandCandidates to find full path first + string fullPath = FindUvxExecutableInPath(uvxPath); + if (string.IsNullOrEmpty(fullPath)) + return false; + uvxPath = fullPath; + } + + // Use ExecPath.TryRun which properly handles async output reading and timeouts + if (!ExecPath.TryRun(uvxPath, "--version", null, out string stdout, out string stderr, 5000)) + return false; + + // Check stdout first, then stderr (some tools output to stderr) + string versionOutput = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + + // uv/uvx outputs "uv x.y.z" or "uvx x.y.z", extract version number + if (versionOutput.StartsWith("uvx ") || versionOutput.StartsWith("uv ")) + { + // Extract version: "uv 0.9.18 (hash date)" -> "0.9.18" + int spaceIndex = versionOutput.IndexOf(' '); + if (spaceIndex >= 0) + { + string afterCommand = versionOutput.Substring(spaceIndex + 1).Trim(); + // Version is up to the first space or parenthesis + int nextSpace = afterCommand.IndexOf(' '); + int parenIndex = afterCommand.IndexOf('('); + int endIndex = Math.Min( + nextSpace >= 0 ? nextSpace : int.MaxValue, + parenIndex >= 0 ? parenIndex : int.MaxValue + ); + version = endIndex < int.MaxValue ? afterCommand.Substring(0, endIndex).Trim() : afterCommand; + return true; + } + } } catch { - return false; + // Ignore validation errors } - } - public bool IsClaudeCliDetected() - { - return !string.IsNullOrEmpty(GetClaudeCliPath()); + return false; } - private static string ResolveUvxFromSystem() + private string FindUvxExecutableInPath(string commandName) { try { - foreach (string candidate in EnumerateUvxCandidates()) + // Generic search for any command in PATH and common locations + foreach (string candidate in EnumerateCommandCandidates(commandName)) { if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) { @@ -144,16 +276,35 @@ private static string ResolveUvxFromSystem() } catch { - // fall back to bare command + // Ignore errors } return null; } - private static IEnumerable EnumerateUvxCandidates() + /// + /// Enumerates candidate paths for a generic command name. + /// Searches PATH and common locations. + /// + private static IEnumerable EnumerateCommandCandidates(string commandName) { - string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uvx.exe" : "uvx"; + string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !commandName.EndsWith(".exe") + ? commandName + ".exe" + : commandName; + + // Search PATH first + string pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathEnv)) + { + foreach (string rawDir in pathEnv.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(rawDir)) continue; + string dir = rawDir.Trim(); + yield return Path.Combine(dir, exeName); + } + } + // User-local binary directories string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrEmpty(home)) { @@ -161,6 +312,7 @@ private static IEnumerable EnumerateUvxCandidates() yield return Path.Combine(home, ".cargo", "bin", exeName); } + // System directories (platform-specific) if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { yield return "/opt/homebrew/bin/" + exeName; @@ -179,6 +331,8 @@ private static IEnumerable EnumerateUvxCandidates() if (!string.IsNullOrEmpty(localAppData)) { yield return Path.Combine(localAppData, "Programs", "uv", exeName); + // WinGet creates shim files in this location + yield return Path.Combine(localAppData, "Microsoft", "WinGet", "Links", exeName); } if (!string.IsNullOrEmpty(programFiles)) @@ -186,65 +340,6 @@ private static IEnumerable EnumerateUvxCandidates() yield return Path.Combine(programFiles, "uv", exeName); } } - - string pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (!string.IsNullOrEmpty(pathEnv)) - { - foreach (string rawDir in pathEnv.Split(Path.PathSeparator)) - { - if (string.IsNullOrWhiteSpace(rawDir)) continue; - string dir = rawDir.Trim(); - yield return Path.Combine(dir, exeName); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Some PATH entries may already contain the file without extension - yield return Path.Combine(dir, "uvx"); - } - } - } - } - - public void SetUvxPathOverride(string path) - { - if (string.IsNullOrEmpty(path)) - { - ClearUvxPathOverride(); - return; - } - - if (!File.Exists(path)) - { - throw new ArgumentException("The selected uvx executable does not exist"); - } - - EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path); - } - - public void SetClaudeCliPathOverride(string path) - { - if (string.IsNullOrEmpty(path)) - { - ClearClaudeCliPathOverride(); - return; - } - - if (!File.Exists(path)) - { - throw new ArgumentException("The selected Claude CLI executable does not exist"); - } - - EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path); - } - - public void ClearUvxPathOverride() - { - EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride); - } - - public void ClearClaudeCliPathOverride() - { - EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); } } } diff --git a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs index 7f782f8a..a754a52b 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs @@ -197,9 +197,12 @@ public void UpdatePathOverrides() uvxPathStatus.RemoveFromClassList("valid"); uvxPathStatus.RemoveFromClassList("invalid"); + if (hasOverride) { - if (!string.IsNullOrEmpty(uvxPath) && File.Exists(uvxPath)) + // Override mode: validate the override path + string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + if (pathService.TryValidateUvxExecutable(overridePath, out _)) { uvxPathStatus.AddToClassList("valid"); } @@ -210,7 +213,16 @@ public void UpdatePathOverrides() } else { - uvxPathStatus.AddToClassList("valid"); + // PATH mode: validate system uvx + string systemUvxPath = pathService.GetUvxPath(); + if (!string.IsNullOrEmpty(systemUvxPath) && pathService.TryValidateUvxExecutable(systemUvxPath, out _)) + { + uvxPathStatus.AddToClassList("valid"); + } + else + { + uvxPathStatus.AddToClassList("invalid"); + } } gitUrlOverride.value = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); diff --git a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml index 1f15748c..685033fb 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml @@ -20,7 +20,7 @@ - +