Skip to content

Commit d647d99

Browse files
committed
fix port detection
1 parent cf80937 commit d647d99

19 files changed

+575
-106
lines changed

MCPForUnity/Editor/Helpers/PortManager.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,17 @@ public static int GetPortWithFallback()
6060
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} became available after short wait");
6161
return storedConfig.unity_port;
6262
}
63-
// Prefer sticking to the same port; let the caller handle bind retries/fallbacks
64-
return storedConfig.unity_port;
63+
// Port is still busy after waiting - find a new available port instead
64+
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative...");
65+
int newPort = FindAvailablePort();
66+
SavePort(newPort);
67+
return newPort;
6568
}
6669

6770
// If no valid stored port, find a new one and save it
68-
int newPort = FindAvailablePort();
69-
SavePort(newPort);
70-
return newPort;
71+
int foundPort = FindAvailablePort();
72+
SavePort(foundPort);
73+
return foundPort;
7174
}
7275

7376
/// <summary>

MCPForUnity/Editor/MCPForUnityBridge.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,22 @@ public static void Start()
362362
}
363363
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
364364
{
365+
// Port is occupied by another instance, get a new available port
366+
int oldPort = currentUnityPort;
365367
currentUnityPort = PortManager.GetPortWithFallback();
368+
369+
// Safety check: ensure we got a different port
370+
if (currentUnityPort == oldPort)
371+
{
372+
McpLog.Error($"Port {oldPort} is occupied and no alternative port available");
373+
throw;
374+
}
375+
376+
if (IsDebugEnabled())
377+
{
378+
McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}");
379+
}
380+
366381
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
367382
listener.Server.SetSocketOption(
368383
SocketOptionLevel.Socket,

MCPForUnity/UnityMcpServer~/src/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Any
2+
from datetime import datetime
23
from pydantic import BaseModel
34

45

@@ -7,3 +8,28 @@ class MCPResponse(BaseModel):
78
message: str | None = None
89
error: str | None = None
910
data: Any | None = None
11+
12+
13+
class UnityInstanceInfo(BaseModel):
14+
"""Information about a Unity Editor instance"""
15+
id: str # "ProjectName@hash" or fallback to hash
16+
name: str # Project name extracted from path
17+
path: str # Full project path (Assets folder)
18+
hash: str # 8-char hash of project path
19+
port: int # TCP port
20+
status: str # "running", "reloading", "offline"
21+
last_heartbeat: datetime | None = None
22+
unity_version: str | None = None
23+
24+
def to_dict(self) -> dict[str, Any]:
25+
"""Convert to dictionary for JSON serialization"""
26+
return {
27+
"id": self.id,
28+
"name": self.name,
29+
"path": self.path,
30+
"hash": self.hash,
31+
"port": self.port,
32+
"status": self.status,
33+
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
34+
"unity_version": self.unity_version
35+
}

MCPForUnity/UnityMcpServer~/src/port_discovery.py

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
import glob
1515
import json
1616
import logging
17+
import os
18+
import struct
19+
from datetime import datetime
1720
from pathlib import Path
1821
import socket
1922
from typing import Optional, List
2023

24+
from models import UnityInstanceInfo
25+
2126
logger = logging.getLogger("mcp-for-unity-server")
2227

2328

@@ -56,22 +61,43 @@ def list_candidate_files() -> List[Path]:
5661
@staticmethod
5762
def _try_probe_unity_mcp(port: int) -> bool:
5863
"""Quickly check if a MCP for Unity listener is on this port.
59-
Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message.
64+
Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed pong.
6065
"""
6166
try:
6267
with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s:
6368
s.settimeout(PortDiscovery.CONNECT_TIMEOUT)
6469
try:
65-
s.sendall(b"ping")
66-
data = s.recv(512)
67-
# Check for Unity bridge welcome message format
68-
if data and (b"WELCOME UNITY-MCP" in data or b'"message":"pong"' in data):
69-
return True
70-
except Exception:
70+
# 1. Receive handshake from Unity
71+
handshake = s.recv(512)
72+
if not handshake or b"FRAMING=1" not in handshake:
73+
# Try legacy mode as fallback
74+
s.sendall(b"ping")
75+
data = s.recv(512)
76+
return data and b'"message":"pong"' in data
77+
78+
# 2. Send framed ping command
79+
# Frame format: 8-byte length header (big-endian uint64) + payload
80+
payload = b"ping"
81+
header = struct.pack('>Q', len(payload))
82+
s.sendall(header + payload)
83+
84+
# 3. Receive framed response
85+
response_header = s.recv(8)
86+
if len(response_header) != 8:
87+
return False
88+
89+
response_length = struct.unpack('>Q', response_header)[0]
90+
if response_length > 10000: # Sanity check
91+
return False
92+
93+
response = s.recv(response_length)
94+
return b'"message":"pong"' in response
95+
except Exception as e:
96+
logger.debug(f"Port probe failed for {port}: {e}")
7197
return False
72-
except Exception:
98+
except Exception as e:
99+
logger.debug(f"Connection failed for port {port}: {e}")
73100
return False
74-
return False
75101

76102
@staticmethod
77103
def _read_latest_status() -> Optional[dict]:
@@ -158,3 +184,94 @@ def get_port_config() -> Optional[dict]:
158184
logger.warning(
159185
f"Could not read port configuration {path}: {e}")
160186
return None
187+
188+
@staticmethod
189+
def _extract_project_name(project_path: str) -> str:
190+
"""Extract project name from Assets path.
191+
192+
Examples:
193+
/Users/sakura/Projects/MyGame/Assets -> MyGame
194+
C:\\Projects\\TestProject\\Assets -> TestProject
195+
"""
196+
if not project_path:
197+
return "Unknown"
198+
199+
try:
200+
# Remove trailing /Assets or \Assets
201+
path = project_path.rstrip('/\\')
202+
if path.endswith('Assets'):
203+
path = path[:-6].rstrip('/\\')
204+
205+
# Get the last directory name
206+
name = os.path.basename(path)
207+
return name if name else "Unknown"
208+
except Exception:
209+
return "Unknown"
210+
211+
@staticmethod
212+
def discover_all_unity_instances() -> List[UnityInstanceInfo]:
213+
"""
214+
Discover all running Unity Editor instances by scanning status files.
215+
216+
Returns:
217+
List of UnityInstanceInfo objects for all discovered instances
218+
"""
219+
instances = []
220+
base = PortDiscovery.get_registry_dir()
221+
222+
# Scan all status files
223+
status_pattern = str(base / "unity-mcp-status-*.json")
224+
status_files = glob.glob(status_pattern)
225+
226+
for status_file_path in status_files:
227+
try:
228+
with open(status_file_path, 'r') as f:
229+
data = json.load(f)
230+
231+
# Extract hash from filename: unity-mcp-status-{hash}.json
232+
filename = os.path.basename(status_file_path)
233+
hash_value = filename.replace('unity-mcp-status-', '').replace('.json', '')
234+
235+
# Extract information
236+
project_path = data.get('project_path', '')
237+
project_name = PortDiscovery._extract_project_name(project_path)
238+
port = data.get('unity_port')
239+
is_reloading = data.get('reloading', False)
240+
241+
# Parse last_heartbeat
242+
last_heartbeat = None
243+
heartbeat_str = data.get('last_heartbeat')
244+
if heartbeat_str:
245+
try:
246+
last_heartbeat = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00'))
247+
except Exception:
248+
pass
249+
250+
# Verify port is actually responding
251+
is_alive = PortDiscovery._try_probe_unity_mcp(port) if isinstance(port, int) else False
252+
253+
if not is_alive:
254+
logger.debug(f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding")
255+
continue
256+
257+
# Create instance info
258+
instance = UnityInstanceInfo(
259+
id=f"{project_name}@{hash_value}",
260+
name=project_name,
261+
path=project_path,
262+
hash=hash_value,
263+
port=port,
264+
status="reloading" if is_reloading else "running",
265+
last_heartbeat=last_heartbeat,
266+
unity_version=data.get('unity_version') # May not be available in current version
267+
)
268+
269+
instances.append(instance)
270+
logger.debug(f"Discovered Unity instance: {instance.id} on port {instance.port}")
271+
272+
except Exception as e:
273+
logger.debug(f"Failed to parse status file {status_file_path}: {e}")
274+
continue
275+
276+
logger.info(f"Discovered {len(instances)} Unity instances")
277+
return instances

MCPForUnity/UnityMcpServer~/src/server.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from config import config
99
from tools import register_all_tools
1010
from resources import register_all_resources
11-
from unity_connection import get_unity_connection, UnityConnection
11+
from unity_connection import get_unity_connection_pool, UnityConnectionPool
1212
import time
1313

1414
# Configure logging using settings from config
@@ -61,14 +61,14 @@
6161
except Exception:
6262
pass
6363

64-
# Global connection state
65-
_unity_connection: UnityConnection = None
64+
# Global connection pool
65+
_unity_connection_pool: UnityConnectionPool = None
6666

6767

6868
@asynccontextmanager
6969
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
7070
"""Handle server startup and shutdown."""
71-
global _unity_connection
71+
global _unity_connection_pool
7272
logger.info("MCP for Unity Server starting up")
7373

7474
# Record server startup telemetry
@@ -101,22 +101,35 @@ def _emit_startup():
101101
logger.info(
102102
"Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
103103
else:
104-
_unity_connection = get_unity_connection()
105-
logger.info("Connected to Unity on startup")
106-
107-
# Record successful Unity connection (deferred)
108-
import threading as _t
109-
_t.Timer(1.0, lambda: record_telemetry(
110-
RecordType.UNITY_CONNECTION,
111-
{
112-
"status": "connected",
113-
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
114-
}
115-
)).start()
104+
# Initialize connection pool and discover instances
105+
_unity_connection_pool = get_unity_connection_pool()
106+
instances = _unity_connection_pool.discover_all_instances()
107+
108+
if instances:
109+
logger.info(f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
110+
111+
# Try to connect to default instance
112+
try:
113+
default_conn = _unity_connection_pool.get_connection()
114+
logger.info("Connected to default Unity instance on startup")
115+
116+
# Record successful Unity connection (deferred)
117+
import threading as _t
118+
_t.Timer(1.0, lambda: record_telemetry(
119+
RecordType.UNITY_CONNECTION,
120+
{
121+
"status": "connected",
122+
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
123+
"instance_count": len(instances)
124+
}
125+
)).start()
126+
except Exception as e:
127+
logger.warning("Could not connect to default Unity instance: %s", e)
128+
else:
129+
logger.warning("No Unity instances found on startup")
116130

117131
except ConnectionError as e:
118132
logger.warning("Could not connect to Unity on startup: %s", e)
119-
_unity_connection = None
120133

121134
# Record connection failure (deferred)
122135
import threading as _t
@@ -132,7 +145,6 @@ def _emit_startup():
132145
except Exception as e:
133146
logger.warning(
134147
"Unexpected error connecting to Unity on startup: %s", e)
135-
_unity_connection = None
136148
import threading as _t
137149
_err_msg = str(e)[:200]
138150
_t.Timer(1.0, lambda: record_telemetry(
@@ -145,13 +157,12 @@ def _emit_startup():
145157
)).start()
146158

147159
try:
148-
# Yield the connection object so it can be attached to the context
149-
# The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)
150-
yield {"bridge": _unity_connection}
160+
# Yield the connection pool so it can be attached to the context
161+
# Note: Tools will use get_unity_connection_pool() directly
162+
yield {"pool": _unity_connection_pool}
151163
finally:
152-
if _unity_connection:
153-
_unity_connection.disconnect()
154-
_unity_connection = None
164+
if _unity_connection_pool:
165+
_unity_connection_pool.disconnect_all()
155166
logger.info("MCP for Unity Server shut down")
156167

157168
# Initialize MCP server

MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ async def execute_menu_item(
1717
ctx: Context,
1818
menu_path: Annotated[str,
1919
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
20+
unity_instance: Annotated[str,
21+
"Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
2022
) -> MCPResponse:
2123
await ctx.info(f"Processing execute_menu_item: {menu_path}")
2224
params_dict: dict[str, Any] = {"menuPath": menu_path}
2325
params_dict = {k: v for k, v in params_dict.items() if v is not None}
24-
result = await async_send_command_with_retry("execute_menu_item", params_dict)
26+
result = await async_send_command_with_retry("execute_menu_item", params_dict, instance_id=unity_instance)
2527
return MCPResponse(**result) if isinstance(result, dict) else result

MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ async def manage_asset(
3131
filter_date_after: Annotated[str,
3232
"Date after which to filter"] | None = None,
3333
page_size: Annotated[int | float | str, "Page size for pagination"] | None = None,
34-
page_number: Annotated[int | float | str, "Page number for pagination"] | None = None
34+
page_number: Annotated[int | float | str, "Page number for pagination"] | None = None,
35+
unity_instance: Annotated[str,
36+
"Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None
3537
) -> dict[str, Any]:
3638
ctx.info(f"Processing manage_asset: {action}")
3739
# Coerce 'properties' from JSON string to dict for client compatibility
@@ -86,7 +88,7 @@ def _coerce_int(value, default=None):
8688
# Get the current asyncio event loop
8789
loop = asyncio.get_running_loop()
8890

89-
# Use centralized async retry helper to avoid blocking the event loop
90-
result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
91+
# Use centralized async retry helper with instance routing
92+
result = await async_send_command_with_retry("manage_asset", params_dict, instance_id=unity_instance, loop=loop)
9193
# Return the result obtained from Unity
9294
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

0 commit comments

Comments
 (0)