Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cf80937
Add support for multiple Unity instances
sakurachan Oct 31, 2025
d647d99
fix port detection
sakurachan Oct 31, 2025
b75202a
add missing unity_instance parameter
sakurachan Oct 31, 2025
83b96ac
add instance params for resources
sakurachan Oct 31, 2025
73f8206
Fix CodeRabbit review feedback
sakurachan Oct 31, 2025
13b8fb2
Fix CodeRabbit feedback: reconnection fallback and annotations safety
sakurachan Oct 31, 2025
0a8591f
Fix instance sorting and logging issues
sakurachan Oct 31, 2025
aa01fe6
update uv.lock to prepare for merging into main
sakurachan Nov 3, 2025
e6f9c4f
Restore Python 3.10 lockfiles and package list_unity_instances tool
dsarno Nov 3, 2025
5e3a035
Deduplicate Unity instance discovery by port
dsarno Nov 3, 2025
97048ae
Merge remote-tracking branch 'upstream/main' into fix/multi-instance
dsarno Nov 3, 2025
198a4ff
Scope status-file reload checks to the active instance
dsarno Nov 3, 2025
9d5b9c6
Merge pull request #1 from dsarno/fix/multi-instance
sakura-habby Nov 3, 2025
328ff49
Address CodeRabbit feedback: refactor connection pool and fix critica…
sakurachan Nov 4, 2025
e0fa11f
Fix status file timing issue and restore parameterless command support
sakurachan Nov 4, 2025
5780adb
Remove unused os import
sakurachan Nov 4, 2025
859b455
Fix hash extraction to handle hash-only instance identifiers
sakurachan Nov 4, 2025
448be09
Fix unused import and empty string edge case in hash extraction
sakurachan Nov 4, 2025
2cf6608
Fix send_command return type annotation to include MCPResponse
sakurachan Nov 4, 2025
020fe3f
Fix critical regression: restore dict return for reload preflight
sakurachan Nov 4, 2025
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
13 changes: 8 additions & 5 deletions MCPForUnity/Editor/Helpers/PortManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,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
56 changes: 56 additions & 0 deletions MCPForUnity/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,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 @@ -474,6 +489,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 @@ -1184,13 +1215,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
237 changes: 237 additions & 0 deletions MCPForUnity/UnityMcpServer~/src/connection_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
"""
Connection pool for managing multiple Unity Editor instances.
"""
import logging
import os
import threading
import time

from models import UnityInstanceInfo
from port_discovery import PortDiscovery

logger = logging.getLogger(__name__)


class UnityConnectionPool:
"""Manages connections to multiple Unity Editor instances"""

def __init__(self):
# Import here to avoid circular dependency
from unity_connection import UnityConnection
self._UnityConnection = UnityConnection

self._connections: dict[str, "UnityConnection"] = {}
self._known_instances: dict[str, UnityInstanceInfo] = {}
self._last_full_scan: float = 0
self._scan_interval: float = 5.0 # Cache for 5 seconds
self._pool_lock = threading.Lock()
self._default_instance_id: str | None = None

# Check for default instance from environment
env_default = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
if env_default:
self._default_instance_id = env_default
logger.info(f"Default Unity instance set from environment: {env_default}")

def discover_all_instances(self, force_refresh: bool = False) -> list[UnityInstanceInfo]:
"""
Discover all running Unity Editor instances.

Args:
force_refresh: If True, bypass cache and scan immediately

Returns:
List of UnityInstanceInfo objects
"""
now = time.time()

# Return cached results if valid
if not force_refresh and (now - self._last_full_scan) < self._scan_interval:
logger.debug(f"Returning cached Unity instances (age: {now - self._last_full_scan:.1f}s)")
return list(self._known_instances.values())

# Scan for instances
logger.debug("Scanning for Unity instances...")
instances = PortDiscovery.discover_all_unity_instances()

# Update cache
with self._pool_lock:
self._known_instances = {inst.id: inst for inst in instances}
self._last_full_scan = now

logger.info(f"Found {len(instances)} Unity instances: {[inst.id for inst in instances]}")
return instances

def _resolve_instance_id(self, instance_identifier: str | None, instances: list[UnityInstanceInfo]) -> UnityInstanceInfo:
"""
Resolve an instance identifier to a specific Unity instance.

Args:
instance_identifier: User-provided identifier (name, hash, name@hash, path, port, or None)
instances: List of available instances

Returns:
Resolved UnityInstanceInfo

Raises:
ConnectionError: If instance cannot be resolved
"""
if not instances:
raise ConnectionError(
"No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
)

# Use default instance if no identifier provided
if instance_identifier is None:
if self._default_instance_id:
instance_identifier = self._default_instance_id
logger.debug(f"Using default instance: {instance_identifier}")
else:
# Use the most recently active instance
# Instances with no heartbeat (None) should be sorted last (use 0.0 as sentinel)
sorted_instances = sorted(
instances,
key=lambda inst: inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0,
reverse=True,
)
logger.info(f"No instance specified, using most recent: {sorted_instances[0].id}")
return sorted_instances[0]

identifier = instance_identifier.strip()

# Try exact ID match first
for inst in instances:
if inst.id == identifier:
return inst

# Try project name match
name_matches = [inst for inst in instances if inst.name == identifier]
if len(name_matches) == 1:
return name_matches[0]
elif len(name_matches) > 1:
# Multiple projects with same name - return helpful error
suggestions = [
{
"id": inst.id,
"path": inst.path,
"port": inst.port,
"suggest": f"Use unity_instance='{inst.id}'"
}
for inst in name_matches
]
raise ConnectionError(
f"Project name '{identifier}' matches {len(name_matches)} instances. "
f"Please use the full format (e.g., '{name_matches[0].id}'). "
f"Available instances: {suggestions}"
)

# Try hash match
hash_matches = [inst for inst in instances if inst.hash == identifier or inst.hash.startswith(identifier)]
if len(hash_matches) == 1:
return hash_matches[0]
elif len(hash_matches) > 1:
raise ConnectionError(
f"Hash '{identifier}' matches multiple instances: {[inst.id for inst in hash_matches]}"
)

# Try composite format: Name@Hash or Name@Port
if "@" in identifier:
name_part, hint_part = identifier.split("@", 1)
composite_matches = [
inst for inst in instances
if inst.name == name_part and (
inst.hash.startswith(hint_part) or str(inst.port) == hint_part
)
]
if len(composite_matches) == 1:
return composite_matches[0]

# Try port match (as string)
try:
port_num = int(identifier)
port_matches = [inst for inst in instances if inst.port == port_num]
if len(port_matches) == 1:
return port_matches[0]
except ValueError:
pass

# Try path match
path_matches = [inst for inst in instances if inst.path == identifier]
if len(path_matches) == 1:
return path_matches[0]

# Nothing matched
available_ids = [inst.id for inst in instances]
raise ConnectionError(
f"Unity instance '{identifier}' not found. "
f"Available instances: {available_ids}. "
f"Use the unity_instances resource to see all instances."
)

def get_connection(self, instance_identifier: str | None = None):
"""
Get or create a connection to a Unity instance.

Args:
instance_identifier: Optional identifier (name, hash, name@hash, etc.)
If None, uses default or most recent instance

Returns:
UnityConnection to the specified instance

Raises:
ConnectionError: If instance cannot be found or connected
"""
# Refresh instance list if cache expired
instances = self.discover_all_instances()

# Resolve identifier to specific instance
target = self._resolve_instance_id(instance_identifier, instances)

# Return existing connection or create new one
with self._pool_lock:
if target.id not in self._connections:
logger.info(f"Creating new connection to Unity instance: {target.id} (port {target.port})")
conn = self._UnityConnection(port=target.port, instance_id=target.id)
if not conn.connect():
raise ConnectionError(
f"Failed to connect to Unity instance '{target.id}' on port {target.port}. "
f"Ensure the Unity Editor is running."
)
self._connections[target.id] = conn
else:
# Update existing connection with instance_id and port if changed
conn = self._connections[target.id]
conn.instance_id = target.id
if conn.port != target.port:
logger.info(f"Updating cached port for {target.id}: {conn.port} -> {target.port}")
conn.port = target.port
logger.debug(f"Reusing existing connection to: {target.id}")

return self._connections[target.id]

def disconnect_all(self):
"""Disconnect all active connections"""
with self._pool_lock:
for instance_id, conn in self._connections.items():
try:
logger.info(f"Disconnecting from Unity instance: {instance_id}")
conn.disconnect()
except Exception:
logger.exception(f"Error disconnecting from {instance_id}")
self._connections.clear()


# Global Unity connection pool
_unity_connection_pool: UnityConnectionPool | None = None
_pool_init_lock = threading.Lock()


def get_unity_connection_pool() -> UnityConnectionPool:
"""Get or create the global Unity connection pool."""
global _unity_connection_pool
if _unity_connection_pool is None:
with _pool_init_lock:
if _unity_connection_pool is None:
_unity_connection_pool = UnityConnectionPool()
return _unity_connection_pool
26 changes: 26 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,28 @@ 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]:
"""Convert to dictionary for JSON serialization"""
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