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.
parent
eabf727894
commit
32274a3965
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de8f5721c34f7194392e9d8c7d0226c0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 711b86bbc1f661e4fb2c822e14970e16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 64b8ff807bc9a401c82015cbafccffac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <returns>Port number to use</returns>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific port is available
|
||||
/// Check if a specific port is available for binding
|
||||
/// </summary>
|
||||
/// <param name="port">Port to check</param>
|
||||
/// <returns>True if port is available</returns>
|
||||
|
|
@ -105,6 +121,61 @@ namespace UnityMcpBridge.Editor.Helpers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="port">Port to check</param>
|
||||
/// <returns>True if port appears to be used by Unity MCP</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save port to persistent storage
|
||||
/// </summary>
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 80c09a76b944f8c4691e06c4d76c4be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5862c6a6d0a914f4d83224f8d039cf7b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f8514fd42f23cb641a36e52550825b35
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6754c84e5deb74749bc3a19e0c9aa280
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5fae9d995f514e9498e9613e2cdbeca9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bcb583553e8173b49be71a5c43bd9502
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b1afa56984aec0d41808edcebf805e6a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c17c09908f0c1524daa8b6957ce1f7f5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: aa63057c9e5282d4887352578bf49971
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e4e45386fcc282249907c2e3c7e5d9c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b61b5a84813b5749a5c64422694a0fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 896e8045986eb0d449ee68395479f1d6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de90a1d9743a2874cb235cf0b83444b1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 43ac60aa36b361b4dbe4a038ae9f35c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7641d7388f0f6634b9d83d34de87b2ee
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b6ddda47f4077e74fbb5092388cefcc2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 626d2d44668019a45ae52e9ee066b7ec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 46c4f3614ed61f547ba823f0b2790267
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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<string> 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the MCP bridge after Unity is fully loaded and compilation is complete.
|
||||
/// This prevents repeated restarts during script compilation that cause port hopping.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1e0fb0e418dd19345a8236c44078972b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 36798bd7b867b8e43ac86885e94f928f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4283e255b343c4546b843cd22214ac93
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 377fe73d52cf0435fabead5f50a0d204
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e65311c160f0d41d4a1b45a3dba8dd5a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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-<hash>.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()
|
||||
candidates = PortDiscovery.list_candidate_files()
|
||||
|
||||
if registry_file.exists():
|
||||
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():
|
||||
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}")
|
||||
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
|
||||
Loading…
Reference in New Issue