diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index f52fa4ac..7b150b7a 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Data @@ -69,12 +70,22 @@ public class McpClients "Claude", "claude_desktop_config.json" ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Claude", - "claude_desktop_config.json" - ), + // For macOS, Claude Desktop stores config under ~/Library/Application Support/Claude + // For Linux, it remains under ~/.config/Claude + linuxConfigPath = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ) + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "Claude", + "claude_desktop_config.json" + ), mcpType = McpTypes.ClaudeDesktop, configStatus = "Not Configured", }, @@ -82,19 +93,31 @@ public class McpClients new() { name = "VSCode GitHub Copilot", + // Windows path is canonical under %AppData%\Code\User windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json" ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Code", - "User", - "mcp.json" - ), + // For macOS, VSCode stores user config under ~/Library/Application Support/Code/User + // For Linux, it remains under ~/.config/Code/User + linuxConfigPath = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "mcp.json" + ) + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "Code", + "User", + "mcp.json" + ), mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index 94ba5d97..deb29708 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -50,7 +50,41 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) { unity["command"] = uvPath; - unity["args"] = JArray.FromObject(new[] { "run", "--directory", directory, "server.py" }); + + // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners + string effectiveDir = directory; +#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX + bool isCursor = !isVSCode && (client == null || client.mcpType != Models.McpTypes.VSCode); + if (isCursor && !string.IsNullOrEmpty(directory)) + { + // Replace canonical path segment with the symlink path if present + const string canonical = "/Library/Application Support/"; + const string symlinkSeg = "/Library/AppSupport/"; + try + { + // Normalize to full path style + if (directory.Contains(canonical)) + { + effectiveDir = directory.Replace(canonical, symlinkSeg); + } + else + { + // If installer returned XDG-style on macOS, map to canonical symlink + string norm = directory.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); + if (idx >= 0) + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... + effectiveDir = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); + } + } + } + catch { /* fallback to original directory on any error */ } + } +#endif + + unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); if (isVSCode) { diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs b/UnityMcpBridge/Editor/Helpers/McpLog.cs new file mode 100644 index 00000000..7e467187 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string Prefix = "MCP-FOR-UNITY:"; + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + Debug.Log($"{Prefix} {message}"); + } + + public static void Warn(string message) + { + Debug.LogWarning($"{Prefix} {message}"); + } + + public static void Error(string message) + { + Debug.LogError($"{Prefix} {message}"); + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 00000000..b9e0fc38 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs new file mode 100644 index 00000000..0a672003 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -0,0 +1,96 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Auto-runs legacy/older install detection on package load/update (log-only). + /// Runs once per embedded server version using an EditorPrefs version-scoped key. + /// + [InitializeOnLoad] + public static class PackageDetector + { + private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; + + static PackageDetector() + { + try + { + string pkgVer = ReadPackageVersionOrFallback(); + string key = DetectOnceFlagKeyPrefix + pkgVer; + + // Always force-run if legacy roots exist or canonical install is missing + bool legacyPresent = LegacyRootsExist(); + bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); + + if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) + { + EditorApplication.delayCall += () => + { + try + { + ServerInstaller.EnsureServerInstalled(); + } + catch (System.Exception ex) + { + Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message); + } + finally + { + EditorPrefs.SetBool(key, true); + } + }; + } + } + catch { /* ignore */ } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); + if (System.IO.File.Exists(p)) + return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); + } + } + catch { } + return "unknown"; + } + + private static string ReadPackageVersionOrFallback() + { + try + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); + if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; + } + catch { } + // Fallback to embedded server version if package info unavailable + return ReadEmbeddedVersionOrFallback(); + } + + private static bool LegacyRootsExist() + { + try + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] roots = + { + System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), + System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") + }; + foreach (var r in roots) + { + try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } + } + } + catch { } + return false; + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta new file mode 100644 index 00000000..af305308 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b82eaef548d164ca095f17db64d15af8 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index e32be859..f6ddeaf0 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -2,6 +2,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Text; +using System.Collections.Generic; using UnityEditor; using UnityEngine; @@ -11,6 +12,7 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; + private const string VersionFileName = "server_version.txt"; /// /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. @@ -21,25 +23,74 @@ public static void EnsureServerInstalled() try { string saveLocation = GetSaveLocation(); + TryCreateMacSymlinkForAppSupport(); string destRoot = Path.Combine(saveLocation, ServerFolder); string destSrc = Path.Combine(destRoot, "src"); - if (File.Exists(Path.Combine(destSrc, "server.py"))) - { - return; // Already installed - } + // Detect legacy installs and version state (logs) + DetectAndLogLegacyInstallStates(destRoot); + // Resolve embedded source and versions if (!TryGetEmbeddedServerSource(out string embeddedSrc)) { throw new Exception("Could not find embedded UnityMcpServer/src in the package."); } + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); + + bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); + bool needOverwrite = !destHasServer + || string.IsNullOrEmpty(installedVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); // Ensure destination exists Directory.CreateDirectory(destRoot); - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); + if (needOverwrite) + { + // Copy the entire UnityMcpServer folder (parent of src) + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer + CopyDirectoryRecursive(embeddedRoot, destRoot); + // Write/refresh version file + try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } + McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); + } + + // Cleanup legacy installs that are missing version or older than embedded + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + try + { + string legacySrc = Path.Combine(legacyRoot, "src"); + if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + bool legacyOlder = string.IsNullOrEmpty(legacyVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); + if (legacyOlder) + { + TryKillUvForPath(legacySrc); + try + { + Directory.Delete(legacyRoot, recursive: true); + McpLog.Info($"Removed legacy server at '{legacyRoot}'."); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); + } + } + } + catch { } + } + + // Clear overrides that might point at legacy locations + try + { + EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); + EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); + } + catch { } + return; } catch (Exception ex) { @@ -49,11 +100,11 @@ public static void EnsureServerInstalled() if (hasInstalled || TryGetEmbeddedServerSource(out _)) { - Debug.LogWarning($"MCP for Unity: Using existing server; skipped install. Details: {ex.Message}"); + McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); return; } - Debug.LogError($"Failed to ensure server installation: {ex.Message}"); + McpLog.Error($"Failed to ensure server installation: {ex.Message}"); } } @@ -69,9 +120,10 @@ private static string GetSaveLocation() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + // Use per-user LocalApplicationData for canonical install location var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, "Programs", RootFolder); + return Path.Combine(localAppData, RootFolder); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { @@ -85,15 +137,60 @@ private static string GetSaveLocation() } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - // Use Application Support for a stable, user-writable location - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - RootFolder - ); + // On macOS, use LocalApplicationData (~/Library/Application Support) + var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support + bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); + if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) + { + // Fallback: construct from $HOME + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + localAppSupport = Path.Combine(home, "Library", "Application Support"); + } + TryCreateMacSymlinkForAppSupport(); + return Path.Combine(localAppSupport, RootFolder); } throw new Exception("Unsupported operating system."); } + /// + /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support + /// to mitigate arg parsing and quoting issues in some MCP clients. + /// Safe to call repeatedly. + /// + private static void TryCreateMacSymlinkForAppSupport() + { + try + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + if (string.IsNullOrEmpty(home)) return; + + string canonical = Path.Combine(home, "Library", "Application Support"); + string symlink = Path.Combine(home, "Library", "AppSupport"); + + // If symlink exists already, nothing to do + if (Directory.Exists(symlink) || File.Exists(symlink)) return; + + // Create symlink only if canonical exists + if (!Directory.Exists(canonical)) return; + + // Use 'ln -s' to create a directory symlink (macOS) + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/ln", + Arguments = $"-s \"{canonical}\" \"{symlink}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + p?.WaitForExit(2000); + } + catch { /* best-effort */ } + } + private static bool IsDirectoryWritable(string path) { try @@ -117,6 +214,173 @@ private static bool IsServerInstalled(string location) && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); } + /// + /// Detects legacy installs or older versions and logs findings (no deletion yet). + /// + private static void DetectAndLogLegacyInstallStates(string canonicalRoot) + { + try + { + string canonicalSrc = Path.Combine(canonicalRoot, "src"); + // Normalize canonical root for comparisons + string normCanonicalRoot = NormalizePathSafe(canonicalRoot); + string embeddedSrc = null; + TryGetEmbeddedServerSource(out embeddedSrc); + + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); + string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); + + // Legacy paths (macOS/Linux .config; Windows roaming as example) + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + // Skip logging for the canonical root itself + if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) + continue; + string legacySrc = Path.Combine(legacyRoot, "src"); + bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + + if (hasServer) + { + // Case 1: No version file + if (string.IsNullOrEmpty(legacyVer)) + { + McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); + } + + // Case 2: Lives in legacy path + McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); + + // Case 3: Has version but appears older than embedded + if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) + { + McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); + } + } + } + + // Also log if canonical is missing version (treated as older) + if (Directory.Exists(canonicalRoot)) + { + if (string.IsNullOrEmpty(installedVer)) + { + McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); + } + else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) + { + McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); + } + } + } + catch (Exception ex) + { + McpLog.Warn("Detect legacy/version state failed: " + ex.Message); + } + } + + private static string NormalizePathSafe(string path) + { + try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } + catch { return path; } + } + + private static bool PathsEqualSafe(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + string na = NormalizePathSafe(a); + string nb = NormalizePathSafe(b); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch { return false; } + } + + private static IEnumerable GetLegacyRootsForDetection() + { + var roots = new System.Collections.Generic.List(); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + // macOS/Linux legacy + roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); + roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); + // Windows roaming example + try + { + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + if (!string.IsNullOrEmpty(roaming)) + roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); + } + catch { } + return roots; + } + + private static void TryKillUvForPath(string serverSrcPath) + { + try + { + if (string.IsNullOrEmpty(serverSrcPath)) return; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/pgrep", + Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return; + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1500); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out int pid)) + { + try { System.Diagnostics.Process.GetProcessById(pid).Kill(); } catch { } + } + } + } + } + catch { } + } + + private static string ReadVersionFile(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + string v = File.ReadAllText(path).Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + catch { return null; } + } + + private static int CompareSemverSafe(string a, string b) + { + try + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; + var ap = a.Split('.'); + var bp = b.Split('.'); + for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) + { + int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; + int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; + if (ai != bi) return ai.CompareTo(bi); + } + return 0; + } + catch { return 0; } + } + /// /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package /// or common development locations. diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 82905cfe..7d75908b 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -310,7 +310,9 @@ public static void Start() isRunning = true; isAutoConnectMode = false; - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}."); + string platform = Application.platform.ToString(); + string serverVer = ReadInstalledServerVersionSafe(); + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; // Write initial heartbeat immediately @@ -727,6 +729,22 @@ private static void WriteHeartbeat(bool reloading, string reason = null) } } + private static string ReadInstalledServerVersionSafe() + { + try + { + string serverSrc = ServerInstaller.GetServerPath(); + string verFile = Path.Combine(serverSrc, "server_version.txt"); + if (File.Exists(verFile)) + { + string v = File.ReadAllText(verFile)?.Trim(); + if (!string.IsNullOrEmpty(v)) return v; + } + } + catch { } + return "unknown"; + } + private static string ComputeProjectHash(string input) { try diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index 895e2d61..bbc15da7 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -5,6 +5,7 @@ public class McpClient public string name; public string windowsConfigPath; public string linuxConfigPath; + public string macConfigPath; // optional macOS-specific config path public McpTypes mcpType; public string configStatus; public McpStatus status = McpStatus.NotConfigured; diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index e1aa073c..96d5038c 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -59,6 +59,10 @@ private void OnEnable() isUnityBridgeRunning = MCPForUnityBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -243,10 +247,79 @@ private void DrawHeader() { debugLogsEnabled = newDebug; EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } } EditorGUILayout.Space(15); } + private void LogDebugPrefsState() + { + try + { + string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); + string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); + string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); + bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); + + // Version-scoped detection key + string embeddedVer = ReadEmbeddedVersionOrFallback(); + string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; + bool detectLogged = SafeGetPrefBool(detectKey); + + // Project-scoped auto-register key + string projectPath = Application.dataPath ?? string.Empty; + string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; + bool autoRegistered = SafeGetPrefBool(autoKey); + + MCPForUnity.Editor.Helpers.McpLog.Info( + "MCP Debug Prefs:\n" + + $" DebugLogs: {debugLogsEnabled}\n" + + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + + $" UvPath: '{uvPathPref}'\n" + + $" ServerSrc: '{serverSrcPref}'\n" + + $" UseEmbeddedServer: {useEmbedded}\n" + + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", + always: false + ); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); + } + } + + private static string SafeGetPrefString(string key) + { + try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } + } + + private static bool SafeGetPrefBool(string key) + { + try { return EditorPrefs.GetBool(key, false); } catch { return false; } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = Path.Combine(embeddedSrc, "server_version.txt"); + if (File.Exists(p)) + { + var s = File.ReadAllText(p)?.Trim(); + if (!string.IsNullOrEmpty(s)) return s; + } + } + } + catch { } + return "unknown"; + } + private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -505,7 +578,7 @@ private void AutoFirstRunSetup() } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); @@ -522,7 +595,7 @@ private void AutoFirstRunSetup() } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); } } @@ -533,7 +606,7 @@ private void AutoFirstRunSetup() } catch (Exception e) { - UnityEngine.Debug.LogWarning($"MCP for Unity auto-setup skipped: {e.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); } } @@ -888,18 +961,15 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); return; } - + // VSCode now reads from mcp.json with a top-level "servers" block var vscodeConfig = new { - mcp = new + servers = new { - servers = new + unityMCP = new { - unityMCP = new - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" } - } + command = uvPath, + args = new[] { "run", "--directory", pythonDir, "server.py" } } } }; @@ -1001,7 +1071,7 @@ private static bool ArgsEqual(string[] a, string[] b) return true; } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } @@ -1073,11 +1143,38 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); if (!serverValid) { - serverSrc = ResolveServerSrc(); + // Prefer the provided pythonDir if valid; fall back to resolver + if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py"))) + { + serverSrc = pythonDir; + } + else + { + serverSrc = ResolveServerSrc(); + } + } + + // macOS normalization: map XDG-style ~/.local/share to canonical Application Support + try + { + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) + && !string.IsNullOrEmpty(serverSrc)) + { + string norm = serverSrc.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); + if (idx >= 0) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... + serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix); + } + } } + catch { } // Hard-block PackageCache on Windows unless dev override is set if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && !string.IsNullOrEmpty(serverSrc) && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 && !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) { @@ -1105,13 +1202,52 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + // Use a more robust atomic write pattern string tmp = configPath + ".tmp"; - // Write UTF-8 without BOM to avoid issues on Windows editors/tools - System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - if (System.IO.File.Exists(configPath)) - System.IO.File.Replace(tmp, configPath, null); - else + string backup = configPath + ".backup"; + + try + { + // Write to temp file first + System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); + + // Create backup of existing file if it exists + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Copy(configPath, backup, true); + } + + // Atomic move operation (more reliable than Replace on macOS) + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Delete(configPath); + } System.IO.File.Move(tmp, configPath); + + // Clean up backup + if (System.IO.File.Exists(backup)) + { + System.IO.File.Delete(backup); + } + } + catch (Exception ex) + { + // Clean up temp file + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + // Restore backup if it exists + try { + if (System.IO.File.Exists(backup)) + { + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Delete(configPath); + } + System.IO.File.Move(backup, configPath); + } + } catch { } + throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); + } try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); @@ -1277,7 +1413,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1319,7 +1462,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1431,7 +1581,14 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1490,7 +1647,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Common logic for checking configuration status if (configExists) { - bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)); + string configuredDir = ExtractDirectoryArg(args); + bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); @@ -1673,31 +1831,7 @@ private void UnregisterWithClaudeCode() } } - private bool ParseTextOutput(string claudePath, string projectDir, string pathPrepend) - { - if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend)) - { - UnityEngine.Debug.Log($"Claude MCP servers (text): {listStdout}"); - - // Check if output indicates no servers or contains "UnityMCP" variants - if (listStdout.Contains("No MCP servers configured") || - listStdout.Contains("no servers") || - listStdout.Contains("No servers") || - string.IsNullOrWhiteSpace(listStdout) || - listStdout.Trim().Length == 0) - { - return false; - } - - // Look for "UnityMCP" variants in the output - return listStdout.Contains("UnityMCP") || - listStdout.Contains("unityMCP") || - listStdout.Contains("unity-mcp"); - } - - // If command failed, assume no servers - return false; - } + // Removed unused ParseTextOutput private string FindUvPath() { @@ -1979,93 +2113,7 @@ private string FindWindowsUvPath() return null; // Will fallback to using 'uv' from PATH } - private string FindClaudeCommand() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Common locations for Claude CLI on Windows - string[] possiblePaths = { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm", "claude.cmd"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "npm", "claude.cmd"), - "claude.cmd", // Fallback to PATH - "claude" // Final fallback - }; - - foreach (string path in possiblePaths) - { - if (path.Contains("\\") && File.Exists(path)) - { - return path; - } - } - - // Try to find via where command (PowerShell compatible) - try - { - var psi = new ProcessStartInfo - { - FileName = "where.exe", - Arguments = "claude", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - string[] lines = output.Split('\n'); - foreach (string line in lines) - { - string cleanPath = line.Trim(); - if (File.Exists(cleanPath)) - { - return cleanPath; - } - } - } - } - catch - { - // If where.exe fails, try PowerShell's Get-Command as fallback - try - { - var psi = new ProcessStartInfo - { - FileName = "powershell.exe", - Arguments = "-Command \"(Get-Command claude -ErrorAction SilentlyContinue).Source\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - return output; - } - } - catch - { - // Ignore PowerShell errors too - } - } - - return "claude"; // Final fallback to PATH - } - else - { - return "/usr/local/bin/claude"; - } - } + // Removed unused FindClaudeCommand private void CheckClaudeCodeConfiguration(McpClient mcpClient) { diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index e7806a94..9fe776a9 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -116,10 +116,13 @@ protected virtual void OnGUI() { displayPath = mcpClient.windowsConfigPath; } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { displayPath = mcpClient.linuxConfigPath; } diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs index 49357982..e5544510 100644 --- a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs @@ -90,25 +90,21 @@ protected override void OnGUI() EditorStyles.boldLabel ); EditorGUILayout.LabelField( - "a) Open VSCode Settings (File > Preferences > Settings)", + "a) Open or create your VSCode MCP config file (mcp.json) at the path below", instructionStyle ); EditorGUILayout.LabelField( - "b) Click on the 'Open Settings (JSON)' button in the top right", + "b) Paste the JSON shown below into mcp.json", instructionStyle ); EditorGUILayout.LabelField( - "c) Add the MCP configuration shown below to your settings.json file", - instructionStyle - ); - EditorGUILayout.LabelField( - "d) Save the file and restart VSCode", + "c) Save the file and restart VSCode", instructionStyle ); EditorGUILayout.Space(5); EditorGUILayout.LabelField( - "3. VSCode settings.json location:", + "3. VSCode mcp.json location:", EditorStyles.boldLabel ); @@ -121,7 +117,7 @@ protected override void OnGUI() System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), "Code", "User", - "settings.json" + "mcp.json" ); } else @@ -132,7 +128,7 @@ protected override void OnGUI() "Application Support", "Code", "User", - "settings.json" + "mcp.json" ); } @@ -205,7 +201,7 @@ protected override void OnGUI() EditorGUILayout.Space(10); EditorGUILayout.LabelField( - "4. Add this configuration to your settings.json:", + "4. Add this configuration to your mcp.json:", EditorStyles.boldLabel ); diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt new file mode 100644 index 00000000..cb2b00e4 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -0,0 +1 @@ +3.0.1 diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index a96a6f00..473ceda2 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "3.0.0", + "version": "3.0.1", "displayName": "MCP for Unity", "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3",