Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions MCPForUnity/Editor/Helpers/PortManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ public class PortConfig
/// Get the port to use - either from storage or discover a new one
/// Will try stored port first, then fallback to discovering new port
/// </summary>
/// <returns>Port number to use</returns>
/// <summary>
/// Selects a TCP port for the current Unity project, preferring a previously saved project-specific port when it is valid and free; if the saved port is busy the method waits briefly for release and otherwise finds and persists an alternative.
/// </summary>
/// <returns>The port number to use. Returns the stored project port if it exists and is available (or becomes available after a short wait); otherwise returns a newly discovered available port which is saved for future use.</returns>
public static int GetPortWithFallback()
{
// Try to load stored port first, but only if it's from the current project
Expand All @@ -60,14 +63,17 @@ public static int GetPortWithFallback()
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} became available after short wait");
return storedConfig.unity_port;
}
// Prefer sticking to the same port; let the caller handle bind retries/fallbacks
return storedConfig.unity_port;
// Port is still busy after waiting - find a new available port instead
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative...");
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
}

// If no valid stored port, find a new one and save it
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
int foundPort = FindAvailablePort();
SavePort(foundPort);
return foundPort;
}

/// <summary>
Expand Down Expand Up @@ -316,4 +322,4 @@ private static string ComputeProjectHash(string input)
}
}
}
}
}
81 changes: 80 additions & 1 deletion MCPForUnity/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ private static bool IsCompiling()
return false;
}

/// <summary>
/// Starts the MCPForUnity bridge: binds a local TCP listener, marks the bridge running, starts the background listener loop, registers editor update and lifecycle handlers, and emits an initial heartbeat.
/// </summary>
/// <remarks>
/// The method prefers a persisted per-project port and will attempt short retries; if the preferred port is occupied it will obtain an alternative port and continue. On success it sets bridge runtime state (including <c>isRunning</c> and <c>currentUnityPort</c>), initializes command handling, and schedules regular heartbeats. Errors encountered while binding the listener are logged.
/// </remarks>
public static void Start()
{
lock (startStopLock)
Expand Down Expand Up @@ -362,7 +368,22 @@ public static void Start()
}
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
{
// Port is occupied by another instance, get a new available port
int oldPort = currentUnityPort;
currentUnityPort = PortManager.GetPortWithFallback();

// Safety check: ensure we got a different port
if (currentUnityPort == oldPort)
{
McpLog.Error($"Port {oldPort} is occupied and no alternative port available");
throw;
}

if (IsDebugEnabled())
{
McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}");
}

listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Server.SetSocketOption(
SocketOptionLevel.Socket,
Expand Down Expand Up @@ -417,6 +438,18 @@ public static void Start()
}
}

/// <summary>
/// Stops the MCP-for-Unity bridge, tears down network listeners and client connections, and cleans up runtime state.
/// </summary>
/// <remarks>
/// This method is safe to call multiple times and performs a best-effort, non-blocking shutdown:
/// - Cancels the listener loop and stops the TCP listener.
/// - Closes active client sockets to unblock pending I/O.
/// - Waits briefly for the listener task to exit.
/// - Unsubscribes editor and assembly reload events.
/// - Attempts to delete the per-project status file under the user's profile directory.
/// Exceptions encountered during shutdown are caught and logged; callers should not rely on exceptions being thrown.
/// </remarks>
public static void Stop()
{
Task toWait = null;
Expand Down Expand Up @@ -474,6 +507,22 @@ public static void Stop()
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }

// Clean up status file when Unity stops
try
{
string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
string statusFile = Path.Combine(statusDir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
if (File.Exists(statusFile))
{
File.Delete(statusFile);
if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}");
}
}
catch (Exception ex)
{
if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}");
}

if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped.");
}

Expand Down Expand Up @@ -1172,6 +1221,11 @@ private static void OnAfterAssemblyReload()
ScheduleInitRetry();
}

/// <summary>
/// Writes a per-project status JSON file for external monitoring containing bridge state and metadata.
/// </summary>
/// <param name="reloading">If true, indicates the editor is reloading; affects the default reason value.</param>
/// <param name="reason">Optional custom reason to include in the status file. If null, defaults to "reloading" when <paramref name="reloading"/> is true, otherwise "ready".</param>
private static void WriteHeartbeat(bool reloading, string reason = null)
{
try
Expand All @@ -1184,13 +1238,38 @@ private static void WriteHeartbeat(bool reloading, string reason = null)
}
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");

// Extract project name from path
string projectName = "Unknown";
try
{
string projectPath = Application.dataPath;
if (!string.IsNullOrEmpty(projectPath))
{
// Remove trailing /Assets or \Assets
projectPath = projectPath.TrimEnd('/', '\\');
if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase))
{
projectPath = projectPath.Substring(0, projectPath.Length - 6).TrimEnd('/', '\\');
}
projectName = Path.GetFileName(projectPath);
if (string.IsNullOrEmpty(projectName))
{
projectName = "Unknown";
}
}
}
catch { }

var payload = new
{
unity_port = currentUnityPort,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
project_path = Application.dataPath,
project_name = projectName,
unity_version = Application.unityVersion,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false));
Expand Down Expand Up @@ -1237,4 +1316,4 @@ private static string ComputeProjectHash(string input)
}
}
}
}
}
34 changes: 34 additions & 0 deletions MCPForUnity/UnityMcpServer~/src/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any
from datetime import datetime
from pydantic import BaseModel


Expand All @@ -7,3 +8,36 @@ class MCPResponse(BaseModel):
message: str | None = None
error: str | None = None
data: Any | None = None


class UnityInstanceInfo(BaseModel):
"""Information about a Unity Editor instance"""
id: str # "ProjectName@hash" or fallback to hash
name: str # Project name extracted from path
path: str # Full project path (Assets folder)
hash: str # 8-char hash of project path
port: int # TCP port
status: str # "running", "reloading", "offline"
last_heartbeat: datetime | None = None
unity_version: str | None = None

def to_dict(self) -> dict[str, Any]:
"""
Serialize the UnityInstanceInfo to a JSON-serializable dictionary.

last_heartbeat is converted to an ISO 8601 string when present; otherwise it is None.

Returns:
dict[str, Any]: Dictionary with keys "id", "name", "path", "hash", "port", "status",
"last_heartbeat", and "unity_version" containing the corresponding field values.
"""
return {
"id": self.id,
"name": self.name,
"path": self.path,
"hash": self.hash,
"port": self.port,
"status": self.status,
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
"unity_version": self.unity_version
}
Loading