From 32274a396586988addd6f7babc89509b826f17f6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 7 Aug 2025 15:32:03 -0700 Subject: [PATCH] UnityMCP stability: robust auto-restart on compile/play transitions; stop on domain reload; start/stop locking; per-project sticky ports + brief release wait; Python discovery scans hashed+legacy files and probes; editor window live status refresh. --- .../Editor/Data/DefaultServerConfig.cs.meta | 11 +- UnityMcpBridge/Editor/Data/McpClients.cs.meta | 11 +- .../Helpers/GameObjectSerializer.cs.meta | 11 +- UnityMcpBridge/Editor/Helpers/PortManager.cs | 135 +++++++++++-- .../Editor/Helpers/Response.cs.meta | 11 +- .../Editor/Helpers/ServerInstaller.cs.meta | 11 +- .../Editor/Helpers/Vector3Helper.cs.meta | 11 +- UnityMcpBridge/Editor/Models/Command.cs.meta | 11 +- .../Editor/Models/MCPConfigServer.cs.meta | 11 +- .../Editor/Models/MCPConfigServers.cs.meta | 11 +- .../Editor/Models/McpClient.cs.meta | 11 +- .../Editor/Models/McpConfig.cs.meta | 11 +- .../Editor/Models/McpStatus.cs.meta | 11 +- UnityMcpBridge/Editor/Models/McpTypes.cs.meta | 11 +- .../Editor/Models/ServerConfig.cs.meta | 11 +- .../Editor/Tools/CommandRegistry.cs.meta | 11 +- .../Editor/Tools/ExecuteMenuItem.cs.meta | 11 +- .../Editor/Tools/ManageAsset.cs.meta | 11 +- .../Editor/Tools/ManageEditor.cs.meta | 11 +- .../Editor/Tools/ManageGameObject.cs.meta | 11 +- .../Editor/Tools/ManageScene.cs.meta | 11 +- .../Editor/Tools/ManageScript.cs.meta | 11 +- .../Editor/Tools/ReadConsole.cs.meta | 11 +- UnityMcpBridge/Editor/UnityMcpBridge.cs | 179 +++++++++++++----- UnityMcpBridge/Editor/UnityMcpBridge.cs.meta | 11 +- .../Windows/ManualConfigEditorWindow.cs.meta | 11 +- .../Windows/UnityMCPEditorWindow.cs.meta | 11 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 10 +- .../Windows/VSCodeManualSetupWindow.cs.meta | 11 +- .../Serialization/UnityTypeConverters.cs.meta | 11 +- UnityMcpServer/src/port_discovery.py | 126 +++++++++--- 31 files changed, 623 insertions(+), 124 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta index 6df0a87..82e437f 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.meta b/UnityMcpBridge/Editor/Data/McpClients.cs.meta index 3c8449a..e5a1081 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 d8df968..9eb69d0 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/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 8e368a6..900cbd9 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -2,6 +2,9 @@ using System; using System.IO; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; using Newtonsoft.Json; using UnityEngine; @@ -31,15 +34,28 @@ namespace UnityMcpBridge.Editor.Helpers /// 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 && + storedConfig.project_path == Application.dataPath && + IsPortAvailable(storedConfig.unity_port)) { - Debug.Log($"Using stored port {storedPort}"); - return storedPort; + Debug.Log($"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)) + { + Debug.Log($"Stored port {storedConfig.unity_port} became available after short wait"); + return storedConfig.unity_port; + } + } + + // If no valid stored port, find a new one and save it int newPort = FindAvailablePort(); SavePort(newPort); return newPort; @@ -86,7 +102,7 @@ namespace UnityMcpBridge.Editor.Helpers } /// - /// 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 +121,61 @@ namespace UnityMcpBridge.Editor.Helpers } } + /// + /// 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,7 +194,7 @@ namespace UnityMcpBridge.Editor.Helpers string registryDir = GetRegistryDirectory(); Directory.CreateDirectory(registryDir); - string registryFile = Path.Combine(registryDir, RegistryFileName); + string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); File.WriteAllText(registryFile, json); @@ -143,11 +214,17 @@ namespace UnityMcpBridge.Editor.Helpers { 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 +247,17 @@ namespace UnityMcpBridge.Editor.Helpers { 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 +274,33 @@ namespace UnityMcpBridge.Editor.Helpers { 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 da59306..6fd11e3 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.meta b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta index 67bd7f4..dfd9023 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 12fdb17..280381c 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 007b085..63618f5 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 4dad0b4..0574c5a 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 9ef1310..1fb5f0b 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 a11df35..b08dcf3 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 1f70925..2a407c3 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 4e5feb5..e8e930d 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 d20128c..377a6d0 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 0c4b377..6e675e9 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 55b6829..15ec884 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 b398ddf..d9520d9 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 c4d71d4..3dbc2e2 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 ed7502e..8b55fb8 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 ec958a9..5093c86 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 9fd63b3..532618a 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 171abb6..091cfe1 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 98ef717..039895f 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 4f3a608..760a608 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -21,6 +21,8 @@ namespace UnityMcpBridge.Editor 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 Dictionary< string, (string commandJson, TaskCompletionSource tcs) @@ -81,75 +83,148 @@ namespace UnityMcpBridge.Editor static UnityMcpBridge() { - Start(); + // Use delayed initialization to avoid repeated restarts during compilation + EditorApplication.delayCall += InitializeAfterCompilation; EditorApplication.quitting += Stop; + AssemblyReloadEvents.beforeAssemblyReload += Stop; // ensure listener releases before domain reload + + // Robust re-init hooks + UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => ScheduleInitRetry(); + EditorApplication.playModeStateChanged += state => + { + if (state == PlayModeStateChange.EnteredEditMode || state == PlayModeStateChange.EnteredPlayMode) + { + ScheduleInitRetry(); + } + }; + } + + /// + /// 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() + { + initScheduled = false; + + // Play-mode friendly: allow starting in play mode; only defer while compiling + if (EditorApplication.isCompiling) + { + ScheduleInitRetry(); + return; + } + + if (!isRunning) + { + Start(); + if (!isRunning) + { + // If a race prevented start, retry later + ScheduleInitRetry(); + } + } + } + + private static void ScheduleInitRetry() + { + if (initScheduled) + { + return; + } + initScheduled = true; + EditorApplication.delayCall += InitializeAfterCompilation; } public static void Start() { - Stop(); - - try + lock (startStopLock) { - ServerInstaller.EnsureServerInstalled(); - } - catch (Exception ex) - { - Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}"); - } - - if (isRunning) - { - return; - } - - 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) + // Don't restart if already running on a working port + if (isRunning && listener != null) { - Debug.LogError( - $"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation." - ); + Debug.Log($"UnityMcpBridge already running on port {currentUnityPort}"); + return; } - else + + Stop(); + + // Removed automatic server installer; assume server exists inside the package (UPM). + + try { - Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + // Try to reuse the current port if it's still available, otherwise get a new one + if (currentUnityPort > 0 && PortManager.IsPortAvailable(currentUnityPort)) + { + Debug.Log($"Reusing current port {currentUnityPort}"); + } + else + { + // 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) + { + Debug.LogError( + $"Port {currentUnityPort} is already in use. Trying to find alternative..." + ); + + // Try once more with a fresh port discovery + try + { + currentUnityPort = PortManager.DiscoverNewPort(); + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Start(); + isRunning = true; + Debug.Log($"UnityMcpBridge started on fallback port {currentUnityPort}."); + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + } + catch (Exception fallbackEx) + { + Debug.LogError($"Failed to start on fallback port: {fallbackEx.Message}"); + } + } + else + { + Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + } } } } 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 + { + listener?.Stop(); + listener = null; + isRunning = false; + EditorApplication.update -= ProcessCommands; + Debug.Log("UnityMcpBridge stopped."); + } + catch (Exception ex) + { + Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta index 3915698..dcaa761 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 b5797cc..41646e6 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 0229c75..c492a9d 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 14c9f5b..691ef45 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -58,6 +58,8 @@ namespace UnityMcpBridge.Editor.Windows 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]; @@ -255,6 +257,9 @@ namespace UnityMcpBridge.Editor.Windows { 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 @@ -458,8 +463,9 @@ namespace UnityMcpBridge.Editor.Windows { 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) diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta index 437ccab..fb13126 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 9596160..caaf285 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/port_discovery.py b/UnityMcpServer/src/port_discovery.py index a0dfe96..c09efe3 100644 --- a/UnityMcpServer/src/port_discovery.py +++ b/UnityMcpServer/src/port_discovery.py @@ -1,69 +1,133 @@ """ Port discovery utility for Unity MCP Server. -Reads port configuration saved by Unity Bridge. + +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 +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" + 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: + # Even if the ping fails, a successful TCP connect is a strong signal. + # Fall back to treating the port as viable if connect succeeded. + return True + except Exception: + return False + return False + @staticmethod def discover_unity_port() -> int: """ - Discover Unity port from registry file with fallback to default + 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 """ - registry_file = PortDiscovery.get_registry_path() - - if registry_file.exists(): + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: 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 - + 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: {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("No port registry found, using default port 6400") - return 6400 + 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 full port configuration from registry + 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 """ - registry_file = PortDiscovery.get_registry_path() - - if not registry_file.exists(): + candidates = PortDiscovery.list_candidate_files() + if not candidates: 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 + 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