diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index cd810c449..9c0ffe41d 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -67,11 +67,11 @@ jobs: jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp mv MCPForUnity/package.json.tmp MCPForUnity/package.json - echo "Updating Server/pyproject.toml to $NEW_VERSION" - sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "Server/pyproject.toml" + echo "Updating MCPForUnity/Server~/pyproject.toml to $NEW_VERSION" + sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/Server~/pyproject.toml" - echo "Updating Server/README.md version references to v$NEW_VERSION" - sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' Server/README.md + echo "Updating MCPForUnity/Server~/README.md version references to v$NEW_VERSION" + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=MCPForUnity/Server~|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=MCPForUnity/Server~|g' "MCPForUnity/Server~/README.md" - name: Commit and push changes env: @@ -81,7 +81,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add MCPForUnity/package.json "Server/pyproject.toml" Server/README.md + git add MCPForUnity/package.json "MCPForUnity/Server~/pyproject.toml" "MCPForUnity/Server~/README.md" if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.github/workflows/claude-mcp-preflight.yml b/.github/workflows/claude-mcp-preflight.yml index dff69bd7b..c018397a2 100644 --- a/.github/workflows/claude-mcp-preflight.yml +++ b/.github/workflows/claude-mcp-preflight.yml @@ -25,10 +25,10 @@ jobs: uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f Server/pyproject.toml ]; then - uv pip install -e Server - elif [ -f Server/requirements.txt ]; then - uv pip install -r Server/requirements.txt + if [ -f MCPForUnity/Server~/pyproject.toml ]; then + uv pip install -e "MCPForUnity/Server~" + elif [ -f MCPForUnity/Server~/requirements.txt ]; then + uv pip install -r MCPForUnity/Server~/requirements.txt else echo "No MCP Python deps found" >&2 exit 1 @@ -48,8 +48,6 @@ jobs: cat > "$UNITY_MCP_STATUS_DIR/unity-mcp-status-dummy.json" < /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } cat /tmp/mcp-preflight.log - - diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 54faaa98c..3bffbae74 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -55,10 +55,10 @@ jobs: uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f Server/pyproject.toml ]; then - uv pip install -e Server - elif [ -f Server/requirements.txt ]; then - uv pip install -r Server/requirements.txt + if [ -f MCPForUnity/Server~/pyproject.toml ]; then + uv pip install -e "MCPForUnity/Server~" + elif [ -f MCPForUnity/Server~/requirements.txt ]; then + uv pip install -r MCPForUnity/Server~/requirements.txt else echo "No MCP Python deps found (skipping)" fi @@ -409,7 +409,7 @@ jobs: "run", "--active", "--directory", - "Server", + "MCPForUnity/Server~", "mcp-for-unity", "--transport", "stdio", @@ -512,7 +512,7 @@ jobs: attempt=0 while true; do attempt=$((attempt+1)) - if uv run --active --directory Server mcp-for-unity --transport stdio --help > /tmp/mcp-preflight.log 2>&1; then + if uv run --active --directory "MCPForUnity/Server~" mcp-for-unity --transport stdio --help > /tmp/mcp-preflight.log 2>&1; then cat /tmp/mcp-preflight.log break fi @@ -541,7 +541,7 @@ jobs: echo "--- PortDiscovery debug ---" python3 - <<'PY' import sys - sys.path.insert(0, "Server/src") + sys.path.insert(0, "MCPForUnity/Server~/src") from transport.legacy.port_discovery import PortDiscovery import json @@ -556,7 +556,7 @@ jobs: import json import subprocess cmd = [ - "uv", "run", "--active", "--directory", "Server", "python", "-c", + "uv", "run", "--active", "--directory", "MCPForUnity/Server~", "python", "-c", "from transport.legacy.stdio_port_registry import stdio_port_registry; " "inst = stdio_port_registry.get_instances(force_refresh=True); " "import json; print(json.dumps([{'id':i.id,'port':i.port} for i in inst]))" @@ -577,7 +577,7 @@ jobs: PY echo "=== Testing MCP server startup with --status-dir flag ===" - uv run --active --directory Server python <<'PYTEST' + uv run --active --directory "MCPForUnity/Server~" python <<'PYTEST' import os import sys import glob @@ -602,7 +602,7 @@ jobs: set -euxo pipefail echo "=== Unity container status ===" docker inspect -f '{{.State.Status}} {{.State.Running}}' unity-mcp || echo "Container not found!" - + echo "=== Raw socket probe to Unity ===" # Try raw TCP connect without Python overhead for host in 127.0.0.1 localhost; do @@ -613,10 +613,10 @@ jobs: echo "$host:6400 - FAILED" fi done - + echo "=== Netstat for port 6400 ===" docker exec unity-mcp netstat -tlnp 2>/dev/null | grep 6400 || ss -tlnp | grep 6400 || echo "No listener found on 6400" - + echo "=== Python probe with timing ===" python3 <<'PY' import socket, time diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2269758cd..34cb31272 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -4,7 +4,7 @@ on: push: branches: ["**"] paths: - - Server/** + - MCPForUnity/Server~/** - .github/workflows/python-tests.yml workflow_dispatch: {} @@ -22,17 +22,17 @@ jobs: version: "latest" - name: Set up Python - run: uv python install 3.10 + run: uv python install 3.11 - name: Install dependencies run: | - cd Server + cd MCPForUnity/Server~ uv sync uv pip install -e ".[dev]" - name: Run tests run: | - cd Server + cd MCPForUnity/Server~ uv run pytest tests/ -v --tb=short - name: Upload test results @@ -41,5 +41,5 @@ jobs: with: name: pytest-results path: | - Server/.pytest_cache/ - Server/tests/ + MCPForUnity/Server~/.pytest_cache/ + MCPForUnity/Server~/tests/ diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index f1dc2eb98..852a9202c 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -102,6 +102,8 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) string configuredUrl = null; bool configExists = false; + string command = null; + if (client.IsVsCodeLayout) { var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; @@ -114,12 +116,8 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) if (unityToken is JObject unityObj) { configExists = true; - - var argsToken = unityObj["args"]; - if (argsToken is JArray) - { - args = argsToken.ToObject(); - } + command = ExtractCommand(unityObj); + args = ExtractArgs(unityObj); var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; if (urlToken != null && urlToken.Type != JTokenType.Null) @@ -131,11 +129,22 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) } else { - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); - if (standardConfig?.mcpServers?.unityMCP != null) + // Use JObject parsing for flexibility (handling both url and serverUrl without strict model dependency) + var rootObj = JsonConvert.DeserializeObject(configJson) as JObject; + var unityToken = rootObj?["mcpServers"]?["unityMCP"]; + + if (unityToken is JObject unityObj) { - args = standardConfig.mcpServers.unityMCP.args; configExists = true; + command = ExtractCommand(unityObj); + args = ExtractArgs(unityObj); + + // Check for both 'url' (standard) and 'serverUrl' (Antigravity) + var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } } } @@ -146,17 +155,44 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) } bool matches = false; - if (args != null && args.Length > 0) + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) { - string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); - string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); - matches = !string.IsNullOrEmpty(configuredUvxUrl) && - McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + // Check: HTTP (Only check this if we are in HTTP mode) + if (!string.IsNullOrEmpty(configuredUrl)) + { + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } } - else if (!string.IsNullOrEmpty(configuredUrl)) + else { - string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); - matches = UrlsEqual(configuredUrl, expectedUrl); + // Check: Node.js Wrapper (Stdio) (Only check this if we are in Stdio mode) + string expectedWrapper = AssetPathUtility.GetWrapperJsPath(); + if (!string.IsNullOrEmpty(command) && args != null && args.Length > 0 && !string.IsNullOrEmpty(expectedWrapper)) + { + try + { + if (Path.GetFileNameWithoutExtension(command).Equals("node", StringComparison.OrdinalIgnoreCase) && + McpConfigurationHelper.PathsEqual(args[0], expectedWrapper)) + { + matches = true; + } + } + catch (ArgumentException) + { + // Invalid command path, skip Node.js wrapper check + } + } + // Check: UVX (Stdio - Legacy/Fallback) + else if (args != null && args.Length > 0) + { + string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + } } if (matches) @@ -190,6 +226,26 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) return client.status; } + private static string ExtractCommand(JObject configObj) + { + var commandToken = configObj["command"]; + if (commandToken != null && commandToken.Type == JTokenType.String) + { + return commandToken.ToString(); + } + return null; + } + + private static string[] ExtractArgs(JObject configObj) + { + var argsToken = configObj["args"]; + if (argsToken is JArray) + { + return argsToken.ToObject(); + } + return null; + } + public override void Configure() { string path = GetConfigPath(); @@ -327,6 +383,9 @@ public override string GetManualSnippet() /// CLI-based configurator (Claude Code). public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase { + private static readonly object _claudeCliLock = new object(); + private static bool _isClaudeCliRunning = false; + public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } public override bool SupportsAutoConfigure => true; @@ -349,28 +408,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) string args = "mcp list"; string projectDir = Path.GetDirectoryName(Application.dataPath); - - 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 { } + string pathPrepend = BuildPathPrepend(claudePath); if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out _, 10000, pathPrepend)) { @@ -405,84 +443,127 @@ public override void Configure() private void Register() { - var pathService = MCPServiceLocator.Paths; - string claudePath = pathService.GetClaudeCliPath(); - if (string.IsNullOrEmpty(claudePath)) + lock (_claudeCliLock) { - throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + if (_isClaudeCliRunning) + { + throw new InvalidOperationException("Claude CLI operation already in progress. Please wait."); + } + _isClaudeCliRunning = true; } + + try + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - string args; - if (useHttpTransport) - { - string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); - args = $"mcp add --transport http UnityMCP {httpUrl}"; - } - else - { - var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; - } + string args; + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + else + { + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; + } - string projectDir = Path.GetDirectoryName(Application.dataPath); + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = BuildPathPrepend(claudePath); - string pathPrepend = null; - if (Application.platform == RuntimePlatform.OSXEditor) - { - pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + bool already = false; + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + string combined = ($"{stdout}\n{stderr}") ?? string.Empty; + if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + { + already = true; + } + else + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + } + + if (!already) + { + McpLog.Info("Successfully registered with Claude Code."); + } + + CheckStatus(); } - else if (Application.platform == RuntimePlatform.LinuxEditor) + finally { - pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + lock (_claudeCliLock) + { + _isClaudeCliRunning = false; + } } + } - try + private void Unregister() + { + lock (_claudeCliLock) { - string claudeDir = Path.GetDirectoryName(claudePath); - if (!string.IsNullOrEmpty(claudeDir)) + if (_isClaudeCliRunning) { - pathPrepend = string.IsNullOrEmpty(pathPrepend) - ? claudeDir - : $"{claudeDir}:{pathPrepend}"; + throw new InvalidOperationException("Claude CLI operation already in progress. Please wait."); } + _isClaudeCliRunning = true; } - catch { } - - bool already = false; - if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + + try { - string combined = ($"{stdout}\n{stderr}") ?? string.Empty; - if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = BuildPathPrepend(claudePath); + + bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + + if (!serverExists) { - already = true; + 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 register with Claude Code:\n{stderr}\n{stdout}"); + throw new InvalidOperationException($"Failed to unregister: {stderr}"); } - } - if (!already) + client.SetStatus(McpStatus.NotConfigured); + CheckStatus(); + } + finally { - McpLog.Info("Successfully registered with Claude Code."); + lock (_claudeCliLock) + { + _isClaudeCliRunning = false; + } } - - CheckStatus(); } - private void Unregister() + private static string BuildPathPrepend(string claudePath) { - var pathService = MCPServiceLocator.Paths; - string claudePath = pathService.GetClaudeCliPath(); - - if (string.IsNullOrEmpty(claudePath)) - { - throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); - } - - string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; if (Application.platform == RuntimePlatform.OSXEditor) { @@ -493,26 +574,19 @@ private void Unregister() pathPrepend = "/usr/local/bin:/usr/bin:/bin"; } - bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - - if (!serverExists) - { - 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 + try { - throw new InvalidOperationException($"Failed to unregister: {stderr}"); + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } } + catch { } - client.SetStatus(McpStatus.NotConfigured); - CheckStatus(); + return pathPrepend; } public override string GetManualSnippet() diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index d6edcf1aa..e597181db 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -21,21 +21,15 @@ internal static class EditorPrefKeys internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; - internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath"; - internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath"; - internal const string PackageDeployLastTargetPath = "MCPForUnity.PackageDeploy.LastTargetPath"; - internal const string PackageDeployLastSourcePath = "MCPForUnity.PackageDeploy.LastSourcePath"; - internal const string ServerSrc = "MCPForUnity.ServerSrc"; internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled"; - internal const string ToolEnabledPrefix = "MCPForUnity.ToolEnabled."; - internal const string ToolFoldoutStatePrefix = "MCPForUnity.ToolFoldout."; - internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel"; internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; + + internal const string EditorWindowActivePanel = "MCPForUnityEditorWindow.ActivePanel"; internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled"; @@ -45,5 +39,18 @@ internal static class EditorPrefKeys internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + + // Path Overrides + internal const string PythonPathOverride = "MCPForUnity.PythonPathOverride"; + internal const string NodePathOverride = "MCPForUnity.NodePathOverride"; + internal const string UvPathOverride = "MCPForUnity.UvPathOverride"; + + // Deployment & Tool Config + internal const string PackageDeploySourcePath = "MCPForUnity.Deploy.SourcePath"; + internal const string PackageDeployLastBackupPath = "MCPForUnity.Deploy.LastBackupPath"; + internal const string PackageDeployLastTargetPath = "MCPForUnity.Deploy.LastTargetPath"; + internal const string PackageDeployLastSourcePath = "MCPForUnity.Deploy.LastSourcePath"; + internal const string ToolFoldoutStatePrefix = "MCPForUnity.Tool.Foldout."; + internal const string ToolEnabledPrefix = "MCPForUnity.Tool.Enabled."; } } diff --git a/Server/__init__.py b/MCPForUnity/Editor/Data/.keep similarity index 100% rename from Server/__init__.py rename to MCPForUnity/Editor/Data/.keep diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs index 3f7b15455..60cd87cbb 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -5,11 +5,24 @@ using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Dependencies.PlatformDetectors; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Dependencies { + /// + /// Context object containing all necessary paths and settings for dependency checking. + /// This allows the check to run on a background thread without accessing Unity APIs. + /// + public class DependencyContext + { + public string PackageRootPath { get; set; } + public string PythonOverridePath { get; set; } + public string NodeOverridePath { get; set; } + public string UvOverridePath { get; set; } + } + /// /// Main orchestrator for dependency validation and management /// @@ -41,9 +54,31 @@ public static IPlatformDetector GetCurrentPlatformDetector() } /// - /// Perform a comprehensive dependency check + /// Perform a comprehensive dependency check (Synchronous - Main Thread Only) /// public static DependencyCheckResult CheckAllDependencies() + { + // Gather context on main thread + var context = new DependencyContext + { + PackageRootPath = AssetPathUtility.GetMcpPackageRootPath(), // Changed to match local API + PythonOverridePath = MCPServiceLocator.Paths.GetPythonPath(), + NodeOverridePath = MCPServiceLocator.Paths.GetNodePath(), + UvOverridePath = MCPServiceLocator.Paths.GetUvxPath() + }; + + return CheckAllDependenciesInternal(context); + } + + /// + /// Perform a comprehensive dependency check (Thread-Safe) + /// + public static DependencyCheckResult CheckAllDependenciesAsync(DependencyContext context) + { + return CheckAllDependenciesInternal(context); + } + + private static DependencyCheckResult CheckAllDependenciesInternal(DependencyContext context) { var result = new DependencyCheckResult(); @@ -53,13 +88,52 @@ public static DependencyCheckResult CheckAllDependencies() McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); // Check Python - var pythonStatus = detector.DetectPython(); + var pythonStatus = detector.DetectPython(context.PythonOverridePath); result.Dependencies.Add(pythonStatus); // Check uv - var uvStatus = detector.DetectUv(); + var uvStatus = detector.DetectUv(context.UvOverridePath); result.Dependencies.Add(uvStatus); + // Check Node.js + // Note: If detector doesn't support DetectNode yet, we might need to update detectors too. + // Assuming detectors are being updated or have default interface implementation. + var nodeStatus = detector.DetectNode(context.NodeOverridePath); + result.Dependencies.Add(nodeStatus); + + // Check Server Environment + // We assume ServerEnvironmentSetup.IsEnvironmentReady exists. + // If not, we'll need to update that too. + // Note: IsEnvironmentReady might access Unity API. If so, this "Async" flavor is risky. + // But CheckAllDependencies() is called on main thread in our UI usage. + + // For now, we use a try-catch block specifically for server check to avoid blocking the whole result + try + { + // We use the path gathered on the main thread to ensure thread safety + bool isServerReady = MCPForUnity.Editor.Setup.ServerEnvironmentSetup.IsEnvironmentReady(context.PackageRootPath); + + result.Dependencies.Add(new DependencyStatus("Server Environment", true) + { + Version = isServerReady ? "Installed" : "Missing", + IsAvailable = isServerReady, + Details = isServerReady ? "Virtual Environment Ready" : "Run 'Install Server Environment'", + ErrorMessage = isServerReady ? null : "Virtual environment not set up" + }); + } + catch (Exception ex) + { + McpLog.Warn($"Server environment check failed: {ex.Message}"); + result.Dependencies.Add(new DependencyStatus("Server Environment", true) + { + Version = "Error", + IsAvailable = false, + Details = "Check failed", + ErrorMessage = ex.Message + }); + } + + // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); @@ -122,12 +196,16 @@ private static void GenerateRecommendations(DependencyCheckResult result, IPlatf { if (dep.Name == "Python") { - result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); + result.RecommendedActions.Add($"Install python 3.11+ from: {detector.GetPythonInstallUrl()}"); } else if (dep.Name == "uv Package Manager") { result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}"); } + else if (dep.Name == "Node.js") + { + result.RecommendedActions.Add("Install Node.js (LTS) from: https://nodejs.org/"); + } else if (dep.Name == "MCP Server") { result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs index 3231105e9..31594995e 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -20,12 +20,17 @@ public interface IPlatformDetector /// /// Detect Python installation on this platform /// - DependencyStatus DetectPython(); + DependencyStatus DetectPython(string overridePath = null); /// /// Detect uv package manager on this platform /// - DependencyStatus DetectUv(); + DependencyStatus DetectUv(string overridePath = null); + + /// + /// Detect Node.js on this platform + /// + DependencyStatus DetectNode(string overridePath = null); /// /// Get platform-specific installation recommendations diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs index 1c5bf4587..38ca98c38 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -16,7 +16,7 @@ public class LinuxPlatformDetector : PlatformDetectorBase public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - public override DependencyStatus DetectPython() + public override DependencyStatus DetectPython(string overridePath = null) { var status = new DependencyStatus("Python", isRequired: true) { @@ -25,6 +25,23 @@ public override DependencyStatus DetectPython() try { + // 1. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.PythonPathOverride, ""); } catch {} + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidatePython(overridePath, out string ovVersion, out string ovPath)) + { + status.IsAvailable = true; + status.Version = ovVersion; + status.Path = ovPath; + status.Details = $"Using custom Python path: {ovPath}"; + return status; + } + } // Try running python directly first if (TryValidatePython("python3", out string version, out string fullPath) || TryValidatePython("python", out version, out fullPath)) @@ -51,7 +68,7 @@ public override DependencyStatus DetectPython() } status.ErrorMessage = "Python not found in PATH"; - status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + status.Details = "Install python 3.11+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -90,7 +107,7 @@ public override string GetInstallationRecommendations() Note: Make sure ~/.local/bin is in your PATH for user-local installations."; } - public override DependencyStatus DetectUv() + public override DependencyStatus DetectUv(string overridePath = null) { var status = new DependencyStatus("uv Package Manager", isRequired: true) { @@ -99,6 +116,24 @@ public override DependencyStatus DetectUv() try { + // 0. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.UvPathOverride, ""); } catch {} + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidateUv(overridePath, out string ovVersion, out string ovPath)) + { + status.IsAvailable = true; + status.Version = ovVersion; + status.Path = ovPath; + status.Details = $"Using custom uv path: {ovPath}"; + return status; + } + } + // Try running uv/uvx directly with augmented PATH if (TryValidateUv("uv", out string version, out string fullPath) || TryValidateUv("uvx", out version, out fullPath)) @@ -140,55 +175,22 @@ private bool TryValidatePython(string pythonPath, out string version, out string version = null; fullPath = null; - try + var env = new System.Collections.Generic.Dictionary { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Set PATH to include common locations - 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; + ["PATH"] = BuildAugmentedPath() + }; - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + if (TryExecuteProcess(pythonPath, "--version", 5000, out string output, env) && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Validate minimum version (Python 4+ or python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // 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); } } - catch - { - // Ignore validation errors - } - return false; } @@ -197,38 +199,17 @@ private bool TryValidateUv(string uvPath, out string version, out string fullPat version = null; fullPath = null; - try + var env = new System.Collections.Generic.Dictionary { - 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); + ["PATH"] = BuildAugmentedPath() + }; - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3).Trim(); - fullPath = uvPath; - return true; - } - } - catch + if (TryExecuteProcess(uvPath, "--version", 5000, out string output, env) && output.StartsWith("uv ")) { - // Ignore validation errors + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; } - return false; } @@ -255,49 +236,20 @@ private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; - try + var env = new System.Collections.Generic.Dictionary { - 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); + ["PATH"] = BuildAugmentedPath() + }; - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + // uses 'which' to find the executable path + if (TryExecuteProcess("/usr/bin/which", executable, 3000, out string output, env)) + { + if (!string.IsNullOrEmpty(output) && File.Exists(output)) { fullPath = output; return true; } } - catch - { - // Ignore errors - } - return false; } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs index 0f9c6e112..04b24f3e3 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -16,7 +16,7 @@ public class MacOSPlatformDetector : PlatformDetectorBase public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - public override DependencyStatus DetectPython() + public override DependencyStatus DetectPython(string overridePath = null) { var status = new DependencyStatus("Python", isRequired: true) { @@ -25,33 +25,50 @@ public override DependencyStatus DetectPython() try { - // 1. Try 'which' command with augmented PATH (prioritizing Homebrew) - if (TryFindInPath("python3", out string pathResult) || - TryFindInPath("python", out pathResult)) + // 1. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.PythonPathOverride, ""); } catch {} + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(overridePath, out string ovVersion, out string ovPath)) { status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; + status.Version = ovVersion; + status.Path = ovPath; + status.Details = $"Using custom Python path: {ovPath}"; return status; } } - - // 2. Fallback: Try running python directly from PATH - if (TryValidatePython("python3", out string v, out string p) || - TryValidatePython("python", out v, out p)) + // 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 = v; - status.Path = p; - status.Details = $"Found Python {v} in PATH"; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} 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."; + // Fallback: try 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install python 3.11+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -88,7 +105,7 @@ public override string GetInstallationRecommendations() Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; } - public override DependencyStatus DetectUv() + public override DependencyStatus DetectUv(string overridePath = null) { var status = new DependencyStatus("uv Package Manager", isRequired: true) { @@ -97,6 +114,23 @@ public override DependencyStatus DetectUv() try { + // 0. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.UvPathOverride, ""); } catch {} + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidateUv(overridePath, out string ovVersion, out string ovPath)) + { + status.IsAvailable = true; + status.Version = ovVersion; + status.Path = ovPath; + status.Details = $"Using custom uv path: {ovPath}"; + return status; + } + } // Try running uv/uvx directly with augmented PATH if (TryValidateUv("uv", out string version, out string fullPath) || TryValidateUv("uvx", out version, out fullPath)) @@ -138,54 +172,22 @@ private bool TryValidatePython(string pythonPath, out string version, out string version = null; fullPath = null; - try + var env = new System.Collections.Generic.Dictionary { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Set PATH to include common locations - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/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; + ["PATH"] = BuildAugmentedPath() + }; - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + if (TryExecuteProcess(pythonPath, "--version", 5000, out string output, env) && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Validate minimum version (Python 4+ or Python 3.11+) + if (TryParseVersion(version, out var major, out var minor)) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // 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 >= 11); } } - catch - { - // Ignore validation errors - } - return false; } @@ -194,39 +196,38 @@ private bool TryValidateUv(string uvPath, out string version, out string fullPat version = null; fullPath = null; - try + var env = new System.Collections.Generic.Dictionary { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - var augmentedPath = BuildAugmentedPath(); - psi.EnvironmentVariables["PATH"] = augmentedPath; + ["PATH"] = BuildAugmentedPath() + }; - using var process = Process.Start(psi); - if (process == null) return false; + if (TryExecuteProcess(uvPath, "--version", 5000, out string output, env) && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; + } + return false; + } - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + var env = new System.Collections.Generic.Dictionary + { + ["PATH"] = BuildAugmentedPath() + }; - if (process.ExitCode == 0 && output.StartsWith("uv ")) + // uses 'which' to find the executable path + if (TryExecuteProcess("/usr/bin/which", executable, 3000, out string output, env)) + { + if (!string.IsNullOrEmpty(output) && File.Exists(output)) { - version = output.Substring(3).Trim(); - fullPath = uvPath; + fullPath = output; return true; } } - catch - { - // Ignore validation errors - } - return false; } @@ -249,55 +250,5 @@ private string[] GetPathAdditions() Path.Combine(homeDir, ".local", "bin") }; } - - private 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; - } } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index dd554aff4..d6407d045 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -1,6 +1,10 @@ using System; using System.Diagnostics; +using System.IO; +using System.Text; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Dependencies.Models; +using UnityEditor; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -12,12 +16,12 @@ public abstract class PlatformDetectorBase : IPlatformDetector public abstract string PlatformName { get; } public abstract bool CanDetect { get; } - public abstract DependencyStatus DetectPython(); + public abstract DependencyStatus DetectPython(string overridePath = null); public abstract string GetPythonInstallUrl(); public abstract string GetUvInstallUrl(); public abstract string GetInstallationRecommendations(); - public virtual DependencyStatus DetectUv() + public virtual DependencyStatus DetectUv(string overridePath = null) { var status = new DependencyStatus("uv Package Manager", isRequired: true) { @@ -26,7 +30,26 @@ public virtual DependencyStatus DetectUv() try { - // Try to find uv/uvx in PATH + // 0. Check Override + if (overridePath == null) + { + overridePath = GetEditorPrefsSafely(EditorPrefKeys.UvPathOverride); + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + // Validate version of the override executable + if (TryExecuteProcess(overridePath, "--version", 3000, out string output) && output.StartsWith("uv ")) + { + status.IsAvailable = true; + status.Version = output.Substring(3).Trim(); + status.Path = overridePath; + status.Details = $"Using custom uv path: {overridePath}"; + return status; + } + } + + // 1. Try to find uv/uvx in PATH if (TryFindUvInPath(out string uvPath, out string version)) { status.IsAvailable = true; @@ -36,6 +59,16 @@ public virtual DependencyStatus DetectUv() return status; } + // 2. Fallback: Try to find uv in Python Scripts (if installed via pip but not in PATH) + if (TryFindUvViaPython(out uvPath, out version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = uvPath; + status.Details = $"Found uv {version} via Python"; + return status; + } + status.ErrorMessage = "uv not found in PATH"; status.Details = "Install uv package manager and ensure it's added to PATH."; } @@ -47,6 +80,112 @@ public virtual DependencyStatus DetectUv() return status; } + protected virtual bool TryFindUvViaPython(out string uvPath, out string version) + { + uvPath = null; + version = null; + try + { + // Ask Python where the Scripts folder is and check for uv + string script = "import sys, os; print(os.path.join(sys.prefix, 'Scripts' if os.name == 'nt' else 'bin', 'uv' + ('.exe' if os.name == 'nt' else '')))"; + + // Assume python is in PATH. + // Note: This might pick up a different python than what the user configured if they have strict overrides, + // but this is a fallback mechanism. + if (TryExecuteProcess("python", $"-c \"{script}\"", 3000, out string path)) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + // Found the binary, now check version + if (TryExecuteProcess(path, "--version", 3000, out string output) && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + uvPath = path; + return true; + } + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"TryFindUvViaPython failed: {ex.Message}"); + } + return false; + } + + public virtual DependencyStatus DetectNode(string overridePath = null) + { + var status = new DependencyStatus("Node.js", isRequired: true) + { + InstallationHint = "https://nodejs.org/" + }; + + try + { + // 1. Check Override + if (overridePath == null) + { + overridePath = GetEditorPrefsSafely(EditorPrefKeys.NodePathOverride); + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidateNode(overridePath, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = overridePath; + status.Details = $"Using custom Node.js path: {overridePath}"; + return status; + } + } + + // 2. Try to find node in PATH + if (TryFindNodeInPath(out string nodePath, out string nodeVersion)) + { + status.IsAvailable = true; + status.Version = nodeVersion; + status.Path = nodePath; + status.Details = $"Found Node.js {nodeVersion} in PATH"; + return status; + } + + status.ErrorMessage = "Node.js not found in PATH"; + status.Details = "Install Node.js (LTS recommended) and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Node.js: {ex.Message}"; + } + + return status; + } + + protected bool TryValidateNode(string nodePath, out string version) + { + version = null; + if (TryExecuteProcess(nodePath, "--version", 5000, out string output) && output.StartsWith("v")) + { + version = output.Substring(1).Trim(); + return true; + } + return false; + } + + protected bool TryFindNodeInPath(out string nodePath, out string version) + { + nodePath = null; + version = null; + + if (TryExecuteProcess("node", "--version", 5000, out string output) && output.StartsWith("v")) + { + version = output.Substring(1).Trim(); + nodePath = "node"; + return true; + } + return false; + } + protected bool TryFindUvInPath(out string uvPath, out string version) { uvPath = null; @@ -57,58 +196,117 @@ protected bool TryFindUvInPath(out string uvPath, out string version) foreach (var cmd in commands) { - try + if (TryExecuteProcess(cmd, "--version", 5000, out string output) && output.StartsWith("uv ")) { - var psi = new ProcessStartInfo - { - FileName = cmd, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + version = output.Substring(3).Trim(); + uvPath = cmd; + return true; + } + } + return false; + } - using var process = Process.Start(psi); - if (process == null) continue; + // --- Helpers --- + + protected string GetEditorPrefsSafely(string key, string defaultValue = "") + { + try + { + return EditorPrefs.GetString(key, defaultValue); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Failed to read EditorPrefs key '{key}': {ex.Message}"); + return defaultValue; + } + } - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + protected bool TryExecuteProcess(string fileName, string arguments, int timeoutMs, out string output, System.Collections.Generic.Dictionary envVars = null) + { + output = string.Empty; + try + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; - if (process.ExitCode == 0 && output.StartsWith("uv ")) + if (envVars != null) + { + foreach (var kvp in envVars) { - version = output.Substring(3).Trim(); - uvPath = cmd; - return true; + psi.EnvironmentVariables[kvp.Key] = kvp.Value; } } - catch + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + var outputBuilder = new StringBuilder(); + + process.OutputDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + outputBuilder.AppendLine(e.Data); + } + }; + + // Consume stderr to prevent deadlocks + process.ErrorDataReceived += (sender, e) => { }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) { - // Try next command + try { process.Kill(); } catch { } + return false; } - } - return false; + output = outputBuilder.ToString().Trim(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"TryExecuteProcess failed for {fileName}: {ex.Message}"); + return false; + } } - - protected bool TryParseVersion(string version, out int major, out int minor) + protected bool TryParseVersion(string versionString, out int major, out int minor) { major = 0; minor = 0; + if (string.IsNullOrEmpty(versionString)) return false; try { - var parts = version.Split('.'); + var parts = versionString.Split('.'); if (parts.Length >= 2) { - return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + if (int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor)) + { + return true; + } + } + else if (parts.Length == 1) + { + if (int.TryParse(parts[0], out major)) + { + minor = 0; + return true; + } } } catch { // Ignore parsing errors } - return false; } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index f21d58ff2..15273e9a5 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -16,7 +16,7 @@ public class WindowsPlatformDetector : PlatformDetectorBase public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - public override DependencyStatus DetectPython() + public override DependencyStatus DetectPython(string overridePath = null) { var status = new DependencyStatus("Python", isRequired: true) { @@ -25,52 +25,166 @@ public override DependencyStatus DetectPython() try { - // Try running python directly first (works with Windows App Execution Aliases) - if (TryValidatePython("python3.exe", out string version, out string fullPath) || - TryValidatePython("python.exe", out version, out fullPath)) + // 1. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.PythonPathOverride, ""); } catch { } + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidatePython(overridePath, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Using custom Python path: {fullPath}"; + return status; + } + } + + // 2. Try running python directly first (works with Windows App Execution Aliases) + if (TryValidatePython("python3.exe", out string ver, out string path) || + TryValidatePython("python.exe", out ver, out path)) { status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH"; + status.Version = ver; + status.Path = path; + status.Details = $"Found Python {ver} in PATH"; return status; } - // Fallback: try 'where' command + // 3. Fallback: try 'where' command if (TryFindInPath("python3.exe", out string pathResult) || TryFindInPath("python.exe", out pathResult)) { - if (TryValidatePython(pathResult, out version, out fullPath)) + if (TryValidatePython(pathResult, out ver, out path)) + { + status.IsAvailable = true; + status.Version = ver; + status.Path = path; + status.Details = $"Found Python {ver} in PATH"; + return status; + } + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install python 3.11+ and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override DependencyStatus DetectUv(string overridePath = null) + { + var status = new DependencyStatus("uv Package Manager", isRequired: true) + { + InstallationHint = GetUvInstallUrl() + }; + + try + { + // 1. Check Override + if (overridePath == null) + { + try { overridePath = UnityEditor.EditorPrefs.GetString(MCPForUnity.Editor.Constants.EditorPrefKeys.UvPathOverride, ""); } catch { } + } + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (TryValidateUv(overridePath, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH"; + status.Details = $"Using custom uv path: {fullPath}"; return status; } } - // Fallback: try to find python via uv - if (TryFindPythonViaUv(out version, out fullPath)) + // 2. Try running uv directly/PATH + if (TryValidateUv("uv", out string ver, out string path)) { status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} via uv"; + status.Version = ver; + status.Path = path; + status.Details = $"Found uv {ver} in PATH"; return status; } + + // 3. Try 'where' command + if (TryFindInPath("uv.exe", out string pathResult)) + { + if (TryValidateUv(pathResult, out ver, out path)) + { + status.IsAvailable = true; + status.Version = ver; + status.Path = path; + status.Details = $"Found uv {ver} in PATH"; + return status; + } + } - status.ErrorMessage = "Python not found in PATH"; - status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + // 4. Force check common install locations + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + var commonPaths = new[] + { + Path.Combine(localAppData, "Programs", "uv", "uv.exe"), // Standard install + Path.Combine(localAppData, "uv", "uv.exe"), // Alternative + Path.Combine(userProfile, ".cargo", "bin", "uv.exe"), // Cargo install + Path.Combine(localAppData, "bin", "uv.exe") + }; + + foreach (var candidate in commonPaths) + { + if (File.Exists(candidate)) + { + if (TryValidateUv(candidate, out ver, out path)) + { + status.IsAvailable = true; + status.Version = ver; + status.Path = path; + status.Details = $"Found uv {ver} at {candidate}"; + return status; + } + } + } + + status.ErrorMessage = "uv not found"; + status.Details = "Install uv package manager via PowerShell or from github.com/astral-sh/uv"; } catch (Exception ex) { - status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; } return status; } + private bool TryValidateUv(string uvPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + if (TryExecuteProcess(uvPath, "--version", 3000, out string output) && output.StartsWith("uv")) + { + var parts = output.Split(' '); + if (parts.Length > 1) + { + version = parts[1].Trim(); + fullPath = uvPath; + return true; + } + } + return false; + } + public override string GetPythonInstallUrl() { return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; @@ -86,7 +200,7 @@ public override string GetInstallationRecommendations() return @"Windows Installation Recommendations: 1. Python: Install from Microsoft Store or python.org - - Microsoft Store: Search for 'Python 3.10' or higher + - Microsoft Store: Search for 'python 3.11' or higher - Direct download: https://python.org/downloads/windows/ 2. uv Package Manager: Install via PowerShell @@ -96,131 +210,33 @@ 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; fullPath = null; - - try + + // 5 second timeout for validation + if (TryExecuteProcess(pythonPath, "--version", 5000, out string output) && output.StartsWith("Python ")) { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("Python ")) + // Validate minimum version (Python 4+ or python 3.11+) + if (TryParseVersion(version, out var major, out var minor)) { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // 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 >= 11); } } - catch - { - // Ignore validation errors - } - return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; - - try + + // 3 second timeout for 'where' + if (TryExecuteProcess("where", executable, 3000, out string output) && !string.IsNullOrEmpty(output)) { - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - 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)) - { // Take the first result var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); if (lines.Length > 0) @@ -228,13 +244,7 @@ private bool TryFindInPath(string executable, out string fullPath) fullPath = lines[0].Trim(); return File.Exists(fullPath); } - } } - catch - { - // Ignore errors - } - return false; } } diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index a310c6e1e..76cbac90b 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -33,6 +33,122 @@ public static string SanitizeAssetPath(string path) return path; } + /// + /// Gets the absolute file system path to the package root. + /// Uses StackTrace/ScriptableObject trick to resolve real path (bypassing PackageCache if symlinked). + /// + public static string GetPackageAbsolutePath() + { + // 1. Try to find path relative to this script file (Most reliable for local dev & mono scripts) + try + { + // Get the path of THIS source file during execution + string scriptFilePath = GetCallerFilePath(); + if (!string.IsNullOrEmpty(scriptFilePath) && File.Exists(scriptFilePath)) + { + // Current file: .../MCPForUnity/Editor/Helpers/AssetPathUtility.cs + // We need: .../MCPForUnity + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(scriptFilePath), "..", "..")); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to resolve path from stack trace: {ex.Message}"); + } + + // 2. Fallback to standard Unity PackageInfo (Reliable for Registry/Git packages) + string packageRoot = GetMcpPackageRootPath(); + if (string.IsNullOrEmpty(packageRoot)) return null; + + // If it's already an absolute path, we're good + if (Path.IsPathRooted(packageRoot)) + { + return packageRoot; + } + + // If it's a virtual path (Packages/...), resolve it to physical path + if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) + { + return packageInfo.resolvedPath; + } + + // If resolvedPath is failing but we have assetPath, try Path.GetFullPath + // Note: This rarely works for Library/PackageCache, but worth a shot for local tarballs + return Path.GetFullPath(packageRoot); + } + else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + string relativePath = packageRoot.Substring("Assets/".Length); + return Path.Combine(Application.dataPath, relativePath); + } + + return Path.GetFullPath(packageRoot); + } + + private static string GetCallerFilePath([System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "") + { + return sourceFilePath; + } + + /// + /// Gets the absolute path to the wrapper.js file in the package. + /// + /// Absolute path to wrapper.js, or null if not found + public static string GetWrapperJsPath() + { + string packageRoot = GetMcpPackageRootPath(); + if (string.IsNullOrEmpty(packageRoot)) + { + return null; + } + + // wrapper.js is expected to be in {packageRoot}/Server~/wrapper.js + // But we need to handle virtual paths if consistent with GetPackageJson logic + + string wrapperPath; + + // Convert virtual asset path to file system path (similar logic to GetPackageJson) + if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) + { + wrapperPath = Path.Combine(packageInfo.resolvedPath, "Server~", "wrapper.js"); + } + else + { + return null; + } + } + else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + string relativePath = packageRoot.Substring("Assets/".Length); + wrapperPath = Path.Combine(Application.dataPath, relativePath, "Server~", "wrapper.js"); + } + else + { + // Already absolute or unknown + wrapperPath = Path.Combine(packageRoot, "Server~", "wrapper.js"); + } + + if (File.Exists(wrapperPath)) + { + return wrapperPath; + } + + // Also check without the ~ just in case + string wrapperPathNoTilde = Path.Combine(Path.GetDirectoryName(wrapperPath), "..", "Server", "wrapper.js"); + if (File.Exists(wrapperPathNoTilde)) + { + return Path.GetFullPath(wrapperPathNoTilde); + } + + return null; + } + /// /// Gets the MCP for Unity package root path. /// Works for registry Package Manager, local Package Manager, and Asset Store installations. @@ -144,22 +260,35 @@ public static JObject GetPackageJson() /// Git URL string, or empty string if version is unknown and no override public static string GetMcpServerGitUrl() { - // Check for Git URL override first + // Check for Git URL override first (still useful for forcing a specific repo) string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); if (!string.IsNullOrEmpty(gitUrlOverride)) { return gitUrlOverride; } - // Fall back to default package version - string version = GetPackageVersion(); - if (version == "unknown") + // 2. Check for local server code in the package (Priority for development & offline use) + string packageRoot = GetPackageAbsolutePath(); + if (!string.IsNullOrEmpty(packageRoot)) { - // Fall back to main repo without pinned version so configs remain valid in test scenarios - return "git+https://github.com/CoplayDev/unity-mcp#subdirectory=Server"; + // The server code is in {packageRoot}/Server~ or {packageRoot}/Server + // Try Server~ first (Unity hidden folder) + string serverPathTilde = Path.Combine(packageRoot, "Server~"); + if (Directory.Exists(serverPathTilde)) + { + // Return absolute path for uv to use directly + return serverPathTilde; + } + + string serverPath = Path.Combine(packageRoot, "Server"); + if (Directory.Exists(serverPath)) + { + return serverPath; + } } - return $"git+https://github.com/CoplayDev/unity-mcp@v{version}#subdirectory=Server"; + // 3. Fallback to official repository URL (For end-users without source access) + return "git+https://github.com/choej2303/unity-mcp-gg.git@main#subdirectory=MCPForUnity/Server~"; } /// diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 3c0ba7058..32b784424 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -78,24 +78,55 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl } else { - // Stdio mode: Use uvx command - var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + // Stdio mode: Use node wrapper.js (Recommended for Windows stability) + // This ensures we use the python fallback logic inside wrapper.js to prevent "invalid trailing data" + + string wrapperPath = AssetPathUtility.GetWrapperJsPath(); - var toolArgs = BuildUvxArgs(fromUrl, packageName); - - if (ShouldUseWindowsCmdShim(client)) + if (string.IsNullOrEmpty(wrapperPath)) { - unity["command"] = ResolveCmdPath(); - - var cmdArgs = new List { "/c", uvxPath }; - cmdArgs.AddRange(toolArgs); - - unity["args"] = JArray.FromObject(cmdArgs.ToArray()); + // Fallback to uvx if wrapper not found + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + var toolArgs = BuildUvxArgs(fromUrl, packageName); + + if (ShouldUseWindowsCmdShim(client)) + { + unity["command"] = ResolveCmdPath(); + + var cmdArgs = new List { "/c", uvxPath }; + cmdArgs.AddRange(toolArgs); + + unity["args"] = JArray.FromObject(cmdArgs.ToArray()); + } + else + { + unity["command"] = uvxPath; + unity["args"] = JArray.FromObject(toolArgs.ToArray()); + } } else { - unity["command"] = uvxPath; - unity["args"] = JArray.FromObject(toolArgs.ToArray()); + // Use node to run wrapper.js + // We assume 'node' is in PATH unless overridden. + string nodeCommand = "node"; + string nodeOverride = EditorPrefs.GetString(EditorPrefKeys.NodePathOverride, ""); + if (!string.IsNullOrEmpty(nodeOverride)) + { + if (File.Exists(nodeOverride)) + { + nodeCommand = nodeOverride; + } + else + { + McpLog.Warn($"Node override path not found: {nodeOverride}, falling back to 'node'"); + } + } + + unity["command"] = nodeCommand; + + var args = new List { wrapperPath }; + + unity["args"] = JArray.FromObject(args.ToArray()); } // Remove url/serverUrl if they exist from previous config @@ -114,6 +145,16 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl unity.Remove("type"); } + // Force UTF-8 environment variables for Python Stdio stability + // (only meaningful when we're launching a process, i.e., stdio mode) + if (!useHttpTransport) + { + var env = EnsureObject(unity, "env"); + env["PYTHONUTF8"] = "1"; + env["PYTHONIOENCODING"] = "utf-8"; + env["PYTHONUNBUFFERED"] = "1"; + } + bool requiresEnv = client?.EnsureEnvObject == true; bool stripEnv = client?.StripEnvWhenNotRequired == true; diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 2224009ed..e5e411da9 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -209,7 +209,37 @@ internal static bool TryRun( if (!process.WaitForExit(timeoutMs)) { - try { process.Kill(); } catch { } + // Timeout occurred - kill the process (child processes may remain due to Unity .NET limitations) + try + { + if (!process.HasExited) + { + int pid = process.Id; + try + { + // Kill process (entireProcessTree not supported in Unity .NET profile) + process.Kill(); + + // Wait a bit to ensure the process actually terminates + if (!process.WaitForExit(1000)) + { + McpLog.Warn($"Process {pid} did not exit after Kill command"); + } + } + catch (InvalidOperationException) + { + // Process already exited - that's fine + } + catch (Exception killEx) + { + McpLog.Warn($"Failed to kill process {pid}: {killEx.Message}"); + } + } + } + catch (Exception ex) + { + McpLog.Debug($"Error during process cleanup: {ex.Message}"); + } return false; } @@ -235,14 +265,33 @@ private static string Which(string exe, string prependPath) { UseShellExecute = false, RedirectStandardOutput = true, + RedirectStandardError = true, // Consume stderr CreateNoWindow = true, }; 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); - return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + + using var p = new Process { StartInfo = psi }; + var outputBuilder = new StringBuilder(); + + p.OutputDataReceived += (sender, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); }; + p.ErrorDataReceived += (sender, e) => { }; // Drain stderr + + if (!p.Start()) return null; + + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + + if (p.WaitForExit(2000)) + { + string output = outputBuilder.ToString().Trim(); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + else + { + try { p.Kill(); } catch { } + return null; + } } catch { return null; } } @@ -257,14 +306,33 @@ private static string Where(string exe) { UseShellExecute = false, RedirectStandardOutput = true, + RedirectStandardError = true, // Consume stderr CreateNoWindow = true, }; - using var p = Process.Start(psi); - string first = p?.StandardOutput.ReadToEnd() - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(); - p?.WaitForExit(1500); - return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + + using var p = new Process { StartInfo = psi }; + var outputBuilder = new StringBuilder(); + + p.OutputDataReceived += (sender, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); }; + p.ErrorDataReceived += (sender, e) => { }; // Drain stderr + + if (!p.Start()) return null; + + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + + if (p.WaitForExit(2000)) + { + string first = outputBuilder.ToString() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + else + { + try { p.Kill(); } catch { } + return null; + } } catch { return null; } } diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef index 47621bdf6..ce06c750d 100644 --- a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -3,7 +3,9 @@ "rootNamespace": "MCPForUnity.Editor", "references": [ "MCPForUnity.Runtime", - "GUID:560b04d1a97f54a46a2660c3cc343a6f" + "GUID:560b04d1a97f54a46a2660c3cc343a6f", + "UnityEditor.TestRunner", + "UnityEngine.TestRunner" ], "includePlatforms": [ "Editor" diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs index 10f052489..4e256b58d 100644 --- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -1,29 +1,15 @@ -using MCPForUnity.Editor.Setup; using MCPForUnity.Editor.Windows; using UnityEditor; -using UnityEngine; namespace MCPForUnity.Editor.MenuItems { public static class MCPForUnityMenu { - [MenuItem("Window/MCP For Unity/Setup Window", priority = 1)] - public static void ShowSetupWindow() + [MenuItem("Window/MCP For Unity %#m", priority = 1)] + public static void ShowMCPWindow() { - SetupWindowService.ShowSetupWindow(); - } - - [MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 2)] - public static void ToggleMCPWindow() - { - if (MCPForUnityEditorWindow.HasAnyOpenWindow()) - { - MCPForUnityEditorWindow.CloseAllOpenWindows(); - } - else - { - MCPForUnityEditorWindow.ShowWindow(); - } + MCPForUnityEditorWindow.ShowWindow(); } } } + diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 3c48abdf5..e35ab287f 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -22,6 +22,8 @@ public ClientConfigurationService() public void ConfigureClient(IMcpClientConfigurator configurator) { + // Ensure status/state is checked before configuring, similar to ConfigureAllDetectedClients + configurator.CheckStatus(attemptAutoRewrite: false); configurator.Configure(); } diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs index 104c31134..c093c6541 100644 --- a/MCPForUnity/Editor/Services/IPathResolverService.cs +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -17,6 +17,18 @@ public interface IPathResolverService /// Path to the claude executable, or null if not found string GetClaudeCliPath(); + /// + /// Gets the Python path (respects override if set) + /// + /// Path to the python executable + string GetPythonPath(); + + /// + /// Gets the Node.js path (respects override if set) + /// + /// Path to the node executable + string GetNodePath(); + /// /// Checks if Python is detected on the system /// @@ -51,6 +63,28 @@ public interface IPathResolverService /// void ClearClaudeCliPathOverride(); + /// + /// Sets an override for the Python path + /// + /// Path to override with + void SetPythonPathOverride(string path); + + /// + /// Clears the Python path override + /// + void ClearPythonPathOverride(); + + /// + /// Sets an override for the Node.js path + /// + /// Path to override with + void SetNodePathOverride(string path); + + /// + /// Clears the Node.js path override + /// + void ClearNodePathOverride(); + /// /// Gets whether a uvx path override is active /// @@ -60,5 +94,15 @@ public interface IPathResolverService /// Gets whether a Claude CLI path override is active /// bool HasClaudeCliPathOverride { get; } + + /// + /// Gets whether a Python path override is active + /// + bool HasPythonPathOverride { get; } + + /// + /// Gets whether a Node.js path override is active + /// + bool HasNodePathOverride { get; } } } diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 4947a16d8..a84e94985 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -1,13 +1,10 @@ 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.Helpers; using UnityEditor; -using UnityEngine; namespace MCPForUnity.Editor.Services { @@ -18,13 +15,15 @@ public class PathResolverService : IPathResolverService { public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null)); public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null)); + public bool HasPythonPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.PythonPathOverride, null)); + public bool HasNodePathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.NodePathOverride, null)); public string GetUvxPath() { try { string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); - if (!string.IsNullOrEmpty(overridePath)) + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { return overridePath; } @@ -35,12 +34,6 @@ public string GetUvxPath() McpLog.Debug("No uvx path override found, falling back to default command"); } - string discovered = ResolveUvxFromSystem(); - if (!string.IsNullOrEmpty(discovered)) - { - return discovered; - } - return "uvx"; } @@ -102,109 +95,72 @@ public string GetClaudeCliPath() return null; } - public bool IsPythonDetected() + public string GetPythonPath() { try { - var psi = new ProcessStartInfo + string overridePath = EditorPrefs.GetString(EditorPrefKeys.PythonPathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { - 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; + return overridePath; + } } catch { - return false; + McpLog.Debug("No Python path override found, falling back to default command"); } + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python" : "python3"; } - public bool IsClaudeCliDetected() - { - return !string.IsNullOrEmpty(GetClaudeCliPath()); - } - - private static string ResolveUvxFromSystem() + public string GetNodePath() { try { - foreach (string candidate in EnumerateUvxCandidates()) + string overridePath = EditorPrefs.GetString(EditorPrefKeys.NodePathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { - if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) - { - return candidate; - } + return overridePath; } } catch { - // fall back to bare command + McpLog.Debug("No Node path override found, falling back to default command"); } - - return null; + return "node"; } - private static IEnumerable EnumerateUvxCandidates() + public bool IsPythonDetected() { - string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uvx.exe" : "uvx"; - - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (!string.IsNullOrEmpty(home)) - { - yield return Path.Combine(home, ".local", "bin", exeName); - yield return Path.Combine(home, ".cargo", "bin", exeName); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - yield return "/opt/homebrew/bin/" + exeName; - yield return "/usr/local/bin/" + exeName; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - yield return "/usr/local/bin/" + exeName; - yield return "/usr/bin/" + exeName; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + try { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - - if (!string.IsNullOrEmpty(localAppData)) + var psi = new ProcessStartInfo { - yield return Path.Combine(localAppData, "Programs", "uv", exeName); - } - - if (!string.IsNullOrEmpty(programFiles)) + FileName = GetPythonPath(), + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + if (!p.WaitForExit(2000)) { - yield return Path.Combine(programFiles, "uv", exeName); + try { p.Kill(); } catch { /* ignore */ } + return false; } + return p.ExitCode == 0; } - - string pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (!string.IsNullOrEmpty(pathEnv)) + catch { - 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"); - } - } + return false; } } + public bool IsClaudeCliDetected() + { + return !string.IsNullOrEmpty(GetClaudeCliPath()); + } + public void SetUvxPathOverride(string path) { if (string.IsNullOrEmpty(path)) @@ -246,5 +202,49 @@ public void ClearClaudeCliPathOverride() { EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); } + + public void SetPythonPathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearPythonPathOverride(); + return; + } + + // Allow commands on PATH, but validate explicit paths + if ((Path.IsPathRooted(path) || path.Contains("/") || path.Contains("\\")) && !File.Exists(path)) + { + throw new ArgumentException("The selected Python executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.PythonPathOverride, path); + } + + public void ClearPythonPathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.PythonPathOverride); + } + + public void SetNodePathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearNodePathOverride(); + return; + } + + // Allow commands on PATH, but validate explicit paths + if ((Path.IsPathRooted(path) || path.Contains("/") || path.Contains("\\")) && !File.Exists(path)) + { + throw new ArgumentException("The selected Node executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.NodePathOverride, path); + } + + public void ClearNodePathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.NodePathOverride); + } } } diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 319275331..cc7585b32 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -13,6 +13,31 @@ namespace MCPForUnity.Editor.Services /// public class ServerManagementService : IServerManagementService { + private static bool _cleanupRegistered = false; + + /// + /// Register cleanup handler for Unity exit + /// + private static void EnsureCleanupRegistered() + { + if (_cleanupRegistered) return; + + EditorApplication.quitting += () => + { + // Try to stop the HTTP server when Unity exits + try + { + var service = new ServerManagementService(); + service.StopLocalHttpServer(); + } + catch (Exception ex) + { + McpLog.Debug($"Cleanup on exit: {ex.Message}"); + } + }; + + _cleanupRegistered = true; + } /// /// Clear the local uvx cache for the MCP server package /// @@ -55,15 +80,17 @@ public bool ClearUvxCache() combinedOutput = "Command failed with no output. Ensure uv is installed, on PATH, or set an override in Advanced Settings."; } - McpLog.Error( + McpLog.Warn( $"Failed to clear uv cache using '{uvCommand} {args}'. " + $"Details: {combinedOutput}{(string.IsNullOrEmpty(lockHint) ? string.Empty : " Hint: " + lockHint)}"); - return false; + + // Cache clearing failure is not critical, so we can return true to proceed + return true; } catch (Exception ex) { - McpLog.Error($"Error clearing uv cache: {ex.Message}"); - return false; + McpLog.Warn($"Error clearing uv cache: {ex.Message}"); + return true; // Proceed anyway } } @@ -75,17 +102,19 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string uvPath = BuildUvPathFromUvx(uvxPath); + string extraPathPrepend = GetPlatformSpecificPathPrepend(); + if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) { - return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000); + // Timeout reduced to 2 seconds to prevent UI freezing + return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 2000, extraPathPrepend); } string command = $"{uvPath} {args}"; - string extraPathPrepend = GetPlatformSpecificPathPrepend(); if (Application.platform == RuntimePlatform.WindowsEditor) { - return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 2000, extraPathPrepend); } string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; @@ -93,10 +122,10 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, if (!string.IsNullOrEmpty(shell) && File.Exists(shell)) { string escaped = command.Replace("\"", "\\\""); - return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 2000, extraPathPrepend); } - return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 2000, extraPathPrepend); } private static string BuildUvPathFromUvx(string uvxPath) @@ -185,15 +214,48 @@ public bool StartLocalHttpServer() "Start Local HTTP Server", $"This will start the MCP server in HTTP mode:\n\n{command}\n\n" + "The server will run in a separate terminal window. " + - "Close the terminal to stop the server.\n\n" + + "Use 'Stop Server' button or close Unity to stop the server.\n\n" + "Continue?", "Start Server", "Cancel")) { try { - // Start the server in a new terminal window (cross-platform) - var startInfo = CreateTerminalProcessStartInfo(command); + // Register cleanup handler for Unity exit + EnsureCleanupRegistered(); + + System.Diagnostics.ProcessStartInfo startInfo; + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Use a batch file to avoid complex escaping issues on Windows (OS error 123) + string tempBatPath = Path.Combine(Path.GetTempPath(), "start_mcp_server.bat"); + + // Add title and pause for better UX + string batContent = "@echo off\r\n"; + batContent += "set PYTHONUNBUFFERED=1\r\n"; // Fix: Prevent stdout buffering which can block SSE/logging + batContent += $"title MCP Server (Port {HttpEndpointUtility.GetBaseUrl()})\r\n"; + batContent += "echo Starting MCP Server...\r\n"; + batContent += command + "\r\n"; + batContent += "if %errorlevel% neq 0 pause\r\n"; // Pause only on error + + File.WriteAllText(tempBatPath, batContent); + McpLog.Debug($"Created batch file at: {tempBatPath}"); + + // Execute the batch file in a new window + startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"MCP Server\" \"{tempBatPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; + } + else + { + // Start the server in a new terminal window (cross-platform for Mac/Linux) + startInfo = CreateTerminalProcessStartInfo(command); + } System.Diagnostics.Process.Start(startInfo); @@ -362,17 +424,75 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) } var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + // NOTE: When running from local source (Server~), we should execute via python directly + // rather than uvx, because 'mcp-for-unity' package name won't be valid for uvx + // unless we install it. So we detect if we are running from a local path. + + string localServerPath = AssetPathUtility.GetWrapperJsPath(); // Wrapper implies local path? No, dedicated method better. + string packageRoot = AssetPathUtility.GetPackageAbsolutePath(); + bool isLocalSource = false; + string serverSrcPath = null; + + if (!string.IsNullOrEmpty(packageRoot)) + { + string checkPath = Path.Combine(packageRoot, "Server~"); + if (Directory.Exists(checkPath)) + { + isLocalSource = true; + serverSrcPath = checkPath; + } + else + { + checkPath = Path.Combine(packageRoot, "Server"); + if (Directory.Exists(checkPath)) + { + isLocalSource = true; + serverSrcPath = checkPath; + } + } + } + + if (isLocalSource && !string.IsNullOrEmpty(serverSrcPath)) + { + // Local execution mode: + // uv run --directory "{serverSrcPath}" python -m src.server --transport http --http-url {httpUrl} + // This ensures dependencies in pyproject.toml are respected and src module is found. + + string uvPath = BuildUvPathFromUvx(uvxPath); + if (string.IsNullOrEmpty(uvPath)) uvPath = "uv"; // Fallback to PATH if empty + + // Fix: Ensure proper quoting for executable path if it has spaces + string safeUvPath = uvPath.Contains(" ") && !uvPath.StartsWith("\"") ? $"\"{uvPath}\"" : uvPath; + + // Fix: Ensure directory path is clean and safe for quoting + // Trim trailing directory separators to prevent escaping the closing quote + string safeSrcPath = serverSrcPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // Add -u flag to force unbuffered stdout/stderr, critical for SSE stability on Windows + command = $"{safeUvPath} run --directory \"{safeSrcPath}\" python -u -m src.main --transport http --http-url {httpUrl}"; + return true; + } + + // Fallback to uvx (remote execution) if (string.IsNullOrEmpty(uvxPath)) { error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings."; return false; } + // Quote the path if it contains spaces and isn't already quoted + string finalUvxPath = uvxPath; + if (uvxPath.Contains(" ") && !uvxPath.StartsWith("\"")) + { + finalUvxPath = $"\"{uvxPath}\""; + } + string args = string.IsNullOrEmpty(fromUrl) ? $"{packageName} --transport http --http-url {httpUrl}" : $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; - command = $"{uvxPath} {args}"; + command = $"{finalUvxPath} {args}"; return true; } @@ -395,11 +515,13 @@ private static bool IsLocalUrl(string url) try { var uri = new Uri(url); - string host = uri.Host.ToLower(); + string host = uri.Host.ToLowerInvariant(); return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; } catch { + // Fallback for simple localhost strings without scheme + if (url.StartsWith("localhost") || url.StartsWith("127.0.0.1")) return true; return false; } } @@ -439,13 +561,25 @@ private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(strin // Windows: Use cmd.exe with start command to open new window // Wrap in quotes for /k and escape internal quotes string escapedCommandWin = command.Replace("\"", "\\\""); - return new System.Diagnostics.ProcessStartInfo + var psi = new System.Diagnostics.ProcessStartInfo { FileName = "cmd.exe", + // We need to inject PATH into the new cmd window. + // Since 'start' launches a separate process, we'll try to set PATH before running the command. + // Note: 'start' inherits environment variables, so setting them on this ProcessStartInfo should work. Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{escapedCommandWin}\"", UseShellExecute = false, CreateNoWindow = true }; + + // Inject PATH + string pathPrepend = GetPlatformSpecificPathPrepend(); + if (!string.IsNullOrEmpty(pathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = pathPrepend + Path.PathSeparator + currentPath; + } + return psi; #else // Linux: Try common terminal emulators // We use bash -c to execute the command, so we must properly quote/escape for bash @@ -464,7 +598,7 @@ private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(strin { try { - var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + using var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "which", Arguments = term, @@ -472,14 +606,32 @@ private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(strin RedirectStandardOutput = true, CreateNoWindow = true }); - which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous - if (which.ExitCode == 0) + + if (which != null) { - terminalCmd = term; - break; + if (!which.WaitForExit(5000)) + { + // Timeout - kill the process + try + { + if (!which.HasExited) + { + which.Kill(); + } + } + catch { } + } + else if (which.ExitCode == 0) + { + terminalCmd = term; + break; + } } } - catch { } + catch (Exception ex) + { + McpLog.Debug($"Terminal check failed for {term}: {ex.Message}"); + } } if (terminalCmd == null) diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs index 0dcfc79be..02a634cac 100644 --- a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -25,37 +25,31 @@ public List DiscoverAllTools() _cachedTools = new Dictionary(); - // Scan all assemblies for [McpForUnityTool] attributes - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + // Use TypeCache for much faster attribute lookup + var types = TypeCache.GetTypesWithAttribute(); - foreach (var assembly in assemblies) + foreach (var type in types) { try { - var types = assembly.GetTypes(); + var toolAttr = type.GetCustomAttribute(); + if (toolAttr == null) + continue; - foreach (var type in types) + var metadata = ExtractToolMetadata(type, toolAttr); + if (metadata != null) { - var toolAttr = type.GetCustomAttribute(); - if (toolAttr == null) - continue; - - var metadata = ExtractToolMetadata(type, toolAttr); - if (metadata != null) - { - _cachedTools[metadata.Name] = metadata; - EnsurePreferenceInitialized(metadata); - } + _cachedTools[metadata.Name] = metadata; + EnsurePreferenceInitialized(metadata); } } catch (Exception ex) { - // Skip assemblies that can't be reflected - McpLog.Info($"Skipping assembly {assembly.FullName}: {ex.Message}"); + McpLog.Warn($"Failed to process tool {type.Name}: {ex.Message}"); } } - McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection"); + McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via TypeCache"); return _cachedTools.Values.ToList(); } @@ -131,7 +125,7 @@ private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute too ClassName = type.Name, Namespace = type.Namespace ?? "", AssemblyName = type.Assembly.GetName().Name, - AssetPath = ResolveScriptAssetPath(type), + AssetPath = null, // Skip expensive resolution during discovery - resolve lazily if needed AutoRegister = toolAttr.AutoRegister, RequiresPolling = toolAttr.RequiresPolling, PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction @@ -140,11 +134,12 @@ private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute too metadata.IsBuiltIn = DetermineIsBuiltIn(type, metadata); if (metadata.IsBuiltIn) { - string summaryDescription = ExtractSummaryDescription(type, metadata); - if (!string.IsNullOrWhiteSpace(summaryDescription)) - { - metadata.Description = summaryDescription; - } + // Skip summary extraction during discovery for performance + // string summaryDescription = ExtractSummaryDescription(type, metadata); + // if (!string.IsNullOrWhiteSpace(summaryDescription)) + // { + // metadata.Description = summaryDescription; + // } } return metadata; diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs index 5490508b8..592d23733 100644 --- a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs @@ -16,6 +16,12 @@ namespace MCPForUnity.Editor.Services.Transport /// Guarantees that MCP commands are executed on the Unity main thread while preserving /// the legacy response format expected by the server. /// + /// + /// Centralised command execution pipeline shared by all transport implementations. + /// Guarantees that MCP commands are executed on the Unity main thread while preserving + /// the legacy response format expected by the server. + /// + [InitializeOnLoad] internal static class TransportCommandDispatcher { private sealed class PendingCommand @@ -56,11 +62,16 @@ public void TrySetCanceled() private static readonly Dictionary Pending = new(); private static readonly object PendingLock = new(); - private static bool updateHooked; - private static bool initialised; + + static TransportCommandDispatcher() + { + // Hook safely on the main thread via InitializeOnLoad semantics + EditorApplication.update += ProcessQueue; + } /// /// Schedule a command for execution on the Unity main thread and await its JSON response. + /// Safe to call from any thread. /// public static Task ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken) { @@ -83,7 +94,6 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat lock (PendingLock) { Pending[id] = pending; - HookUpdate(); } return tcs.Task; @@ -91,43 +101,18 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat private static void EnsureInitialised() { - if (initialised) - { - return; - } - + // CommandRegistry is thread-safe or handles its own init checks CommandRegistry.Initialize(); - initialised = true; - } - - private static void HookUpdate() - { - if (updateHooked) - { - return; - } - - updateHooked = true; - EditorApplication.update += ProcessQueue; - } - - private static void UnhookUpdateIfIdle() - { - if (Pending.Count > 0 || !updateHooked) - { - return; - } - - updateHooked = false; - EditorApplication.update -= ProcessQueue; } private static void ProcessQueue() { - List<(string id, PendingCommand pending)> ready; + List<(string id, PendingCommand pending)> ready = null; lock (PendingLock) { + if (Pending.Count == 0) return; + ready = new List<(string, PendingCommand)>(Pending.Count); foreach (var kvp in Pending) { @@ -139,14 +124,10 @@ private static void ProcessQueue() kvp.Value.IsExecuting = true; ready.Add((kvp.Key, kvp.Value)); } - - if (ready.Count == 0) - { - UnhookUpdateIfIdle(); - return; - } } + if (ready == null || ready.Count == 0) return; + foreach (var (id, pending) in ready) { ProcessCommand(id, pending); @@ -254,10 +235,7 @@ private static void CancelPending(string id, CancellationToken token) PendingCommand pending = null; lock (PendingLock) { - if (Pending.Remove(id, out pending)) - { - UnhookUpdateIfIdle(); - } + Pending.Remove(id, out pending); } pending?.TrySetCanceled(); @@ -269,7 +247,6 @@ private static void RemovePending(string id, PendingCommand pending) lock (PendingLock) { Pending.Remove(id); - UnhookUpdateIfIdle(); } pending.Dispose(); diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 30e31958e..a5041ce69 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -20,19 +20,7 @@ namespace MCPForUnity.Editor.Services.Transport.Transports { - class Outbound - { - public byte[] Payload; - public string Tag; - public int? ReqId; - } - class QueuedCommand - { - public string CommandJson; - public TaskCompletionSource Tcs; - public bool IsExecuting; - } [InitializeOnLoad] public static class StdioBridgeHost @@ -43,17 +31,16 @@ public static class StdioBridgeHost private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly HashSet activeClients = new(); - private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); + private static readonly ConcurrentDictionary activeClientTasks = new(); + private static CancellationTokenSource cts; private static Task listenerTask; - private static int processingCommands = 0; private static bool initScheduled = false; private static bool ensureUpdateHooked = false; private static bool isStarting = false; private static double nextStartAt = 0.0f; private static double nextHeartbeatAt = 0.0f; private static int heartbeatSeq = 0; - private static Dictionary commandQueue = new(); private static int mainThreadId; private static int currentUnityPort = 6400; private static bool isAutoConnectMode = false; @@ -122,30 +109,7 @@ public static bool FolderExists(string path) static StdioBridgeHost() { try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } - try - { - var writerThread = new Thread(() => - { - foreach (var item in _outbox.GetConsumingEnumerable()) - { - try - { - long seq = Interlocked.Increment(ref _ioSeq); - IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - sw.Stop(); - IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); - } - } - }) - { IsBackground = true, Name = "MCP-Writer" }; - writerThread.Start(); - } - catch { } + if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { @@ -371,7 +335,7 @@ public static void Start() cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); CommandRegistry.Initialize(); - EditorApplication.update += ProcessCommands; + EditorApplication.update += UpdateHeartbeat; try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { } heartbeatSeq++; @@ -415,6 +379,8 @@ private static TcpListener CreateConfiguredListener(int port) public static void Stop() { Task toWait = null; + Task[] clientTasksToWait = null; + lock (startStopLock) { if (!isRunning) @@ -438,27 +404,46 @@ public static void Stop() } catch (Exception ex) { - McpLog.Error($"Error stopping StdioBridgeHost: {ex.Message}"); + McpLog.Error($"[StdioBridge] Error during stop: {ex.Message}"); } } + // Close all active clients TcpClient[] toClose; lock (clientsLock) { toClose = activeClients.ToArray(); activeClients.Clear(); } + foreach (var c in toClose) { try { c.Close(); } catch { } } + // Collect all active client tasks + clientTasksToWait = activeClientTasks.Values.ToArray(); + + // Wait for listener task if (toWait != null) { try { toWait.Wait(100); } catch { } } - try { EditorApplication.update -= ProcessCommands; } catch { } + // Wait for all client tasks to complete + if (clientTasksToWait != null && clientTasksToWait.Length > 0) + { + try + { + Task.WaitAll(clientTasksToWait, TimeSpan.FromSeconds(2)); + } + catch (Exception ex) + { + McpLog.Debug($"[StdioBridge] Some client tasks did not complete: {ex.Message}"); + } + } + + try { EditorApplication.update -= UpdateHeartbeat; } catch { } try { EditorApplication.quitting -= Stop; } catch { } try @@ -498,7 +483,24 @@ private static async Task ListenerLoopAsync(CancellationToken token) client.ReceiveTimeout = 60000; - _ = Task.Run(() => HandleClientAsync(client, token), token); + // Use TaskCompletionSource to ensure the task is tracked in activeClientTasks + // before the handler starts, preventing a race where the handler could complete + // and remove itself before being added to the dictionary. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + activeClientTasks.TryAdd(client, tcs.Task); + + _ = Task.Run(async () => + { + try + { + await HandleClientAsync(client, token); + tcs.TrySetResult(null); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }, token); } catch (ObjectDisposedException) { @@ -515,7 +517,7 @@ private static async Task ListenerLoopAsync(CancellationToken token) { if (isRunning && !token.IsCancellationRequested) { - if (IsDebugEnabled()) McpLog.Error($"Listener error: {ex.Message}"); + McpLog.Error($"[StdioBridge] Error accepting client: {ex.Message}"); } } } @@ -588,35 +590,20 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken continue; } - lock (lockObj) - { - commandQueue[commandId] = new QueuedCommand - { - CommandJson = commandText, - Tcs = tcs, - IsExecuting = false - }; - } - string response; try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); - var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); - if (completed == tcs.Task) - { - respCts.Cancel(); - response = tcs.Task.Result; - } - else + response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandText, respCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + var timeoutResponse = new { - var timeoutResponse = new - { - status = "error", - error = $"Command processing timed out after {FrameIOTimeoutMs} ms", - }; - response = JsonConvert.SerializeObject(timeoutResponse); - } + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + response = JsonConvert.SerializeObject(timeoutResponse); } catch (Exception ex) { @@ -680,6 +667,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken finally { lock (clientsLock) { activeClients.Remove(client); } + activeClientTasks.TryRemove(client, out _); } } } @@ -805,10 +793,11 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value) dest[7] = (byte)(value); } - private static void ProcessCommands() + + + private static void UpdateHeartbeat() { if (!isRunning) return; - if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; try { double now = EditorApplication.timeSinceStartup; @@ -817,118 +806,8 @@ private static void ProcessCommands() WriteHeartbeat(false); nextHeartbeatAt = now + 0.5f; } - - List<(string id, QueuedCommand command)> work; - lock (lockObj) - { - work = new List<(string, QueuedCommand)>(commandQueue.Count); - foreach (var kvp in commandQueue) - { - var queued = kvp.Value; - if (queued.IsExecuting) continue; - queued.IsExecuting = true; - work.Add((kvp.Key, queued)); - } - } - - foreach (var item in work) - { - string id = item.id; - QueuedCommand queuedCommand = item.command; - string commandText = queuedCommand.CommandJson; - TaskCompletionSource tcs = queuedCommand.Tcs; - - if (string.IsNullOrWhiteSpace(commandText)) - { - var emptyResponse = new - { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - commandText = commandText.Trim(); - if (commandText == "ping") - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - if (!IsValidJson(commandText)) - { - var invalidJsonResponse = new - { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - ExecuteQueuedCommand(id, commandText, tcs); - } } - finally - { - Interlocked.Exchange(ref processingCommands, 0); - } - } - - private static void ExecuteQueuedCommand(string commandId, string payload, TaskCompletionSource completionSource) - { - async void Runner() - { - try - { - using var cts = new CancellationTokenSource(FrameIOTimeoutMs); - string response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(payload, cts.Token).ConfigureAwait(true); - completionSource.TrySetResult(response); - } - catch (OperationCanceledException) - { - var timeoutResponse = new - { - status = "error", - error = $"Command processing timed out after {FrameIOTimeoutMs} ms", - }; - completionSource.TrySetResult(JsonConvert.SerializeObject(timeoutResponse)); - } - catch (Exception ex) - { - McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - var response = new - { - status = "error", - error = ex.Message, - receivedText = payload?.Length > 50 - ? payload[..50] + "..." - : payload, - }; - completionSource.TrySetResult(JsonConvert.SerializeObject(response)); - } - finally - { - lock (lockObj) - { - commandQueue.Remove(commandId); - } - } - } - - Runner(); + catch { } } private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index f2beb1f4b..da6d4b4a1 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -113,16 +113,34 @@ public async Task StartAsync() _endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl()); _sessionId = null; - if (!await EstablishConnectionAsync(_lifecycleCts.Token)) + // Retry logic for initial connection (server might be starting up) + int maxRetries = 5; + for (int i = 0; i < maxRetries; i++) { - await StopAsync(); - return false; + if (await EstablishConnectionAsync(_lifecycleCts.Token)) + { + // State is connected but session ID might be pending until 'registered' message + _state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString()); + _isConnected = true; + return true; + } + + if (i < maxRetries - 1) + { + McpLog.Info($"[WebSocket] Connection attempt {i + 1} failed, retrying in 2s..."); + try + { + await Task.Delay(2000, _lifecycleCts.Token); + } + catch (OperationCanceledException) + { + break; + } + } } - // State is connected but session ID might be pending until 'registered' message - _state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString()); - _isConnected = true; - return true; + await StopAsync(); + return false; } public async Task StopAsync() diff --git a/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs b/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs new file mode 100644 index 000000000..b3ca5e9a7 --- /dev/null +++ b/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs @@ -0,0 +1,327 @@ +using System; +using System.IO; +using System.Diagnostics; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Automates the setup of the Python server environment (venv, dependencies) + /// + public static class ServerEnvironmentSetup + { + public static string ServerRoot => Path.Combine(AssetPathUtility.GetPackageAbsolutePath(), "Server~"); + // Corrected path: .venv is at the root of Server~, not inside Server~/Server + public static string VenvPath => Path.Combine(ServerRoot, ".venv"); + public static string RequirementsPath => Path.Combine(ServerRoot, "Server", "requirements.txt"); + + public static bool IsEnvironmentReady(string packageRootPath = null) + { + string root = packageRootPath ?? AssetPathUtility.GetPackageAbsolutePath(); + if (string.IsNullOrEmpty(root)) + { + McpLog.Warn("[MCP Setup] Package root is null or empty."); + return false; + } + + string serverRoot = Path.Combine(root, "Server~"); + string venvPath = Path.Combine(serverRoot, ".venv"); + + string venvPython = Path.Combine(venvPath, "Scripts", "python.exe"); + if (!File.Exists(venvPython)) + { + venvPython = Path.Combine(venvPath, "bin", "python"); + } + + bool exists = File.Exists(venvPython); + // Debug Log (Remove later) + if (!exists) + { + McpLog.Warn($"[MCP Setup] Python not found at: {venvPython}"); + // Also check if serverRoot exists + if (!Directory.Exists(serverRoot)) McpLog.Warn($"[MCP Setup] Server root directory not found: {serverRoot}"); + } + + return exists; + } + + public static void InstallServerEnvironment() + { + // 0. Pre-check prerequisites (Python & Node) + bool hasPython = CheckPython(); + bool hasNode = CheckNode(); + + if (!hasPython || !hasNode) + { + SetupWindowService.ShowSetupWindow(); + return; + } + + try + { + // 1. Check/Install uv + EditorUtility.DisplayProgressBar("MCP Setup", "Checking 'uv' package manager...", 0.3f); + string uvPath = GetOrInstallUv(); + if (string.IsNullOrEmpty(uvPath)) + { + McpLog.Warn("Could not find 'uv'. Falling back to standard 'pip' for installation (slower)."); + } + + // 3. Create venv (Skip if uv sync is used, as it handles venv creation) + // However, for safety and fallback support, we can still ensure it exists or let uv handle it. + // If using uv sync, we don't necessarily need to manually create venv, but doing so explicitly doesn't hurt. + if (string.IsNullOrEmpty(uvPath)) + { + EditorUtility.DisplayProgressBar("MCP Setup", "Creating virtual environment...", 0.5f); + if (!CreateVenv()) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("Error", "Failed to create virtual environment.", "OK"); + return; + } + } + + // 4. Install Dependencies + EditorUtility.DisplayProgressBar("MCP Setup", "Syncing dependencies...", 0.7f); + if (!InstallDependencies(uvPath)) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("Error", "Failed to install dependencies.", "OK"); + return; + } + + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("Success", "MCP Server environment setup complete!\n\nYou can now connect using Cursor/Claude.", "OK"); + } + catch (Exception ex) + { + EditorUtility.ClearProgressBar(); + McpLog.Error($"[MCP Setup] Error: {ex}"); + EditorUtility.DisplayDialog("Setup Failed", + $"Setup failed: {ex.Message}\n\nRunning processes might be locking files, or PATH environment variables might need a refresh.\n\nTry restarting Unity (or your computer) and run Setup again.", "OK"); + } + } + + private static bool CheckPython() + { + string pythonCmd = GetPythonCommand(); + if (RunCommand(pythonCmd, "--version", out string output)) + { + output = output.Trim(); + // Output format: "Python 3.x.x" + if (output.StartsWith("Python ")) + { + string versionStr = output.Substring(7); + string[] parts = versionStr.Split('.'); + if (parts.Length >= 2 && + int.TryParse(parts[0], out int major) && + int.TryParse(parts[1], out int minor)) + { + if (major > 3 || (major == 3 && minor >= 11)) return true; + else + { + McpLog.Error($"[MCP Setup] Python version {versionStr} is too old. Required: 3.11+"); + return false; + } + } + } + McpLog.Warn($"[MCP Setup] Could not parse Python version: {output}"); + return false; + } + return false; + } + + private static bool CheckNode() + { + // Use override if available + return RunCommand(GetNodeCommand(), "--version", out _); + } + + public static string InstallUvExplicitly() + { + return GetOrInstallUv(); + } + + private static string GetOrInstallUv() + { + // Check if uv is already in PATH + if (RunCommand("uv", "--version", out _)) return "uv"; + + // Check if uv is in the known install locations (override or default) + string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvPathOverride, ""); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) return overridePath; + + McpLog.Info("[MCP Setup] 'uv' not found in PATH. Installing via pip..."); + + string pythonCmd = GetPythonCommand(); + + // Install via pip + if (!RunCommand(pythonCmd, "-m pip install uv", out string pipOutput)) + { + McpLog.Warn($"Failed to install uv via pip: {pipOutput}"); + return null; + } + + // Check if uv is now in PATH + if (RunCommand("uv", "--version", out _)) return "uv"; + + // Try to find uv in likely locations (Fallback logic) + string findUvScript = "import site, os, sys; " + + "candidates = [" + + "os.path.join(sys.prefix, 'Scripts', 'uv.exe'), " + + "os.path.join(sys.prefix, 'bin', 'uv'), " + + "os.path.join(site.getuserbase(), 'Scripts', 'uv.exe'), " + + "os.path.join(site.getuserbase(), 'bin', 'uv')" + + "]; " + + "found = next((p for p in candidates if os.path.exists(p)), ''); " + + "print(found)"; + + if (RunCommand(pythonCmd, $"-c \"{findUvScript}\"", out string foundPath)) + { + foundPath = foundPath.Trim(); + if (!string.IsNullOrEmpty(foundPath) && File.Exists(foundPath)) return foundPath; + } + + return null; + } + + /// + /// Creates a Python virtual environment manually. No-op if UV is available (UV handles venv creation). + /// + private static bool CreateVenv() + { + if (Directory.Exists(VenvPath)) + { + McpLog.Info("[MCP Setup] Cleaning existing .venv..."); + try { Directory.Delete(VenvPath, true); } catch { /* ignore */ } + } + + McpLog.Info($"[MCP Setup] Creating virtual environment at: {VenvPath}"); + return RunCommand(GetPythonCommand(), $"-m venv \"{VenvPath}\"", out string output, workingDirectory: ServerRoot); + } + + private static bool InstallDependencies(string uvPath) + { + string workingDir = Path.GetFullPath(ServerRoot); + + if (!string.IsNullOrEmpty(uvPath)) + { + McpLog.Info("[MCP Setup] Using 'uv sync' to install dependencies..."); + // uv sync will create .venv if needed and install everything defined in pyproject.toml + // Removed --frozen to allow lock file generation if missing + return RunCommand(uvPath, "sync", out string output, workingDirectory: workingDir); + } + else + { + // Fallback for standard pip + string venvPython = Path.Combine(VenvPath, "Scripts", "python.exe"); + if (!File.Exists(venvPython)) venvPython = Path.Combine(VenvPath, "bin", "python"); + venvPython = Path.GetFullPath(venvPython); + + if (!File.Exists(venvPython)) + { + McpLog.Error($"[MCP Setup] Virtual environment python not found at: {venvPython}"); + return false; + } + + McpLog.Info("[MCP Setup] Using standard pip to install dependencies..."); + return RunCommand(venvPython, "-m pip install -e .", out string output, workingDirectory: workingDir); + } + } + + private static bool RunCommand(string fileName, string arguments, out string output, string workingDirectory = null) + { + output = ""; + try + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = fileName; + psi.Arguments = arguments; + psi.UseShellExecute = false; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.CreateNoWindow = true; + + if (!string.IsNullOrEmpty(workingDirectory)) + { + psi.WorkingDirectory = workingDirectory; + } + + using (Process p = Process.Start(psi)) + { + if (p == null) + { + McpLog.Error($"[MCP Setup] Failed to start process: {fileName}"); + return false; + } + + // Read streams asynchronously to prevent deadlock on buffer fill + // Note: ReadToEndAsync is not available in .NET Standard 2.0 (Unity's default profile), using wrapper or async delegate + var outputTask = Task.Run(() => p.StandardOutput.ReadToEnd()); + var errorTask = Task.Run(() => p.StandardError.ReadToEnd()); + + // 2 minute timeout to prevent hanging indefinitely + if (!p.WaitForExit(120000)) + { + try { p.Kill(); } catch {} + McpLog.Error($"[MCP Setup] Command timed out: {fileName} {arguments}"); + return false; + } + + output = outputTask.Result; + string error = errorTask.Result; + + // 2 minute timeout to prevent hanging indefinitely + if (!p.WaitForExit(120000)) + { + try { p.Kill(); } catch {} + McpLog.Error($"[MCP Setup] Command timed out: {fileName} {arguments}"); + return false; + } + + if (p.ExitCode != 0) + { + McpLog.Error($"[MCP Setup] Command failed: {fileName} {arguments}\nOutput: {output}\nError: {error}"); + return false; + } + return true; + } + } + catch (System.ComponentModel.Win32Exception) + { + // File not found (common when checking if a tool exists in PATH). + // Do not log as Error to avoid confusing the user. + return false; + } + catch (Exception ex) + { + McpLog.Error($"[MCP Setup] Exception running command '{fileName} {arguments}': {ex.Message}"); + return false; + } + } + + private static string GetPythonCommand() + { + string overridePath = EditorPrefs.GetString(EditorPrefKeys.PythonPathOverride, ""); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } + return "python"; + } + + private static string GetNodeCommand() + { + string overridePath = EditorPrefs.GetString(EditorPrefKeys.NodePathOverride, ""); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } + return "node"; + } + } +} diff --git a/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs.meta b/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs.meta new file mode 100644 index 000000000..1ac299141 --- /dev/null +++ b/MCPForUnity/Editor/Setup/ServerEnvironmentSetup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f607182930415263748596 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 781d1c0ed..8e7e733f4 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -41,7 +41,7 @@ public class McpClientConfigSection private readonly List configurators; private readonly Dictionary lastStatusChecks = new(); private readonly HashSet statusRefreshInFlight = new(); - private static readonly TimeSpan StatusRefreshInterval = TimeSpan.FromSeconds(45); + private int selectedClientIndex = 0; public VisualElement Root { get; private set; } @@ -90,9 +90,10 @@ private void RegisterCallbacks() clientDropdown.RegisterValueChangedCallback(evt => { selectedClientIndex = clientDropdown.index; - UpdateClientStatus(); + // Only update UI, don't check status automatically UpdateManualConfiguration(); UpdateClaudeCliPathVisibility(); + ApplyStatusToUi(configurators[selectedClientIndex]); }); configureAllButton.clicked += OnConfigureAllClientsClicked; @@ -103,14 +104,7 @@ private void RegisterCallbacks() copyJsonButton.clicked += OnCopyJsonClicked; } - public void UpdateClientStatus() - { - if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) - return; - var client = configurators[selectedClientIndex]; - RefreshClientStatus(client); - } private string GetStatusDisplayString(McpStatus status) { @@ -198,8 +192,9 @@ private void OnConfigureAllClientsClicked() if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { - UpdateClientStatus(); + // Just update UI, don't check status UpdateManualConfiguration(); + ApplyStatusToUi(configurators[selectedClientIndex]); } } catch (Exception ex) @@ -243,7 +238,7 @@ private void OnBrowseClaudeClicked() { MCPServiceLocator.Paths.SetClaudeCliPathOverride(picked); UpdateClaudeCliPathVisibility(); - UpdateClientStatus(); + // Status will be checked when user clicks Configure button McpLog.Info($"Claude CLI path override set to: {picked}"); } catch (Exception ex) @@ -307,9 +302,16 @@ private void RefreshClientStatus(IMcpClientConfigurator client, bool forceImmedi return; } - if (forceImmediate || ShouldRefreshClient(client)) + // Only check status when explicitly requested (forceImmediate = true) + // This happens when: + // - User clicks Configure button + // - User changes client dropdown + // - User manually refreshes + if (forceImmediate) { - MCPServiceLocator.Client.CheckClientStatus(client); + // Only check status, don't auto-rewrite config files + // Config files should only be written when user explicitly clicks Configure + MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); lastStatusChecks[client] = DateTime.UtcNow; } @@ -318,72 +320,17 @@ private void RefreshClientStatus(IMcpClientConfigurator client, bool forceImmedi private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate) { + // For Claude CLI, only check status when explicitly requested (forceImmediate) if (forceImmediate) { MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); lastStatusChecks[client] = DateTime.UtcNow; - ApplyStatusToUi(client); - return; } - bool hasStatus = lastStatusChecks.ContainsKey(client); - bool needsRefresh = !hasStatus || ShouldRefreshClient(client); - - if (!hasStatus) - { - ApplyStatusToUi(client, showChecking: true); - } - else - { - ApplyStatusToUi(client); - } - - if (needsRefresh && !statusRefreshInFlight.Contains(client)) - { - statusRefreshInFlight.Add(client); - ApplyStatusToUi(client, showChecking: true); - - Task.Run(() => - { - MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); - }).ContinueWith(t => - { - bool faulted = false; - string errorMessage = null; - if (t.IsFaulted && t.Exception != null) - { - var baseException = t.Exception.GetBaseException(); - errorMessage = baseException?.Message ?? "Status check failed"; - McpLog.Error($"Failed to refresh Claude CLI status: {errorMessage}"); - faulted = true; - } - - EditorApplication.delayCall += () => - { - statusRefreshInFlight.Remove(client); - lastStatusChecks[client] = DateTime.UtcNow; - if (faulted) - { - if (client is McpClientConfiguratorBase baseConfigurator) - { - baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage ?? "Status check failed"); - } - } - ApplyStatusToUi(client); - }; - }); - } + ApplyStatusToUi(client); } - private bool ShouldRefreshClient(IMcpClientConfigurator client) - { - if (!lastStatusChecks.TryGetValue(client, out var last)) - { - return true; - } - return (DateTime.UtcNow - last) > StatusRefreshInterval; - } private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false) { diff --git a/MCPForUnity/Editor/Windows/Components/Common.uss b/MCPForUnity/Editor/Windows/Components/Common.uss index e89e0bec7..87e62ee6a 100644 --- a/MCPForUnity/Editor/Windows/Components/Common.uss +++ b/MCPForUnity/Editor/Windows/Components/Common.uss @@ -359,7 +359,12 @@ } .tool-parameters { - font-style: italic; + -unity-font-style: italic; +} + +.tool-item-actions { + flex-direction: row; + margin-top: 6px; } /* Advanced Settings */ @@ -469,22 +474,6 @@ border-color: rgba(0, 0, 0, 0.15); } -.unity-theme-dark .tool-tag { - color: rgba(220, 220, 220, 1); - background-color: rgba(80, 80, 80, 0.6); -} - -.unity-theme-dark .tool-item { - background-color: rgba(255, 255, 255, 0.04); - border-color: rgba(255, 255, 255, 0.08); -} - -.unity-theme-dark .tool-item-description, -.unity-theme-dark .tool-parameters { - color: rgba(200, 200, 200, 0.8); -} - - .unity-theme-light .validation-description { background-color: rgba(100, 150, 200, 0.1); } @@ -520,3 +509,144 @@ .unity-theme-light .path-display-field > .unity-text-field__input { background-color: rgba(0, 0, 0, 0.05); } + +/* Dependency Row Styles */ +.dependency-row { + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 4px 0; + height: 30px; +} + +.dependency-info { + flex-direction: row; + align-items: center; + flex-grow: 1; +} + +.dependency-label { + width: 120px; + -unity-font-style: bold; +} + +.unity-theme-light .dependency-label { + color: #333333; +} + +.unity-theme-dark .dependency-label { + color: #E0E0E0; +} + +.dependency-status { + margin-left: 10px; +} + +.unity-theme-dark .dependency-status { + color: #AAAAAA; +} + +.unity-theme-dark .dependency-status.installed { + color: #4CAF50; /* Green */ +} + +.unity-theme-dark .dependency-status.missing { + color: #F44336; /* Red */ +} + +.unity-theme-light .dependency-status { + color: #666666; +} + +.unity-theme-light .dependency-status.installed { + color: #2E7D32; +} + +.unity-theme-light .dependency-status.missing { + color: #C62828; +} + +.dependency-actions { + flex-direction: row; + align-items: center; +} + +.mini-button { + height: 24px; + padding: 0 10px; + margin-left: 5px; + font-size: 11px; + border-width: 0; + border-radius: 3px; +} + +.unity-theme-dark .mini-button { + background-color: #444; + color: #EEE; +} + +.unity-theme-dark .mini-button:hover { + background-color: #555; +} + +.unity-theme-dark .mini-button.secondary { + background-color: transparent; + border-width: 1px; + border-color: #555; +} + +.unity-theme-dark .mini-button.secondary:hover { + background-color: #383838; +} + +.unity-theme-dark .mini-button.primary { + background-color: #2196F3; /* Blue */ +} + +.unity-theme-dark .mini-button.primary:hover { + background-color: #1E88E5; +} + +.unity-theme-light .mini-button { + background-color: #E0E0E0; + color: #333; +} + +.unity-theme-light .mini-button:hover { + background-color: #CCCCCC; +} + +.unity-theme-light .mini-button.secondary { + background-color: transparent; + border-width: 1px; + border-color: #AAAAAA; +} + +.unity-theme-light .mini-button.secondary:hover { + background-color: #F5F5F5; +} + +.unity-theme-light .mini-button.primary { + background-color: #2196F3; + color: #FFF; +} + +.unity-theme-light .mini-button.primary:hover { + background-color: #1976D2; +} + +/* Dark Theme Tool Overrides */ +.unity-theme-dark .tool-tag { + color: rgba(220, 220, 220, 1); + background-color: rgba(80, 80, 80, 0.6); +} + +.unity-theme-dark .tool-item { + background-color: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); +} + +.unity-theme-dark .tool-item-description, +.unity-theme-dark .tool-parameters { + color: rgba(200, 200, 200, 0.8); +} diff --git a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs index 1489304e9..48327497d 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs @@ -13,7 +13,8 @@ namespace MCPForUnity.Editor.Windows.Components.Settings { /// /// Controller for the Settings section of the MCP For Unity editor window. - /// Handles version display, debug logs, validation level, and advanced path overrides. + /// Handles version display, debug logs, validation level. + /// Advanced settings and overrides are handled in McpSetupSection or McpClientConfigSection. /// public class McpSettingsSection { @@ -22,28 +23,12 @@ public class McpSettingsSection private Toggle debugLogsToggle; private EnumField validationLevelField; private Label validationDescription; - private Foldout advancedSettingsFoldout; - private TextField uvxPathOverride; - private Button browseUvxButton; - private Button clearUvxButton; - private VisualElement uvxPathStatus; - private TextField gitUrlOverride; - private Button clearGitUrlButton; - private TextField deploySourcePath; - private Button browseDeploySourceButton; - private Button clearDeploySourceButton; - private Button deployButton; - private Button deployRestoreButton; - private Label deployTargetLabel; - private Label deployBackupLabel; - private Label deployStatusLabel; // Data private ValidationLevel currentValidationLevel = ValidationLevel.Standard; // Events - public event Action OnGitUrlChanged; - public event Action OnHttpServerCommandUpdateRequested; + // Validation levels private enum ValidationLevel @@ -70,21 +55,6 @@ private void CacheUIElements() debugLogsToggle = Root.Q("debug-logs-toggle"); validationLevelField = Root.Q("validation-level"); validationDescription = Root.Q