From ee23346ca2832facea1204ae06d6852b2b6dc130 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 23 Aug 2025 22:13:47 -0700 Subject: [PATCH 01/17] feat: installer cleanup, auto-migration, logging normalization --- UnityMcpBridge/Editor/Helpers/McpLog.cs | 33 +++ UnityMcpBridge/Editor/Helpers/McpLog.cs.meta | 13 + .../Editor/Helpers/PackageDetector.cs | 96 +++++++ .../Editor/Helpers/PackageDetector.cs.meta | 2 + .../Editor/Helpers/ServerInstaller.cs | 252 ++++++++++++++++-- .../Editor/Windows/MCPForUnityEditorWindow.cs | 81 +++++- .../UnityMcpServer~/src/server_version.txt | 2 + UnityMcpBridge/package.json | 2 +- 8 files changed, 461 insertions(+), 20 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/McpLog.cs create mode 100644 UnityMcpBridge/Editor/Helpers/McpLog.cs.meta create mode 100644 UnityMcpBridge/Editor/Helpers/PackageDetector.cs create mode 100644 UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta create mode 100644 UnityMcpBridge/UnityMcpServer~/src/server_version.txt 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..1a1780d5 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. @@ -24,22 +26,70 @@ public static void EnsureServerInstalled() 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 +99,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 +119,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,11 +136,15 @@ 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); + if (string.IsNullOrEmpty(localAppSupport)) + { + // Fallback: construct from $HOME + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + localAppSupport = Path.Combine(home, "Library", "Application Support"); + } + return Path.Combine(localAppSupport, RootFolder); } throw new Exception("Unsupported operating system."); } @@ -117,6 +172,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/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 3091f371..cd2d5f37 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -58,6 +58,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); @@ -242,10 +246,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); @@ -504,7 +577,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(); @@ -521,7 +594,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}"); } } @@ -532,7 +605,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}"); } } @@ -1000,7 +1073,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 { } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt new file mode 100644 index 00000000..d4c4b54b --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -0,0 +1,2 @@ +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", From f21c2cedd553bc9ff3faf6f04a7f87ad4f722f4f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 09:40:29 -0700 Subject: [PATCH 02/17] fix(installer): skip legacy deletion when still referenced in prefs or Cursor config --- .../Editor/Helpers/ServerInstaller.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 1a1780d5..188bb768 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -67,6 +67,12 @@ public static void EnsureServerInstalled() || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); if (legacyOlder) { + // Skip deletion if this path is still referenced by prefs or known client configs + if (IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc)) + { + McpLog.Info($"Skipping removal of legacy server at '{legacyRoot}' (still referenced).", always: false); + continue; + } TryKillUvForPath(legacySrc); try { @@ -258,6 +264,85 @@ private static bool PathsEqualSafe(string a, string b) catch { return false; } } + private static bool IsPathPossiblyReferencedByPrefsOrKnownConfigs(string serverSrcPath) + { + try + { + if (string.IsNullOrEmpty(serverSrcPath)) return false; + + // EditorPrefs overrides + try + { + string prefServerSrc = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty) ?? string.Empty; + if (!string.IsNullOrEmpty(prefServerSrc) && PathsEqualSafe(prefServerSrc, serverSrcPath)) return true; + + string prefOverride = EditorPrefs.GetString("MCPForUnity.PythonDirOverride", string.Empty) ?? string.Empty; + if (!string.IsNullOrEmpty(prefOverride) && PathsEqualSafe(prefOverride, serverSrcPath)) return true; + } + catch { } + + // Cursor config (~/.cursor/mcp.json) + string user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string cursorCfg = Path.Combine(user, ".cursor", "mcp.json"); + if (File.Exists(cursorCfg)) + { + try + { + string json = File.ReadAllText(cursorCfg); + string dir = ExtractDirectoryArgFromJson(json); + if (!string.IsNullOrEmpty(dir) && PathsEqualSafe(dir, serverSrcPath)) return true; + } + catch { } + } + } + catch { } + return false; + } + + // Minimal helper to extract the value following a --directory token in a plausible JSON args array + private static string ExtractDirectoryArgFromJson(string json) + { + try + { + if (string.IsNullOrEmpty(json)) return null; + int argsIdx = json.IndexOf("\"args\"", StringComparison.OrdinalIgnoreCase); + if (argsIdx < 0) return null; + int arrStart = json.IndexOf('[', argsIdx); + if (arrStart < 0) return null; + int depth = 0; + int arrEnd = -1; + for (int i = arrStart; i < json.Length; i++) + { + char c = json[i]; + if (c == '[') depth++; + else if (c == ']') { depth--; if (depth == 0) { arrEnd = i; break; } } + } + if (arrEnd <= arrStart) return null; + string arrBody = json.Substring(arrStart + 1, arrEnd - arrStart - 1); + // Split on commas at top-level (best effort for simple arrays of strings) + string[] raw = arrBody.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); + var parts = new List(raw.Length); + foreach (var r in raw) + { + string s = r.Trim(); + if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') + { + s = s.Substring(1, s.Length - 2); + } + parts.Add(s); + } + for (int i = 0; i < parts.Count - 1; i++) + { + if (string.Equals(parts[i], "--directory", StringComparison.OrdinalIgnoreCase)) + { + return parts[i + 1]; + } + } + } + catch { } + return null; + } + private static IEnumerable GetLegacyRootsForDetection() { var roots = new System.Collections.Generic.List(); From 175d5ae1500f80acd3c18cf39cabce2d13705e55 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 09:44:39 -0700 Subject: [PATCH 03/17] chore(editor): WriteToConfig honors pythonDir; robust config match; remove unused helpers --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 128 ++---------------- 1 file changed, 14 insertions(+), 114 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index cd2d5f37..39547ead 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1145,11 +1145,20 @@ 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(); + } } // 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)) { @@ -1616,7 +1625,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); @@ -1799,31 +1809,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() { @@ -2105,93 +2091,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) { From 742d168b518427d31c3341affbc81396af12417a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 09:46:54 -0700 Subject: [PATCH 04/17] sec(installer): escape server path in pgrep pattern to prevent injection/regex issues --- .../Editor/Helpers/ServerInstaller.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 188bb768..5bc1a4ac 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -368,10 +368,11 @@ private static void TryKillUvForPath(string serverSrcPath) if (string.IsNullOrEmpty(serverSrcPath)) return; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + string safePath = EscapeForPgrep(serverSrcPath); var psi = new System.Diagnostics.ProcessStartInfo { FileName = "/usr/bin/pgrep", - Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", + Arguments = $"-f \"uv .*--directory {safePath}\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -406,6 +407,26 @@ private static string ReadVersionFile(string path) catch { return null; } } + // Escape regex metacharacters so the path is treated literally by pgrep -f + private static string EscapeForPgrep(string path) + { + if (string.IsNullOrEmpty(path)) return path; + // Escape backslash first, then regex metacharacters + string s = path.Replace("\\", "\\\\"); + char[] meta = new[] {'.','+','*','?','^','$','(',')','[',']','{','}','|'}; + var sb = new StringBuilder(s.Length * 2); + foreach (char c in s) + { + if (Array.IndexOf(meta, c) >= 0) + { + sb.Append('\\'); + } + sb.Append(c); + } + // Also escape double quotes which we wrap the pattern with + return sb.ToString().Replace("\"", "\\\""); + } + private static int CompareSemverSafe(string a, string b) { try From 4da30cc43b953279d984ecfc5e42204200d967ea Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:00:58 -0700 Subject: [PATCH 05/17] bump server version --- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index d4c4b54b..3fd41e9c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1,2 +1,2 @@ -3.0.1 +3.0.2 From 58ed4d4e1346195ae268070e10bf4c335ee01f46 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:11:27 -0700 Subject: [PATCH 06/17] chore(server): bump server_version.txt to 3.0.4 --- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index 3fd41e9c..6e94a523 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1,2 +1,2 @@ -3.0.2 +3.0.4 From 86b4dc14bce60415d85977594c8a9b736293f2ba Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:28:47 -0700 Subject: [PATCH 07/17] chore(logging): include OS and server version in MCP bridge start log --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 From 48c1b7a51eaf35da08492b02974a16cb77049400 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:31:38 -0700 Subject: [PATCH 08/17] fix(installer): use Application.platform for OS detection; add canonical root logs; fallback to RuntimeInformation --- .../Editor/Helpers/ServerInstaller.cs | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 5bc1a4ac..e8200853 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -123,35 +123,57 @@ public static string GetServerPath() /// private static string GetSaveLocation() { + // Prefer Unity's platform to avoid RuntimeInformation quirks under Mono/macOS + try + { + if (Application.platform == RuntimePlatform.OSXEditor) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + string appSupport = Path.Combine(home, "Library", "Application Support"); + string path = Path.Combine(appSupport, RootFolder); + McpLog.Info($"Resolved canonical install root (macOS): {path}", always: false); + return path; + } + if (Application.platform == RuntimePlatform.WindowsEditor) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); + string path = Path.Combine(localAppData, RootFolder); + McpLog.Info($"Resolved canonical install root (Windows): {path}", always: false); + return path; + } + if (Application.platform == RuntimePlatform.LinuxEditor) + { + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrEmpty(xdg)) + { + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); + } + string path = Path.Combine(xdg, RootFolder); + McpLog.Info($"Resolved canonical install root (Linux): {path}", always: false); + return path; + } + } + catch { } + + // Fallback to RuntimeInformation if Application.platform is unavailable + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + return Path.Combine(home, "Library", "Application Support", RootFolder); + } 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, RootFolder); } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) - { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, - ".local", "share"); - } + if (string.IsNullOrEmpty(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); return Path.Combine(xdg, RootFolder); } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On macOS, use LocalApplicationData (~/Library/Application Support) - var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrEmpty(localAppSupport)) - { - // Fallback: construct from $HOME - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - localAppSupport = Path.Combine(home, "Library", "Application Support"); - } - return Path.Combine(localAppSupport, RootFolder); - } throw new Exception("Unsupported operating system."); } From 755e1d784bd1587945b6b3b24ead8aee3164a569 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:32:13 -0700 Subject: [PATCH 09/17] chore(version): bump server_version.txt to 3.0.5 --- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index 6e94a523..eca690e7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1,2 +1 @@ -3.0.4 - +3.0.5 From ce1104e9e74a249e7025733a932cd6e1d493e292 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:39:01 -0700 Subject: [PATCH 10/17] feat(installer): rewire known configs (EditorPrefs, Cursor mcp.json) to canonical path; then remove legacy if unreferenced --- .../Editor/Helpers/ServerInstaller.cs | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index e8200853..b62d7acd 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -67,8 +67,19 @@ public static void EnsureServerInstalled() || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); if (legacyOlder) { - // Skip deletion if this path is still referenced by prefs or known client configs - if (IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc)) + // If referenced, attempt to rewire known configs (EditorPrefs, Cursor) to canonical + bool stillRef = IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc); + if (stillRef) + { + bool rewired = TryRewriteKnownConfigsToCanonical(legacySrc, destSrc); + if (rewired) + { + McpLog.Info($"Rewired configs from legacy '{legacySrc}' to canonical '{destSrc}'.", always: false); + } + stillRef = IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc); + } + // If still referenced after rewrite attempts, skip deletion + if (stillRef) { McpLog.Info($"Skipping removal of legacy server at '{legacyRoot}' (still referenced).", always: false); continue; @@ -321,6 +332,121 @@ private static bool IsPathPossiblyReferencedByPrefsOrKnownConfigs(string serverS return false; } + private static bool TryRewriteKnownConfigsToCanonical(string legacySrc, string canonicalSrc) + { + bool changed = false; + try + { + // Normalize for comparison + string normLegacy = NormalizePathSafe(legacySrc); + string normCanon = NormalizePathSafe(canonicalSrc); + + // EditorPrefs + try + { + string prefServerSrc = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty) ?? string.Empty; + if (!string.IsNullOrEmpty(prefServerSrc) && PathsEqualSafe(prefServerSrc, normLegacy)) + { + EditorPrefs.SetString("MCPForUnity.ServerSrc", normCanon); + changed = true; + } + string prefOverride = EditorPrefs.GetString("MCPForUnity.PythonDirOverride", string.Empty) ?? string.Empty; + if (!string.IsNullOrEmpty(prefOverride) && PathsEqualSafe(prefOverride, normLegacy)) + { + EditorPrefs.SetString("MCPForUnity.PythonDirOverride", normCanon); + changed = true; + } + } + catch { } + + // Cursor config (~/.cursor/mcp.json) + try + { + string user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string cursorCfg = Path.Combine(user, ".cursor", "mcp.json"); + if (File.Exists(cursorCfg)) + { + string json = File.ReadAllText(cursorCfg); + string currentDir = ExtractDirectoryArgFromJson(json); + if (!string.IsNullOrEmpty(currentDir) && PathsEqualSafe(currentDir, normLegacy)) + { + string updated = ReplaceDirectoryArgInJson(json, normCanon); + if (!string.IsNullOrEmpty(updated) && !string.Equals(updated, json, StringComparison.Ordinal)) + { + try + { + string backup = cursorCfg + ".bak"; + File.Copy(cursorCfg, backup, overwrite: true); + } + catch { } + File.WriteAllText(cursorCfg, updated); + changed = true; + } + } + } + } + catch { } + } + catch { } + return changed; + } + + // Best-effort: rewrite the value following --directory in the first args array found + private static string ReplaceDirectoryArgInJson(string json, string newDirectory) + { + try + { + if (string.IsNullOrEmpty(json)) return json; + int argsIdx = json.IndexOf("\"args\"", StringComparison.OrdinalIgnoreCase); + if (argsIdx < 0) return json; + int arrStart = json.IndexOf('[', argsIdx); + if (arrStart < 0) return json; + int depth = 0; + int arrEnd = -1; + for (int i = arrStart; i < json.Length; i++) + { + char c = json[i]; + if (c == '[') depth++; + else if (c == ']') { depth--; if (depth == 0) { arrEnd = i; break; } } + } + if (arrEnd <= arrStart) return json; + + string arrBody = json.Substring(arrStart + 1, arrEnd - arrStart - 1); + // Split simple string array by commas at top level + string[] raw = arrBody.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); + var parts = new List(raw.Length); + foreach (var r in raw) + { + string s = r.Trim(); + if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') + { + s = s.Substring(1, s.Length - 2); + } + parts.Add(s); + } + + for (int i = 0; i < parts.Count - 1; i++) + { + if (string.Equals(parts[i], "--directory", StringComparison.OrdinalIgnoreCase)) + { + parts[i + 1] = newDirectory; + // Rebuild array JSON + var sb = new StringBuilder(); + for (int j = 0; j < parts.Count; j++) + { + if (j > 0) sb.Append(", "); + sb.Append('"').Append(parts[j].Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); + } + string newArr = sb.ToString(); + string rebuilt = json.Substring(0, arrStart + 1) + newArr + json.Substring(arrEnd); + return rebuilt; + } + } + } + catch { } + return json; + } + // Minimal helper to extract the value following a --directory token in a plausible JSON args array private static string ExtractDirectoryArgFromJson(string json) { From 01ea2f46acb21878032b9e4202f3373b9ff35f91 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 11:47:04 -0700 Subject: [PATCH 11/17] refactor(installer): revert to lean installer logic from ee23346c; fix macOS path via Application.platform; escape pgrep pattern --- .../Editor/Helpers/ServerInstaller.cs | 271 ++---------------- 1 file changed, 26 insertions(+), 245 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index b62d7acd..9fc34d26 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -67,23 +67,6 @@ public static void EnsureServerInstalled() || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); if (legacyOlder) { - // If referenced, attempt to rewire known configs (EditorPrefs, Cursor) to canonical - bool stillRef = IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc); - if (stillRef) - { - bool rewired = TryRewriteKnownConfigsToCanonical(legacySrc, destSrc); - if (rewired) - { - McpLog.Info($"Rewired configs from legacy '{legacySrc}' to canonical '{destSrc}'.", always: false); - } - stillRef = IsPathPossiblyReferencedByPrefsOrKnownConfigs(legacySrc); - } - // If still referenced after rewrite attempts, skip deletion - if (stillRef) - { - McpLog.Info($"Skipping removal of legacy server at '{legacyRoot}' (still referenced).", always: false); - continue; - } TryKillUvForPath(legacySrc); try { @@ -134,24 +117,20 @@ public static string GetServerPath() /// private static string GetSaveLocation() { - // Prefer Unity's platform to avoid RuntimeInformation quirks under Mono/macOS + // Prefer Unity's platform first (more reliable under Mono/macOS), then fallback try { if (Application.platform == RuntimePlatform.OSXEditor) { string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; string appSupport = Path.Combine(home, "Library", "Application Support"); - string path = Path.Combine(appSupport, RootFolder); - McpLog.Info($"Resolved canonical install root (macOS): {path}", always: false); - return path; + return Path.Combine(appSupport, RootFolder); } if (Application.platform == RuntimePlatform.WindowsEditor) { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - string path = Path.Combine(localAppData, RootFolder); - McpLog.Info($"Resolved canonical install root (Windows): {path}", always: false); - return path; + return Path.Combine(localAppData, RootFolder); } if (Application.platform == RuntimePlatform.LinuxEditor) { @@ -160,19 +139,12 @@ private static string GetSaveLocation() { xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); } - string path = Path.Combine(xdg, RootFolder); - McpLog.Info($"Resolved canonical install root (Linux): {path}", always: false); - return path; + return Path.Combine(xdg, RootFolder); } } catch { } - // Fallback to RuntimeInformation if Application.platform is unavailable - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - return Path.Combine(home, "Library", "Application Support", RootFolder); - } + // Fallback to RuntimeInformation if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) @@ -182,9 +154,17 @@ private static string GetSaveLocation() if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); + if (string.IsNullOrEmpty(xdg)) + { + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); + } return Path.Combine(xdg, RootFolder); } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + return Path.Combine(home, "Library", "Application Support", RootFolder); + } throw new Exception("Unsupported operating system."); } @@ -297,200 +277,6 @@ private static bool PathsEqualSafe(string a, string b) catch { return false; } } - private static bool IsPathPossiblyReferencedByPrefsOrKnownConfigs(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return false; - - // EditorPrefs overrides - try - { - string prefServerSrc = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty) ?? string.Empty; - if (!string.IsNullOrEmpty(prefServerSrc) && PathsEqualSafe(prefServerSrc, serverSrcPath)) return true; - - string prefOverride = EditorPrefs.GetString("MCPForUnity.PythonDirOverride", string.Empty) ?? string.Empty; - if (!string.IsNullOrEmpty(prefOverride) && PathsEqualSafe(prefOverride, serverSrcPath)) return true; - } - catch { } - - // Cursor config (~/.cursor/mcp.json) - string user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string cursorCfg = Path.Combine(user, ".cursor", "mcp.json"); - if (File.Exists(cursorCfg)) - { - try - { - string json = File.ReadAllText(cursorCfg); - string dir = ExtractDirectoryArgFromJson(json); - if (!string.IsNullOrEmpty(dir) && PathsEqualSafe(dir, serverSrcPath)) return true; - } - catch { } - } - } - catch { } - return false; - } - - private static bool TryRewriteKnownConfigsToCanonical(string legacySrc, string canonicalSrc) - { - bool changed = false; - try - { - // Normalize for comparison - string normLegacy = NormalizePathSafe(legacySrc); - string normCanon = NormalizePathSafe(canonicalSrc); - - // EditorPrefs - try - { - string prefServerSrc = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty) ?? string.Empty; - if (!string.IsNullOrEmpty(prefServerSrc) && PathsEqualSafe(prefServerSrc, normLegacy)) - { - EditorPrefs.SetString("MCPForUnity.ServerSrc", normCanon); - changed = true; - } - string prefOverride = EditorPrefs.GetString("MCPForUnity.PythonDirOverride", string.Empty) ?? string.Empty; - if (!string.IsNullOrEmpty(prefOverride) && PathsEqualSafe(prefOverride, normLegacy)) - { - EditorPrefs.SetString("MCPForUnity.PythonDirOverride", normCanon); - changed = true; - } - } - catch { } - - // Cursor config (~/.cursor/mcp.json) - try - { - string user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string cursorCfg = Path.Combine(user, ".cursor", "mcp.json"); - if (File.Exists(cursorCfg)) - { - string json = File.ReadAllText(cursorCfg); - string currentDir = ExtractDirectoryArgFromJson(json); - if (!string.IsNullOrEmpty(currentDir) && PathsEqualSafe(currentDir, normLegacy)) - { - string updated = ReplaceDirectoryArgInJson(json, normCanon); - if (!string.IsNullOrEmpty(updated) && !string.Equals(updated, json, StringComparison.Ordinal)) - { - try - { - string backup = cursorCfg + ".bak"; - File.Copy(cursorCfg, backup, overwrite: true); - } - catch { } - File.WriteAllText(cursorCfg, updated); - changed = true; - } - } - } - } - catch { } - } - catch { } - return changed; - } - - // Best-effort: rewrite the value following --directory in the first args array found - private static string ReplaceDirectoryArgInJson(string json, string newDirectory) - { - try - { - if (string.IsNullOrEmpty(json)) return json; - int argsIdx = json.IndexOf("\"args\"", StringComparison.OrdinalIgnoreCase); - if (argsIdx < 0) return json; - int arrStart = json.IndexOf('[', argsIdx); - if (arrStart < 0) return json; - int depth = 0; - int arrEnd = -1; - for (int i = arrStart; i < json.Length; i++) - { - char c = json[i]; - if (c == '[') depth++; - else if (c == ']') { depth--; if (depth == 0) { arrEnd = i; break; } } - } - if (arrEnd <= arrStart) return json; - - string arrBody = json.Substring(arrStart + 1, arrEnd - arrStart - 1); - // Split simple string array by commas at top level - string[] raw = arrBody.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); - var parts = new List(raw.Length); - foreach (var r in raw) - { - string s = r.Trim(); - if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') - { - s = s.Substring(1, s.Length - 2); - } - parts.Add(s); - } - - for (int i = 0; i < parts.Count - 1; i++) - { - if (string.Equals(parts[i], "--directory", StringComparison.OrdinalIgnoreCase)) - { - parts[i + 1] = newDirectory; - // Rebuild array JSON - var sb = new StringBuilder(); - for (int j = 0; j < parts.Count; j++) - { - if (j > 0) sb.Append(", "); - sb.Append('"').Append(parts[j].Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); - } - string newArr = sb.ToString(); - string rebuilt = json.Substring(0, arrStart + 1) + newArr + json.Substring(arrEnd); - return rebuilt; - } - } - } - catch { } - return json; - } - - // Minimal helper to extract the value following a --directory token in a plausible JSON args array - private static string ExtractDirectoryArgFromJson(string json) - { - try - { - if (string.IsNullOrEmpty(json)) return null; - int argsIdx = json.IndexOf("\"args\"", StringComparison.OrdinalIgnoreCase); - if (argsIdx < 0) return null; - int arrStart = json.IndexOf('[', argsIdx); - if (arrStart < 0) return null; - int depth = 0; - int arrEnd = -1; - for (int i = arrStart; i < json.Length; i++) - { - char c = json[i]; - if (c == '[') depth++; - else if (c == ']') { depth--; if (depth == 0) { arrEnd = i; break; } } - } - if (arrEnd <= arrStart) return null; - string arrBody = json.Substring(arrStart + 1, arrEnd - arrStart - 1); - // Split on commas at top-level (best effort for simple arrays of strings) - string[] raw = arrBody.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); - var parts = new List(raw.Length); - foreach (var r in raw) - { - string s = r.Trim(); - if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') - { - s = s.Substring(1, s.Length - 2); - } - parts.Add(s); - } - for (int i = 0; i < parts.Count - 1; i++) - { - if (string.Equals(parts[i], "--directory", StringComparison.OrdinalIgnoreCase)) - { - return parts[i + 1]; - } - } - } - catch { } - return null; - } - private static IEnumerable GetLegacyRootsForDetection() { var roots = new System.Collections.Generic.List(); @@ -544,37 +330,32 @@ private static void TryKillUvForPath(string serverSrcPath) 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; } - } - // Escape regex metacharacters so the path is treated literally by pgrep -f private static string EscapeForPgrep(string path) { if (string.IsNullOrEmpty(path)) return path; - // Escape backslash first, then regex metacharacters string s = path.Replace("\\", "\\\\"); char[] meta = new[] {'.','+','*','?','^','$','(',')','[',']','{','}','|'}; var sb = new StringBuilder(s.Length * 2); foreach (char c in s) { - if (Array.IndexOf(meta, c) >= 0) - { - sb.Append('\\'); - } + if (Array.IndexOf(meta, c) >= 0) sb.Append('\\'); sb.Append(c); } - // Also escape double quotes which we wrap the pattern with return sb.ToString().Replace("\"", "\\\""); } + 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 From ad5c3112ca63ba7e22cd6afcb633adfec5de55a8 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 14:18:08 -0700 Subject: [PATCH 12/17] MCP: Fix macOS paths and VSCode manual setup Normalize macOS to Application Support; use AppSupport symlink for Cursor args Map XDG (~/.local/share, ~/.config) to Application Support VSCode manual: show mcp.json path; use top-level servers JSON VSCode macOS path: ~/Library/Application Support/Code/User/mcp.json --- UnityMcpBridge/Editor/Data/McpClients.cs | 27 +++-- .../Editor/Helpers/ConfigJsonBuilder.cs | 36 +++++- .../Editor/Helpers/ServerInstaller.cs | 107 ++++++++++-------- .../Editor/Windows/MCPForUnityEditorWindow.cs | 31 +++-- .../Editor/Windows/VSCodeManualSetupWindow.cs | 18 ++- 5 files changed, 142 insertions(+), 77 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index f52fa4ac..be21770d 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 @@ -82,19 +83,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/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 9fc34d26..f6ddeaf0 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -23,6 +23,7 @@ public static void EnsureServerInstalled() try { string saveLocation = GetSaveLocation(); + TryCreateMacSymlinkForAppSupport(); string destRoot = Path.Combine(saveLocation, ServerFolder); string destSrc = Path.Combine(destRoot, "src"); @@ -117,57 +118,79 @@ public static string GetServerPath() /// private static string GetSaveLocation() { - // Prefer Unity's platform first (more reliable under Mono/macOS), then fallback - try - { - if (Application.platform == RuntimePlatform.OSXEditor) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - string appSupport = Path.Combine(home, "Library", "Application Support"); - return Path.Combine(appSupport, RootFolder); - } - if (Application.platform == RuntimePlatform.WindowsEditor) - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, RootFolder); - } - if (Application.platform == RuntimePlatform.LinuxEditor) - { - var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) - { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); - } - return Path.Combine(xdg, RootFolder); - } - } - catch { } - - // Fallback to RuntimeInformation 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, RootFolder); } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); if (string.IsNullOrEmpty(xdg)) { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, ".local", "share"); + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, + ".local", "share"); } return Path.Combine(xdg, RootFolder); } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - return Path.Combine(home, "Library", "Application Support", 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 @@ -302,11 +325,10 @@ private static void TryKillUvForPath(string serverSrcPath) if (string.IsNullOrEmpty(serverSrcPath)) return; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - string safePath = EscapeForPgrep(serverSrcPath); var psi = new System.Diagnostics.ProcessStartInfo { FileName = "/usr/bin/pgrep", - Arguments = $"-f \"uv .*--directory {safePath}\"", + Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -330,21 +352,6 @@ private static void TryKillUvForPath(string serverSrcPath) catch { } } - // Escape regex metacharacters so the path is treated literally by pgrep -f - private static string EscapeForPgrep(string path) - { - if (string.IsNullOrEmpty(path)) return path; - string s = path.Replace("\\", "\\\\"); - char[] meta = new[] {'.','+','*','?','^','$','(',')','[',']','{','}','|'}; - var sb = new StringBuilder(s.Length * 2); - foreach (char c in s) - { - if (Array.IndexOf(meta, c) >= 0) sb.Append('\\'); - sb.Append(c); - } - return sb.ToString().Replace("\"", "\\\""); - } - private static string ReadVersionFile(string path) { try diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f17a14c6..f29a1920 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -961,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" } } } }; @@ -1157,6 +1154,24 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } } + // 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) 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 ); From aa4fc1c0ea234169e974f81a6b98758586156922 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 24 Aug 2025 14:19:34 -0700 Subject: [PATCH 13/17] Server: set server_version.txt to 3.0.1 --- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index eca690e7..cb2b00e4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.0.5 +3.0.1 From daa105da3529d8ea409743581280fc9df511584b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 14:51:03 -0700 Subject: [PATCH 14/17] Fix Claude Desktop config path and atomic write issues - Fix macOS path for Claude Desktop config: use ~/Library/Application Support/Claude/ instead of ~/.config/Claude/ - Improve atomic write pattern with backup/restore safety - Replace File.Replace() with File.Move() for better macOS compatibility - Add proper error handling and cleanup for file operations - Resolves issue where installer couldn't find Claude Desktop config on macOS --- UnityMcpBridge/Editor/Data/McpClients.cs | 22 ++++++--- .../Editor/Windows/MCPForUnityEditorWindow.cs | 49 +++++++++++++++++-- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index be21770d..7b150b7a 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -70,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", }, diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f29a1920..12a83030 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1202,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); From 99d7a120c90326602ceadd9238ac3081186c7a00 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 15:22:04 -0700 Subject: [PATCH 15/17] Editor: use macConfigPath on macOS for MCP client config writes (Claude Desktop, etc.). Fallback to linuxConfigPath only if mac path missing. --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 12a83030..96d5038c 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1413,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; @@ -1455,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; @@ -1567,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; From 88b6390359665cbf01dfab07dfba15fd4d0c6a2e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 16:49:42 -0700 Subject: [PATCH 16/17] Models: add macConfigPath to McpClient for macOS config path selection (fixes CS1061 in editor window). --- UnityMcpBridge/Editor/Models/McpClient.cs | 1 + 1 file changed, 1 insertion(+) 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; From a7d7bcdc42f5fbce5e970ab931bac573c8e8a8ff Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 16:56:43 -0700 Subject: [PATCH 17/17] Editor: on macOS, prefer macConfigPath in ManualConfigEditorWindow (fallback to linux path); Linux/Windows unchanged. --- .../Editor/Windows/ManualConfigEditorWindow.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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; }