Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
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
}
147 changes: 138 additions & 9 deletions MCPForUnity/UnityMcpServer~/src/port_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
import glob
import json
import logging
import os
import struct
from datetime import datetime
from pathlib import Path
import socket
from typing import Optional, List

from models import UnityInstanceInfo

logger = logging.getLogger("mcp-for-unity-server")


Expand Down Expand Up @@ -56,22 +61,55 @@ def list_candidate_files() -> List[Path]:
@staticmethod
def _try_probe_unity_mcp(port: int) -> bool:
"""Quickly check if a MCP for Unity listener is on this port.
Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message.
Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed 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)
# Check for Unity bridge welcome message format
if data and (b"WELCOME UNITY-MCP" in data or b'"message":"pong"' in data):
return True
except Exception:
# 1. Receive handshake from Unity
handshake = s.recv(512)
if not handshake or b"FRAMING=1" not in handshake:
# Try legacy mode as fallback
s.sendall(b"ping")
data = s.recv(512)
return data and b'"message":"pong"' in data

# 2. Send framed ping command
# Frame format: 8-byte length header (big-endian uint64) + payload
payload = b"ping"
header = struct.pack('>Q', len(payload))
s.sendall(header + payload)

# 3. Receive framed response
# Helper to receive exact number of bytes
def _recv_exact(expected: int) -> bytes | None:
chunks = bytearray()
while len(chunks) < expected:
chunk = s.recv(expected - len(chunks))
if not chunk:
return None
chunks.extend(chunk)
return bytes(chunks)

response_header = _recv_exact(8)
if response_header is None:
return False

response_length = struct.unpack('>Q', response_header)[0]
if response_length > 10000: # Sanity check
return False

response = _recv_exact(response_length)
if response is None:
return False
return b'"message":"pong"' in response
except Exception as e:
logger.debug(f"Port probe failed for {port}: {e}")
return False
except Exception:
except Exception as e:
logger.debug(f"Connection failed for port {port}: {e}")
return False
return False

@staticmethod
def _read_latest_status() -> Optional[dict]:
Expand Down Expand Up @@ -158,3 +196,94 @@ def get_port_config() -> Optional[dict]:
logger.warning(
f"Could not read port configuration {path}: {e}")
return None

@staticmethod
def _extract_project_name(project_path: str) -> str:
"""Extract project name from Assets path.

Examples:
/Users/sakura/Projects/MyGame/Assets -> MyGame
C:\\Projects\\TestProject\\Assets -> TestProject
"""
if not project_path:
return "Unknown"

try:
# Remove trailing /Assets or \Assets
path = project_path.rstrip('/\\')
if path.endswith('Assets'):
path = path[:-6].rstrip('/\\')

# Get the last directory name
name = os.path.basename(path)
return name if name else "Unknown"
except Exception:
return "Unknown"

@staticmethod
def discover_all_unity_instances() -> List[UnityInstanceInfo]:
"""
Discover all running Unity Editor instances by scanning status files.

Returns:
List of UnityInstanceInfo objects for all discovered instances
"""
instances = []
base = PortDiscovery.get_registry_dir()

# Scan all status files
status_pattern = str(base / "unity-mcp-status-*.json")
status_files = glob.glob(status_pattern)

for status_file_path in status_files:
try:
with open(status_file_path, 'r') as f:
data = json.load(f)

# Extract hash from filename: unity-mcp-status-{hash}.json
filename = os.path.basename(status_file_path)
hash_value = filename.replace('unity-mcp-status-', '').replace('.json', '')

# Extract information
project_path = data.get('project_path', '')
project_name = PortDiscovery._extract_project_name(project_path)
port = data.get('unity_port')
is_reloading = data.get('reloading', False)

# Parse last_heartbeat
last_heartbeat = None
heartbeat_str = data.get('last_heartbeat')
if heartbeat_str:
try:
last_heartbeat = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00'))
except Exception:
pass

# Verify port is actually responding
is_alive = PortDiscovery._try_probe_unity_mcp(port) if isinstance(port, int) else False

if not is_alive:
logger.debug(f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding")
continue

# Create instance info
instance = UnityInstanceInfo(
id=f"{project_name}@{hash_value}",
name=project_name,
path=project_path,
hash=hash_value,
port=port,
status="reloading" if is_reloading else "running",
last_heartbeat=last_heartbeat,
unity_version=data.get('unity_version') # May not be available in current version
)

instances.append(instance)
logger.debug(f"Discovered Unity instance: {instance.id} on port {instance.port}")

except Exception as e:
logger.debug(f"Failed to parse status file {status_file_path}: {e}")
continue

logger.info(f"Discovered {len(instances)} Unity instances")
return instances
2 changes: 1 addition & 1 deletion MCPForUnity/UnityMcpServer~/src/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27.2",
"fastmcp>=2.12.5",
"fastmcp>=2.13.0",
"mcp>=1.16.0",
"pydantic>=2.12.0",
"tomli>=2.3.0",
Expand Down
Loading