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.

main
David Sarno 2025-08-07 15:32:03 -07:00
parent eabf727894
commit 32274a3965
31 changed files with 623 additions and 124 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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";
}
}
} }
} }

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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}");
}
} }
} }

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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