diff --git a/README-DEV.md b/README-DEV.md index 398bdab2..98dafae6 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -46,16 +46,23 @@ Restores original files from backup. ## Finding Unity Package Cache Path -Unity package cache is typically located at: +Unity stores Git packages under a version-or-hash folder. Expect something like: ``` -X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@ +``` +Example (hash): +``` +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e + ``` -To find it: +To find it reliably: 1. Open Unity Package Manager 2. Select "Unity MCP" package -3. Right click on the package and "Show in Explorer" -4. Navigate to the path above with your username and version +3. Right click the package and choose "Show in Explorer" +4. That opens the exact cache folder Unity is using for your project + +Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. ## Workflow diff --git a/README.md b/README.md index ec23c4f9..673837b8 100644 --- a/README.md +++ b/README.md @@ -118,17 +118,18 @@ Unity MCP connects your tools using two components: Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1. -image +UnityMCP-Readme-Image -**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)** +**Option A: Auto-Setup (Recommended for Claude/Cursor/VSC Copilot)** 1. In Unity, go to `Window > Unity MCP`. -2. Click `Auto Configure` on the IDE you uses. -3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client\'s config file automatically)*. +2. Click `Auto-Setup`. +3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically)*. + **Option B: Manual Configuration** -If Auto-Configure fails or you use a different client: +If Auto-Setup fails or you use a different client: 1. **Find your MCP Client\'s configuration file.** (Check client documentation). * *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json` diff --git a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta index 6df0a871..82e437f2 100644 --- a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta +++ b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: de8f5721c34f7194392e9d8c7d0226c0 \ No newline at end of file +guid: de8f5721c34f7194392e9d8c7d0226c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index aa97d337..362ecdcc 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -9,24 +9,24 @@ public class McpClients { public List clients = new() { + // 1) Cursor new() { - name = "Claude Desktop", + name = "Cursor", windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Claude", - "claude_desktop_config.json" + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Claude", - "claude_desktop_config.json" + ".cursor", + "mcp.json" ), - mcpType = McpTypes.ClaudeDesktop, + mcpType = McpTypes.Cursor, configStatus = "Not Configured", }, + // 2) Claude Code new() { name = "Claude Code", @@ -41,58 +41,63 @@ public class McpClients mcpType = McpTypes.ClaudeCode, configStatus = "Not Configured", }, + // 3) Windsurf new() { - name = "Cursor", + name = "Windsurf", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" + ".codeium", + "windsurf", + "mcp_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" + ".codeium", + "windsurf", + "mcp_config.json" ), - mcpType = McpTypes.Cursor, + mcpType = McpTypes.Windsurf, configStatus = "Not Configured", }, + // 4) Claude Desktop new() { - name = "VSCode GitHub Copilot", + name = "Claude Desktop", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Code", - "User", - "settings.json" + "Claude", + "claude_desktop_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", - "Code", - "User", - "settings.json" + "Claude", + "claude_desktop_config.json" ), - mcpType = McpTypes.VSCode, + mcpType = McpTypes.ClaudeDesktop, configStatus = "Not Configured", }, + // 5) VSCode GitHub Copilot new() { - name = "Windsurf", + name = "VSCode GitHub Copilot", windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Code", + "User", + "settings.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" + "Library", + "Application Support", + "Code", + "User", + "settings.json" ), - mcpType = McpTypes.Windsurf, + mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, }; diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs.meta b/UnityMcpBridge/Editor/Data/McpClients.cs.meta index 3c8449ae..e5a10813 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs.meta +++ b/UnityMcpBridge/Editor/Data/McpClients.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 711b86bbc1f661e4fb2c822e14970e16 \ No newline at end of file +guid: 711b86bbc1f661e4fb2c822e14970e16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta index d8df9686..9eb69d04 100644 --- a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 64b8ff807bc9a401c82015cbafccffac \ No newline at end of file +guid: 64b8ff807bc9a401c82015cbafccffac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs new file mode 100644 index 00000000..ae420a26 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs @@ -0,0 +1,43 @@ +using UnityEditor; +using UnityEngine; + +namespace UnityMcpBridge.Editor.Helpers +{ + /// + /// Handles automatic installation of the Python server when the package is first installed. + /// + [InitializeOnLoad] + public static class PackageInstaller + { + private const string InstallationFlagKey = "UnityMCP.ServerInstalled"; + + static PackageInstaller() + { + // Check if this is the first time the package is loaded + if (!EditorPrefs.GetBool(InstallationFlagKey, false)) + { + // Schedule the installation for after Unity is fully loaded + EditorApplication.delayCall += InstallServerOnFirstLoad; + } + } + + private static void InstallServerOnFirstLoad() + { + try + { + Debug.Log("UNITY-MCP: Installing Python server..."); + ServerInstaller.EnsureServerInstalled(); + + // Mark as installed + EditorPrefs.SetBool(InstallationFlagKey, true); + + Debug.Log("UNITY-MCP: Python server installation completed successfully."); + } + catch (System.Exception ex) + { + Debug.LogError($"UNITY-MCP: Failed to install Python server: {ex.Message}"); + Debug.LogWarning("UNITY-MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); + } + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta new file mode 100644 index 00000000..156e75fb --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19e6eaa637484e9fa19f9a0459809de2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 8e368a6a..376f9163 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -1,7 +1,11 @@ using System; using System.IO; +using UnityEditor; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; using Newtonsoft.Json; using UnityEngine; @@ -12,6 +16,12 @@ namespace UnityMcpBridge.Editor.Helpers /// public static class PortManager { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } + catch { return false; } + } + private const int DefaultPort = 6400; private const int MaxPortAttempts = 100; private const string RegistryFileName = "unity-mcp-port.json"; @@ -31,15 +41,30 @@ public class PortConfig /// Port number to use public static int GetPortWithFallback() { - // Try to load stored port first - int storedPort = LoadStoredPort(); - if (storedPort > 0 && IsPortAvailable(storedPort)) + // Try to load stored port first, but only if it's from the current project + var storedConfig = GetStoredPortConfig(); + if (storedConfig != null && + storedConfig.unity_port > 0 && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + IsPortAvailable(storedConfig.unity_port)) { - Debug.Log($"Using stored port {storedPort}"); - return storedPort; + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using stored port {storedConfig.unity_port} for current project"); + return storedConfig.unity_port; } - // If no stored port or stored port is unavailable, find a new one + // If stored port exists but is currently busy, wait briefly for release + if (storedConfig != null && storedConfig.unity_port > 0) + { + if (WaitForPortRelease(storedConfig.unity_port, 1500)) + { + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Stored port {storedConfig.unity_port} became available after short wait"); + return storedConfig.unity_port; + } + // Prefer sticking to the same port; let the caller handle bind retries/fallbacks + return storedConfig.unity_port; + } + + // If no valid stored port, find a new one and save it int newPort = FindAvailablePort(); SavePort(newPort); return newPort; @@ -53,7 +78,7 @@ public static int DiscoverNewPort() { int newPort = FindAvailablePort(); SavePort(newPort); - Debug.Log($"Discovered and saved new port: {newPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Discovered and saved new port: {newPort}"); return newPort; } @@ -66,18 +91,18 @@ private static int FindAvailablePort() // Always try default port first if (IsPortAvailable(DefaultPort)) { - Debug.Log($"Using default port {DefaultPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using default port {DefaultPort}"); return DefaultPort; } - Debug.Log($"Default port {DefaultPort} is in use, searching for alternative..."); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Default port {DefaultPort} is in use, searching for alternative..."); // Search for alternatives for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) { if (IsPortAvailable(port)) { - Debug.Log($"Found available port {port}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Found available port {port}"); return port; } } @@ -86,7 +111,7 @@ private static int FindAvailablePort() } /// - /// Check if a specific port is available + /// Check if a specific port is available for binding /// /// Port to check /// True if port is available @@ -105,6 +130,61 @@ public static bool IsPortAvailable(int port) } } + /// + /// Check if a port is currently being used by Unity MCP Bridge + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by Unity MCP + public static bool IsPortUsedByUnityMcp(int port) + { + try + { + // Try to make a quick connection to see if it's a Unity MCP server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the Unity MCP server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by an MCP instance, continue waiting briefly + if (!IsPortUsedByUnityMcp(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + /// /// Save port to persistent storage /// @@ -123,11 +203,15 @@ private static void SavePort(int port) string registryDir = GetRegistryDirectory(); Directory.CreateDirectory(registryDir); - string registryFile = Path.Combine(registryDir, RegistryFileName); + string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to hashed, project-scoped file File.WriteAllText(registryFile, json); + // Also write to legacy stable filename to avoid hash/case drift across reloads + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + File.WriteAllText(legacy, json); - Debug.Log($"Saved port {port} to storage"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Saved port {port} to storage"); } catch (Exception ex) { @@ -143,11 +227,17 @@ private static int LoadStoredPort() { try { - string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName); + string registryFile = GetRegistryFilePath(); if (!File.Exists(registryFile)) { - return 0; + // Backwards compatibility: try the legacy file name + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return 0; + } + registryFile = legacy; } string json = File.ReadAllText(registryFile); @@ -170,11 +260,17 @@ public static PortConfig GetStoredPortConfig() { try { - string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName); + string registryFile = GetRegistryFilePath(); if (!File.Exists(registryFile)) { - return null; + // Backwards compatibility: try the legacy file + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return null; + } + registryFile = legacy; } string json = File.ReadAllText(registryFile); @@ -191,5 +287,33 @@ private static string GetRegistryDirectory() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + string hash = ComputeProjectHash(Application.dataPath); + string fileName = $"unity-mcp-port-{hash}.json"; + return Path.Combine(dir, fileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } } } \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs.meta b/UnityMcpBridge/Editor/Helpers/Response.cs.meta index da593068..6fd11e39 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/Response.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 80c09a76b944f8c4691e06c4d76c4be8 \ No newline at end of file +guid: 80c09a76b944f8c4691e06c4d76c4be8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 6fd05bcc..32a30701 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,8 +1,7 @@ using System; using System.IO; -using System.Linq; -using System.Net; using System.Runtime.InteropServices; +using UnityEditor; using UnityEngine; namespace UnityMcpBridge.Editor.Helpers @@ -11,37 +10,35 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; - private const string BranchName = "main"; - private const string GitUrl = "https://github.com/CoplayDev/unity-mcp.git"; - private const string PyprojectUrl = - "https://raw.githubusercontent.com/CoplayDev/unity-mcp/refs/heads/" - + BranchName - + "/UnityMcpServer/src/pyproject.toml"; /// - /// Ensures the unity-mcp-server is installed and up to date. + /// Ensures the unity-mcp-server is installed locally by copying from the embedded package source. + /// No network calls or Git operations are performed. /// public static void EnsureServerInstalled() { try { string saveLocation = GetSaveLocation(); + string destRoot = Path.Combine(saveLocation, ServerFolder); + string destSrc = Path.Combine(destRoot, "src"); - if (!IsServerInstalled(saveLocation)) + if (File.Exists(Path.Combine(destSrc, "server.py"))) { - InstallServer(saveLocation); + return; // Already installed } - else - { - string installedVersion = GetInstalledVersion(); - string latestVersion = GetLatestVersion(); - if (IsNewerVersion(latestVersion, installedVersion)) - { - UpdateServer(saveLocation); - } - else { } + if (!TryGetEmbeddedServerSource(out string embeddedSrc)) + { + throw new Exception("Could not find embedded UnityMcpServer/src in the package."); } + + // 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); } catch (Exception ex) { @@ -79,14 +76,11 @@ private static string GetSaveLocation() } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - string path = "/usr/local/bin"; - return !Directory.Exists(path) || !IsDirectoryWritable(path) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Applications", - RootFolder - ) - : Path.Combine(path, RootFolder); + // Use Application Support for a stable, user-writable location + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "UnityMCP" + ); } throw new Exception("Unsupported operating system."); } @@ -111,140 +105,372 @@ private static bool IsDirectoryWritable(string path) private static bool IsServerInstalled(string location) { return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "src", "pyproject.toml")); + && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); } /// - /// Installs the server by cloning only the UnityMcpServer folder from the repository and setting up dependencies. + /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package + /// or common development locations. /// - private static void InstallServer(string location) + private static bool TryGetEmbeddedServerSource(out string srcPath) { - // Create the src directory where the server code will reside - Directory.CreateDirectory(location); + // 1) Development mode: common repo layouts + try + { + string projectRoot = Path.GetDirectoryName(Application.dataPath); + string[] devCandidates = + { + Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), + }; + foreach (string candidate in devCandidates) + { + string full = Path.GetFullPath(candidate); + if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) + { + srcPath = full; + return true; + } + } + } + catch { /* ignore */ } - // Initialize git repo in the src directory - RunCommand("git", $"init", workingDirectory: location); + // 2) Installed package: resolve via Package Manager + // 2) Installed package: resolve via Package Manager (support new + legacy IDs, warn on legacy) +try +{ + var list = UnityEditor.PackageManager.Client.List(); + while (!list.IsCompleted) { } + if (list.Status == UnityEditor.PackageManager.StatusCode.Success) + { + const string CurrentId = "com.coplaydev.unity-mcp"; + const string LegacyId = "com.justinpbarnett.unity-mcp"; - // Add remote - RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location); + foreach (var pkg in list.Result) + { + if (pkg.name == CurrentId || pkg.name == LegacyId) + { + if (pkg.name == LegacyId) + { + Debug.LogWarning( + "UnityMCP: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " + + "Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage." + ); + } - // Configure sparse checkout - RunCommand("git", "config core.sparseCheckout true", workingDirectory: location); + string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path - // Set sparse checkout path to only include UnityMcpServer folder - string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); - File.WriteAllText(sparseCheckoutPath, $"{ServerFolder}/"); + // Preferred: tilde folder embedded alongside Editor/Runtime within the package + string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) + { + srcPath = embeddedTilde; + return true; + } - // Fetch and checkout the branch - RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); - RunCommand("git", $"checkout {BranchName}", workingDirectory: location); - } + // Fallback: legacy non-tilde folder name inside the package + string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); + if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) + { + srcPath = embedded; + return true; + } - /// - /// Fetches the currently installed version from the local pyproject.toml file. - /// - public static string GetInstalledVersion() - { - string pyprojectPath = Path.Combine( - GetSaveLocation(), - ServerFolder, - "src", - "pyproject.toml" - ); - return ParseVersionFromPyproject(File.ReadAllText(pyprojectPath)); + // Legacy: sibling of the package folder (dev-linked). Only valid when present on disk. + string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); + if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) + { + srcPath = sibling; + return true; + } + } } + } +} - /// - /// Fetches the latest version from the GitHub pyproject.toml file. - /// - public static string GetLatestVersion() - { - using WebClient webClient = new(); - string pyprojectContent = webClient.DownloadString(PyprojectUrl); - return ParseVersionFromPyproject(pyprojectContent); + catch { /* ignore */ } + + // 3) Fallback to previous common install locations + try + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string[] candidates = + { + Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), + }; + foreach (string candidate in candidates) + { + if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) + { + srcPath = candidate; + return true; + } + } + } + catch { /* ignore */ } + + srcPath = null; + return false; } - /// - /// Updates the server by pulling the latest changes for the UnityMcpServer folder only. - /// - private static void UpdateServer(string location) + private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) { - RunCommand("git", $"pull origin {BranchName}", workingDirectory: location); + Directory.CreateDirectory(destinationDir); + + foreach (string filePath in Directory.GetFiles(sourceDir)) + { + string fileName = Path.GetFileName(filePath); + string destFile = Path.Combine(destinationDir, fileName); + File.Copy(filePath, destFile, overwrite: true); + } + + foreach (string dirPath in Directory.GetDirectories(sourceDir)) + { + string dirName = Path.GetFileName(dirPath); + string destSubDir = Path.Combine(destinationDir, dirName); + CopyDirectoryRecursive(dirPath, destSubDir); + } } - /// - /// Parses the version number from pyproject.toml content. - /// - private static string ParseVersionFromPyproject(string content) + public static bool RepairPythonEnvironment() { - foreach (string line in content.Split('\n')) + try { - if (line.Trim().StartsWith("version =")) + string serverSrc = GetServerPath(); + bool hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); + if (!hasServer) { - string[] parts = line.Split('='); - if (parts.Length == 2) + // In dev mode or if not installed yet, try the embedded/dev source + if (TryGetEmbeddedServerSource(out string embeddedSrc) && File.Exists(Path.Combine(embeddedSrc, "server.py"))) { - return parts[1].Trim().Trim('"'); + serverSrc = embeddedSrc; + hasServer = true; } + else + { + // Attempt to install then retry + EnsureServerInstalled(); + serverSrc = GetServerPath(); + hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); + } + } + + if (!hasServer) + { + Debug.LogWarning("RepairPythonEnvironment: server.py not found; ensure server is installed first."); + return false; + } + + // Remove stale venv and pinned version file if present + string venvPath = Path.Combine(serverSrc, ".venv"); + if (Directory.Exists(venvPath)) + { + try { Directory.Delete(venvPath, recursive: true); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .venv: {ex.Message}"); } + } + string pyPin = Path.Combine(serverSrc, ".python-version"); + if (File.Exists(pyPin)) + { + try { File.Delete(pyPin); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .python-version: {ex.Message}"); } + } + + string uvPath = FindUvPath(); + if (uvPath == null) + { + Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); + return false; + } + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = uvPath, + Arguments = "sync", + WorkingDirectory = serverSrc, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var p = System.Diagnostics.Process.Start(psi); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(60000); + + if (p.ExitCode != 0) + { + Debug.LogError($"uv sync failed: {stderr}\n{stdout}"); + return false; } + + Debug.Log("UNITY-MCP: Python environment repaired successfully."); + return true; + } + catch (Exception ex) + { + Debug.LogError($"RepairPythonEnvironment failed: {ex.Message}"); + return false; } - throw new Exception("Version not found in pyproject.toml"); } - /// - /// Compares two version strings to determine if the latest is newer. - /// - public static bool IsNewerVersion(string latest, string installed) + private static string FindUvPath() { - int[] latestParts = latest.Split('.').Select(int.Parse).ToArray(); - int[] installedParts = installed.Split('.').Select(int.Parse).ToArray(); - for (int i = 0; i < Math.Min(latestParts.Length, installedParts.Length); i++) + // Allow user override via EditorPrefs + try { - if (latestParts[i] > installedParts[i]) + string overridePath = EditorPrefs.GetString("UnityMCP.UvPath", string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { - return true; + if (ValidateUvBinary(overridePath)) return overridePath; } + } + catch { } + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - if (latestParts[i] < installedParts[i]) + // Platform-specific candidate lists + string[] candidates; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + candidates = new[] { - return false; + // Common per-user installs + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"), + // Program Files style installs (if a native installer was used) + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"uv\uv.exe"), + // Try simple name resolution later via PATH + "uv.exe", + "uv" + }; + } + else + { + candidates = new[] + { + "/opt/homebrew/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + "/opt/local/bin/uv", + Path.Combine(home, ".local", "bin", "uv"), + "/opt/homebrew/opt/uv/bin/uv", + // Framework Python installs + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", + // Fallback to PATH resolution by name + "uv" + }; + } + + foreach (string c in candidates) + { + try + { + if (File.Exists(c) && ValidateUvBinary(c)) return c; + } + catch { /* ignore */ } + } + + // Use platform-appropriate which/where to resolve from PATH + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var wherePsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "where", + Arguments = "uv.exe", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string path = line.Trim(); + if (File.Exists(path) && ValidateUvBinary(path)) return path; + } + } + } + else + { + var whichPsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + if (ValidateUvBinary(output)) return output; + } } } - return latestParts.Length > installedParts.Length; + catch { } + + // Manual PATH scan + try + { + string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + string[] parts = pathEnv.Split(Path.PathSeparator); + foreach (string part in parts) + { + try + { + // Check both uv and uv.exe + string candidateUv = Path.Combine(part, "uv"); + string candidateUvExe = Path.Combine(part, "uv.exe"); + if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; + if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; + } + catch { } + } + } + catch { } + + return null; } - /// - /// Runs a command-line process and handles output/errors. - /// - private static void RunCommand( - string command, - string arguments, - string workingDirectory = null - ) + private static bool ValidateUvBinary(string uvPath) { - System.Diagnostics.Process process = new() + try { - StartInfo = new System.Diagnostics.ProcessStartInfo + var psi = new System.Diagnostics.ProcessStartInfo { - FileName = command, - Arguments = arguments, + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = workingDirectory ?? string.Empty, - }, - }; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (process.ExitCode != 0) - { - throw new Exception( - $"Command failed: {command} {arguments}\nOutput: {output}\nError: {error}" - ); + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } + if (p.ExitCode == 0) + { + string output = p.StandardOutput.ReadToEnd().Trim(); + return output.StartsWith("uv "); + } } + catch { } + return false; } } } diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta index 67bd7f4e..dfd9023b 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5862c6a6d0a914f4d83224f8d039cf7b \ No newline at end of file +guid: 5862c6a6d0a914f4d83224f8d039cf7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta index 12fdb173..280381ca 100644 --- a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: f8514fd42f23cb641a36e52550825b35 \ No newline at end of file +guid: f8514fd42f23cb641a36e52550825b35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/Command.cs.meta b/UnityMcpBridge/Editor/Models/Command.cs.meta index 007b085f..63618f53 100644 --- a/UnityMcpBridge/Editor/Models/Command.cs.meta +++ b/UnityMcpBridge/Editor/Models/Command.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 6754c84e5deb74749bc3a19e0c9aa280 \ No newline at end of file +guid: 6754c84e5deb74749bc3a19e0c9aa280 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta index 4dad0b4b..0574c5a6 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta +++ b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5fae9d995f514e9498e9613e2cdbeca9 \ No newline at end of file +guid: 5fae9d995f514e9498e9613e2cdbeca9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta index 9ef13109..1fb5f0b2 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta +++ b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: bcb583553e8173b49be71a5c43bd9502 \ No newline at end of file +guid: bcb583553e8173b49be71a5c43bd9502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs.meta b/UnityMcpBridge/Editor/Models/McpClient.cs.meta index a11df35e..b08dcf3b 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpClient.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b1afa56984aec0d41808edcebf805e6a \ No newline at end of file +guid: b1afa56984aec0d41808edcebf805e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpConfig.cs.meta b/UnityMcpBridge/Editor/Models/McpConfig.cs.meta index 1f70925f..2a407c31 100644 --- a/UnityMcpBridge/Editor/Models/McpConfig.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: c17c09908f0c1524daa8b6957ce1f7f5 \ No newline at end of file +guid: c17c09908f0c1524daa8b6957ce1f7f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpStatus.cs.meta b/UnityMcpBridge/Editor/Models/McpStatus.cs.meta index 4e5feb51..e8e930d3 100644 --- a/UnityMcpBridge/Editor/Models/McpStatus.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpStatus.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: aa63057c9e5282d4887352578bf49971 \ No newline at end of file +guid: aa63057c9e5282d4887352578bf49971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpTypes.cs.meta b/UnityMcpBridge/Editor/Models/McpTypes.cs.meta index d20128c2..377a6d0b 100644 --- a/UnityMcpBridge/Editor/Models/McpTypes.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 \ No newline at end of file +guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta b/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta index 0c4b377e..6e675e9e 100644 --- a/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta +++ b/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: e4e45386fcc282249907c2e3c7e5d9c6 \ No newline at end of file +guid: e4e45386fcc282249907c2e3c7e5d9c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta index 55b68298..15ec884b 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5b61b5a84813b5749a5c64422694a0fa \ No newline at end of file +guid: 5b61b5a84813b5749a5c64422694a0fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta index b398ddf7..d9520d98 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 896e8045986eb0d449ee68395479f1d6 \ No newline at end of file +guid: 896e8045986eb0d449ee68395479f1d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta b/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta index c4d71d4e..3dbc2e2f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: de90a1d9743a2874cb235cf0b83444b1 \ No newline at end of file +guid: de90a1d9743a2874cb235cf0b83444b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta b/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta index ed7502eb..8b55fb87 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 43ac60aa36b361b4dbe4a038ae9f35c8 \ No newline at end of file +guid: 43ac60aa36b361b4dbe4a038ae9f35c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta index ec958a90..5093c861 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 7641d7388f0f6634b9d83d34de87b2ee \ No newline at end of file +guid: 7641d7388f0f6634b9d83d34de87b2ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta index 9fd63b34..532618aa 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b6ddda47f4077e74fbb5092388cefcc2 \ No newline at end of file +guid: b6ddda47f4077e74fbb5092388cefcc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta index 171abb65..091cfe1c 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 626d2d44668019a45ae52e9ee066b7ec \ No newline at end of file +guid: 626d2d44668019a45ae52e9ee066b7ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta b/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta index 98ef7171..039895f8 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 46c4f3614ed61f547ba823f0b2790267 \ No newline at end of file +guid: 46c4f3614ed61f547ba823f0b2790267 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 4f3a6082..b7e8ef0e 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -21,12 +22,33 @@ public static partial class UnityMcpBridge private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); + private static readonly object startStopLock = new(); + private static bool initScheduled = false; + private static bool ensureUpdateHooked = false; + private static bool isStarting = false; + private static double nextStartAt = 0.0f; + private static double nextHeartbeatAt = 0.0f; + private static int heartbeatSeq = 0; private static Dictionary< string, (string commandJson, TaskCompletionSource tcs) > commandQueue = new(); private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; + + // Debug helpers + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } catch { return false; } + } + + private static void LogBreadcrumb(string stage) + { + if (IsDebugEnabled()) + { + Debug.Log($"UNITY-MCP: [{stage}]"); + } + } public static bool IsRunning => isRunning; public static int GetCurrentPort() => currentUnityPort; @@ -41,17 +63,10 @@ public static void StartAutoConnect() try { - // Discover new port and save it - currentUnityPort = PortManager.DiscoverNewPort(); - - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Start(); - isRunning = true; + // Prefer stored project port and start using the robust Start() path (with retries/options) + currentUnityPort = PortManager.GetPortWithFallback(); + Start(); isAutoConnectMode = true; - - Debug.Log($"UnityMcpBridge auto-connected on port {currentUnityPort}"); - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; } catch (Exception ex) { @@ -81,51 +96,229 @@ public static bool FolderExists(string path) static UnityMcpBridge() { - Start(); + // Skip bridge in headless/batch environments (CI/builds) + if (Application.isBatchMode) + { + return; + } + // Defer start until the editor is idle and not compiling + ScheduleInitRetry(); + // Add a safety net update hook in case delayCall is missed during reload churn + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } EditorApplication.quitting += Stop; + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + // Also coalesce play mode transitions into a deferred init + EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); } - public static void Start() + /// + /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. + /// This prevents repeated restarts during script compilation that cause port hopping. + /// + private static void InitializeAfterCompilation() { - Stop(); + initScheduled = false; - try + // Play-mode friendly: allow starting in play mode; only defer while compiling + if (IsCompiling()) + { + ScheduleInitRetry(); + return; + } + + if (!isRunning) { - ServerInstaller.EnsureServerInstalled(); + Start(); + if (!isRunning) + { + // If a race prevented start, retry later + ScheduleInitRetry(); + } } - catch (Exception ex) + } + + private static void ScheduleInitRetry() + { + if (initScheduled) { - Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}"); + return; } + initScheduled = true; + // Debounce: start ~200ms after the last trigger + nextStartAt = EditorApplication.timeSinceStartup + 0.20f; + // Ensure the update pump is active + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + // Keep the original delayCall as a secondary path + EditorApplication.delayCall += InitializeAfterCompilation; + } + // Safety net: ensure the bridge starts shortly after domain reload when editor is idle + private static void EnsureStartedOnEditorIdle() + { + // Do nothing while compiling + if (IsCompiling()) + { + return; + } + + // If already running, remove the hook if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + return; + } + + // Debounced start: wait until the scheduled time + if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) + { + return; + } + + if (isStarting) { return; } + isStarting = true; + // Attempt start; if it succeeds, remove the hook to avoid overhead + Start(); + isStarting = false; + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + } + } + + // Helper to check compilation status across Unity versions + private static bool IsCompiling() + { + if (EditorApplication.isCompiling) + { + return true; + } try { - // Use PortManager to get available port with automatic fallback - currentUnityPort = PortManager.GetPortWithFallback(); - - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Start(); - isRunning = true; - isAutoConnectMode = false; // Normal startup mode - Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); - // Assuming ListenerLoop and ProcessCommands are defined elsewhere - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; - } - catch (SocketException ex) - { - if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) { - Debug.LogError( - $"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation." - ); + return (bool)prop.GetValue(null); + } + } + catch { } + return false; + } + + public static void Start() + { + lock (startStopLock) + { + // Don't restart if already running on a working port + if (isRunning && listener != null) + { + Debug.Log($"UNITY-MCP: UnityMcpBridge already running on port {currentUnityPort}"); + return; + } + + Stop(); + + // Attempt fast bind with stored-port preference (sticky per-project) + try + { + // Always consult PortManager first so we prefer the persisted project port + currentUnityPort = PortManager.GetPortWithFallback(); + + // Breadcrumb: Start + LogBreadcrumb("Start"); + + const int maxImmediateRetries = 3; + const int retrySleepMs = 75; + int attempt = 0; + for (;;) + { + try + { + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif + // Minimize TIME_WAIT by sending RST on close + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + // Ignore if not supported on platform + } + listener.Start(); + break; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) + { + attempt++; + Thread.Sleep(retrySleepMs); + continue; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) + { + currentUnityPort = PortManager.GetPortWithFallback(); + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + } + listener.Start(); + break; + } + } + + isRunning = true; + isAutoConnectMode = false; + Debug.Log($"UNITY-MCP: UnityMcpBridge started on port {currentUnityPort}."); + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + // Write initial heartbeat immediately + heartbeatSeq++; + WriteHeartbeat(false, "ready"); + nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; } - else + catch (SocketException ex) { Debug.LogError($"Failed to start TCP listener: {ex.Message}"); } @@ -134,22 +327,28 @@ public static void Start() public static void Stop() { - if (!isRunning) + lock (startStopLock) { - return; - } + if (!isRunning) + { + return; + } - try - { - listener?.Stop(); - listener = null; - isRunning = false; - EditorApplication.update -= ProcessCommands; - Debug.Log("UnityMcpBridge stopped."); - } - catch (Exception ex) - { - Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + try + { + // Mark as stopping early to avoid accept logging during disposal + isRunning = false; + // Mark heartbeat one last time before stopping + WriteHeartbeat(false); + listener?.Stop(); + listener = null; + EditorApplication.update -= ProcessCommands; + Debug.Log("UNITY-MCP: UnityMcpBridge stopped."); + } + catch (Exception ex) + { + Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + } } } @@ -173,6 +372,14 @@ private static async Task ListenerLoop() // Fire and forget each client connection _ = HandleClientAsync(client); } + catch (ObjectDisposedException) + { + // Listener was disposed during stop/reload; exit quietly + if (!isRunning) + { + break; + } + } catch (Exception ex) { if (isRunning) @@ -242,6 +449,14 @@ private static void ProcessCommands() List processedIds = new(); lock (lockObj) { + // Periodic heartbeat while editor is idle/processing + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + WriteHeartbeat(false); + nextHeartbeatAt = now + 0.5f; + } + foreach ( KeyValuePair< string, @@ -469,5 +684,67 @@ private static string GetParamsSummary(JObject @params) return "Could not summarize parameters"; } } + + // Heartbeat/status helpers + private static void OnBeforeAssemblyReload() + { + // Stop cleanly before reload so sockets close and clients see 'reloading' + try { Stop(); } catch { } + WriteHeartbeat(true, "reloading"); + LogBreadcrumb("Reload"); + } + + private static void OnAfterAssemblyReload() + { + // Will be overwritten by Start(), but mark as alive quickly + WriteHeartbeat(false, "idle"); + LogBreadcrumb("Idle"); + // Schedule a safe restart after reload to avoid races during compilation + ScheduleInitRetry(); + } + + private static void WriteHeartbeat(bool reloading, string reason = null) + { + try + { + string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + var payload = new + { + unity_port = currentUnityPort, + reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, + project_path = Application.dataPath, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(filePath, JsonConvert.SerializeObject(payload)); + } + catch (Exception) + { + // Best-effort only + } + } + + private static string ComputeProjectHash(string input) + { + try + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new System.Text.StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; + } + catch + { + return "default"; + } + } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta index 39156984..dcaa7616 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 1e0fb0e418dd19345a8236c44078972b \ No newline at end of file +guid: 1e0fb0e418dd19345a8236c44078972b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta index b5797cc2..41646e62 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 36798bd7b867b8e43ac86885e94f928f \ No newline at end of file +guid: 36798bd7b867b8e43ac86885e94f928f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta index 0229c757..c492a9d6 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 4283e255b343c4546b843cd22214ac93 \ No newline at end of file +guid: 4283e255b343c4546b843cd22214ac93 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 13fadf36..859ce15c 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1,6 +1,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Net.Sockets; +using System.Net; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -21,6 +25,11 @@ public class UnityMcpEditorWindow : EditorWindow private Color pythonServerInstallationStatusColor = Color.red; private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) private readonly McpClients mcpClients = new(); + private bool autoRegisterEnabled; + private bool lastClientRegisteredOk; + private bool lastBridgeVerifiedOk; + private string pythonDirOverride = null; + private bool debugLogsEnabled; // Script validation settings private int validationLevelIndex = 1; // Default to Standard @@ -47,6 +56,8 @@ private void OnEnable() // Refresh bridge status isUnityBridgeRunning = UnityMcpBridge.IsRunning; + autoRegisterEnabled = EditorPrefs.GetBool("UnityMCP.AutoRegisterEnabled", true); + debugLogsEnabled = EditorPrefs.GetBool("UnityMCP.DebugLogs", false); foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -54,10 +65,18 @@ private void OnEnable() // Load validation level setting LoadValidationLevelSetting(); + + // First-run auto-setup (register client(s) and ensure bridge is listening) + if (autoRegisterEnabled) + { + AutoFirstRunSetup(); + } } private void OnFocus() { + // Refresh bridge running state on focus in case initialization completed after domain reload + isUnityBridgeRunning = UnityMcpBridge.IsRunning; if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; @@ -83,25 +102,32 @@ private Color GetStatusColor(McpStatus status) private void UpdatePythonServerInstallationStatus() { - string serverPath = ServerInstaller.GetServerPath(); - - if (File.Exists(Path.Combine(serverPath, "server.py"))) + try { - string installedVersion = ServerInstaller.GetInstalledVersion(); - string latestVersion = ServerInstaller.GetLatestVersion(); + string installedPath = ServerInstaller.GetServerPath(); + bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); + if (installedOk) + { + pythonServerInstallationStatus = "Installed"; + pythonServerInstallationStatusColor = Color.green; + return; + } - if (ServerInstaller.IsNewerVersion(latestVersion, installedVersion)) + // Fall back to embedded/dev source via our existing resolution logic + string embeddedPath = FindPackagePythonDirectory(); + bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); + if (embeddedOk) { - pythonServerInstallationStatus = "Newer Version Available"; - pythonServerInstallationStatusColor = Color.yellow; + pythonServerInstallationStatus = "Installed (Embedded)"; + pythonServerInstallationStatusColor = Color.green; } else { - pythonServerInstallationStatus = "Up to Date"; - pythonServerInstallationStatusColor = Color.green; + pythonServerInstallationStatus = "Not Installed"; + pythonServerInstallationStatusColor = Color.red; } } - else + catch { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; @@ -142,27 +168,50 @@ private void OnGUI() // Header DrawHeader(); - // Main sections in a more compact layout + // Compute equal column widths for uniform layout + float horizontalSpacing = 2f; + float outerPadding = 20f; // approximate padding + // Make columns a bit less wide for a tighter layout + float computed = (position.width - outerPadding - horizontalSpacing) / 2f; + float colWidth = Mathf.Clamp(computed, 220f, 340f); + // Use fixed heights per row so paired panels match exactly + float topPanelHeight = 190f; + float bottomPanelHeight = 230f; + + // Top row: Server Status (left) and Unity Bridge (right) EditorGUILayout.BeginHorizontal(); - - // Left column - Status and Bridge - EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.5f)); - DrawServerStatusSection(); - EditorGUILayout.Space(5); - DrawBridgeSection(); - EditorGUILayout.EndVertical(); - - // Right column - Validation Settings - EditorGUILayout.BeginVertical(); - DrawValidationSection(); - EditorGUILayout.EndVertical(); - + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawServerStatusSection(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawBridgeSection(); + EditorGUILayout.EndVertical(); + } EditorGUILayout.EndHorizontal(); - + EditorGUILayout.Space(10); - - // Unified MCP Client Configuration - DrawUnifiedClientConfiguration(); + + // Second row: MCP Client Configuration (left) and Script Validation (right) + EditorGUILayout.BeginHorizontal(); + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawUnifiedClientConfiguration(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawValidationSection(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); + + // Minimal bottom padding + EditorGUILayout.Space(2); EditorGUILayout.EndScrollView(); } @@ -184,6 +233,16 @@ private void DrawHeader() "Unity MCP Editor", titleStyle ); + + // Place the Show Debug Logs toggle on the same header row, right-aligned + float toggleWidth = 160f; + Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); + bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); + if (newDebug != debugLogsEnabled) + { + debugLogsEnabled = newDebug; + EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); + } EditorGUILayout.Space(15); } @@ -212,42 +271,96 @@ private void DrawServerStatusSection() EditorGUILayout.Space(5); - // Connection mode and Auto-Connect button EditorGUILayout.BeginHorizontal(); - bool isAutoMode = UnityMcpBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); - // Auto-Connect button - if (GUILayout.Button(isAutoMode ? "Connected ✓" : "Auto-Connect", GUILayout.Width(100), GUILayout.Height(24))) + int currentUnityPort = UnityMcpBridge.GetCurrentPort(); + GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 11 + }; + EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); + EditorGUILayout.Space(5); + + /// Auto-Setup button below ports + string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; + if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) { - if (!isAutoMode) + RunSetupNow(); + } + EditorGUILayout.Space(4); + + // Repair Python Env button with tooltip tag + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + GUIContent repairLabel = new GUIContent( + "Repair Python Env", + "Deletes the server's .venv and runs 'uv sync' to rebuild a clean environment. Use this if modules are missing or Python upgraded." + ); + if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) { - try + bool ok = global::UnityMcpBridge.Editor.Helpers.ServerInstaller.RepairPythonEnvironment(); + if (ok) { - UnityMcpBridge.StartAutoConnect(); - // Update UI state - isUnityBridgeRunning = UnityMcpBridge.IsRunning; - Repaint(); + EditorUtility.DisplayDialog("Unity MCP", "Python environment repaired.", "OK"); + UpdatePythonServerInstallationStatus(); } - catch (Exception ex) + else { - EditorUtility.DisplayDialog("Auto-Connect Failed", ex.Message, "OK"); + EditorUtility.DisplayDialog("Unity MCP", "Repair failed. Please check Console for details.", "OK"); } } } - - EditorGUILayout.EndHorizontal(); - - // Current ports display - int currentUnityPort = UnityMcpBridge.GetCurrentPort(); - GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) + // (Removed descriptive tool tag under the Repair button) + + // (Show Debug Logs toggle moved to header) + EditorGUILayout.Space(2); + + // Python detection warning with link + if (!IsPythonDetected()) { - fontSize = 11 - }; - EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); - EditorGUILayout.Space(5); + GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; + EditorGUILayout.LabelField("Warning: No Python installation found.", warnStyle); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open install instructions", GUILayout.Width(200))) + { + Application.OpenURL("https://www.python.org/downloads/"); + } + } + EditorGUILayout.Space(4); + } + + // Troubleshooting helpers + if (pythonServerInstallationStatusColor != Color.green) + { + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) + { + string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); + if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) + { + pythonDirOverride = picked; + EditorPrefs.SetString("UnityMCP.PythonDirOverride", pythonDirOverride); + UpdatePythonServerInstallationStatus(); + } + else if (!string.IsNullOrEmpty(picked)) + { + EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); + } + } + if (GUILayout.Button("Verify again", GUILayout.Width(120))) + { + UpdatePythonServerInstallationStatus(); + } + } + } EditorGUILayout.EndVertical(); } @@ -255,6 +368,9 @@ private void DrawBridgeSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); + // Always reflect the live state each repaint to avoid stale UI after recompiles + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 @@ -305,7 +421,9 @@ private void DrawValidationSection() EditorGUILayout.Space(8); string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); - EditorGUILayout.Space(5); + EditorGUILayout.Space(4); + // (Show Debug Logs toggle moved to header) + EditorGUILayout.Space(2); EditorGUILayout.EndVertical(); } @@ -320,6 +438,15 @@ private void DrawUnifiedClientConfiguration() EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); + // Auto-connect toggle (moved from Server Status) + bool newAuto = EditorGUILayout.ToggleLeft("Auto-connect to MCP Clients", autoRegisterEnabled); + if (newAuto != autoRegisterEnabled) + { + autoRegisterEnabled = newAuto; + EditorPrefs.SetBool("UnityMCP.AutoRegisterEnabled", autoRegisterEnabled); + } + EditorGUILayout.Space(6); + // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); EditorGUI.BeginChangeCheck(); @@ -341,6 +468,222 @@ private void DrawUnifiedClientConfiguration() EditorGUILayout.EndVertical(); } + private void AutoFirstRunSetup() + { + try + { + // Project-scoped one-time flag + string projectPath = Application.dataPath ?? string.Empty; + string key = $"UnityMCP.AutoRegistered.{ComputeSha1(projectPath)}"; + if (EditorPrefs.GetBool(key, false)) + { + return; + } + + // Attempt client registration using discovered Python server dir + pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) + { + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!IsClaudeConfigured()) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + // For Cursor/others, skip if already configured + if (!IsCursorConfigured(pythonDir)) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + } + + // Ensure the bridge is listening and has a fresh saved port + if (!UnityMcpBridge.IsRunning) + { + try + { + UnityMcpBridge.StartAutoConnect(); + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + Repaint(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}"); + } + } + + // Verify bridge with a quick ping + lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort()); + + EditorPrefs.SetBool(key, true); + } + catch (Exception e) + { + UnityEngine.Debug.LogWarning($"Unity MCP auto-setup skipped: {e.Message}"); + } + } + + private static string ComputeSha1(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hash = sha1.ComputeHash(bytes); + StringBuilder sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); + } + catch + { + return ""; + } + } + + private void RunSetupNow() + { + // Force a one-shot setup regardless of first-run flag + try + { + pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); + return; + } + + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!IsClaudeConfigured()) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + if (!IsCursorConfigured(pythonDir)) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + + // Restart/ensure bridge + UnityMcpBridge.StartAutoConnect(); + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + + // Verify + lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort()); + Repaint(); + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); + } + } + + private static bool IsCursorConfigured(string pythonDir) + { + try + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", "mcp.json") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", "mcp.json"); + if (!File.Exists(configPath)) return false; + string json = File.ReadAllText(configPath); + dynamic cfg = JsonConvert.DeserializeObject(json); + var servers = cfg?.mcpServers; + if (servers == null) return false; + var unity = servers.unityMCP ?? servers.UnityMCP; + if (unity == null) return false; + var args = unity.args; + if (args == null) return false; + foreach (var a in args) + { + string s = (string)a; + if (!string.IsNullOrEmpty(s) && s.Contains(pythonDir, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + catch { return false; } + } + + private static bool IsClaudeConfigured() + { + try + { + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "claude" : "/usr/local/bin/claude"; + var psi = new ProcessStartInfo { FileName = command, Arguments = "mcp list", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; + using var p = Process.Start(psi); + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(3000); + if (p.ExitCode != 0) return false; + return output.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch { return false; } + } + + private static bool VerifyBridgePing(int port) + { + try + { + using TcpClient c = new TcpClient(); + var task = c.ConnectAsync(IPAddress.Loopback, port); + if (!task.Wait(500)) return false; + using NetworkStream s = c.GetStream(); + byte[] ping = Encoding.UTF8.GetBytes("ping"); + s.Write(ping, 0, ping.Length); + s.ReadTimeout = 1000; + byte[] buf = new byte[256]; + int n = s.Read(buf, 0, buf.Length); + if (n <= 0) return false; + string resp = Encoding.UTF8.GetString(buf, 0, n); + return resp.Contains("pong", StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + private void DrawClientConfigurationCompact(McpClient mcpClient) { // Status display @@ -458,8 +801,9 @@ private void ToggleUnityBridge() { UnityMcpBridge.Start(); } - - isUnityBridgeRunning = !isUnityBridgeRunning; + // Reflect the actual state post-operation (avoid optimistic toggle) + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + Repaint(); } private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) @@ -545,7 +889,10 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC return "Configured successfully"; } - private void ShowManualConfigurationInstructions(string configPath, McpClient mcpClient) + private void ShowManualConfigurationInstructions( + string configPath, + McpClient mcpClient + ) { mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); @@ -617,6 +964,29 @@ private string FindPackagePythonDirectory() try { + // Only check dev paths if we're using a file-based package (development mode) + bool isDevelopmentMode = IsDevelopmentMode(); + if (isDevelopmentMode) + { + string currentPackagePath = Path.GetDirectoryName(Application.dataPath); + string[] devPaths = { + Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), + }; + + foreach (string devPath in devPaths) + { + if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) + { + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); + } + return devPath; + } + } + } + // Try to find the package using Package Manager API UnityEditor.PackageManager.Requests.ListRequest request = UnityEditor.PackageManager.Client.List(); @@ -630,7 +1000,14 @@ private string FindPackagePythonDirectory() { string packagePath = package.resolvedPath; - // Check for local package structure (UnityMcpServer/src) + // Preferred: check for tilde folder inside package + string packagedTildeDir = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(packagedTildeDir) && File.Exists(Path.Combine(packagedTildeDir, "server.py"))) + { + return packagedTildeDir; + } + + // Fallback: legacy local package structure (UnityMcpServer/src) string localPythonDir = Path.Combine(Path.GetDirectoryName(packagePath), "UnityMcpServer", "src"); if (Directory.Exists(localPythonDir) && File.Exists(Path.Combine(localPythonDir, "server.py"))) { @@ -655,10 +1032,6 @@ private string FindPackagePythonDirectory() // Check for local development structure string[] possibleDirs = { - // Check in the Unity project's Packages folder (for local package development) - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "unity-mcp", "UnityMcpServer", "src")), - // Check relative to the Unity project (for development) - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "unity-mcp", "UnityMcpServer", "src")), // Check in user's home directory (common installation location) Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "unity-mcp", "UnityMcpServer", "src"), // Check in Applications folder (macOS/Linux common location) @@ -676,7 +1049,10 @@ private string FindPackagePythonDirectory() } // If still not found, return the placeholder path - UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); + } } catch (Exception e) { @@ -686,6 +1062,35 @@ private string FindPackagePythonDirectory() return pythonDir; } + private bool IsDevelopmentMode() + { + try + { + // Only treat as development if manifest explicitly references a local file path for the package + string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); + if (!File.Exists(manifestPath)) return false; + + string manifestContent = File.ReadAllText(manifestPath); + // Look specifically for our package dependency set to a file: URL + // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk + if (manifestContent.IndexOf("\"com.justinpbarnett.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + int idx = manifestContent.IndexOf("com.justinpbarnett.unity-mcp", StringComparison.OrdinalIgnoreCase); + // Crude but effective: check for "file:" in the same line/value + if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 + && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + catch + { + return false; + } + } + private string ConfigureMcpClient(McpClient mcpClient) { try @@ -712,8 +1117,8 @@ private string ConfigureMcpClient(McpClient mcpClient) // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - // Find the server.py file location - string pythonDir = ServerInstaller.GetServerPath(); + // Find the server.py file location using the same logic as FindPackagePythonDirectory + string pythonDir = FindPackagePythonDirectory(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { @@ -871,7 +1276,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) } string configJson = File.ReadAllText(configPath); - string pythonDir = ServerInstaller.GetServerPath(); + // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode + string pythonDir = FindPackagePythonDirectory(); // Use switch statement to handle different client types, extracting common logic string[] args = null; @@ -905,14 +1311,38 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Common logic for checking configuration status if (configExists) { - if (pythonDir != null && - Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal))) + bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)); + if (matches) { mcpClient.SetStatus(McpStatus.Configured); } else { - mcpClient.SetStatus(McpStatus.IncorrectPath); + // Attempt auto-rewrite once if the package path changed + try + { + string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") + { + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + } + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + } + } } } else @@ -1034,7 +1464,10 @@ private void RegisterWithClaudeCode(string pythonDir) } else if (!string.IsNullOrEmpty(errors)) { - UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); + } } } catch (Exception e) @@ -1521,7 +1954,10 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath; - UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + } if (!File.Exists(configPath)) { @@ -1580,5 +2016,99 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) mcpClient.SetStatus(McpStatus.Error, e.Message); } } + + private bool IsPythonDetected() + { + try + { + // Windows-specific Python detection + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Common Windows Python installation paths + string[] windowsCandidates = + { + @"C:\Python313\python.exe", + @"C:\Python312\python.exe", + @"C:\Python311\python.exe", + @"C:\Python310\python.exe", + @"C:\Python39\python.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), + }; + + foreach (string c in windowsCandidates) + { + if (File.Exists(c)) return true; + } + + // Try 'where python' command (Windows equivalent of 'which') + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = "python", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + string[] lines = outp.Split('\n'); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (File.Exists(trimmed)) return true; + } + } + } + else + { + // macOS/Linux detection (existing code) + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/local/bin/python3", + Path.Combine(home, ".local", "bin", "python3"), + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + }; + foreach (string c in candidates) + { + if (File.Exists(c)) return true; + } + + // Try 'which python3' + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "python3", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + } + } + catch { } + return false; + } } } diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta index 437ccab6..fb13126b 100644 --- a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 377fe73d52cf0435fabead5f50a0d204 \ No newline at end of file +guid: 377fe73d52cf0435fabead5f50a0d204 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta index 9596160f..caaf2859 100644 --- a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta +++ b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: e65311c160f0d41d4a1b45a3dba8dd5a \ No newline at end of file +guid: e65311c160f0d41d4a1b45a3dba8dd5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpServer/src/Dockerfile b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile similarity index 100% rename from UnityMcpServer/src/Dockerfile rename to UnityMcpBridge/UnityMcpServer~/src/Dockerfile diff --git a/UnityMcpServer/src/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/__init__.py similarity index 100% rename from UnityMcpServer/src/__init__.py rename to UnityMcpBridge/UnityMcpServer~/src/__init__.py diff --git a/UnityMcpServer/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py similarity index 62% rename from UnityMcpServer/src/config.py rename to UnityMcpBridge/UnityMcpServer~/src/config.py index c42437a7..6100b2aa 100644 --- a/UnityMcpServer/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -15,7 +15,7 @@ class ServerConfig: mcp_port: int = 6500 # Connection settings - connection_timeout: float = 600.0 # 10 minutes timeout + connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Logging settings @@ -23,8 +23,13 @@ class ServerConfig: log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Server settings - max_retries: int = 3 - retry_delay: float = 1.0 + max_retries: int = 10 + retry_delay: float = 0.25 + # Backoff hint returned to clients when Unity is reloading (milliseconds) + reload_retry_ms: int = 250 + # Number of polite retries when Unity reports reloading + # 40 × 250ms ≈ 10s default window + reload_max_retries: int = 40 # Create a global config instance config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py new file mode 100644 index 00000000..98855333 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py @@ -0,0 +1,155 @@ +""" +Port discovery utility for Unity MCP Server. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a Unity MCP listener + (quick socket connect + ping) before choosing it. +""" + +import json +import os +import logging +from pathlib import Path +from typing import Optional, List +import glob +import socket + +logger = logging.getLogger("unity-mcp-server") + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a Unity MCP listener is on this port. + Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Minimal validation: look for a success pong response + if data and b'"message":"pong"' in data: + return True + except Exception: + return False + except Exception: + return False + return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. + + Returns: + Port number to connect to + """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: + try: + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info(f"Using Unity port from {path.name}: {unity_port}") + return unity_port + except Exception as e: + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info(f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + + # Fallback to default port + logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. + + Returns: + Port configuration dict or None if not found + """ + candidates = PortDiscovery.list_candidate_files() + if not candidates: + return None + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not read port configuration {path}: {e}") + return None \ No newline at end of file diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml similarity index 94% rename from UnityMcpServer/src/pyproject.toml rename to UnityMcpBridge/UnityMcpServer~/src/pyproject.toml index eebcde11..2c05fb83 100644 --- a/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml @@ -3,7 +3,7 @@ name = "UnityMcpServer" version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.10" dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] [build-system] diff --git a/UnityMcpServer/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py similarity index 100% rename from UnityMcpServer/src/server.py rename to UnityMcpBridge/UnityMcpServer~/src/server.py diff --git a/UnityMcpServer/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py similarity index 100% rename from UnityMcpServer/src/tools/__init__.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py diff --git a/UnityMcpServer/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py similarity index 75% rename from UnityMcpServer/src/tools/execute_menu_item.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py index a4ebc672..a448465d 100644 --- a/UnityMcpServer/src/tools/execute_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py @@ -3,7 +3,9 @@ """ from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection # Import unity_connection module +from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper +from config import config +import time def register_execute_menu_item_tools(mcp: FastMCP): """Registers the execute_menu_item tool with the MCP server.""" @@ -42,10 +44,6 @@ async def execute_menu_item( if "parameters" not in params_dict: params_dict["parameters"] = {} # Ensure parameters dict exists - # Get Unity connection and send the command - # We use the unity_connection module to communicate with Unity - unity_conn = get_unity_connection() - - # Send command to the ExecuteMenuItem C# handler - # The command type should match what the Unity side expects - return unity_conn.send_command("execute_menu_item", params_dict) \ No newline at end of file + # Use centralized retry helper + resp = send_command_with_retry("execute_menu_item", params_dict) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py similarity index 85% rename from UnityMcpServer/src/tools/manage_asset.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index dada66b3..19ac0c2e 100644 --- a/UnityMcpServer/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -5,7 +5,9 @@ from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context # from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection # Use absolute import relative to Python dir +from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper +from config import config +import time def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" @@ -71,13 +73,7 @@ async def manage_asset( # Get the Unity connection instance connection = get_unity_connection() - # Run the synchronous send_command in the default executor (thread pool) - # This prevents blocking the main async event loop. - result = await loop.run_in_executor( - None, # Use default executor - connection.send_command, # The function to call - "manage_asset", # First argument for send_command - params_dict # Second argument for send_command - ) + # Use centralized async retry helper to avoid blocking the event loop + result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity - return result \ No newline at end of file + return result if isinstance(result, dict) else {"success": False, "message": str(result)} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py similarity index 78% rename from UnityMcpServer/src/tools/manage_editor.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index b256e6cf..8ff7378f 100644 --- a/UnityMcpServer/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,6 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context +import time from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" @@ -40,14 +42,13 @@ def manage_editor( } params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity - response = get_unity_connection().send_command("manage_editor", params) + # Send command using centralized retry helper + response = send_command_with_retry("manage_editor", params) - # Process response - if response.get("success"): + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py similarity index 92% rename from UnityMcpServer/src/tools/manage_gameobject.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 83ab9c74..cbe29a31 100644 --- a/UnityMcpServer/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,6 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config +import time def register_manage_gameobject_tools(mcp: FastMCP): """Register all GameObject management tools with the MCP server.""" @@ -122,17 +124,14 @@ def manage_gameobject( params.pop("prefab_folder", None) # -------------------------------- - # Send the command to Unity via the established connection - # Use the get_unity_connection function to retrieve the active connection instance - # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation - response = get_unity_connection().send_command("manage_gameobject", params) + # Use centralized retry helper + response = send_command_with_retry("manage_gameobject", params) # Check if the response indicates success # If the response is not successful, raise an exception with the error message - if response.get("success"): + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py similarity index 74% rename from UnityMcpServer/src/tools/manage_scene.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 44981f65..c2257ef4 100644 --- a/UnityMcpServer/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,6 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config +import time def register_manage_scene_tools(mcp: FastMCP): """Register all scene management tools with the MCP server.""" @@ -34,14 +36,13 @@ def manage_scene( } params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity - response = get_unity_connection().send_command("manage_scene", params) + # Use centralized retry helper + response = send_command_with_retry("manage_scene", params) - # Process response - if response.get("success"): + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py similarity index 86% rename from UnityMcpServer/src/tools/manage_script.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 22e09530..a41fb85c 100644 --- a/UnityMcpServer/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,6 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config +import time import os import base64 @@ -53,11 +55,11 @@ def manage_script( # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity - response = get_unity_connection().send_command("manage_script", params) + # Send command via centralized retry helper + response = send_command_with_retry("manage_script", params) # Process response from Unity - if response.get("success"): + if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') @@ -66,8 +68,7 @@ def manage_script( del response["data"]["contentsEncoded"] return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: # Handle Python-side errors (e.g., connection issues) diff --git a/UnityMcpServer/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py similarity index 85% rename from UnityMcpServer/src/tools/manage_shader.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index c447a3a3..8ddb6c7c 100644 --- a/UnityMcpServer/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,6 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config +import time import os import base64 @@ -46,11 +48,11 @@ def manage_shader( # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity - response = get_unity_connection().send_command("manage_shader", params) + # Send command via centralized retry helper + response = send_command_with_retry("manage_shader", params) # Process response from Unity - if response.get("success"): + if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') @@ -59,8 +61,7 @@ def manage_shader( del response["data"]["contentsEncoded"] return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: # Handle Python-side errors (e.g., connection issues) diff --git a/UnityMcpServer/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py similarity index 88% rename from UnityMcpServer/src/tools/read_console.py rename to UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 3d4bd121..098951c6 100644 --- a/UnityMcpServer/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -2,8 +2,10 @@ Defines the read_console tool for accessing Unity Editor console messages. """ from typing import List, Dict, Any +import time from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" @@ -66,5 +68,6 @@ def read_console( if 'count' not in params_dict: params_dict['count'] = None - # Forward the command using the bridge's send_command method - return bridge.send_command("read_console", params_dict) \ No newline at end of file + # Use centralized retry helper + resp = send_command_with_retry("read_console", params_dict) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py new file mode 100644 index 00000000..9bad736d --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -0,0 +1,331 @@ +import socket +import json +import logging +from dataclasses import dataclass +from pathlib import Path +import time +import random +import errno +from typing import Dict, Any +from config import config +from port_discovery import PortDiscovery + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("unity-mcp-server") + +@dataclass +class UnityConnection: + """Manages the socket connection to the Unity Editor.""" + host: str = config.unity_host + port: int = None # Will be set dynamically + sock: socket.socket = None # Socket for Unity communication + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() + + def connect(self) -> bool: + """Establish a connection to the Unity Editor.""" + if self.sock: + return True + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + return True + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + self.sock = None + return False + + def disconnect(self): + """Close the connection to the Unity Editor.""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error disconnecting from Unity: {str(e)}") + finally: + self.sock = None + + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + chunks = [] + sock.settimeout(config.connection_timeout) # Use timeout from config + try: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception("Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far + data = b''.join(chunks) + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info(f"Received complete response ({len(data)} bytes)") + return data + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning(f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise + + def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + # Defensive guard: catch empty/placeholder invocations early + if not command_type: + raise ValueError("MCP call missing command_type") + if params is None: + # Return a fast, structured error that clients can display without hanging + return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"} + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely + try: + status = read_status_file() + if status and (status.get('reloading') or status.get('reason') == 'reloading'): + return { + "success": False, + "state": "reloading", + "retry_after_ms": int(config.reload_retry_ms), + "error": "Unity domain reload in progress", + "message": "Unity is reloading scripts; please retry shortly" + } + except Exception: + pass + + for attempt in range(attempts + 1): + try: + # Ensure connected + if not self.sock: + # During retries use short connect timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(1.0) + self.sock.connect((self.host, self.port)) + # restore steady-state timeout for receive + self.sock.settimeout(config.connection_timeout) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + + # Send + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + response_data = self.receive_full_response(self.sock) + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(config.connection_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get('message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) + except Exception as e: + logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info(f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # Heartbeat-aware, jittered backoff + status = read_status_file() + # Base exponential backoff + backoff = base_backoff * (2 ** attempt) + # Decorrelated jitter multiplier + jitter = random.uniform(0.1, 0.3) + + # Fast‑retry for transient socket failures + fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + if not fast_error: + try: + err_no = getattr(e, 'errno', None) + fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + except Exception: + pass + + # Cap backoff depending on state + if status and status.get('reloading'): + cap = 0.8 + elif fast_error: + cap = 0.25 + else: + cap = 3.0 + + sleep_s = min(cap, jitter * (2 ** attempt)) + time.sleep(sleep_s) + continue + raise + +# Global Unity connection +_unity_connection = None + +def get_unity_connection() -> UnityConnection: + """Retrieve or establish a persistent Unity connection.""" + global _unity_connection + if _unity_connection is not None: + try: + # Try to ping with a short timeout to verify connection + result = _unity_connection.send_command("ping") + # If we get here, the connection is still valid + logger.debug("Reusing existing Unity connection") + return _unity_connection + except Exception as e: + logger.warning(f"Existing connection failed: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + + # Create a new connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + + try: + # Verify the new connection works + _unity_connection.send_command("ping") + logger.info("Successfully established new Unity connection") + return _unity_connection + except Exception as e: + logger.error(f"Could not verify new connection: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") + + +# ----------------------------- +# Centralized retry helpers +# ----------------------------- + +def _is_reloading_response(resp: dict) -> bool: + """Return True if the Unity response indicates the editor is reloading.""" + if not isinstance(resp, dict): + return False + if resp.get("state") == "reloading": + return True + message_text = (resp.get("message") or resp.get("error") or "").lower() + return "reload" in message_text + + +def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Send a command via the shared connection, waiting politely through Unity reloads. + + Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the + structured failure if retries are exhausted. + """ + conn = get_unity_connection() + if max_retries is None: + max_retries = getattr(config, "reload_max_retries", 40) + if retry_ms is None: + retry_ms = getattr(config, "reload_retry_ms", 250) + + response = conn.send_command(command_type, params) + retries = 0 + while _is_reloading_response(response) and retries < max_retries: + delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms + time.sleep(max(0.0, delay_ms / 1000.0)) + retries += 1 + response = conn.send_command(command_type, params) + return response + + +async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Async wrapper that runs the blocking retry helper in a thread pool.""" + try: + import asyncio # local import to avoid mandatory asyncio dependency for sync callers + if loop is None: + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms), + ) + except Exception as e: + # Return a structured error dict for consistency with other responses + return {"success": False, "error": f"Python async retry helper failed: {str(e)}"} diff --git a/UnityMcpServer/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock similarity index 100% rename from UnityMcpServer/src/uv.lock rename to UnityMcpBridge/UnityMcpServer~/src/uv.lock diff --git a/UnityMcpServer/src/.python-version b/UnityMcpServer/src/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/UnityMcpServer/src/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/UnityMcpServer/src/port_discovery.py b/UnityMcpServer/src/port_discovery.py deleted file mode 100644 index a0dfe961..00000000 --- a/UnityMcpServer/src/port_discovery.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Port discovery utility for Unity MCP Server. -Reads port configuration saved by Unity Bridge. -""" - -import json -import os -import logging -from pathlib import Path -from typing import Optional - -logger = logging.getLogger("unity-mcp-server") - -class PortDiscovery: - """Handles port discovery from Unity Bridge registry""" - - REGISTRY_FILE = "unity-mcp-port.json" - - @staticmethod - def get_registry_path() -> Path: - """Get the path to the port registry file""" - return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - - @staticmethod - def discover_unity_port() -> int: - """ - Discover Unity port from registry file with fallback to default - - Returns: - Port number to connect to - """ - registry_file = PortDiscovery.get_registry_path() - - if registry_file.exists(): - try: - with open(registry_file, 'r') as f: - port_config = json.load(f) - - unity_port = port_config.get('unity_port') - if unity_port and isinstance(unity_port, int): - logger.info(f"Discovered Unity port from registry: {unity_port}") - return unity_port - - except Exception as e: - logger.warning(f"Could not read port registry: {e}") - - # Fallback to default port - logger.info("No port registry found, using default port 6400") - return 6400 - - @staticmethod - def get_port_config() -> Optional[dict]: - """ - Get the full port configuration from registry - - Returns: - Port configuration dict or None if not found - """ - registry_file = PortDiscovery.get_registry_path() - - if not registry_file.exists(): - return None - - try: - with open(registry_file, 'r') as f: - return json.load(f) - except Exception as e: - logger.warning(f"Could not read port configuration: {e}") - return None \ No newline at end of file diff --git a/UnityMcpServer/src/unity_connection.py b/UnityMcpServer/src/unity_connection.py deleted file mode 100644 index da88d9bd..00000000 --- a/UnityMcpServer/src/unity_connection.py +++ /dev/null @@ -1,207 +0,0 @@ -import socket -import json -import logging -from dataclasses import dataclass -from typing import Dict, Any -from config import config -from port_discovery import PortDiscovery - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("unity-mcp-server") - -@dataclass -class UnityConnection: - """Manages the socket connection to the Unity Editor.""" - host: str = config.unity_host - port: int = None # Will be set dynamically - sock: socket.socket = None # Socket for Unity communication - - def __post_init__(self): - """Set port from discovery if not explicitly provided""" - if self.port is None: - self.port = PortDiscovery.discover_unity_port() - - def connect(self) -> bool: - """Establish a connection to the Unity Editor.""" - if self.sock: - return True - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") - return True - except Exception as e: - logger.error(f"Failed to connect to Unity: {str(e)}") - self.sock = None - return False - - def disconnect(self): - """Close the connection to the Unity Editor.""" - if self.sock: - try: - self.sock.close() - except Exception as e: - logger.error(f"Error disconnecting from Unity: {str(e)}") - finally: - self.sock = None - - def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: - """Receive a complete response from Unity, handling chunked data.""" - chunks = [] - sock.settimeout(config.connection_timeout) # Use timeout from config - try: - while True: - chunk = sock.recv(buffer_size) - if not chunk: - if not chunks: - raise Exception("Connection closed before receiving data") - break - chunks.append(chunk) - - # Process the data received so far - data = b''.join(chunks) - decoded_data = data.decode('utf-8') - - # Check if we've received a complete response - try: - # Special case for ping-pong - if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): - logger.debug("Received ping response") - return data - - # Handle escaped quotes in the content - if '"content":' in decoded_data: - # Find the content field and its value - content_start = decoded_data.find('"content":') + 9 - content_end = decoded_data.rfind('"', content_start) - if content_end > content_start: - # Replace escaped quotes in content with regular quotes - content = decoded_data[content_start:content_end] - content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - - # Validate JSON format - json.loads(decoded_data) - - # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") - return data - except json.JSONDecodeError: - # We haven't received a complete valid JSON response yet - continue - except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") - # Continue reading more chunks as this might not be the complete response - continue - except socket.timeout: - logger.warning("Socket timeout during receive") - raise Exception("Timeout receiving Unity response") - except Exception as e: - logger.error(f"Error during receive: {str(e)}") - raise - - def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send a command to Unity and return its response.""" - if not self.sock and not self.connect(): - raise ConnectionError("Not connected to Unity") - - # Special handling for ping command - if command_type == "ping": - try: - logger.debug("Sending ping to verify connection") - self.sock.sendall(b"ping") - response_data = self.receive_full_response(self.sock) - response = json.loads(response_data.decode('utf-8')) - - if response.get("status") != "success": - logger.warning("Ping response was not successful") - self.sock = None - raise ConnectionError("Connection verification failed") - - return {"message": "pong"} - except Exception as e: - logger.error(f"Ping error: {str(e)}") - self.sock = None - raise ConnectionError(f"Connection verification failed: {str(e)}") - - # Normal command handling - command = {"type": command_type, "params": params or {}} - try: - # Check for very large content that might cause JSON issues - command_size = len(json.dumps(command)) - - if command_size > config.buffer_size / 2: - logger.warning(f"Large command detected ({command_size} bytes). This might cause issues.") - - logger.info(f"Sending command: {command_type} with params size: {command_size} bytes") - - # Ensure we have a valid JSON string before sending - command_json = json.dumps(command, ensure_ascii=False) - self.sock.sendall(command_json.encode('utf-8')) - - response_data = self.receive_full_response(self.sock) - try: - response = json.loads(response_data.decode('utf-8')) - except json.JSONDecodeError as je: - logger.error(f"JSON decode error: {str(je)}") - # Log partial response for debugging - partial_response = response_data.decode('utf-8')[:500] + "..." if len(response_data) > 500 else response_data.decode('utf-8') - logger.error(f"Partial response: {partial_response}") - raise Exception(f"Invalid JSON response from Unity: {str(je)}") - - if response.get("status") == "error": - error_message = response.get("error") or response.get("message", "Unknown Unity error") - logger.error(f"Unity error: {error_message}") - raise Exception(error_message) - - return response.get("result", {}) - except Exception as e: - logger.error(f"Communication error with Unity: {str(e)}") - self.sock = None - raise Exception(f"Failed to communicate with Unity: {str(e)}") - -# Global Unity connection -_unity_connection = None - -def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" - global _unity_connection - if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection - logger.info("Creating new Unity connection") - _unity_connection = UnityConnection() - if not _unity_connection.connect(): - _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/deploy-dev.bat b/deploy-dev.bat index 6a83fcf0..2b04c22b 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -9,7 +9,7 @@ echo. :: Configuration set "SCRIPT_DIR=%~dp0" set "BRIDGE_SOURCE=%SCRIPT_DIR%UnityMcpBridge" -set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpServer\src" +set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpBridge\UnityMcpServer~\src" set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" diff --git a/restore-dev.bat b/restore-dev.bat index 69d9312b..553ccc12 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -5,6 +5,9 @@ echo =============================================== echo Unity MCP Development Restore Script echo =============================================== echo. +echo Note: The Python server is bundled under UnityMcpBridge\UnityMcpServer~ in the package. +echo This script restores your installed server path from backups, not the repo copy. +echo. :: Configuration set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup"