From 8ae595d2f4f2525b0e44ece948883ea37138add4 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 10 Oct 2025 06:53:03 -0700 Subject: [PATCH 01/10] Update github-repo-stats.yml --- .github/workflows/github-repo-stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index fda0851b2..6eb1eb724 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -1,10 +1,10 @@ name: github-repo-stats on: - schedule: + # schedule: # Run this once per day, towards the end of the day for keeping the most # recent data point most meaningful (hours are interpreted in UTC). - - cron: "0 23 * * *" + #- cron: "0 23 * * *" workflow_dispatch: # Allow for running this manually. jobs: From 74d35d371a28b2d86cb7722e28017b29be053efd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 30 Oct 2025 19:28:51 -0700 Subject: [PATCH 02/10] Server: refine shutdown logic per bot feedback\n- Parameterize _force_exit(code) and use timers with args\n- Consistent behavior on BrokenPipeError (no immediate exit)\n- Exit code 1 on unexpected exceptions\n\nTests: restore telemetry module after disabling to avoid bleed-over --- Server/server.py | 95 +++++++++++++++++++++++++++++++++++++++- Server/test_telemetry.py | 42 +++++++----------- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/Server/server.py b/Server/server.py index 11053ac87..3148a563c 100644 --- a/Server/server.py +++ b/Server/server.py @@ -4,6 +4,9 @@ from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager +import sys +import signal +import threading from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools @@ -64,6 +67,9 @@ # Global connection state _unity_connection: UnityConnection = None +# Global shutdown coordination +_shutdown_flag = threading.Event() + @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: @@ -186,9 +192,96 @@ def _emit_startup(): register_all_resources(mcp) +def _force_exit(code: int = 0): + """Force process exit, bypassing any background threads that might linger.""" + try: + sys.exit(code) + except SystemExit: + os._exit(code) + + +def _signal_handler(signum, frame): + logger.info(f"Received signal {signum}, initiating shutdown...") + _shutdown_flag.set() + threading.Timer(1.0, _force_exit, args=(0,)).start() + + +def _monitor_stdin(): + """Background thread to detect stdio detach (stdin EOF) or parent exit.""" + try: + parent_pid = os.getppid() if hasattr(os, "getppid") else None + while not _shutdown_flag.is_set(): + if _shutdown_flag.wait(0.5): + break + + if parent_pid is not None: + try: + os.kill(parent_pid, 0) + except (ProcessLookupError, OSError): + logger.info(f"Parent process {parent_pid} no longer exists; shutting down") + break + + try: + if sys.stdin.closed: + logger.info("stdin.closed is True; client disconnected") + break + fd = sys.stdin.fileno() + if fd < 0: + logger.info("stdin fd invalid; client disconnected") + break + except (ValueError, OSError, AttributeError): + # Closed pipe or unavailable stdin + break + except Exception: + # Ignore transient errors + pass + + if not _shutdown_flag.is_set(): + logger.info("Client disconnected (stdin or parent), initiating shutdown...") + _shutdown_flag.set() + if not _shutdown_flag.is_set(): + threading.Timer(0.5, _force_exit, args=(0,)).start() + else: + threading.Timer(0.5, _force_exit, args=(0,)).start() + except Exception: + # Never let monitor thread crash the process + pass + + def main(): """Entry point for uvx and console scripts.""" - mcp.run(transport='stdio') + try: + signal.signal(signal.SIGTERM, _signal_handler) + signal.signal(signal.SIGINT, _signal_handler) + if hasattr(signal, "SIGPIPE"): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _signal_handler) + except Exception: + # Signals can fail in some environments + pass + + t = threading.Thread(target=_monitor_stdin, daemon=True) + t.start() + + try: + mcp.run(transport='stdio') + logger.info("FastMCP run() returned (stdin EOF or disconnect)") + except (KeyboardInterrupt, SystemExit): + logger.info("Server interrupted; shutting down") + _shutdown_flag.set() + except BrokenPipeError: + logger.info("Broken pipe; shutting down") + _shutdown_flag.set() + # rely on finally to schedule exit for consistency + except Exception as e: + logger.error(f"Server error: {e}", exc_info=True) + _shutdown_flag.set() + _force_exit(1) + finally: + _shutdown_flag.set() + logger.info("Server main loop exited") + threading.Timer(0.5, _force_exit, args=(0,)).start() # Run the server diff --git a/Server/test_telemetry.py b/Server/test_telemetry.py index 3e4b7ce75..5225ff03d 100644 --- a/Server/test_telemetry.py +++ b/Server/test_telemetry.py @@ -23,8 +23,8 @@ def test_telemetry_basic(): ) pass except ImportError as e: - # Silent failure path for tests - return False + # Fail explicitly when imports are missing + assert False, f"telemetry import failed: {e}" # Test telemetry enabled status _ = is_telemetry_enabled() @@ -37,8 +37,7 @@ def test_telemetry_basic(): }) pass except Exception as e: - # Silent failure path for tests - return False + assert False, f"record_telemetry failed: {e}" # Test milestone recording try: @@ -47,26 +46,23 @@ def test_telemetry_basic(): }) _ = is_first except Exception as e: - # Silent failure path for tests - return False + assert False, f"record_milestone failed: {e}" # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: - # Silent failure path for tests - return False + assert False, f"get_telemetry failed: {e}" + assert True - return True - -def test_telemetry_disabled(): +def test_telemetry_disabled(monkeypatch): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry - os.environ["DISABLE_TELEMETRY"] = "true" + monkeypatch.setenv("DISABLE_TELEMETRY", "true") # Re-import to get fresh config import importlib @@ -77,17 +73,12 @@ def test_telemetry_disabled(): _ = is_telemetry_enabled() - if not is_telemetry_enabled(): - pass - - # Test that records are ignored when disabled - record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) - pass - - return True - else: - pass - return False + assert is_telemetry_enabled() is False + # Test that records are ignored when disabled (should not raise) + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + # Restore module state for subsequent tests + monkeypatch.delenv("DISABLE_TELEMETRY", raising=False) + importlib.reload(telemetry) def test_data_storage(): @@ -114,11 +105,10 @@ def test_data_storage(): else: pass - return True + assert True except Exception as e: - # Silent failure path for tests - return False + assert False, f"data storage test failed: {e}" def main(): From 1bb280ee23af023973bdc7a619adb823ff5e1993 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 30 Oct 2025 19:33:30 -0700 Subject: [PATCH 03/10] Revert "Server: refine shutdown logic per bot feedback\n- Parameterize _force_exit(code) and use timers with args\n- Consistent behavior on BrokenPipeError (no immediate exit)\n- Exit code 1 on unexpected exceptions\n\nTests: restore telemetry module after disabling to avoid bleed-over" This reverts commit 74d35d371a28b2d86cb7722e28017b29be053efd. --- Server/server.py | 95 +--------------------------------------- Server/test_telemetry.py | 42 +++++++++++------- 2 files changed, 27 insertions(+), 110 deletions(-) diff --git a/Server/server.py b/Server/server.py index 3148a563c..11053ac87 100644 --- a/Server/server.py +++ b/Server/server.py @@ -4,9 +4,6 @@ from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager -import sys -import signal -import threading from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools @@ -67,9 +64,6 @@ # Global connection state _unity_connection: UnityConnection = None -# Global shutdown coordination -_shutdown_flag = threading.Event() - @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: @@ -192,96 +186,9 @@ def _emit_startup(): register_all_resources(mcp) -def _force_exit(code: int = 0): - """Force process exit, bypassing any background threads that might linger.""" - try: - sys.exit(code) - except SystemExit: - os._exit(code) - - -def _signal_handler(signum, frame): - logger.info(f"Received signal {signum}, initiating shutdown...") - _shutdown_flag.set() - threading.Timer(1.0, _force_exit, args=(0,)).start() - - -def _monitor_stdin(): - """Background thread to detect stdio detach (stdin EOF) or parent exit.""" - try: - parent_pid = os.getppid() if hasattr(os, "getppid") else None - while not _shutdown_flag.is_set(): - if _shutdown_flag.wait(0.5): - break - - if parent_pid is not None: - try: - os.kill(parent_pid, 0) - except (ProcessLookupError, OSError): - logger.info(f"Parent process {parent_pid} no longer exists; shutting down") - break - - try: - if sys.stdin.closed: - logger.info("stdin.closed is True; client disconnected") - break - fd = sys.stdin.fileno() - if fd < 0: - logger.info("stdin fd invalid; client disconnected") - break - except (ValueError, OSError, AttributeError): - # Closed pipe or unavailable stdin - break - except Exception: - # Ignore transient errors - pass - - if not _shutdown_flag.is_set(): - logger.info("Client disconnected (stdin or parent), initiating shutdown...") - _shutdown_flag.set() - if not _shutdown_flag.is_set(): - threading.Timer(0.5, _force_exit, args=(0,)).start() - else: - threading.Timer(0.5, _force_exit, args=(0,)).start() - except Exception: - # Never let monitor thread crash the process - pass - - def main(): """Entry point for uvx and console scripts.""" - try: - signal.signal(signal.SIGTERM, _signal_handler) - signal.signal(signal.SIGINT, _signal_handler) - if hasattr(signal, "SIGPIPE"): - signal.signal(signal.SIGPIPE, signal.SIG_IGN) - if hasattr(signal, "SIGBREAK"): - signal.signal(signal.SIGBREAK, _signal_handler) - except Exception: - # Signals can fail in some environments - pass - - t = threading.Thread(target=_monitor_stdin, daemon=True) - t.start() - - try: - mcp.run(transport='stdio') - logger.info("FastMCP run() returned (stdin EOF or disconnect)") - except (KeyboardInterrupt, SystemExit): - logger.info("Server interrupted; shutting down") - _shutdown_flag.set() - except BrokenPipeError: - logger.info("Broken pipe; shutting down") - _shutdown_flag.set() - # rely on finally to schedule exit for consistency - except Exception as e: - logger.error(f"Server error: {e}", exc_info=True) - _shutdown_flag.set() - _force_exit(1) - finally: - _shutdown_flag.set() - logger.info("Server main loop exited") - threading.Timer(0.5, _force_exit, args=(0,)).start() + mcp.run(transport='stdio') # Run the server diff --git a/Server/test_telemetry.py b/Server/test_telemetry.py index 5225ff03d..3e4b7ce75 100644 --- a/Server/test_telemetry.py +++ b/Server/test_telemetry.py @@ -23,8 +23,8 @@ def test_telemetry_basic(): ) pass except ImportError as e: - # Fail explicitly when imports are missing - assert False, f"telemetry import failed: {e}" + # Silent failure path for tests + return False # Test telemetry enabled status _ = is_telemetry_enabled() @@ -37,7 +37,8 @@ def test_telemetry_basic(): }) pass except Exception as e: - assert False, f"record_telemetry failed: {e}" + # Silent failure path for tests + return False # Test milestone recording try: @@ -46,23 +47,26 @@ def test_telemetry_basic(): }) _ = is_first except Exception as e: - assert False, f"record_milestone failed: {e}" + # Silent failure path for tests + return False # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: - assert False, f"get_telemetry failed: {e}" - assert True + # Silent failure path for tests + return False + return True -def test_telemetry_disabled(monkeypatch): + +def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry - monkeypatch.setenv("DISABLE_TELEMETRY", "true") + os.environ["DISABLE_TELEMETRY"] = "true" # Re-import to get fresh config import importlib @@ -73,12 +77,17 @@ def test_telemetry_disabled(monkeypatch): _ = is_telemetry_enabled() - assert is_telemetry_enabled() is False - # Test that records are ignored when disabled (should not raise) - record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) - # Restore module state for subsequent tests - monkeypatch.delenv("DISABLE_TELEMETRY", raising=False) - importlib.reload(telemetry) + if not is_telemetry_enabled(): + pass + + # Test that records are ignored when disabled + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + pass + + return True + else: + pass + return False def test_data_storage(): @@ -105,10 +114,11 @@ def test_data_storage(): else: pass - assert True + return True except Exception as e: - assert False, f"data storage test failed: {e}" + # Silent failure path for tests + return False def main(): From 2d7844ca74e89db604af82f3d5d267ce16736b8b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 25 Nov 2025 14:52:19 -0800 Subject: [PATCH 04/10] fix: Add missing os and struct imports to port_discovery.py Problem: The port discovery mechanism was failing to detect running Unity instances because the _try_probe_unity_mcp() function was using struct.pack() and struct.unpack() without importing the struct module, and other functions were using os.path.basename() without importing the os module. This caused NameError exceptions that were silently caught, preventing Unity instances from being discovered. Root Cause: - struct.pack() used at line 80 to create message headers - struct.unpack() used at line 98 to parse response headers - os.path.basename() used at lines 217 and 247 to extract filenames - Neither os nor struct modules were imported Impact: - Unity instances running on port 6400 were not discoverable - The MCP server would fail to connect even when Unity was running - The probe function would fail silently due to exception handling Solution: Added missing imports: - import os (for os.path.basename) - import struct (for struct.pack/unpack in the framed protocol) Verification: - Test script confirmed Unity is responding correctly on port 6400 - The framed protocol (8-byte header + payload) works correctly - Once the MCP server restarts with these imports, Unity discovery will work --- Server/src/transport/legacy/port_discovery.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index fe7de5bf6..117f18058 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -14,6 +14,8 @@ import glob import json import logging +import os +import struct from datetime import datetime from pathlib import Path import socket From d7cbbfb4c3e9a0b8bec0f3ab3a33b4378a8f51b4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 25 Nov 2025 16:06:06 -0800 Subject: [PATCH 05/10] Fix: Resolve absolute path for uvx to support Claude Desktop on macOS Claude Desktop on macOS does not inherit the user's full PATH, causing 'spawn uvx ENOENT' errors when it tries to run the server. This change adds auto-discovery logic to find 'uvx' in common locations (like ~/.local/bin, ~/.cargo/bin, /opt/homebrew/bin) and writes the absolute path to the config. Also updated README with troubleshooting steps. --- MCPForUnity/Editor/Helpers/ExecPath.cs | 50 +++++++++++++++++++ .../Editor/Services/PathResolverService.cs | 7 +++ MCPForUnity/README.md | 4 ++ 3 files changed, 61 insertions(+) diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 9190ec388..ac3ec83ae 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -132,6 +132,56 @@ private static string ResolveClaudeFromNvm(string home) catch { return null; } } + // Resolve uvx absolute path. Pref -> env -> common locations -> PATH. + internal static string ResolveUvx() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + Path.Combine(home, ".local", "bin", "uvx"), + Path.Combine(home, ".cargo", "bin", "uvx"), + "/usr/local/bin/uvx", + "/opt/homebrew/bin/uvx", + "/usr/bin/uvx" + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("uvx", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + string[] candidates = + { + Path.Combine(userProfile, ".cargo", "bin", "uvx.exe"), + Path.Combine(localAppData, "uv", "uvx.exe"), + Path.Combine(userProfile, "uv", "uvx.exe"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + + string fromWhere = Where("uvx.exe") ?? Where("uvx"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + } + catch { } + + return null; + } + // Explicitly set the Claude CLI absolute path override in EditorPrefs internal static void SetClaudeCliPath(string absolutePath) { diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 4b6b07fbb..b8444eb57 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -34,6 +34,13 @@ public string GetUvxPath() McpLog.Debug("No uvx path override found, falling back to default command"); } + // Auto-discovery of absolute path + string discovered = ExecPath.ResolveUvx(); + if (!string.IsNullOrEmpty(discovered)) + { + return discovered; + } + return "uvx"; } diff --git a/MCPForUnity/README.md b/MCPForUnity/README.md index b4048f5ec..e70d2c49e 100644 --- a/MCPForUnity/README.md +++ b/MCPForUnity/README.md @@ -82,6 +82,10 @@ Notes: - Help: [Fix MCP for Unity with Cursor, VS Code & Windsurf](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf) - Claude CLI not found: - Help: [Fix MCP for Unity with Claude Code](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code) +- Claude Desktop "spawn uvx ENOENT" error on macOS: + - Claude Desktop may not inherit your shell's PATH. + - The MCP for Unity plugin attempts to automatically resolve the absolute path to `uvx`. + - If this fails, use the "Choose UV Install Location" button in the MCP for Unity window to select your `uvx` executable (typically `~/.local/bin/uvx`), or manually update your Claude Desktop config to use the absolute path to `uvx`. --- From e6136073ee7bb19aa3de4a110eb00d34ae2b443e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 25 Nov 2025 14:52:19 -0800 Subject: [PATCH 06/10] fix: Add missing os and struct imports to port_discovery.py Problem: The port discovery mechanism was failing to detect running Unity instances because the _try_probe_unity_mcp() function was using struct.pack() and struct.unpack() without importing the struct module, and other functions were using os.path.basename() without importing the os module. This caused NameError exceptions that were silently caught, preventing Unity instances from being discovered. Root Cause: - struct.pack() used at line 80 to create message headers - struct.unpack() used at line 98 to parse response headers - os.path.basename() used at lines 217 and 247 to extract filenames - Neither os nor struct modules were imported Impact: - Unity instances running on port 6400 were not discoverable - The MCP server would fail to connect even when Unity was running - The probe function would fail silently due to exception handling Solution: Added missing imports: - import os (for os.path.basename) - import struct (for struct.pack/unpack in the framed protocol) Verification: - Test script confirmed Unity is responding correctly on port 6400 - The framed protocol (8-byte header + payload) works correctly - Once the MCP server restarts with these imports, Unity discovery will work --- Server/src/transport/legacy/port_discovery.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index fe7de5bf6..117f18058 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -14,6 +14,8 @@ import glob import json import logging +import os +import struct from datetime import datetime from pathlib import Path import socket From d463aa553c8ffbbc68269acbe426e4324fdfc83c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 25 Nov 2025 16:06:06 -0800 Subject: [PATCH 07/10] Fix: Resolve absolute path for uvx to support Claude Desktop on macOS Claude Desktop on macOS does not inherit the user's full PATH, causing 'spawn uvx ENOENT' errors when it tries to run the server. This change adds auto-discovery logic to find 'uvx' in common locations (like ~/.local/bin, ~/.cargo/bin, /opt/homebrew/bin) and writes the absolute path to the config. Also updated README with troubleshooting steps. --- MCPForUnity/Editor/Helpers/ExecPath.cs | 50 +++++++++++++++++++ .../Editor/Services/PathResolverService.cs | 7 +++ MCPForUnity/README.md | 4 ++ 3 files changed, 61 insertions(+) diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 9190ec388..ac3ec83ae 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -132,6 +132,56 @@ private static string ResolveClaudeFromNvm(string home) catch { return null; } } + // Resolve uvx absolute path. Pref -> env -> common locations -> PATH. + internal static string ResolveUvx() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + Path.Combine(home, ".local", "bin", "uvx"), + Path.Combine(home, ".cargo", "bin", "uvx"), + "/usr/local/bin/uvx", + "/opt/homebrew/bin/uvx", + "/usr/bin/uvx" + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("uvx", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + string[] candidates = + { + Path.Combine(userProfile, ".cargo", "bin", "uvx.exe"), + Path.Combine(localAppData, "uv", "uvx.exe"), + Path.Combine(userProfile, "uv", "uvx.exe"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + + string fromWhere = Where("uvx.exe") ?? Where("uvx"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + } + catch { } + + return null; + } + // Explicitly set the Claude CLI absolute path override in EditorPrefs internal static void SetClaudeCliPath(string absolutePath) { diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 4b6b07fbb..b8444eb57 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -34,6 +34,13 @@ public string GetUvxPath() McpLog.Debug("No uvx path override found, falling back to default command"); } + // Auto-discovery of absolute path + string discovered = ExecPath.ResolveUvx(); + if (!string.IsNullOrEmpty(discovered)) + { + return discovered; + } + return "uvx"; } diff --git a/MCPForUnity/README.md b/MCPForUnity/README.md index b4048f5ec..e70d2c49e 100644 --- a/MCPForUnity/README.md +++ b/MCPForUnity/README.md @@ -82,6 +82,10 @@ Notes: - Help: [Fix MCP for Unity with Cursor, VS Code & Windsurf](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf) - Claude CLI not found: - Help: [Fix MCP for Unity with Claude Code](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code) +- Claude Desktop "spawn uvx ENOENT" error on macOS: + - Claude Desktop may not inherit your shell's PATH. + - The MCP for Unity plugin attempts to automatically resolve the absolute path to `uvx`. + - If this fails, use the "Choose UV Install Location" button in the MCP for Unity window to select your `uvx` executable (typically `~/.local/bin/uvx`), or manually update your Claude Desktop config to use the absolute path to `uvx`. --- From 293bd1f7833945a5de46e44ea7cadbc1be358d92 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 26 Nov 2025 12:20:12 -0800 Subject: [PATCH 08/10] fix: resolve Windows path issue in uv cache clearing (preserve .exe extension) --- .../Services/ServerManagementService.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 8a323aba9..09315a8f3 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -14,6 +14,30 @@ namespace MCPForUnity.Editor.Services /// public class ServerManagementService : IServerManagementService { + /// + /// Convert a uvx path to a uv path by replacing "uvx" with "uv" while preserving the extension + /// + private string ConvertUvxToUv(string uvxPath) + { + if (string.IsNullOrEmpty(uvxPath)) + return uvxPath; + + // Handle case-insensitive replacement of "uvx" with "uv" + // This works for paths like: + // - /usr/bin/uvx -> /usr/bin/uv + // - C:\path\to\uvx.exe -> C:\path\to\uv.exe + // - uvx -> uv + + int lastIndex = uvxPath.LastIndexOf("uvx", StringComparison.OrdinalIgnoreCase); + if (lastIndex >= 0) + { + return uvxPath.Substring(0, lastIndex) + "uv" + uvxPath.Substring(lastIndex + 3); + } + + // Fallback: if "uvx" not found, try removing last character (original behavior) + return uvxPath.Length > 0 ? uvxPath.Remove(uvxPath.Length - 1, 1) : uvxPath; + } + /// /// Clear the local uvx cache for the MCP server package /// @@ -23,7 +47,7 @@ public bool ClearUvxCache() try { string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); - string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1); + string uvCommand = ConvertUvxToUv(uvxPath); // Get the package name string packageName = "mcp-for-unity"; @@ -65,7 +89,7 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, stderr = null; string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); - string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1); + string uvPath = ConvertUvxToUv(uvxPath); if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) { From 7bfad3b7aaba28531c36e3828e572511b812f5cf Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 26 Nov 2025 13:34:25 -0800 Subject: [PATCH 09/10] fix: wrap uvx in cmd /c for Windows clients to ensure PATH resolution --- .../Editor/Helpers/ConfigJsonBuilder.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 084e2a7ea..b86f15cb0 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -85,15 +85,31 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl // Stdio mode: Use uvx command var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - unity["command"] = uvxPath; + var args = new List(); - var args = new List { packageName }; - if (!string.IsNullOrEmpty(fromUrl)) + // Fix for Windows GUI apps (Claude Desktop, Cursor, etc.): + // Wrap in cmd /c to ensure PATH and environment are properly resolved. + if (UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor) + { + unity["command"] = "cmd"; + args.Add("/c"); + + // If uvxPath contains spaces, we might need to ensure it's treated as a command. + // But typically in JSON args, it's just the next argument. + args.Add(uvxPath); + } + else { - args.Insert(0, fromUrl); - args.Insert(0, "--from"); + unity["command"] = uvxPath; } + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Add("--from"); + args.Add(fromUrl); + } + + args.Add(packageName); args.Add("--transport"); args.Add("stdio"); From 42b8eef44414a79df5ffb57dcef6187a748955ad Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 26 Nov 2025 19:03:15 -0800 Subject: [PATCH 10/10] Fix: Increase port discovery timeout to 1.0s and fix uv cache cleaning on Windows --- Server/src/services/resources/unity_instances.py | 5 +++++ Server/src/transport/legacy/port_discovery.py | 5 +++-- Server/uv.lock | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Server/src/services/resources/unity_instances.py b/Server/src/services/resources/unity_instances.py index 74cb42b9e..511e7f912 100644 --- a/Server/src/services/resources/unity_instances.py +++ b/Server/src/services/resources/unity_instances.py @@ -9,6 +9,11 @@ from transport.plugin_hub import PluginHub from transport.unity_transport import _is_http_transport +try: + pass +except: pass + + @mcp_for_unity_resource( uri="unity://instances", diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index 117f18058..50ec59135 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -29,7 +29,7 @@ class PortDiscovery: """Handles port discovery from Unity Bridge registry""" REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file DEFAULT_PORT = 6400 - CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + CONNECT_TIMEOUT = 1.0 # seconds, keep this snappy during discovery @staticmethod def get_registry_path() -> Path: @@ -102,6 +102,7 @@ def _recv_exact(expected: int) -> bytes | None: response = _recv_exact(response_length) if response is None: return False + return b'"message":"pong"' in response except Exception as e: logger.debug(f"Port probe failed for {port}: {e}") @@ -308,7 +309,7 @@ def discover_all_unity_instances() -> list[UnityInstanceInfo]: deduped_instances = [entry[0] for entry in sorted( instances_by_port.values(), key=lambda item: item[1], reverse=True)] - + logger.info( f"Discovered {len(deduped_instances)} Unity instances (after de-duplication by port)") return deduped_instances diff --git a/Server/uv.lock b/Server/uv.lock index 26152be78..94423e54c 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -694,7 +694,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "7.0.0" +version = "8.0.1" source = { editable = "." } dependencies = [ { name = "fastapi" },