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
|
fileFormatVersion: 2
|
||||||
guid: de8f5721c34f7194392e9d8c7d0226c0
|
guid: de8f5721c34f7194392e9d8c7d0226c0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 711b86bbc1f661e4fb2c822e14970e16
|
guid: 711b86bbc1f661e4fb2c822e14970e16
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 64b8ff807bc9a401c82015cbafccffac
|
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.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
|
|
@ -31,15 +34,28 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
/// <returns>Port number to use</returns>
|
/// <returns>Port number to use</returns>
|
||||||
public static int GetPortWithFallback()
|
public static int GetPortWithFallback()
|
||||||
{
|
{
|
||||||
// Try to load stored port first
|
// Try to load stored port first, but only if it's from the current project
|
||||||
int storedPort = LoadStoredPort();
|
var storedConfig = GetStoredPortConfig();
|
||||||
if (storedPort > 0 && IsPortAvailable(storedPort))
|
if (storedConfig != null &&
|
||||||
|
storedConfig.unity_port > 0 &&
|
||||||
|
storedConfig.project_path == Application.dataPath &&
|
||||||
|
IsPortAvailable(storedConfig.unity_port))
|
||||||
{
|
{
|
||||||
Debug.Log($"Using stored port {storedPort}");
|
Debug.Log($"Using stored port {storedConfig.unity_port} for current project");
|
||||||
return storedPort;
|
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();
|
int newPort = FindAvailablePort();
|
||||||
SavePort(newPort);
|
SavePort(newPort);
|
||||||
return newPort;
|
return newPort;
|
||||||
|
|
@ -86,7 +102,7 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if a specific port is available
|
/// Check if a specific port is available for binding
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="port">Port to check</param>
|
/// <param name="port">Port to check</param>
|
||||||
/// <returns>True if port is available</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Save port to persistent storage
|
/// Save port to persistent storage
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -123,7 +194,7 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
string registryDir = GetRegistryDirectory();
|
string registryDir = GetRegistryDirectory();
|
||||||
Directory.CreateDirectory(registryDir);
|
Directory.CreateDirectory(registryDir);
|
||||||
|
|
||||||
string registryFile = Path.Combine(registryDir, RegistryFileName);
|
string registryFile = GetRegistryFilePath();
|
||||||
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
|
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
|
||||||
File.WriteAllText(registryFile, json);
|
File.WriteAllText(registryFile, json);
|
||||||
|
|
||||||
|
|
@ -143,11 +214,17 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
|
string registryFile = GetRegistryFilePath();
|
||||||
|
|
||||||
if (!File.Exists(registryFile))
|
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);
|
string json = File.ReadAllText(registryFile);
|
||||||
|
|
@ -170,11 +247,17 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
|
string registryFile = GetRegistryFilePath();
|
||||||
|
|
||||||
if (!File.Exists(registryFile))
|
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);
|
string json = File.ReadAllText(registryFile);
|
||||||
|
|
@ -191,5 +274,33 @@ namespace UnityMcpBridge.Editor.Helpers
|
||||||
{
|
{
|
||||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
|
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
|
fileFormatVersion: 2
|
||||||
guid: 80c09a76b944f8c4691e06c4d76c4be8
|
guid: 80c09a76b944f8c4691e06c4d76c4be8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 5862c6a6d0a914f4d83224f8d039cf7b
|
guid: 5862c6a6d0a914f4d83224f8d039cf7b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: f8514fd42f23cb641a36e52550825b35
|
guid: f8514fd42f23cb641a36e52550825b35
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 6754c84e5deb74749bc3a19e0c9aa280
|
guid: 6754c84e5deb74749bc3a19e0c9aa280
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 5fae9d995f514e9498e9613e2cdbeca9
|
guid: 5fae9d995f514e9498e9613e2cdbeca9
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: bcb583553e8173b49be71a5c43bd9502
|
guid: bcb583553e8173b49be71a5c43bd9502
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: b1afa56984aec0d41808edcebf805e6a
|
guid: b1afa56984aec0d41808edcebf805e6a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: c17c09908f0c1524daa8b6957ce1f7f5
|
guid: c17c09908f0c1524daa8b6957ce1f7f5
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: aa63057c9e5282d4887352578bf49971
|
guid: aa63057c9e5282d4887352578bf49971
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1
|
guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: e4e45386fcc282249907c2e3c7e5d9c6
|
guid: e4e45386fcc282249907c2e3c7e5d9c6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 5b61b5a84813b5749a5c64422694a0fa
|
guid: 5b61b5a84813b5749a5c64422694a0fa
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 896e8045986eb0d449ee68395479f1d6
|
guid: 896e8045986eb0d449ee68395479f1d6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: de90a1d9743a2874cb235cf0b83444b1
|
guid: de90a1d9743a2874cb235cf0b83444b1
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 43ac60aa36b361b4dbe4a038ae9f35c8
|
guid: 43ac60aa36b361b4dbe4a038ae9f35c8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 7641d7388f0f6634b9d83d34de87b2ee
|
guid: 7641d7388f0f6634b9d83d34de87b2ee
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: b6ddda47f4077e74fbb5092388cefcc2
|
guid: b6ddda47f4077e74fbb5092388cefcc2
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 626d2d44668019a45ae52e9ee066b7ec
|
guid: 626d2d44668019a45ae52e9ee066b7ec
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 46c4f3614ed61f547ba823f0b2790267
|
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 TcpListener listener;
|
||||||
private static bool isRunning = false;
|
private static bool isRunning = false;
|
||||||
private static readonly object lockObj = new();
|
private static readonly object lockObj = new();
|
||||||
|
private static readonly object startStopLock = new();
|
||||||
|
private static bool initScheduled = false;
|
||||||
private static Dictionary<
|
private static Dictionary<
|
||||||
string,
|
string,
|
||||||
(string commandJson, TaskCompletionSource<string> tcs)
|
(string commandJson, TaskCompletionSource<string> tcs)
|
||||||
|
|
@ -81,75 +83,148 @@ namespace UnityMcpBridge.Editor
|
||||||
|
|
||||||
static UnityMcpBridge()
|
static UnityMcpBridge()
|
||||||
{
|
{
|
||||||
Start();
|
// Use delayed initialization to avoid repeated restarts during compilation
|
||||||
|
EditorApplication.delayCall += InitializeAfterCompilation;
|
||||||
EditorApplication.quitting += Stop;
|
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()
|
public static void Start()
|
||||||
{
|
{
|
||||||
Stop();
|
lock (startStopLock)
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
ServerInstaller.EnsureServerInstalled();
|
// Don't restart if already running on a working port
|
||||||
}
|
if (isRunning && listener != null)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
Debug.LogError(
|
Debug.Log($"UnityMcpBridge already running on port {currentUnityPort}");
|
||||||
$"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation."
|
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()
|
public static void Stop()
|
||||||
{
|
{
|
||||||
if (!isRunning)
|
lock (startStopLock)
|
||||||
{
|
{
|
||||||
return;
|
if (!isRunning)
|
||||||
}
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
listener?.Stop();
|
listener?.Stop();
|
||||||
listener = null;
|
listener = null;
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
EditorApplication.update -= ProcessCommands;
|
EditorApplication.update -= ProcessCommands;
|
||||||
Debug.Log("UnityMcpBridge stopped.");
|
Debug.Log("UnityMcpBridge stopped.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}");
|
Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 1e0fb0e418dd19345a8236c44078972b
|
guid: 1e0fb0e418dd19345a8236c44078972b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 36798bd7b867b8e43ac86885e94f928f
|
guid: 36798bd7b867b8e43ac86885e94f928f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 4283e255b343c4546b843cd22214ac93
|
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()
|
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)
|
if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count)
|
||||||
{
|
{
|
||||||
McpClient selectedClient = mcpClients.clients[selectedClientIndex];
|
McpClient selectedClient = mcpClients.clients[selectedClientIndex];
|
||||||
|
|
@ -255,6 +257,9 @@ namespace UnityMcpBridge.Editor.Windows
|
||||||
{
|
{
|
||||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
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)
|
GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||||
{
|
{
|
||||||
fontSize = 14
|
fontSize = 14
|
||||||
|
|
@ -458,8 +463,9 @@ namespace UnityMcpBridge.Editor.Windows
|
||||||
{
|
{
|
||||||
UnityMcpBridge.Start();
|
UnityMcpBridge.Start();
|
||||||
}
|
}
|
||||||
|
// Reflect the actual state post-operation (avoid optimistic toggle)
|
||||||
isUnityBridgeRunning = !isUnityBridgeRunning;
|
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
|
||||||
|
Repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null)
|
private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null)
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 377fe73d52cf0435fabead5f50a0d204
|
guid: 377fe73d52cf0435fabead5f50a0d204
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: e65311c160f0d41d4a1b45a3dba8dd5a
|
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.
|
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 json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import glob
|
||||||
|
import socket
|
||||||
|
|
||||||
logger = logging.getLogger("unity-mcp-server")
|
logger = logging.getLogger("unity-mcp-server")
|
||||||
|
|
||||||
class PortDiscovery:
|
class PortDiscovery:
|
||||||
"""Handles port discovery from Unity Bridge registry"""
|
"""Handles port discovery from Unity Bridge registry"""
|
||||||
|
REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file
|
||||||
REGISTRY_FILE = "unity-mcp-port.json"
|
DEFAULT_PORT = 6400
|
||||||
|
CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_registry_path() -> Path:
|
def get_registry_path() -> Path:
|
||||||
"""Get the path to the port registry file"""
|
"""Get the path to the port registry file"""
|
||||||
return Path.home() / ".unity-mcp" / PortDiscovery.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
|
@staticmethod
|
||||||
def discover_unity_port() -> int:
|
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:
|
Returns:
|
||||||
Port number to connect to
|
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:
|
try:
|
||||||
with open(registry_file, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
port_config = json.load(f)
|
cfg = json.load(f)
|
||||||
|
unity_port = cfg.get('unity_port')
|
||||||
unity_port = port_config.get('unity_port')
|
if isinstance(unity_port, int):
|
||||||
if unity_port and isinstance(unity_port, int):
|
if first_seen_port is None:
|
||||||
logger.info(f"Discovered Unity port from registry: {unity_port}")
|
first_seen_port = unity_port
|
||||||
return 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:
|
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
|
# Fallback to default port
|
||||||
logger.info("No port registry found, using default port 6400")
|
logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}")
|
||||||
return 6400
|
return PortDiscovery.DEFAULT_PORT
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_port_config() -> Optional[dict]:
|
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:
|
Returns:
|
||||||
Port configuration dict or None if not found
|
Port configuration dict or None if not found
|
||||||
"""
|
"""
|
||||||
registry_file = PortDiscovery.get_registry_path()
|
candidates = PortDiscovery.list_candidate_files()
|
||||||
|
if not candidates:
|
||||||
if not registry_file.exists():
|
|
||||||
return None
|
return None
|
||||||
|
for path in candidates:
|
||||||
try:
|
try:
|
||||||
with open(registry_file, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not read port configuration: {e}")
|
logger.warning(f"Could not read port configuration {path}: {e}")
|
||||||
return None
|
return None
|
||||||
Loading…
Reference in New Issue