diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs
index 09d85798..802276c0 100644
--- a/MCPForUnity/Editor/Helpers/PortManager.cs
+++ b/MCPForUnity/Editor/Helpers/PortManager.cs
@@ -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
///
- /// Port number to use
+ ///
+ /// 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.
+ ///
+ /// 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.
public static int GetPortWithFallback()
{
// Try to load stored port first, but only if it's from the current project
@@ -60,14 +63,17 @@ public static int GetPortWithFallback()
if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: 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($"MCP-FOR-UNITY: 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;
}
///
@@ -316,4 +322,4 @@ private static string ComputeProjectHash(string input)
}
}
}
-}
+}
\ No newline at end of file
diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/MCPForUnityBridge.cs
index 5fb9f694..af9755c1 100644
--- a/MCPForUnity/Editor/MCPForUnityBridge.cs
+++ b/MCPForUnity/Editor/MCPForUnityBridge.cs
@@ -297,6 +297,12 @@ private static bool IsCompiling()
return false;
}
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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 isRunning and currentUnityPort), initializes command handling, and schedules regular heartbeats. Errors encountered while binding the listener are logged.
+ ///
public static void Start()
{
lock (startStopLock)
@@ -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,
@@ -417,6 +438,18 @@ public static void Start()
}
}
+ ///
+ /// Stops the MCP-for-Unity bridge, tears down network listeners and client connections, and cleans up runtime state.
+ ///
+ ///
+ /// 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.
+ ///
public static void Stop()
{
Task toWait = null;
@@ -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.");
}
@@ -1172,6 +1221,11 @@ private static void OnAfterAssemblyReload()
ScheduleInitRetry();
}
+ ///
+ /// Writes a per-project status JSON file for external monitoring containing bridge state and metadata.
+ ///
+ /// If true, indicates the editor is reloading; affects the default reason value.
+ /// Optional custom reason to include in the status file. If null, defaults to "reloading" when is true, otherwise "ready".
private static void WriteHeartbeat(bool reloading, string reason = null)
{
try
@@ -1184,6 +1238,29 @@ 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,
@@ -1191,6 +1268,8 @@ private static void WriteHeartbeat(bool reloading, string reason = null)
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));
@@ -1237,4 +1316,4 @@ private static string ComputeProjectHash(string input)
}
}
}
-}
+}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/models.py b/MCPForUnity/UnityMcpServer~/src/models.py
index cf1d33da..fc101be4 100644
--- a/MCPForUnity/UnityMcpServer~/src/models.py
+++ b/MCPForUnity/UnityMcpServer~/src/models.py
@@ -1,4 +1,5 @@
from typing import Any
+from datetime import datetime
from pydantic import BaseModel
@@ -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
+ }
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/port_discovery.py b/MCPForUnity/UnityMcpServer~/src/port_discovery.py
index c759e745..bc77bed4 100644
--- a/MCPForUnity/UnityMcpServer~/src/port_discovery.py
+++ b/MCPForUnity/UnityMcpServer~/src/port_discovery.py
@@ -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")
@@ -55,26 +60,80 @@ 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.
+ """
+ Probe a localhost port to detect whether an MCP for Unity listener is active.
+
+ Attempts the modern framed Unity protocol (handshake, framed ping with an 8-byte big-endian length header, framed pong response) and falls back to a legacy plain ping/pong check when framing is not negotiated.
+
+ Returns:
+ `true` if a responsive MCP for Unity listener that returns a pong is detected on the port, `false` otherwise.
"""
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:
+ """
+ Read exactly `expected` bytes from the active socket and return them.
+
+ Parameters:
+ expected (int): Number of bytes to read.
+
+ Returns:
+ bytes: A bytes object containing exactly `expected` bytes if read successfully.
+ None: If the connection is closed before `expected` bytes are received.
+ """
+ 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]:
+ """
+ Load the most recent Unity MCP status file from the registry directory.
+
+ Returns:
+ dict or None: Parsed JSON object from the newest status file, or `None` if no status file exists or it cannot be read.
+ """
try:
base = PortDiscovery.get_registry_dir()
status_files = sorted(
@@ -140,12 +199,12 @@ def discover_unity_port() -> int:
@staticmethod
def get_port_config() -> Optional[dict]:
"""
- 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.
-
+ Retrieve the most relevant port configuration from the registry.
+
+ Prefers the newest per-project hashed registry file; falls back to the legacy registry file if present. Files are tried in newest-first order until a readable JSON file is found.
+
Returns:
- Port configuration dict or None if not found
+ Port configuration dict from the most relevant registry file, or `None` if no readable config is found.
"""
candidates = PortDiscovery.list_candidate_files()
if not candidates:
@@ -158,3 +217,100 @@ 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 the project folder name from a Unity Assets path.
+
+ Returns:
+ project_name (str): The extracted project directory name, or "Unknown" if the name cannot be determined.
+
+ 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 running Unity Editor instances by scanning per-project status files in the registry directory.
+
+ Reads all `unity-mcp-status-*.json` files under the registry directory, parses instance metadata (project path, port, reloading flag, last heartbeat, unity version), verifies the Unity MCP listener is responding on the recorded port, and returns a list of corresponding UnityInstanceInfo objects. Status files that are unreadable, malformed, or reference non-responsive ports are skipped.
+
+ Returns:
+ List[UnityInstanceInfo]: UnityInstanceInfo objects for each discovered and responsive Unity Editor instance.
+ """
+ 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
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
index a3577891..eaf0aa5a 100644
--- a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
+++ b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
@@ -2,6 +2,7 @@
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import logging
+import inspect
from pathlib import Path
from fastmcp import FastMCP
@@ -16,12 +17,75 @@
__all__ = ['register_all_resources']
-def register_all_resources(mcp: FastMCP):
+def _create_fixed_wrapper(original_func, has_other_params):
+ """
+ Create a wrapper that calls `original_func` with `unity_instance=None`, preserving whether the wrapper is synchronous or asynchronous.
+
+ Parameters:
+ original_func (callable): The function to wrap.
+ has_other_params (bool): If True, the wrapper will accept and forward positional and keyword arguments before injecting `unity_instance=None`; if False, the wrapper will take no arguments.
+
+ Returns:
+ callable: A wrapper function with the same sync/async nature as `original_func` that calls it with `unity_instance=None`.
"""
- Auto-discover and register all resources in the resources/ directory.
+ is_async = inspect.iscoroutinefunction(original_func)
+
+ if has_other_params:
+ if is_async:
+ async def fixed_wrapper(*args, **kwargs):
+ """
+ Call the original function with the provided positional and keyword arguments and with `unity_instance` set to None.
+
+ Returns:
+ The value returned by the wrapped `original_func`.
+ """
+ return await original_func(*args, **kwargs, unity_instance=None)
+ else:
+ def fixed_wrapper(*args, **kwargs):
+ """
+ Wraps and calls the original resource function with unity_instance set to None while forwarding all positional and keyword arguments.
+
+ Parameters:
+ *args: Positional arguments forwarded to the original function.
+ **kwargs: Keyword arguments forwarded to the original function.
+
+ Returns:
+ The value returned by the original function.
+ """
+ return original_func(*args, **kwargs, unity_instance=None)
+ else:
+ if is_async:
+ async def fixed_wrapper():
+ """
+ Invoke the original resource function with `unity_instance` set to None.
+
+ Calls the wrapped resource function using the default Unity instance (None) and returns its result.
+
+ Returns:
+ The value produced by the original function.
+ """
+ return await original_func(unity_instance=None)
+ else:
+ def fixed_wrapper():
+ """
+ Calls the original resource function with unity_instance set to None.
+
+ Returns:
+ The value returned by the wrapped original function.
+ """
+ return original_func(unity_instance=None)
+
+ return fixed_wrapper
- Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
- functions will be automatically registered.
+
+def register_all_resources(mcp: FastMCP):
+ """
+ Auto-discover and register MCP resources located in the same package directory.
+
+ Discovers and imports Python modules under this package, collects functions previously registered by the @mcp_for_unity_resource decorator, and registers them with the provided FastMCP instance. When a resource URI contains query parameters, registers both a template variant (with the query parameters) and a fixed default variant (URI without query parameters) whose wrapper invokes the original resource with unity_instance=None.
+
+ Parameters:
+ mcp (FastMCP): MCP server instance used to register discovered resources.
"""
logger.info("Auto-discovering MCP for Unity Server resources...")
# Dynamic import of all modules in this directory
@@ -36,6 +100,7 @@ def register_all_resources(mcp: FastMCP):
logger.warning("No MCP resources registered!")
return
+ registered_count = 0
for resource_info in resources:
func = resource_info['func']
uri = resource_info['uri']
@@ -43,11 +108,68 @@ def register_all_resources(mcp: FastMCP):
description = resource_info['description']
kwargs = resource_info['kwargs']
- # Apply the @mcp.resource decorator and telemetry
- wrapped = telemetry_resource(resource_name)(func)
- wrapped = mcp.resource(uri=uri, name=resource_name,
- description=description, **kwargs)(wrapped)
- resource_info['func'] = wrapped
- logger.debug(f"Registered resource: {resource_name} - {description}")
+ # Check if URI contains query parameters (e.g., {?unity_instance})
+ has_query_params = '{?' in uri
+
+ if has_query_params:
+ # Register two versions for backward compatibility:
+ # 1. Template version with query parameters (for multi-instance)
+ wrapped_template = telemetry_resource(resource_name)(func)
+ wrapped_template = mcp.resource(uri=uri, name=resource_name,
+ description=description, **kwargs)(wrapped_template)
+ logger.debug(f"Registered resource template: {resource_name} - {uri}")
+ registered_count += 1
+
+ # 2. Fixed version without query parameters (for single-instance/default)
+ # Remove query parameters from URI
+ fixed_uri = uri.split('{?')[0]
+ fixed_name = f"{resource_name}_default"
+ fixed_description = f"{description} (default instance)"
+
+ # Create a wrapper function that doesn't accept unity_instance parameter
+ # This wrapper will call the original function with unity_instance=None
+ sig = inspect.signature(func)
+ params = list(sig.parameters.values())
+
+ # Filter out unity_instance parameter
+ fixed_params = [p for p in params if p.name != 'unity_instance']
+
+ # Create wrapper using factory function to avoid closure issues
+ has_other_params = len(fixed_params) > 0
+ fixed_wrapper = _create_fixed_wrapper(func, has_other_params)
+
+ # Update signature to match filtered parameters
+ if has_other_params:
+ fixed_wrapper.__signature__ = sig.replace(parameters=fixed_params)
+ fixed_wrapper.__annotations__ = {
+ k: v for k, v in getattr(func, '__annotations__', {}).items()
+ if k != 'unity_instance'
+ }
+ else:
+ fixed_wrapper.__signature__ = inspect.Signature(parameters=[])
+ fixed_wrapper.__annotations__ = {
+ k: v for k, v in getattr(func, '__annotations__', {}).items()
+ if k == 'return'
+ }
+
+ # Preserve function metadata
+ fixed_wrapper.__name__ = fixed_name
+ fixed_wrapper.__doc__ = func.__doc__
+
+ wrapped_fixed = telemetry_resource(fixed_name)(fixed_wrapper)
+ wrapped_fixed = mcp.resource(uri=fixed_uri, name=fixed_name,
+ description=fixed_description, **kwargs)(wrapped_fixed)
+ logger.debug(f"Registered resource (fixed): {fixed_name} - {fixed_uri}")
+ registered_count += 1
+
+ resource_info['func'] = wrapped_template
+ else:
+ # No query parameters, register as-is
+ wrapped = telemetry_resource(resource_name)(func)
+ wrapped = mcp.resource(uri=uri, name=resource_name,
+ description=description, **kwargs)(wrapped)
+ resource_info['func'] = wrapped
+ logger.debug(f"Registered resource: {resource_name} - {description}")
+ registered_count += 1
- logger.info(f"Registered {len(resources)} MCP resources")
+ logger.info(f"Registered {registered_count} MCP resources ({len(resources)} unique)")
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py
index d3724659..f8f15c3f 100644
--- a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py
+++ b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py
@@ -8,18 +8,24 @@ class GetMenuItemsResponse(MCPResponse):
@mcp_for_unity_resource(
- uri="mcpforunity://menu-items",
+ uri="mcpforunity://menu-items{?unity_instance}",
name="get_menu_items",
description="Provides a list of all menu items."
)
-async def get_menu_items() -> GetMenuItemsResponse:
- """Provides a list of all menu items."""
- # Later versions of FastMCP support these as query parameters
- # See: https://gofastmcp.com/servers/resources#query-parameters
+async def get_menu_items(unity_instance: str | None = None) -> GetMenuItemsResponse:
+ """
+ Retrieve a list of all menu items.
+
+ Args:
+ unity_instance (str | None): Optional Unity instance identifier (project name, hash, or 'Name@hash'). If omitted, the default instance is used.
+
+ Returns:
+ GetMenuItemsResponse or other: A GetMenuItemsResponse containing a `data` list of menu item strings when the backend returns a dict; otherwise the raw backend response.
+ """
params = {
"refresh": True,
"search": "",
}
- response = await async_send_command_with_retry("get_menu_items", params)
- return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
+ response = await async_send_command_with_retry("get_menu_items", params, instance_id=unity_instance)
+ return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tests.py b/MCPForUnity/UnityMcpServer~/src/resources/tests.py
index 4268a143..3e190ba9 100644
--- a/MCPForUnity/UnityMcpServer~/src/resources/tests.py
+++ b/MCPForUnity/UnityMcpServer~/src/resources/tests.py
@@ -17,15 +17,35 @@ class GetTestsResponse(MCPResponse):
data: list[TestItem] = []
-@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.")
-async def get_tests() -> GetTestsResponse:
- """Provides a list of all tests."""
- response = await async_send_command_with_retry("get_tests", {})
+@mcp_for_unity_resource(uri="mcpforunity://tests{?unity_instance}", name="get_tests", description="Provides a list of all tests.")
+async def get_tests(unity_instance: str | None = None) -> GetTestsResponse:
+ """
+ Provides a list of all tests.
+
+ Parameters:
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ GetTestsResponse: Response containing a list of tests in the `data` field.
+ """
+ response = await async_send_command_with_retry("get_tests", {}, instance_id=unity_instance)
return GetTestsResponse(**response) if isinstance(response, dict) else response
-@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.")
-async def get_tests_for_mode(mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")]) -> GetTestsResponse:
- """Provides a list of tests for a specific mode."""
- response = await async_send_command_with_retry("get_tests_for_mode", {"mode": mode})
- return GetTestsResponse(**response) if isinstance(response, dict) else response
+@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}{?unity_instance}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.")
+async def get_tests_for_mode(
+ mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
+ unity_instance: str | None = None
+) -> GetTestsResponse:
+ """
+ Provides a list of tests for the specified mode.
+
+ Parameters:
+ mode: The mode to filter tests by; either "EditMode" or "PlayMode".
+ unity_instance: Optional target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ GetTestsResponse: Response containing a list of tests matching the requested mode.
+ """
+ response = await async_send_command_with_retry("get_tests_for_mode", {"mode": mode}, instance_id=unity_instance)
+ return GetTestsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py
index 11053ac8..40580d84 100644
--- a/MCPForUnity/UnityMcpServer~/src/server.py
+++ b/MCPForUnity/UnityMcpServer~/src/server.py
@@ -3,12 +3,13 @@
import logging
from logging.handlers import RotatingFileHandler
import os
+import argparse
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any
from config import config
from tools import register_all_tools
from resources import register_all_resources
-from unity_connection import get_unity_connection, UnityConnection
+from unity_connection import get_unity_connection_pool, UnityConnectionPool
import time
# Configure logging using settings from config
@@ -61,14 +62,21 @@
except Exception:
pass
-# Global connection state
-_unity_connection: UnityConnection = None
+# Global connection pool
+_unity_connection_pool: UnityConnectionPool = None
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
- """Handle server startup and shutdown."""
- global _unity_connection
+ """
+ Manage server startup and shutdown, initialize the Unity connection pool, and expose it to MCP tools.
+
+ This async context manager records startup telemetry, optionally discovers Unity Editor instances and attempts an initial connection (unless skipped via environment), and on exit disconnects all Unity connections and records shutdown. The yielded context provides access to the initialized UnityConnectionPool under the key "pool" (may be None if not initialized).
+
+ Returns:
+ context (Dict[str, Any]): A mapping containing the key "pool" with the initialized UnityConnectionPool instance or `None`.
+ """
+ global _unity_connection_pool
logger.info("MCP for Unity Server starting up")
# Record server startup telemetry
@@ -101,22 +109,35 @@ def _emit_startup():
logger.info(
"Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
else:
- _unity_connection = get_unity_connection()
- logger.info("Connected to Unity on startup")
-
- # Record successful Unity connection (deferred)
- import threading as _t
- _t.Timer(1.0, lambda: record_telemetry(
- RecordType.UNITY_CONNECTION,
- {
- "status": "connected",
- "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
- }
- )).start()
+ # Initialize connection pool and discover instances
+ _unity_connection_pool = get_unity_connection_pool()
+ instances = _unity_connection_pool.discover_all_instances()
+
+ if instances:
+ logger.info(f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
+
+ # Try to connect to default instance
+ try:
+ _unity_connection_pool.get_connection()
+ logger.info("Connected to default Unity instance on startup")
+
+ # Record successful Unity connection (deferred)
+ import threading as _t
+ _t.Timer(1.0, lambda: record_telemetry(
+ RecordType.UNITY_CONNECTION,
+ {
+ "status": "connected",
+ "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
+ "instance_count": len(instances)
+ }
+ )).start()
+ except Exception as e:
+ logger.warning("Could not connect to default Unity instance: %s", e)
+ else:
+ logger.warning("No Unity instances found on startup")
except ConnectionError as e:
logger.warning("Could not connect to Unity on startup: %s", e)
- _unity_connection = None
# Record connection failure (deferred)
import threading as _t
@@ -132,7 +153,6 @@ def _emit_startup():
except Exception as e:
logger.warning(
"Unexpected error connecting to Unity on startup: %s", e)
- _unity_connection = None
import threading as _t
_err_msg = str(e)[:200]
_t.Timer(1.0, lambda: record_telemetry(
@@ -145,13 +165,12 @@ def _emit_startup():
)).start()
try:
- # Yield the connection object so it can be attached to the context
- # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)
- yield {"bridge": _unity_connection}
+ # Yield the connection pool so it can be attached to the context
+ # Note: Tools will use get_unity_connection_pool() directly
+ yield {"pool": _unity_connection_pool}
finally:
- if _unity_connection:
- _unity_connection.disconnect()
- _unity_connection = None
+ if _unity_connection_pool:
+ _unity_connection_pool.disconnect_all()
logger.info("MCP for Unity Server shut down")
# Initialize MCP server
@@ -188,9 +207,41 @@ def _emit_startup():
def main():
"""Entry point for uvx and console scripts."""
+ parser = argparse.ArgumentParser(
+ description="MCP for Unity Server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Environment Variables:
+ UNITY_MCP_DEFAULT_INSTANCE Default Unity instance to target (project name, hash, or 'Name@hash')
+ UNITY_MCP_SKIP_STARTUP_CONNECT Skip initial Unity connection attempt (set to 1/true/yes/on)
+ UNITY_MCP_TELEMETRY_ENABLED Enable telemetry (set to 1/true/yes/on)
+
+Examples:
+ # Use specific Unity project as default
+ python -m src.server --default-instance "MyProject"
+
+ # Or use environment variable
+ UNITY_MCP_DEFAULT_INSTANCE="MyProject" python -m src.server
+ """
+ )
+ parser.add_argument(
+ "--default-instance",
+ type=str,
+ metavar="INSTANCE",
+ help="Default Unity instance to target (project name, hash, or 'Name@hash'). "
+ "Overrides UNITY_MCP_DEFAULT_INSTANCE environment variable."
+ )
+
+ args = parser.parse_args()
+
+ # Set environment variable if --default-instance is provided
+ if args.default_instance:
+ os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance
+ logger.info(f"Using default Unity instance from command-line: {args.default_instance}")
+
mcp.run(transport='stdio')
# Run the server
if __name__ == "__main__":
- main()
+ main()
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py b/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py
index a1489c59..32fcd3b7 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py
@@ -17,9 +17,21 @@ async def execute_menu_item(
ctx: Context,
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> MCPResponse:
+ """
+ Execute a Unity Editor menu item specified by its menu path, optionally targeting a specific Unity instance.
+
+ Parameters:
+ menu_path (str | None): Unity menu path to execute (e.g., "File/Save Project"). If None, no menu path is sent.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If None, the default instance is used.
+
+ Returns:
+ MCPResponse if the command result is a dictionary, otherwise the raw command result.
+ """
await ctx.info(f"Processing execute_menu_item: {menu_path}")
params_dict: dict[str, Any] = {"menuPath": menu_path}
params_dict = {k: v for k, v in params_dict.items() if v is not None}
- result = await async_send_command_with_retry("execute_menu_item", params_dict)
- return MCPResponse(**result) if isinstance(result, dict) else result
+ result = await async_send_command_with_retry("execute_menu_item", params_dict, instance_id=unity_instance)
+ return MCPResponse(**result) if isinstance(result, dict) else result
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py
index a577e94d..160a3840 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py
@@ -31,9 +31,33 @@ async def manage_asset(
filter_date_after: Annotated[str,
"Date after which to filter"] | None = None,
page_size: Annotated[int | float | str, "Page size for pagination"] | None = None,
- page_number: Annotated[int | float | str, "Page number for pagination"] | None = None
+ page_number: Annotated[int | float | str, "Page number for pagination"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None
) -> dict[str, Any]:
- ctx.info(f"Processing manage_asset: {action}")
+ """
+ Perform the specified asset operation in a Unity instance.
+
+ This sends a centralized "manage_asset" command to Unity with the provided parameters; string `properties` will be parsed as JSON when possible, and `page_size`/`page_number` are defensively coerced to integers. Parameters with a value of None are omitted from the sent payload.
+
+ Parameters:
+ action (Literal): CRUD-like action to perform (e.g., "import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components").
+ path (str): Asset path or search scope (e.g., "Materials/MyMaterial.mat").
+ asset_type (str | None): Asset type required for "create" (e.g., "Material", "Folder").
+ properties (dict | str | None): Properties for "create"/"modify"; if a JSON string is passed it will be parsed to a dict; defaults to an empty dict when omitted.
+ destination (str | None): Target path for "duplicate" or "move".
+ generate_preview (bool): Whether to generate an asset preview/thumbnail when supported.
+ search_pattern (str | None): Pattern used for searches (e.g., "*.prefab").
+ filter_type (str | None): Type filter for search results.
+ filter_date_after (str | None): Date string used to filter results after the given date.
+ page_size (int | float | str | None): Page size for pagination; will be coerced to an integer when possible.
+ page_number (int | float | str | None): Page number for pagination; will be coerced to an integer when possible.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted, the default instance is used.
+
+ Returns:
+ dict: The response from Unity as a dictionary. If Unity returns a non-dict result, returns {"success": False, "message": str(result)}.
+ """
+ ctx.info(f"Processing manage_asset: {action} (unity_instance={unity_instance or 'default'})")
# Coerce 'properties' from JSON string to dict for client compatibility
if isinstance(properties, str):
try:
@@ -86,7 +110,7 @@ def _coerce_int(value, default=None):
# Get the current asyncio event loop
loop = asyncio.get_running_loop()
- # Use centralized async retry helper to avoid blocking the event loop
- result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
+ # Use centralized async retry helper with instance routing
+ result = await async_send_command_with_retry("manage_asset", params_dict, instance_id=unity_instance, loop=loop)
# Return the result obtained from Unity
- return result if isinstance(result, dict) else {"success": False, "message": str(result)}
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py
index f7911458..5da639a1 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py
@@ -21,7 +21,27 @@ def manage_editor(
"Tag name when adding and removing tags"] | None = None,
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Control or query Unity Editor state and settings.
+
+ Parameters:
+ ctx (Context): Execution context used for logging and environment.
+ action (str): Editor operation to perform or query (e.g., "play", "pause", "get_state", "add_tag", "telemetry_status").
+ wait_for_completion (bool | str | None): If True, wait for the editor action to complete; accepts boolean or common truthy/falsey strings (e.g., "true", "false"). If None, default behavior is used.
+ tool_name (str | None): Name of the tool when action is "set_active_tool".
+ tag_name (str | None): Tag name when action is "add_tag" or "remove_tag".
+ layer_name (str | None): Layer name when action is "add_layer" or "remove_layer".
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted, the default instance is used.
+
+ Returns:
+ dict[str, Any]: A response dictionary. Typically contains:
+ - "success" (bool): Operation success flag.
+ - "message" (str): Human-readable status or error message.
+ - "data" (Any, optional): Additional data returned by the editor for successful queries.
+ """
ctx.info(f"Processing manage_editor: {action}")
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
@@ -62,8 +82,8 @@ def _coerce_bool(value, default=None):
}
params = {k: v for k, v in params.items() if v is not None}
- # Send command using centralized retry helper
- response = send_command_with_retry("manage_editor", params)
+ # Send command using centralized retry helper with instance routing
+ response = send_command_with_retry("manage_editor", params, instance_id=unity_instance)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
@@ -71,4 +91,4 @@ def _coerce_bool(value, default=None):
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
- return {"success": False, "message": f"Python error managing editor: {str(e)}"}
+ return {"success": False, "message": f"Python error managing editor: {str(e)}"}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py
index 794013b9..e8916db9 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py
@@ -64,7 +64,33 @@ def manage_gameobject(
# Controls whether serialization of private [SerializeField] fields is included
includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage Unity GameObjects and their components (create, modify, delete, find, add/remove components, set/get component properties).
+
+ Coerces tolerant input formats (stringified booleans and vectors), validates parameter combinations, constructs prefab paths when requested, and dispatches the assembled command to the backend (optionally routed to a specific Unity instance). Returns the backend response wrapped as a dictionary with a success flag, a human-readable message, and optional data.
+
+ Parameters:
+ ctx (Context): Execution context for logging and runtime metadata.
+ action (str): Operation to perform: "create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", or "get_component".
+ target (str | None): Identifier (name or path) used for modify/delete/component actions.
+ search_method (str | None): How to locate objects for lookups (e.g., "by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component").
+ name (str | None): GameObject name for create/modify actions (not used for find).
+ search_term (str | None): Term to search for when action is "find" (required for find; do not use `name`).
+ component_properties (dict[str, dict[str, Any]] | str | None): Mapping of component names to property dictionaries; may be a JSON string (will be parsed) or a dict.
+ save_as_prefab (bool | str | None): If true, save created GameObject as a prefab. Accepts boolean or string representations.
+ prefab_path (str | None): Explicit prefab path. If omitted and save_as_prefab is true, a default path will be constructed from `prefab_folder` and `name`.
+ prefab_folder (str | None): Folder to use when constructing a default prefab path.
+ unity_instance (str | None): Optional Unity instance identifier (project name, hash, or "Name@hash") to route the command to a specific instance.
+
+ Returns:
+ dict[str, Any]: Result object containing:
+ - "success" (bool): `true` if the operation succeeded, `false` otherwise.
+ - "message" (str): Human-readable status or error message.
+ - "data" (any, optional): Additional payload returned by the backend on success.
+ """
ctx.info(f"Processing manage_gameobject: {action}")
# Coercers to tolerate stringified booleans and vectors
@@ -195,8 +221,8 @@ def _to_vec3(parts):
params.pop("prefabFolder", None)
# --------------------------------
- # Use centralized retry helper
- response = send_command_with_retry("manage_gameobject", params)
+ # Use centralized retry helper with instance routing
+ response = send_command_with_retry("manage_gameobject", params, instance_id=unity_instance)
# Check if the response indicates success
# If the response is not successful, raise an exception with the error message
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py
index 2540e9f2..38bf8f24 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py
@@ -28,7 +28,35 @@ def manage_prefabs(
"Allow replacing an existing prefab at the same path"] | None = None,
search_inactive: Annotated[bool,
"Include inactive objects when resolving the target name"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Send a prefab management command to Unity to control prefab stages or create prefabs.
+
+ Parameters:
+ action (Literal["open_stage", "close_stage", "save_open_stage", "create_from_gameobject"]):
+ The operation to perform: "open_stage", "close_stage", "save_open_stage", or "create_from_gameobject".
+ prefab_path (str | None):
+ Prefab asset path relative to the project Assets folder (e.g. "Assets/Prefabs/favorite.prefab").
+ mode (str | None):
+ Optional prefab stage mode (only "InIsolation" is currently supported).
+ save_before_close (bool | None):
+ When true and action is "close_stage", save the prefab before exiting the stage.
+ target (str | None):
+ Scene GameObject name required when action is "create_from_gameobject".
+ allow_overwrite (bool | None):
+ When true, allow replacing an existing prefab at the same path.
+ search_inactive (bool | None):
+ When true, include inactive objects when resolving the target GameObject name.
+ unity_instance (str | None):
+ Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted the default instance is used.
+
+ Returns:
+ dict[str, Any]:
+ A result dictionary. On success: {"success": True, "message": , "data": }.
+ On failure: a dict describing the error or {"success": False, "message": }.
+ """
ctx.info(f"Processing manage_prefabs: {action}")
try:
params: dict[str, Any] = {"action": action}
@@ -45,7 +73,7 @@ def manage_prefabs(
params["allowOverwrite"] = bool(allow_overwrite)
if search_inactive is not None:
params["searchInactive"] = bool(search_inactive)
- response = send_command_with_retry("manage_prefabs", params)
+ response = send_command_with_retry("manage_prefabs", params, instance_id=unity_instance)
if isinstance(response, dict) and response.get("success"):
return {
@@ -55,4 +83,4 @@ def manage_prefabs(
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as exc:
- return {"success": False, "message": f"Python error managing prefabs: {exc}"}
+ return {"success": False, "message": f"Python error managing prefabs: {exc}"}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py
index 50927ca9..a31783d6 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py
@@ -15,7 +15,23 @@ def manage_scene(
"Asset path for scene operations (default: 'Assets/')"] | None = None,
build_index: Annotated[int | str,
"Build index for load/build settings actions (accepts int or string, e.g., 0 or '0')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage Unity scene operations (create, load, save) and scene queries.
+
+ Parameters:
+ path (str | None): Asset path for scene operations. Defaults to the Unity project Assets root (e.g., "Assets/") if not provided.
+ build_index (int | str | None): Build index for actions that accept an index (accepts integers or numeric strings like "0"); non-numeric or missing values are treated as not provided.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict[str, Any]: A normalized response with keys:
+ - `success` (bool): `true` if the operation succeeded, `false` otherwise.
+ - `message` (str): Human-readable status or error message.
+ - `data` (Any, optional): Optional payload returned by the Unity side when available.
+ """
ctx.info(f"Processing manage_scene: {action}")
try:
# Coerce numeric inputs defensively
@@ -44,8 +60,8 @@ def _coerce_int(value, default=None):
if coerced_build_index is not None:
params["buildIndex"] = coerced_build_index
- # Use centralized retry helper
- response = send_command_with_retry("manage_scene", params)
+ # Use centralized retry helper with instance routing
+ response = send_command_with_retry("manage_scene", params, instance_id=unity_instance)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
@@ -53,4 +69,4 @@ def _coerce_int(value, default=None):
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
- return {"success": False, "message": f"Python error managing scene: {str(e)}"}
+ return {"success": False, "message": f"Python error managing scene: {str(e)}"}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py
index 6ed8cbca..d4168a0c 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py
@@ -85,7 +85,32 @@ def apply_text_edits(
"Optional strict flag, used to enforce strict mode"] | None = None,
options: Annotated[dict[str, Any],
"Optional options, used to pass additional options to the script editor"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Apply a set of text edits to a C# script identified by a URI under the Assets/ tree.
+
+ Normalize common edit shapes (explicit 1-based line/column, LSP-style ranges, or 0-based index ranges),
+ validate and reject overlapping non‑zero-length spans, and forward a normalized edit batch to the Unity backend.
+
+ Parameters:
+ ctx (Context): Execution context (logger / command utilities).
+ uri (str): URI or path referencing the script (e.g., unity://path/Assets/..., file://..., or Assets/...).
+ edits (list[dict]): List of edits to apply. Each edit should resolve to fields
+ startLine, startCol, endLine, endCol, and newText after normalization. Accepted input forms:
+ - explicit fields (1-based),
+ - LSP-style range objects ({ "range": { "start": { "line", "character" }, "end": {...} }, "newText" }),
+ - index ranges as [startIndex, endIndex] (0-based character indices) with "text" or "newText".
+ precondition_sha256 (str | None): Optional SHA256 that must match the current file state to proceed.
+ strict (bool | None): If true, treat zero-based explicit line/column fields as an error instead of normalizing.
+ options (dict | None): Additional options for the apply operation (e.g., debug_preview, applyMode, force_sentinel_reload).
+ unity_instance (str | None): Target Unity instance identifier; when omitted the default instance is used.
+
+ Returns:
+ dict: A result object with at least a boolean "success". On success "data" will include
+ "normalizedEdits" and optionally "warnings" and backend response fields. On failure includes error details.
+ """
ctx.info(f"Processing apply_text_edits: {uri}")
name, directory = _split_uri(uri)
@@ -107,7 +132,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
"action": "read",
"name": name,
"path": directory,
- })
+ }, instance_id=unity_instance)
if not (isinstance(read_resp, dict) and read_resp.get("success")):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data", {})
@@ -304,7 +329,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
"options": opts,
}
params = {k: v for k, v in params.items() if v is not None}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
@@ -331,6 +356,11 @@ def _latest_status() -> dict | None:
return None
def _flip_async():
+ """
+ Trigger a Unity "Flip Reload Sentinel" menu action after a short delay unless a reload is already in progress.
+
+ Performs a delayed check of the latest Unity status and, if Unity is not currently reloading, sends the "execute_menu_item" command to toggle the reload sentinel. All exceptions are suppressed to avoid raising from background execution.
+ """
try:
time.sleep(0.1)
st = _latest_status()
@@ -341,6 +371,7 @@ def _flip_async():
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
+ instance_id=unity_instance,
)
except Exception:
pass
@@ -359,7 +390,25 @@ def create_script(
contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Create a new C# script file under the project's Assets/ directory.
+
+ Validates that the provided path is an Assets-relative, non-traversing, non-absolute path that ends with ".cs" and contains a file name. If contents are provided they are base64-encoded for transport.
+
+ Parameters:
+ ctx: Operation context (logging, request metadata).
+ path: Path under Assets/ where the script will be created (e.g., "Assets/Scripts/My.cs").
+ contents: Script source text to write; will be base64-encoded when sent to Unity.
+ script_type: Optional script type hint (for example, "C#").
+ namespace: Optional namespace to apply to the created script.
+ unity_instance: Optional target Unity instance identifier; if omitted the default instance is used.
+
+ Returns:
+ A dict containing the Unity backend response on success or a failure dict with keys such as `success`, `code`, and `message` describing the error.
+ """
ctx.info(f"Processing create_script: {path}")
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
@@ -386,22 +435,34 @@ def create_script(
contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path."))
def delete_script(
ctx: Context,
- uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
+ uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
- """Delete a C# script by URI."""
+ """
+ Delete a C# script located under the project's Assets/ path identified by a URI.
+
+ Parameters:
+ ctx (Context): Command context provided by the MCP framework.
+ uri (str): URI or path that resolves to a file under Assets/ (e.g., 'unity://path/Assets/...', 'file://...', or 'Assets/...').
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'); if None, the default instance is used.
+
+ Returns:
+ dict: The Unity backend response when successful, or a failure dict containing `success: False` and a `message` or `code` describing the error.
+ """
ctx.info(f"Processing delete_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@@ -412,8 +473,26 @@ def validate_script(
level: Annotated[Literal['basic', 'standard'],
"Validation level"] = "basic",
include_diagnostics: Annotated[bool,
- "Include full diagnostics and summary"] = False
+ "Include full diagnostics and summary"] = False,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Validate a C# script located under Assets/ and return either a diagnostics summary or full diagnostics.
+
+ Parameters:
+ ctx (Context): Request context and logger.
+ uri (str): URI or path of the script to validate (e.g., unity://path/Assets/..., file://..., or Assets/...). Must resolve under `Assets/`.
+ level (Literal['basic','standard']): Validation level to perform; must be 'basic' or 'standard'. Default is 'basic'.
+ include_diagnostics (bool): If True, return the full diagnostics list and a summary; if False, return only counts. Default is False.
+ unity_instance (str | None): Optional target Unity instance identifier (project name, hash, or 'Name@hash'). If omitted, the default instance is used.
+
+ Returns:
+ dict: A result object. On success:
+ - If include_diagnostics is True: {"success": True, "data": {"diagnostics": [...], "summary": {"warnings": N, "errors": M}}}
+ - If include_diagnostics is False: {"success": True, "data": {"warnings": N, "errors": M}}
+ On failure: a dict with "success": False and either an error "message" or structured error "code" (for example "path_outside_assets" if the URI is not under Assets/ or "bad_level" if level is invalid).
+ """
ctx.info(f"Processing validate_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
@@ -426,7 +505,7 @@ def validate_script(
"path": directory,
"level": level,
}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(
@@ -450,7 +529,27 @@ def manage_script(
script_type: Annotated[str, "Script type (e.g., 'C#')",
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Perform the requested CRUD operation ('create', 'read', or 'delete') on a C# script in the Unity project and return the processed backend response.
+
+ Parameters:
+ action (str): One of 'create', 'read', or 'delete' indicating the operation to perform.
+ name (str): Script name without the .cs extension.
+ path (str): Asset path for the script (e.g., 'Assets/Scripts/My.cs'); must be under the project's Assets folder.
+ contents (str | None): Script source to send for 'create' (encoded for transport) or for updates; omitted for read/delete.
+ script_type (str | None): Optional hint for script type (for example, 'C#' or 'MonoBehaviour').
+ namespace (str | None): Optional namespace to associate with the script.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'); if omitted, the default instance is used.
+
+ Returns:
+ dict: A result object with at least:
+ - 'success' (bool): Operation success flag.
+ - 'message' (str): Human-readable message.
+ - 'data' (dict | None): Optional backend-provided data (may include script metadata and, when present, a decoded 'contents' field).
+ """
ctx.info(f"Processing manage_script: {action}")
try:
# Prepare parameters for Unity
@@ -473,7 +572,7 @@ def manage_script(
params = {k: v for k, v in params.items() if v is not None}
- response = unity_connection.send_command_with_retry("manage_script", params)
+ response = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(response, dict):
if response.get("success"):
@@ -535,13 +634,26 @@ def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
@mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
def get_sha(
ctx: Context,
- uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
+ uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Retrieve SHA-256 and byte length metadata for a C# script identified by a URI.
+
+ Parameters:
+ uri (str): URI or path to the script (e.g., "unity://path/Assets/...", "file://...", or "Assets/..."). The path will be normalized and resolved relative to the Assets/ root when applicable.
+ unity_instance (str | None): Optional target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict: On success, returns {"success": True, "data": {"sha256": , "lengthBytes": }}.
+ On failure, returns either the backend response dict or {"success": False, "message": }.
+ """
ctx.info(f"Processing get_sha: {uri}")
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get(
@@ -549,4 +661,4 @@ def get_sha(
return {"success": True, "data": minimal}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
except Exception as e:
- return {"success": False, "message": f"get_sha error: {e}"}
+ return {"success": False, "message": f"get_sha error: {e}"}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py
index 19b94550..d6278541 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py
@@ -16,7 +16,26 @@ def manage_shader(
path: Annotated[str, "Asset path (default: \"Assets/\")"],
contents: Annotated[str,
"Shader code for 'create'/'update'"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage Unity shader scripts (create, read, update, delete) for a target project instance.
+
+ Parameters:
+ ctx (Context): Execution context used for logging and helpers.
+ action (Literal['create','read','update','delete']): CRUD operation to perform on the shader.
+ name (str): Shader name (without file extension).
+ path (str): Asset path for the shader (e.g., "Assets/").
+ contents (str | None): Shader source to send when creating or updating; ignored for read/delete.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'); if omitted the default instance is used.
+
+ Returns:
+ dict[str, Any]: Result dictionary with at least:
+ - 'success' (bool): `true` when the operation succeeded, `false` otherwise.
+ - 'message' (str): Human-readable status or error message.
+ - 'data' (dict, optional): Payload returned from Unity; may include a 'contents' string when reading a shader.
+ """
ctx.info(f"Processing manage_shader: {action}")
try:
# Prepare parameters for Unity
@@ -39,8 +58,8 @@ def manage_shader(
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
- # Send command via centralized retry helper
- response = send_command_with_retry("manage_shader", params)
+ # Send command via centralized retry helper with instance routing
+ response = send_command_with_retry("manage_shader", params, instance_id=unity_instance)
# Process response from Unity
if isinstance(response, dict) and response.get("success"):
@@ -57,4 +76,4 @@ def manage_shader(
except Exception as e:
# Handle Python-side errors (e.g., connection issues)
- return {"success": False, "message": f"Python error managing shader: {str(e)}"}
+ return {"success": False, "message": f"Python error managing shader: {str(e)}"}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py
index d922982c..d9bda463 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py
@@ -23,8 +23,27 @@ def read_console(
format: Annotated[Literal['plain', 'detailed',
'json'], "Output format"] | None = None,
include_stacktrace: Annotated[bool | str,
- "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None
+ "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None
) -> dict[str, Any]:
+ """
+ Read or clear Unity Editor console messages and return a structured response.
+
+ Parameters:
+ ctx (Context): Execution context (logger/metadata).
+ action (str | None): "get" to retrieve messages or "clear" to clear the console. Defaults to "get".
+ types (list[str] | None): Message types to include (e.g., ["error","warning","log"] or ["all"]). Defaults to ["error","warning","log"].
+ count (int | str | None): Maximum number of messages to return. Accepts an int or numeric string; None can mean no limit.
+ filter_text (str | None): Substring to filter messages by text.
+ since_timestamp (str | None): ISO 8601 timestamp; only messages after this time are returned.
+ format (str | None): Output format: "plain", "detailed", or "json". Defaults to "detailed".
+ include_stacktrace (bool | str | None): Whether to include stack traces in returned messages. Accepts boolean or strings like "true"/"false". Defaults to True.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); when omitted the default instance is used.
+
+ Returns:
+ dict[str, Any]: A response dictionary from the Unity command. On success the dict typically contains "success": True and a "data" payload; if the underlying call returns a non-dict value, the function returns {"success": False, "message": str(resp)}.
+ """
ctx.info(f"Processing read_console: {action}")
# Set defaults if values are None
action = action if action is not None else 'get'
@@ -87,8 +106,8 @@ def _coerce_int(value, default=None):
if 'count' not in params_dict:
params_dict['count'] = None
- # Use centralized retry helper
- resp = send_command_with_retry("read_console", params_dict)
+ # Use centralized retry helper with instance routing
+ resp = send_command_with_retry("read_console", params_dict, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present
try:
@@ -98,4 +117,4 @@ def _coerce_int(value, default=None):
line.pop("stacktrace", None)
except Exception:
pass
- return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py
index d84bf7be..1e8046de 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py
@@ -18,8 +18,16 @@
def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None:
- """Safely coerce various inputs (str/float/etc.) to an int.
- Returns default on failure; clamps to minimum when provided.
+ """
+ Coerce a value to an integer when possible.
+
+ Parameters:
+ value (Any): The input to convert. Boolean values are rejected and will return `default`.
+ default (int | None): Value to return if conversion is not possible.
+ minimum (int | None): If provided, the coerced integer is raised to at least this value.
+
+ Returns:
+ int | None: The coerced integer, clamped to `minimum` when applicable, or `default` if conversion fails.
"""
if value is None:
return default
@@ -42,8 +50,18 @@ def _coerce_int(value: Any, default: int | None = None, minimum: int | None = No
return default
-def _resolve_project_root(override: str | None) -> Path:
+def _resolve_project_root(override: str | None, unity_instance: str | None = None) -> Path:
# 1) Explicit override
+ """
+ Determine the Unity project root directory using multiple strategies (override, UNITY_PROJECT_ROOT env var, editor query via manage_editor, upward search from CWD, shallow downward search) and fall back to the current working directory.
+
+ Parameters:
+ override (str | None): Explicit project root path to use before other discovery strategies.
+ unity_instance (str | None): Optional Unity instance identifier passed to the editor query when asking for the project root.
+
+ Returns:
+ Path: An absolute, resolved Path to the chosen project root (typically validated by the presence of Unity markers such as an Assets directory or ProjectSettings when possible).
+ """
if override:
pr = Path(override).expanduser().resolve()
if (pr / "Assets").exists():
@@ -60,7 +78,7 @@ def _resolve_project_root(override: str | None) -> Path:
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry(
- "manage_editor", {"action": "get_project_root"})
+ "manage_editor", {"action": "get_project_root"}, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get(
"projectRoot", "")).expanduser().resolve()
@@ -141,10 +159,26 @@ async def list_resources(
"Folder under project root, default is Assets"] = "Assets",
limit: Annotated[int, "Page limit"] = 200,
project_root: Annotated[str, "Project path"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ List Unity asset script URIs under a project folder, constrained to C# files in Assets/.
+
+ Parameters:
+ pattern (str | None): Filename glob to filter results (default `"*.cs"`). If None, no glob filtering is applied.
+ under (str): Subfolder under the project root to search (default `"Assets"`). Must resolve inside `Assets/`.
+ limit (int): Maximum number of URIs to return (minimum 1; default 200).
+ project_root (str | None): Explicit project root path override. If omitted, the project root is discovered automatically.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or `Name@hash`). If omitted, the default instance is used.
+
+ Returns:
+ dict: On success: `{"success": True, "data": {"uris": [...], "count": }}`.
+ On failure: `{"success": False, "error": ""}`.
+ """
ctx.info(f"Processing list_resources: {pattern}")
try:
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
base = (project / under).resolve()
try:
base.relative_to(project)
@@ -201,7 +235,31 @@ async def read_resource(
project_root: Annotated[str,
"The project root directory"] | None = None,
request: Annotated[str, "The request ID"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Read a resource addressed by a Unity-compatible URI under the project's Assets/ and optionally return a text slice or file metadata.
+
+ Supports a canonical spec payload when the URI is "unity://spec/script-edits" (or equivalent bare forms). The function resolves the project root (optionally for a specific Unity instance), restricts reads to files under Assets/, accepts natural-language hints in `request` (for example "last 100 lines", "first 200 lines", or "show 40 lines around MethodName"), and provides mutually exclusive windowing options with precedence: head_bytes, then tail_lines, then start_line+line_count. When a selection is requested, the selected text and metadata (sha256 and length in bytes) are returned; otherwise only metadata is returned.
+
+ Parameters:
+ ctx (Context): Operation context used for logging.
+ uri (str): Resource URI to read; supports "unity://..." schemes, "file://..." forms, and Asset-relative paths.
+ start_line (int | float | str | None): Starting line number (1-based when used); used with line_count to extract a window.
+ line_count (int | float | str | None): Number of lines to read starting at start_line.
+ head_bytes (int | float | str | None): Number of bytes to return from the start of the file; takes highest precedence when set.
+ tail_lines (int | float | str | None): Number of lines to return from the end of the file.
+ project_root (str | None): Explicit project root path override; resolved if provided.
+ request (str | None): Natural-language request hints that can set windowing parameters (e.g., "last 120 lines").
+ unity_instance (str | None): Optional Unity instance identifier to use when resolving the project root.
+
+ Returns:
+ dict: On success, returns {"success": True, "data": {...}} where data contains either:
+ - "metadata": {"sha256": "", "lengthBytes": } when no text selection was requested, or
+ - "text": "", "metadata": {"sha256": "", "lengthBytes": } when a text window was requested.
+ On failure, returns {"success": False, "error": ""} describing the problem.
+ """
ctx.info(f"Processing read_resource: {uri}")
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
@@ -266,7 +324,7 @@ async def read_resource(
sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest()
return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}}
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
@@ -356,10 +414,29 @@ async def find_in_file(
"The project root directory"] | None = None,
max_results: Annotated[int,
"Cap results to avoid huge payloads"] = 200,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Search a file for occurrences of a regular expression and return their line-and-column locations.
+
+ Parameters:
+ ctx: Context object used for logging and operation context.
+ uri (str): Resource URI (e.g., "unity://path/..." or supported file URL/path) that resolves to a file under the Unity project Assets/ directory.
+ pattern (str): Regular expression to search for. Multiline mode is enabled; matches are performed per-line.
+ ignore_case (bool | str, optional): If true (or truthy string like "true"), perform case-insensitive matching; defaults to True.
+ project_root (str | None, optional): Explicit project root path used to resolve the URI. If omitted, the project root is discovered automatically.
+ max_results (int, optional): Maximum number of matches to return; values less than 1 are treated as 1. Defaults to 200.
+ unity_instance (str | None, optional): Optional Unity instance identifier to use when resolving the project root.
+
+ Returns:
+ dict: On success, {"success": True, "data": {"matches": [match, ...], "count": N}} where each match is
+ {"startLine": int, "startCol": int, "endLine": int, "endCol": int} (1-based columns; endCol is exclusive).
+ On failure, {"success": False, "error": ""}.
+ """
ctx.info(f"Processing find_in_file: {uri}")
try:
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
@@ -403,4 +480,4 @@ def _coerce_bool(val, default=None):
return {"success": True, "data": {"matches": results, "count": len(results)}}
except Exception as e:
- return {"success": False, "error": str(e)}
+ return {"success": False, "error": str(e)}
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py
index e70fd00c..0252c7c2 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py
@@ -45,7 +45,19 @@ async def run_tests(
description="Unity test mode to run")] = "edit",
timeout_seconds: Annotated[str, Field(
description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> RunTestsResponse:
+ """
+ Run Unity test suites for the specified mode.
+
+ Parameters:
+ timeout_seconds (str | None): Optional timeout for the test run expressed as a string (e.g., "30"). Accepts numeric strings or floats; values like "", "none", or "null" are treated as not provided.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If not provided, the default instance is used.
+
+ Returns:
+ RunTestsResponse | Any: A RunTestsResponse constructed from the command response when the response is a dict; otherwise the original response object.
+ """
await ctx.info(f"Processing run_tests: mode={mode}")
# Coerce timeout defensively (string/float -> int)
@@ -69,6 +81,6 @@ def _coerce_int(value, default=None):
if ts is not None:
params["timeoutSeconds"] = ts
- response = await async_send_command_with_retry("run_tests", params)
+ response = await async_send_command_with_retry("run_tests", params, instance_id=unity_instance)
await ctx.info(f'Response {response}')
- return RunTestsResponse(**response) if isinstance(response, dict) else response
+ return RunTestsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py
index e339a754..f8b56515 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py
@@ -365,7 +365,31 @@ def script_apply_edits(
"Type of the script to edit"] = "MonoBehaviour",
namespace: Annotated[str,
"Namespace of the script to edit"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Apply a set of structured or textual edits to a Unity C# script, optionally previewing changes before writing.
+
+ Processes and normalizes a heterogeneous list of edits (structured: class/method/anchor ops; text: prepend/append/replace_range/regex_replace), routes purely structured edits to Unity's structured editor, and otherwise reads the current file from Unity to compute and apply text edits. Supports mixed batches (apply text edits first with a SHA precondition, then structured edits), local previews/diffs for regex-based edits, and a confirm/preview workflow. Returns a machine-parsable result describing success, errors, and optional preview diff or normalized edits.
+
+ Parameters:
+ ctx (Context): Execution context for logging/telemetry.
+ name (str): Script name or locator; normalized to a canonical class name.
+ path (str): Path to the script under the Assets/ directory; normalized as needed.
+ edits (list[dict[str, Any]]): List of edit descriptors to apply. Each edit must specify an operation via keys like "op", "operation", or wrapper shapes (e.g., {"replace_method": {...}}). Supported ops include structured ops (replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert/replace/delete) and text ops (prepend, append, replace_range, regex_replace). Various common aliases are accepted and normalized.
+ options (dict[str, Any] | None): Optional behavior flags (examples: "preview", "confirm", "refresh", "validate", "applyMode"). If omitted, sensible defaults are used.
+ script_type (str): Script type hint for Unity (default "MonoBehaviour").
+ namespace (str | None): Optional C# namespace for the script.
+ unity_instance (str | None): Optional target Unity instance identifier (project name, hash, or "Name@hash"). When provided, Unity commands are directed to that instance; otherwise the default instance is used.
+
+ Returns:
+ dict[str, Any]: A structured response indicating outcome. Typical keys include:
+ - "success" (bool): whether the operation succeeded.
+ - "message" (str): human-readable summary.
+ - "data" (dict, optional): additional payload such as {"diff": "...", "normalizedEdits": [...]} for previews or {"no_op": True} for no-op results.
+ - Error responses include machine-readable "code" and may include "normalizedEdits" to assist clients in correcting requests.
+ """
ctx.info(f"Processing script_apply_edits: {name}")
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
@@ -586,7 +610,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
"options": opts2,
}
resp_struct = send_command_with_retry(
- "manage_script", params_struct)
+ "manage_script", params_struct, instance_id=unity_instance)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
@@ -598,7 +622,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
"path": path,
"namespace": namespace,
"scriptType": script_type,
- })
+ }, instance_id=unity_instance)
if not isinstance(read_resp, dict) or not read_resp.get("success"):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
@@ -722,7 +746,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
}
resp_text = send_command_with_retry(
- "manage_script", params_text)
+ "manage_script", params_text, instance_id=unity_instance)
if not (isinstance(resp_text, dict) and resp_text.get("success")):
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
# Optional sentinel reload removed (deprecated)
@@ -743,7 +767,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": opts2
}
resp_struct = send_command_with_retry(
- "manage_script", params_struct)
+ "manage_script", params_struct, instance_id=unity_instance)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
@@ -871,7 +895,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
- resp = send_command_with_retry("manage_script", params)
+ resp = send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@@ -955,7 +979,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": options or {"validate": "standard", "refresh": "debounced"},
}
- write_resp = send_command_with_retry("manage_script", params)
+ write_resp = send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@@ -963,4 +987,4 @@ def _expand_dollars(rep: str, _m=m) -> str:
else {"success": False, "message": str(write_resp)},
normalized_for_echo,
routing="text",
- )
+ )
\ No newline at end of file
diff --git a/MCPForUnity/UnityMcpServer~/src/unity_connection.py b/MCPForUnity/UnityMcpServer~/src/unity_connection.py
index f0e06b76..fac8c101 100644
--- a/MCPForUnity/UnityMcpServer~/src/unity_connection.py
+++ b/MCPForUnity/UnityMcpServer~/src/unity_connection.py
@@ -4,6 +4,7 @@
import errno
import json
import logging
+import os
from pathlib import Path
from port_discovery import PortDiscovery
import random
@@ -11,9 +12,9 @@
import struct
import threading
import time
-from typing import Any, Dict
+from typing import Any, Dict, Optional, List
-from models import MCPResponse
+from models import MCPResponse, UnityInstanceInfo
# Configure logging using settings from config
@@ -37,9 +38,15 @@ class UnityConnection:
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
use_framing: bool = False # Negotiated per-connection
+ instance_id: str | None = None # Instance identifier for reconnection
def __post_init__(self):
- """Set port from discovery if not explicitly provided"""
+ """
+ Initialize connection state and discover the Unity port when not provided.
+
+ If `self.port` is None, sets it by querying PortDiscovery.discover_unity_port().
+ Also creates per-connection locks: `_io_lock` for serializing I/O and `_conn_lock` for guarding connection setup.
+ """
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
self._io_lock = threading.Lock()
@@ -224,7 +231,22 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
raise
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
- """Send a command with retry/backoff and port rediscovery. Pings only when requested."""
+ """
+ Send a command to the connected Unity Editor, retrying with backoff and attempting port rediscovery on failure.
+
+ Sends a framed or legacy payload depending on the negotiated transport, treats 'ping' as a special case, and parses Unity's JSON responses into Python dictionaries. Retries transient errors with jittered backoff, attempts to rediscover or update the Unity port for this connection if the socket fails, and returns a structured result when successful.
+
+ Parameters:
+ command_type (str): The MCP command name to send (e.g., "ping" or other command types expected by Unity).
+ params (Dict[str, Any] | None): The command parameters; must be provided for non-placeholder calls.
+
+ Returns:
+ Dict[str, Any] | MCPResponse: For non-ping commands, the parsed `result` object from Unity's JSON response. For 'ping', returns {"message": "pong"} on success. May return an MCPResponse indicating an error state (for example, when params is None or Unity reports a reload).
+
+ Raises:
+ ValueError: If `command_type` is empty.
+ Exception: Propagates the final exception if all retry attempts are exhausted.
+ """
# Defensive guard: catch empty/placeholder invocations early
if not command_type:
raise ValueError("MCP call missing command_type")
@@ -328,9 +350,28 @@ def read_status_file() -> dict | None:
finally:
self.sock = None
- # Re-discover port each time
+ # Re-discover the port for this specific instance
try:
- new_port = PortDiscovery.discover_unity_port()
+ new_port: int | None = None
+ if self.instance_id:
+ # Try to rediscover the specific instance
+ pool = get_unity_connection_pool()
+ refreshed = pool.discover_all_instances(force_refresh=True)
+ match = next((inst for inst in refreshed if inst.id == self.instance_id), None)
+ if match:
+ new_port = match.port
+ logger.debug(f"Rediscovered instance {self.instance_id} on port {new_port}")
+ else:
+ logger.warning(f"Instance {self.instance_id} not found during reconnection")
+
+ # Fallback to generic port discovery if instance-specific discovery failed
+ if new_port is None:
+ if self.instance_id:
+ raise ConnectionError(
+ f"Unity instance '{self.instance_id}' could not be rediscovered"
+ ) from e
+ new_port = PortDiscovery.discover_unity_port()
+
if new_port != self.port:
logger.info(
f"Unity port changed {self.port} -> {new_port}")
@@ -371,32 +412,261 @@ def read_status_file() -> dict | None:
raise
-# Global Unity connection
-_unity_connection = None
+# -----------------------------
+# Connection Pool for Multiple Unity Instances
+# -----------------------------
+
+class UnityConnectionPool:
+ """Manages connections to multiple Unity Editor instances"""
+
+ def __init__(self):
+ """
+ Initialize a UnityConnectionPool instance, setting up caches, locks, and an optional default instance from the UNITY_MCP_DEFAULT_INSTANCE environment variable.
+
+ If the UNITY_MCP_DEFAULT_INSTANCE environment variable is set and non-empty, it is recorded as the pool's default instance identifier.
+ """
+ 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: Optional[str] = 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]:
+ """
+ Retrieve discovered Unity Editor instances, using a cached result unless a fresh scan is requested.
+
+ Parameters:
+ force_refresh (bool): If True, bypass the cached results and perform a new discovery scan.
+
+ Returns:
+ List[UnityInstanceInfo]: Discovered running Unity Editor instances.
+ """
+ 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: Optional[str], instances: List[UnityInstanceInfo]) -> UnityInstanceInfo:
+ """
+ Resolve a user-provided instance identifier to a single UnityInstanceInfo.
+
+ Parameters:
+ instance_identifier (Optional[str]): Identifier provided by the caller. May be an instance id, project name, full or partial hash, composite "Name@Hash" or "Name@Port", filesystem path, port number (as string or int), or None to select a default/most-recent instance.
+ instances (List[UnityInstanceInfo]): List of discovered Unity instances to search.
+
+ Returns:
+ UnityInstanceInfo: The matching Unity instance.
+
+ Raises:
+ ConnectionError: If no instances are available or the identifier is ambiguous or does not match any instance.
+ """
+ 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 epoch as sentinel)
+ from datetime import datetime
+ sorted_instances = sorted(instances, key=lambda i: i.last_heartbeat or datetime.fromtimestamp(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 list_unity_instances() to see all instances."
+ )
+ def get_connection(self, instance_identifier: Optional[str] = None) -> UnityConnection:
+ """
+ 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 = 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):
+ """
+ Close and remove all pooled Unity connections.
+
+ Iterates the pool's cached connections, calls each connection's disconnect() (logging any errors per-instance), and clears the internal connection cache.
+ """
+ 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()
-def get_unity_connection() -> UnityConnection:
- """Retrieve or establish a persistent Unity connection.
- Note: Do NOT ping on every retrieval to avoid connection storms. Rely on
- send_command() exceptions to detect broken sockets and reconnect there.
+# Global Unity connection pool
+_unity_connection_pool: Optional[UnityConnectionPool] = None
+_pool_init_lock = threading.Lock()
+
+
+def get_unity_connection_pool() -> UnityConnectionPool:
"""
- global _unity_connection
- if _unity_connection is not None:
- return _unity_connection
-
- # Double-checked locking to avoid concurrent socket creation
- with _connection_lock:
- if _unity_connection is not None:
- return _unity_connection
- logger.info("Creating new Unity connection")
- _unity_connection = UnityConnection()
- if not _unity_connection.connect():
- _unity_connection = None
- raise ConnectionError(
- "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
- logger.info("Connected to Unity on startup")
- return _unity_connection
+ Get the global UnityConnectionPool instance, creating and initializing it if necessary.
+
+ Returns:
+ UnityConnectionPool: The global Unity connection pool instance.
+ """
+ global _unity_connection_pool
+
+ if _unity_connection_pool is not None:
+ return _unity_connection_pool
+
+ with _pool_init_lock:
+ if _unity_connection_pool is not None:
+ return _unity_connection_pool
+
+ logger.info("Initializing Unity connection pool")
+ _unity_connection_pool = UnityConnectionPool()
+ return _unity_connection_pool
+
+
+# Backwards compatibility: keep old single-connection function
+def get_unity_connection(instance_identifier: Optional[str] = None) -> UnityConnection:
+ """
+ Get a UnityConnection for a specific Unity Editor instance or the default/most-recent instance.
+
+ Parameters:
+ instance_identifier (Optional[str]): Identifier to resolve a specific Unity instance (id, project name, hash, "Name@Hash", "Name@Port", port number, or path). If None, selects the default instance if configured or the most recently active instance.
+
+ Returns:
+ UnityConnection: Connection object for the resolved Unity Editor instance.
+ """
+ pool = get_unity_connection_pool()
+ return pool.get_connection(instance_identifier)
# -----------------------------
@@ -404,7 +674,15 @@ def get_unity_connection() -> UnityConnection:
# -----------------------------
def _is_reloading_response(resp: dict) -> bool:
- """Return True if the Unity response indicates the editor is reloading."""
+ """
+ Determine whether a Unity MCP response indicates the Editor is reloading.
+
+ Parameters:
+ resp (dict): The parsed response object from Unity.
+
+ Returns:
+ bool: True if the response's "state" equals "reloading" or if the lowercased "message" or "error" text contains "reload", False otherwise.
+ """
if not isinstance(resp, dict):
return False
if resp.get("state") == "reloading":
@@ -413,13 +691,38 @@ def _is_reloading_response(resp: dict) -> bool:
return "reload" in message_text
-def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
- """Send a command via the shared connection, waiting politely through Unity reloads.
-
- Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
- structured failure if retries are exhausted.
+def send_command_with_retry(
+ command_type: str,
+ params: Dict[str, Any],
+ *,
+ instance_id: Optional[str] = None,
+ max_retries: int | None = None,
+ retry_ms: int | None = None
+) -> Dict[str, Any]:
+ """
+ Send a command to a Unity instance and retry while Unity reports a reload state.
+
+ If `max_retries` or `retry_ms` are None, uses config.reload_max_retries and
+ config.reload_retry_ms (fallbacks: 40 and 250 ms). Repeats the command until the
+ response no longer indicates a reload or the retry limit is reached; preserves
+ the final response (including structured error information) if retries are exhausted.
+
+ Parameters:
+ command_type (str): The command type to send.
+ params (Dict[str, Any]): Command parameters.
+ instance_id (Optional[str]): Optional Unity instance identifier (id, project name,
+ hash, composite forms like "Name@Hash" or "Name@Port", or port). If omitted,
+ the default or most-recent Unity instance is used.
+ max_retries (int | None): Maximum number of retry attempts while the editor reports
+ a reloading state; when None the configured default is used.
+ retry_ms (int | None): Delay between retries in milliseconds; when None the
+ configured default is used.
+
+ Returns:
+ Dict[str, Any]: The parsed response dictionary returned by Unity (may contain
+ error or retry metadata).
"""
- conn = get_unity_connection()
+ conn = get_unity_connection(instance_id)
if max_retries is None:
max_retries = getattr(config, "reload_max_retries", 40)
if retry_ms is None:
@@ -436,8 +739,30 @@ def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_re
return response
-async def async_send_command_with_retry(command_type: str, params: dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> dict[str, Any] | MCPResponse:
- """Async wrapper that runs the blocking retry helper in a thread pool."""
+async def async_send_command_with_retry(
+ command_type: str,
+ params: dict[str, Any],
+ *,
+ instance_id: Optional[str] = None,
+ loop=None,
+ max_retries: int | None = None,
+ retry_ms: int | None = None
+) -> dict[str, Any] | MCPResponse:
+ """
+ Asynchronously send a command with reload-aware retries by running the blocking retry helper in a thread pool.
+
+ Parameters:
+ command_type (str): The command type to send.
+ params (dict[str, Any]): Command parameters.
+ instance_id (Optional[str]): Unity instance identifier to target; if None uses the default resolution.
+ loop: asyncio event loop to use; if None the currently running loop is used.
+ max_retries (int | None): Maximum number of retry attempts when Unity reports a reload; when None uses the caller's defaults.
+ retry_ms (int | None): Delay between retry attempts in milliseconds; when None uses the caller's defaults.
+
+ Returns:
+ dict[str, Any]: The response dictionary on success.
+ MCPResponse: An MCPResponse with success=False describing the error on failure.
+ """
try:
import asyncio # local import to avoid mandatory asyncio dependency for sync callers
if loop is None:
@@ -445,7 +770,7 @@ async def async_send_command_with_retry(command_type: str, params: dict[str, Any
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(
- command_type, params, max_retries=max_retries, retry_ms=retry_ms),
+ command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
)
except Exception as e:
- return MCPResponse(success=False, error=str(e))
+ return MCPResponse(success=False, error=str(e))
\ No newline at end of file
diff --git a/Server/models.py b/Server/models.py
index cf1d33da..63c6f028 100644
--- a/Server/models.py
+++ b/Server/models.py
@@ -1,4 +1,5 @@
from typing import Any
+from datetime import datetime
from pydantic import BaseModel
@@ -7,3 +8,35 @@ 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]:
+ """
+ Return a dictionary representation of the UnityInstanceInfo suitable for JSON serialization.
+
+ The mapping includes keys "id", "name", "path", "hash", "port", "status", "last_heartbeat", and "unity_version". If last_heartbeat is present, it is converted to an ISO 8601 string via isoformat(); otherwise its value is None.
+
+ Returns:
+ dict[str, Any]: Serialized representation of the instance.
+ """
+ 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
+ }
\ No newline at end of file
diff --git a/Server/port_discovery.py b/Server/port_discovery.py
index c759e745..16de182e 100644
--- a/Server/port_discovery.py
+++ b/Server/port_discovery.py
@@ -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")
@@ -55,26 +60,82 @@ 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.
+ """
+ Check whether an MCP for Unity listener is responsive on the given TCP port.
+
+ Attempts the protocol handshake and sends a ping using the framed protocol, falling back to the legacy ping if framing is not negotiated.
+
+ Returns:
+ `true` if a valid "pong" response is received from the listener, `false` otherwise.
"""
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:
+ """
+ Read exactly `expected` bytes from the connected socket and return them.
+
+ Parameters:
+ expected (int): Number of bytes to read.
+
+ Returns:
+ bytes: The bytes read when exactly `expected` bytes are received.
+ None: If the connection is closed or EOF is reached before `expected` bytes are read.
+ """
+ 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]:
+ """
+ Return the contents of the most recently modified Unity MCP status file as a parsed JSON object.
+
+ Reads the newest file matching the pattern `unity-mcp-status-*.json` in the registry directory and returns its parsed JSON content. If no matching file exists or an error occurs while reading or parsing the file, returns `None`.
+
+ Returns:
+ status (dict | None): Parsed JSON object from the latest status file, or `None` if not found or unreadable.
+ """
try:
base = PortDiscovery.get_registry_dir()
status_files = sorted(
@@ -158,3 +219,99 @@ 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:
+ """
+ Derives a simple project name from a Unity project path that points to an Assets folder.
+
+ Parameters:
+ project_path (str): Filesystem path to a Unity project or its Assets directory.
+
+ Returns:
+ str: The last directory name before the trailing "Assets" segment (project name), or "Unknown" if the input is empty or the name cannot be determined.
+ """
+ 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]:
+ """
+ Scan local Unity MCP status files and return UnityInstanceInfo objects for active editor instances.
+
+ Reads per-project status files under the registry directory, parses instance metadata (project path, port, reloading flag, last heartbeat, Unity version), validates that the reported port is responding, and returns a list of UnityInstanceInfo objects for instances whose ports are reachable. Files that fail to parse or instances with non-responsive ports are skipped.
+
+ Returns:
+ List[UnityInstanceInfo]: Discovered Unity editor instances (one entry per responsive status file).
+ """
+ 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
\ No newline at end of file
diff --git a/Server/resources/__init__.py b/Server/resources/__init__.py
index a3577891..411677b7 100644
--- a/Server/resources/__init__.py
+++ b/Server/resources/__init__.py
@@ -2,6 +2,7 @@
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import logging
+import inspect
from pathlib import Path
from fastmcp import FastMCP
@@ -16,12 +17,72 @@
__all__ = ['register_all_resources']
-def register_all_resources(mcp: FastMCP):
+def _create_fixed_wrapper(original_func, has_other_params):
+ """
+ Create a call wrapper that invokes `original_func` with `unity_instance=None`, preserving the original function's synchronous or asynchronous nature.
+
+ Parameters:
+ original_func (callable): The function to wrap; may be synchronous or an async coroutine function.
+ has_other_params (bool): True if the wrapper should accept and forward other parameters besides `unity_instance`.
+
+ Returns:
+ fixed_wrapper (callable): A wrapper function that calls `original_func` with `unity_instance=None`. If `original_func` is async, the wrapper will be an async function; otherwise it will be a regular function. The wrapper forwards any provided args/kwargs when `has_other_params` is True, and accepts no arguments when `has_other_params` is False.
"""
- Auto-discover and register all resources in the resources/ directory.
+ is_async = inspect.iscoroutinefunction(original_func)
+
+ if has_other_params:
+ if is_async:
+ async def fixed_wrapper(*args, **kwargs):
+ """
+ Invoke the original callable with `unity_instance` set to None while forwarding all other arguments.
+
+ @returns The value returned by the original callable.
+ """
+ return await original_func(*args, **kwargs, unity_instance=None)
+ else:
+ def fixed_wrapper(*args, **kwargs):
+ """
+ Call the original resource function with any provided arguments and inject a fixed `unity_instance=None`.
+
+ Parameters:
+ *args: Positional arguments forwarded to the original function.
+ **kwargs: Keyword arguments forwarded to the original function.
+
+ Returns:
+ The value returned by the original function.
+ """
+ return original_func(*args, **kwargs, unity_instance=None)
+ else:
+ if is_async:
+ async def fixed_wrapper():
+ """
+ Call the original resource function with `unity_instance` set to None.
+
+ Returns:
+ The value returned by the original function.
+ """
+ return await original_func(unity_instance=None)
+ else:
+ def fixed_wrapper():
+ """
+ Call the wrapped resource function with unity_instance set to None and return its result.
+
+ Returns:
+ The value returned by the wrapped resource function.
+ """
+ return original_func(unity_instance=None)
+
+ return fixed_wrapper
- Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
- functions will be automatically registered.
+
+def register_all_resources(mcp: FastMCP):
+ """
+ Auto-discovers Python modules in the resources package and registers all discovered MCP resources with the provided FastMCP instance.
+
+ Scans this module's resources directory for modules that contain functions previously decorated/registered as MCP resources, imports them, and registers each discovered resource with the given FastMCP instance. For URIs that include query-parameter templates (identified by '{?'), two registrations are performed for compatibility: a template registration (preserving the original URI and parameters) and a fixed default registration (a second resource with the query portion removed and the function wrapped to call with unity_instance=None). Updates each resource entry to reference the template version when both registrations occur. If no resources are found, the function logs a warning and returns without raising.
+
+ Parameters:
+ mcp (FastMCP): The FastMCP instance used to register discovered resources.
"""
logger.info("Auto-discovering MCP for Unity Server resources...")
# Dynamic import of all modules in this directory
@@ -36,6 +97,7 @@ def register_all_resources(mcp: FastMCP):
logger.warning("No MCP resources registered!")
return
+ registered_count = 0
for resource_info in resources:
func = resource_info['func']
uri = resource_info['uri']
@@ -43,11 +105,68 @@ def register_all_resources(mcp: FastMCP):
description = resource_info['description']
kwargs = resource_info['kwargs']
- # Apply the @mcp.resource decorator and telemetry
- wrapped = telemetry_resource(resource_name)(func)
- wrapped = mcp.resource(uri=uri, name=resource_name,
- description=description, **kwargs)(wrapped)
- resource_info['func'] = wrapped
- logger.debug(f"Registered resource: {resource_name} - {description}")
+ # Check if URI contains query parameters (e.g., {?unity_instance})
+ has_query_params = '{?' in uri
+
+ if has_query_params:
+ # Register two versions for backward compatibility:
+ # 1. Template version with query parameters (for multi-instance)
+ wrapped_template = telemetry_resource(resource_name)(func)
+ wrapped_template = mcp.resource(uri=uri, name=resource_name,
+ description=description, **kwargs)(wrapped_template)
+ logger.debug(f"Registered resource template: {resource_name} - {uri}")
+ registered_count += 1
+
+ # 2. Fixed version without query parameters (for single-instance/default)
+ # Remove query parameters from URI
+ fixed_uri = uri.split('{?')[0]
+ fixed_name = f"{resource_name}_default"
+ fixed_description = f"{description} (default instance)"
+
+ # Create a wrapper function that doesn't accept unity_instance parameter
+ # This wrapper will call the original function with unity_instance=None
+ sig = inspect.signature(func)
+ params = list(sig.parameters.values())
+
+ # Filter out unity_instance parameter
+ fixed_params = [p for p in params if p.name != 'unity_instance']
+
+ # Create wrapper using factory function to avoid closure issues
+ has_other_params = len(fixed_params) > 0
+ fixed_wrapper = _create_fixed_wrapper(func, has_other_params)
+
+ # Update signature to match filtered parameters
+ if has_other_params:
+ fixed_wrapper.__signature__ = sig.replace(parameters=fixed_params)
+ fixed_wrapper.__annotations__ = {
+ k: v for k, v in getattr(func, '__annotations__', {}).items()
+ if k != 'unity_instance'
+ }
+ else:
+ fixed_wrapper.__signature__ = inspect.Signature(parameters=[])
+ fixed_wrapper.__annotations__ = {
+ k: v for k, v in getattr(func, '__annotations__', {}).items()
+ if k == 'return'
+ }
+
+ # Preserve function metadata
+ fixed_wrapper.__name__ = fixed_name
+ fixed_wrapper.__doc__ = func.__doc__
+
+ wrapped_fixed = telemetry_resource(fixed_name)(fixed_wrapper)
+ wrapped_fixed = mcp.resource(uri=fixed_uri, name=fixed_name,
+ description=fixed_description, **kwargs)(wrapped_fixed)
+ logger.debug(f"Registered resource (fixed): {fixed_name} - {fixed_uri}")
+ registered_count += 1
+
+ resource_info['func'] = wrapped_template
+ else:
+ # No query parameters, register as-is
+ wrapped = telemetry_resource(resource_name)(func)
+ wrapped = mcp.resource(uri=uri, name=resource_name,
+ description=description, **kwargs)(wrapped)
+ resource_info['func'] = wrapped
+ logger.debug(f"Registered resource: {resource_name} - {description}")
+ registered_count += 1
- logger.info(f"Registered {len(resources)} MCP resources")
+ logger.info(f"Registered {registered_count} MCP resources ({len(resources)} unique)")
\ No newline at end of file
diff --git a/Server/resources/menu_items.py b/Server/resources/menu_items.py
index d3724659..d748c896 100644
--- a/Server/resources/menu_items.py
+++ b/Server/resources/menu_items.py
@@ -8,18 +8,24 @@ class GetMenuItemsResponse(MCPResponse):
@mcp_for_unity_resource(
- uri="mcpforunity://menu-items",
+ uri="mcpforunity://menu-items{?unity_instance}",
name="get_menu_items",
description="Provides a list of all menu items."
)
-async def get_menu_items() -> GetMenuItemsResponse:
- """Provides a list of all menu items."""
- # Later versions of FastMCP support these as query parameters
- # See: https://gofastmcp.com/servers/resources#query-parameters
+async def get_menu_items(unity_instance: str | None = None) -> GetMenuItemsResponse:
+ """
+ Provides a list of all menu items.
+
+ Parameters:
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'). If None, the default instance is used.
+
+ Returns:
+ GetMenuItemsResponse: When the backend response is a dictionary, returns a GetMenuItemsResponse constructed from that data; otherwise returns the raw backend response.
+ """
params = {
"refresh": True,
"search": "",
}
- response = await async_send_command_with_retry("get_menu_items", params)
- return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
+ response = await async_send_command_with_retry("get_menu_items", params, instance_id=unity_instance)
+ return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/Server/resources/tests.py b/Server/resources/tests.py
index 4268a143..826f0d3c 100644
--- a/Server/resources/tests.py
+++ b/Server/resources/tests.py
@@ -17,15 +17,35 @@ class GetTestsResponse(MCPResponse):
data: list[TestItem] = []
-@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.")
-async def get_tests() -> GetTestsResponse:
- """Provides a list of all tests."""
- response = await async_send_command_with_retry("get_tests", {})
+@mcp_for_unity_resource(uri="mcpforunity://tests{?unity_instance}", name="get_tests", description="Provides a list of all tests.")
+async def get_tests(unity_instance: str | None = None) -> GetTestsResponse:
+ """
+ Provides a list of all tests.
+
+ Parameters:
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ GetTestsResponse: Response containing the list of tests in its `data` field when the underlying response is a dict; otherwise returns the raw response value.
+ """
+ response = await async_send_command_with_retry("get_tests", {}, instance_id=unity_instance)
return GetTestsResponse(**response) if isinstance(response, dict) else response
-@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.")
-async def get_tests_for_mode(mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")]) -> GetTestsResponse:
- """Provides a list of tests for a specific mode."""
- response = await async_send_command_with_retry("get_tests_for_mode", {"mode": mode})
- return GetTestsResponse(**response) if isinstance(response, dict) else response
+@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}{?unity_instance}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.")
+async def get_tests_for_mode(
+ mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
+ unity_instance: str | None = None
+) -> GetTestsResponse:
+ """
+ Provides a list of tests for a specific mode.
+
+ Parameters:
+ mode (Literal["EditMode", "PlayMode"]): The test mode to filter by.
+ unity_instance (str | None): Target Unity instance (project name, hash, or "Name@hash"). If not specified, the default instance is used.
+
+ Returns:
+ GetTestsResponse | Any: A GetTestsResponse containing the tests when the backend returns a dict; otherwise returns the raw response value.
+ """
+ response = await async_send_command_with_retry("get_tests_for_mode", {"mode": mode}, instance_id=unity_instance)
+ return GetTestsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/Server/server.py b/Server/server.py
index 11053ac8..7027ab4a 100644
--- a/Server/server.py
+++ b/Server/server.py
@@ -3,12 +3,13 @@
import logging
from logging.handlers import RotatingFileHandler
import os
+import argparse
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any
from config import config
from tools import register_all_tools
from resources import register_all_resources
-from unity_connection import get_unity_connection, UnityConnection
+from unity_connection import get_unity_connection_pool, UnityConnectionPool
import time
# Configure logging using settings from config
@@ -61,14 +62,19 @@
except Exception:
pass
-# Global connection state
-_unity_connection: UnityConnection = None
+# Global connection pool
+_unity_connection_pool: UnityConnectionPool = None
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
- """Handle server startup and shutdown."""
- global _unity_connection
+ """
+ Manage the server lifecycle: initialize the Unity connection pool and telemetry on startup, yield a context exposing the pool, and disconnect all Unity connections on shutdown.
+
+ Yields:
+ dict: A context mapping with key "pool" to the initialized UnityConnectionPool instance or None if not created.
+ """
+ global _unity_connection_pool
logger.info("MCP for Unity Server starting up")
# Record server startup telemetry
@@ -101,22 +107,35 @@ def _emit_startup():
logger.info(
"Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
else:
- _unity_connection = get_unity_connection()
- logger.info("Connected to Unity on startup")
-
- # Record successful Unity connection (deferred)
- import threading as _t
- _t.Timer(1.0, lambda: record_telemetry(
- RecordType.UNITY_CONNECTION,
- {
- "status": "connected",
- "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
- }
- )).start()
+ # Initialize connection pool and discover instances
+ _unity_connection_pool = get_unity_connection_pool()
+ instances = _unity_connection_pool.discover_all_instances()
+
+ if instances:
+ logger.info(f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
+
+ # Try to connect to default instance
+ try:
+ _unity_connection_pool.get_connection()
+ logger.info("Connected to default Unity instance on startup")
+
+ # Record successful Unity connection (deferred)
+ import threading as _t
+ _t.Timer(1.0, lambda: record_telemetry(
+ RecordType.UNITY_CONNECTION,
+ {
+ "status": "connected",
+ "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
+ "instance_count": len(instances)
+ }
+ )).start()
+ except Exception as e:
+ logger.warning("Could not connect to default Unity instance: %s", e)
+ else:
+ logger.warning("No Unity instances found on startup")
except ConnectionError as e:
logger.warning("Could not connect to Unity on startup: %s", e)
- _unity_connection = None
# Record connection failure (deferred)
import threading as _t
@@ -132,7 +151,6 @@ def _emit_startup():
except Exception as e:
logger.warning(
"Unexpected error connecting to Unity on startup: %s", e)
- _unity_connection = None
import threading as _t
_err_msg = str(e)[:200]
_t.Timer(1.0, lambda: record_telemetry(
@@ -145,13 +163,12 @@ def _emit_startup():
)).start()
try:
- # Yield the connection object so it can be attached to the context
- # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)
- yield {"bridge": _unity_connection}
+ # Yield the connection pool so it can be attached to the context
+ # Note: Tools will use get_unity_connection_pool() directly
+ yield {"pool": _unity_connection_pool}
finally:
- if _unity_connection:
- _unity_connection.disconnect()
- _unity_connection = None
+ if _unity_connection_pool:
+ _unity_connection_pool.disconnect_all()
logger.info("MCP for Unity Server shut down")
# Initialize MCP server
@@ -187,10 +204,46 @@ def _emit_startup():
def main():
- """Entry point for uvx and console scripts."""
+ """
+ Start the MCP server using command-line options and environment variables.
+
+ Parses command-line arguments (supports --default-instance). If a default instance is provided, sets UNITY_MCP_DEFAULT_INSTANCE and logs the selection. Starts the MCP server using the stdio transport.
+ """
+ parser = argparse.ArgumentParser(
+ description="MCP for Unity Server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Environment Variables:
+ UNITY_MCP_DEFAULT_INSTANCE Default Unity instance to target (project name, hash, or 'Name@hash')
+ UNITY_MCP_SKIP_STARTUP_CONNECT Skip initial Unity connection attempt (set to 1/true/yes/on)
+ UNITY_MCP_TELEMETRY_ENABLED Enable telemetry (set to 1/true/yes/on)
+
+Examples:
+ # Use specific Unity project as default
+ python -m src.server --default-instance "MyProject"
+
+ # Or use environment variable
+ UNITY_MCP_DEFAULT_INSTANCE="MyProject" python -m src.server
+ """
+ )
+ parser.add_argument(
+ "--default-instance",
+ type=str,
+ metavar="INSTANCE",
+ help="Default Unity instance to target (project name, hash, or 'Name@hash'). "
+ "Overrides UNITY_MCP_DEFAULT_INSTANCE environment variable."
+ )
+
+ args = parser.parse_args()
+
+ # Set environment variable if --default-instance is provided
+ if args.default_instance:
+ os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance
+ logger.info(f"Using default Unity instance from command-line: {args.default_instance}")
+
mcp.run(transport='stdio')
# Run the server
if __name__ == "__main__":
- main()
+ main()
\ No newline at end of file
diff --git a/Server/tools/execute_menu_item.py b/Server/tools/execute_menu_item.py
index a1489c59..b4c643f1 100644
--- a/Server/tools/execute_menu_item.py
+++ b/Server/tools/execute_menu_item.py
@@ -17,9 +17,21 @@ async def execute_menu_item(
ctx: Context,
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> MCPResponse:
+ """
+ Execute a Unity Editor menu item identified by its menu path.
+
+ Parameters:
+ menu_path (str | None): Unity menu path (for example, 'File/Save Project'). If omitted, no menu path is sent.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'). If omitted, the default instance is used.
+
+ Returns:
+ MCPResponse | Any: An MCPResponse constructed from Unity's response when the command returns a dictionary, otherwise the raw result value.
+ """
await ctx.info(f"Processing execute_menu_item: {menu_path}")
params_dict: dict[str, Any] = {"menuPath": menu_path}
params_dict = {k: v for k, v in params_dict.items() if v is not None}
- result = await async_send_command_with_retry("execute_menu_item", params_dict)
- return MCPResponse(**result) if isinstance(result, dict) else result
+ result = await async_send_command_with_retry("execute_menu_item", params_dict, instance_id=unity_instance)
+ return MCPResponse(**result) if isinstance(result, dict) else result
\ No newline at end of file
diff --git a/Server/tools/list_unity_instances.py b/Server/tools/list_unity_instances.py
new file mode 100644
index 00000000..b2c7c688
--- /dev/null
+++ b/Server/tools/list_unity_instances.py
@@ -0,0 +1,67 @@
+"""
+Tool to list all available Unity Editor instances.
+"""
+from typing import Annotated, Any
+
+from fastmcp import Context
+from registry import mcp_for_unity_tool
+from unity_connection import get_unity_connection_pool
+
+
+@mcp_for_unity_tool(description="List all running Unity Editor instances with their details.")
+def list_unity_instances(
+ ctx: Context,
+ force_refresh: Annotated[bool, "Force refresh the instance list, bypassing cache"] = False
+) -> dict[str, Any]:
+ """
+ Produce a structured summary of all detected Unity Editor instances.
+
+ Parameters:
+ force_refresh (bool): If True, bypass cached discovery and rescan for instances.
+
+ Returns:
+ dict: A result dictionary with the following keys:
+ - success (bool): `True` if discovery completed, `False` on error.
+ - instance_count (int): Number of instances discovered.
+ - instances (list[dict]): List of instance summaries; each dictionary includes
+ keys such as `id` (ProjectName@hash), `name`, `path`, `hash`, `port`,
+ `status`, `last_heartbeat`, and `unity_version` when available.
+ - warning (str, optional): Present when duplicate project names are detected,
+ advising use of the full `ProjectName@hash` format to disambiguate.
+ - error (str, optional): Present when `success` is `False`, describing the failure.
+ """
+ ctx.info(f"Listing Unity instances (force_refresh={force_refresh})")
+
+ try:
+ pool = get_unity_connection_pool()
+ instances = pool.discover_all_instances(force_refresh=force_refresh)
+
+ # Check for duplicate project names
+ name_counts = {}
+ for inst in instances:
+ name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
+
+ duplicates = [name for name, count in name_counts.items() if count > 1]
+
+ result = {
+ "success": True,
+ "instance_count": len(instances),
+ "instances": [inst.to_dict() for inst in instances],
+ }
+
+ if duplicates:
+ result["warning"] = (
+ f"Multiple instances found with duplicate project names: {duplicates}. "
+ f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
+ )
+
+ return result
+
+ except Exception as e:
+ ctx.error(f"Error listing Unity instances: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to list Unity instances: {str(e)}",
+ "instance_count": 0,
+ "instances": []
+ }
\ No newline at end of file
diff --git a/Server/tools/manage_asset.py b/Server/tools/manage_asset.py
index a577e94d..bbb0cb9e 100644
--- a/Server/tools/manage_asset.py
+++ b/Server/tools/manage_asset.py
@@ -31,9 +31,32 @@ async def manage_asset(
filter_date_after: Annotated[str,
"Date after which to filter"] | None = None,
page_size: Annotated[int | float | str, "Page size for pagination"] | None = None,
- page_number: Annotated[int | float | str, "Page number for pagination"] | None = None
+ page_number: Annotated[int | float | str, "Page number for pagination"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None
) -> dict[str, Any]:
- ctx.info(f"Processing manage_asset: {action}")
+ """
+ Perform an asset operation in a target Unity instance.
+
+ Parameters:
+ ctx (Context): Execution context for logging and interaction.
+ action (Literal[...]): Operation to perform: "import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", or "get_components".
+ path (str): Asset path (e.g., "Materials/MyMaterial.mat") or search scope.
+ asset_type (str | None): Asset type required when creating an asset (e.g., "Material", "Folder").
+ properties (dict[str, Any] | None): Properties for "create" or "modify"; if a JSON string is provided it will be parsed to a dict.
+ destination (str | None): Target path for "duplicate" or "move".
+ generate_preview (bool): If true, request generation of a preview/thumbnail when supported.
+ search_pattern (str | None): Pattern for searching assets (e.g., "*.prefab").
+ filter_type (str | None): Additional filter to apply during search.
+ filter_date_after (str | None): ISO-like date string to filter assets modified after this date.
+ page_size (int | float | str | None): Page size for paginated search results; non-integer or invalid values are coerced or ignored.
+ page_number (int | float | str | None): Page number for paginated search results; non-integer or invalid values are coerced or ignored.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted the default instance is used.
+
+ Returns:
+ result (dict[str, Any]): Response from Unity as a dictionary; on unexpected non-dict responses returns {"success": False, "message": }.
+ """
+ ctx.info(f"Processing manage_asset: {action} (unity_instance={unity_instance or 'default'})")
# Coerce 'properties' from JSON string to dict for client compatibility
if isinstance(properties, str):
try:
@@ -86,7 +109,7 @@ def _coerce_int(value, default=None):
# Get the current asyncio event loop
loop = asyncio.get_running_loop()
- # Use centralized async retry helper to avoid blocking the event loop
- result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
+ # Use centralized async retry helper with instance routing
+ result = await async_send_command_with_retry("manage_asset", params_dict, instance_id=unity_instance, loop=loop)
# Return the result obtained from Unity
- return result if isinstance(result, dict) else {"success": False, "message": str(result)}
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}
\ No newline at end of file
diff --git a/Server/tools/manage_editor.py b/Server/tools/manage_editor.py
index f7911458..126c5867 100644
--- a/Server/tools/manage_editor.py
+++ b/Server/tools/manage_editor.py
@@ -21,7 +21,26 @@ def manage_editor(
"Tag name when adding and removing tags"] | None = None,
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage or query Unity Editor state and perform editor-related actions.
+
+ Parameters:
+ wait_for_completion (bool | str | None): Optional. If True, wait for actions that support completion waiting; accepts boolean or string representations like 'true'/'false'.
+ tool_name (str | None): Name of the tool when setting the active tool.
+ tag_name (str | None): Tag name when adding or removing tags.
+ layer_name (str | None): Layer name when adding or removing layers.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or 'Name@hash'); if omitted, the default instance is used.
+
+ Returns:
+ dict[str, Any]: Result object. On success may contain
+ - "success" (bool): true,
+ - "message" (str): human-readable status,
+ - "data" (Any): optional payload returned by the editor.
+ On failure returns a dict with "success": False and a "message" describing the error or failure reason.
+ """
ctx.info(f"Processing manage_editor: {action}")
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
@@ -62,8 +81,8 @@ def _coerce_bool(value, default=None):
}
params = {k: v for k, v in params.items() if v is not None}
- # Send command using centralized retry helper
- response = send_command_with_retry("manage_editor", params)
+ # Send command using centralized retry helper with instance routing
+ response = send_command_with_retry("manage_editor", params, instance_id=unity_instance)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
@@ -71,4 +90,4 @@ def _coerce_bool(value, default=None):
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
- return {"success": False, "message": f"Python error managing editor: {str(e)}"}
+ return {"success": False, "message": f"Python error managing editor: {str(e)}"}
\ No newline at end of file
diff --git a/Server/tools/manage_gameobject.py b/Server/tools/manage_gameobject.py
index 794013b9..73a8a1f4 100644
--- a/Server/tools/manage_gameobject.py
+++ b/Server/tools/manage_gameobject.py
@@ -64,7 +64,37 @@ def manage_gameobject(
# Controls whether serialization of private [SerializeField] fields is included
includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Perform the requested action on a Unity GameObject or its components.
+
+ Parameters:
+ ctx (Context): Execution context (logging, environment) — not documented further.
+ action (Literal): One of "create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component".
+ target (str|None): Identifier (name or path) of the target GameObject for modify/delete/component actions.
+ search_method (Literal|None): Strategy for locating objects (e.g., "by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"); used with `find` and some target lookups.
+ name (str|None): Initial or new GameObject name for "create" or "modify". Do NOT use for "find" — use `search_term`.
+ search_term (str|None): Term to search with when action is "find". Required for "find"; `name` must not be provided.
+ position, rotation, scale (list[float]|str|None): 3-element vectors. Accepts list [x,y,z] or tolerant string forms like "[x,y,z]", "x,y,z", or "x y z".
+ save_as_prefab (bool|str|None): If truthy, saves created GameObject as a prefab. Accepts booleans or string booleans.
+ prefab_path (str|None): Explicit path for prefab (must end with ".prefab" if provided). If omitted and saving as prefab, a default path is constructed from `prefab_folder` and `name`.
+ prefab_folder (str|None): Folder used when constructing a default prefab path.
+ component_properties (dict|str|None): Mapping of component names to property dictionaries to set. May be provided as a JSON string (will be parsed). Example:
+ {"MyScript": {"player": {"find": "Player", "component": "HealthComponent"}}} or {"MeshRenderer": {"sharedMaterial.color": [1,0,0,1]}}.
+ components_to_add / components_to_remove (list[str]|None): Component names to add or remove.
+ set_active (bool|str|None), find_all (bool|str|None), search_in_children (bool|str|None), search_inactive (bool|str|None), includeNonPublicSerialized (bool|str|None):
+ Boolean-like flags accepted as booleans or string booleans ("true"/"false", "1"/"0", etc.).
+ component_name (str|None): Component name used by add/remove component actions.
+ unity_instance (str|None): Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted, the default instance is used.
+
+ Returns:
+ dict: Result object with keys:
+ - "success" (bool): `true` when the operation succeeded, `false` otherwise.
+ - "message" (str): Human-readable status or error message.
+ - "data" (Any|None): Additional data returned by the Unity side when available.
+ """
ctx.info(f"Processing manage_gameobject: {action}")
# Coercers to tolerate stringified booleans and vectors
@@ -195,8 +225,8 @@ def _to_vec3(parts):
params.pop("prefabFolder", None)
# --------------------------------
- # Use centralized retry helper
- response = send_command_with_retry("manage_gameobject", params)
+ # Use centralized retry helper with instance routing
+ response = send_command_with_retry("manage_gameobject", params, instance_id=unity_instance)
# Check if the response indicates success
# If the response is not successful, raise an exception with the error message
diff --git a/Server/tools/manage_prefabs.py b/Server/tools/manage_prefabs.py
index 2540e9f2..51d83c5f 100644
--- a/Server/tools/manage_prefabs.py
+++ b/Server/tools/manage_prefabs.py
@@ -28,7 +28,28 @@ def manage_prefabs(
"Allow replacing an existing prefab at the same path"] | None = None,
search_inactive: Annotated[bool,
"Include inactive objects when resolving the target name"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage prefab stages and create prefabs in a connected Unity instance.
+
+ Parameters:
+ action: One of "open_stage", "close_stage", "save_open_stage", or "create_from_gameobject" specifying the prefab operation to perform.
+ prefab_path: Prefab asset path relative to the project Assets folder (e.g. "Assets/Prefabs/favorite.prefab"); used for stage/open/save operations and creation target.
+ mode: Optional prefab stage mode (currently only "InIsolation" is supported).
+ save_before_close: When true and action is "close_stage", save the prefab before exiting the stage.
+ target: Scene GameObject name required when action is "create_from_gameobject".
+ allow_overwrite: When true, allow replacing an existing prefab at the specified prefab_path.
+ search_inactive: When true, include inactive GameObjects when resolving the target name.
+ unity_instance: Identifier for the target Unity instance (project name, hash, or "Name@hash"); if omitted, the default instance is used.
+
+ Returns:
+ result (dict[str, Any]): Outcome object with:
+ - "success" (bool): `true` if the operation succeeded, `false` otherwise.
+ - "message" (str): Human-readable status or error message.
+ - "data" (optional): Additional result data returned by the Unity tool.
+ """
ctx.info(f"Processing manage_prefabs: {action}")
try:
params: dict[str, Any] = {"action": action}
@@ -45,7 +66,7 @@ def manage_prefabs(
params["allowOverwrite"] = bool(allow_overwrite)
if search_inactive is not None:
params["searchInactive"] = bool(search_inactive)
- response = send_command_with_retry("manage_prefabs", params)
+ response = send_command_with_retry("manage_prefabs", params, instance_id=unity_instance)
if isinstance(response, dict) and response.get("success"):
return {
@@ -55,4 +76,4 @@ def manage_prefabs(
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as exc:
- return {"success": False, "message": f"Python error managing prefabs: {exc}"}
+ return {"success": False, "message": f"Python error managing prefabs: {exc}"}
\ No newline at end of file
diff --git a/Server/tools/manage_scene.py b/Server/tools/manage_scene.py
index 50927ca9..9e6faa21 100644
--- a/Server/tools/manage_scene.py
+++ b/Server/tools/manage_scene.py
@@ -15,7 +15,27 @@ def manage_scene(
"Asset path for scene operations (default: 'Assets/')"] | None = None,
build_index: Annotated[int | str,
"Build index for load/build settings actions (accepts int or string, e.g., 0 or '0')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage Unity scenes by creating, loading, saving, or querying scene information.
+
+ Parameters:
+ action (Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"]):
+ Operation to perform on the scene.
+ name (str | None):
+ Scene name for operations that require a scene (not required for `get_active` or `get_build_settings`).
+ path (str | None):
+ Asset path for scene operations (defaults to "Assets/" if not provided).
+ build_index (int | str | None):
+ Build index for load or build-settings operations. Accepts integers or numeric strings; empty, boolean, or non-convertible values are treated as unspecified.
+ unity_instance (str | None):
+ Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict: A result dictionary. On success: `{"success": True, "message": , "data": }`. On failure: a dict with `success: False` and a `message` describing the error, or the original response dict if it was non-success structured data.
+ """
ctx.info(f"Processing manage_scene: {action}")
try:
# Coerce numeric inputs defensively
@@ -44,8 +64,8 @@ def _coerce_int(value, default=None):
if coerced_build_index is not None:
params["buildIndex"] = coerced_build_index
- # Use centralized retry helper
- response = send_command_with_retry("manage_scene", params)
+ # Use centralized retry helper with instance routing
+ response = send_command_with_retry("manage_scene", params, instance_id=unity_instance)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
@@ -53,4 +73,4 @@ def _coerce_int(value, default=None):
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
- return {"success": False, "message": f"Python error managing scene: {str(e)}"}
+ return {"success": False, "message": f"Python error managing scene: {str(e)}"}
\ No newline at end of file
diff --git a/Server/tools/manage_script.py b/Server/tools/manage_script.py
index 6ed8cbca..423e574c 100644
--- a/Server/tools/manage_script.py
+++ b/Server/tools/manage_script.py
@@ -85,7 +85,28 @@ def apply_text_edits(
"Optional strict flag, used to enforce strict mode"] | None = None,
options: Annotated[dict[str, Any],
"Optional options, used to pass additional options to the script editor"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Apply one or more text edits to a C# script located under the Assets/ directory.
+
+ Parameters:
+ ctx (Context): Execution context / logger.
+ uri (str): URI or path identifying the script (e.g. unity://path/Assets/..., file://..., or Assets/...).
+ edits (list[dict[str, Any]]): List of edits to apply. Each edit must include either explicit 1-based
+ `startLine`, `startCol`, `endLine`, `endCol`, and `newText`, or a normalizable `range` (LSP-style
+ `{start:{line,character},end:{...}}` or a `[startIndex,endIndex]` pair) and `newText`/`text`.
+ precondition_sha256 (str|None): Optional SHA256 expected for the current file to guard against concurrent changes.
+ strict (bool|None): If true, reject edits that require normalization (e.g., zero-based explicit fields) instead of auto-normalizing.
+ options (dict[str, Any]|None): Additional editor options (e.g., `applyMode`, `debug_preview`, `force_sentinel_reload`).
+ unity_instance (str|None): Target Unity instance identifier; if omitted the default instance is used.
+
+ Returns:
+ dict[str, Any]: The Unity/tool response. On success `data.normalizedEdits` will contain the edits sent,
+ and `data.warnings` may include normalization warnings. On failure returns a dict with `success: False`
+ and error `code`/`message` (e.g., overlap, missing_field, zero_based_explicit_fields).
+ """
ctx.info(f"Processing apply_text_edits: {uri}")
name, directory = _split_uri(uri)
@@ -107,7 +128,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
"action": "read",
"name": name,
"path": directory,
- })
+ }, instance_id=unity_instance)
if not (isinstance(read_resp, dict) and read_resp.get("success")):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data", {})
@@ -304,7 +325,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
"options": opts,
}
params = {k: v for k, v in params.items() if v is not None}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
@@ -331,6 +352,11 @@ def _latest_status() -> dict | None:
return None
def _flip_async():
+ """
+ Trigger a sentinel flip in the Unity editor if the editor is not currently reloading.
+
+ Performs the action after a short delay; if the editor is not in a reloading state, sends the "MCP/Flip Reload Sentinel" menu command to the configured Unity instance. All exceptions raised during the operation are suppressed and the function does not return a value.
+ """
try:
time.sleep(0.1)
st = _latest_status()
@@ -341,6 +367,7 @@ def _flip_async():
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
+ instance_id=unity_instance,
)
except Exception:
pass
@@ -359,7 +386,23 @@ def create_script(
contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Create a new C# script file under the project's Assets/ directory.
+
+ Parameters:
+ ctx (Context): Request context and logger.
+ path (str): Path under Assets/ where the script will be created (e.g., "Assets/Scripts/My.cs").
+ contents (str): Script contents to write; will be base64-encoded for transport.
+ script_type (str | None): Script type (for example, "C#"); included in the create request when provided.
+ namespace (str | None): Namespace to assign to the new script.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict: Unity tool response on success, or a dict with "success": False and an error code/message on validation or failure.
+ """
ctx.info(f"Processing create_script: {path}")
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
@@ -386,22 +429,33 @@ def create_script(
contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path."))
def delete_script(
ctx: Context,
- uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
+ uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
- """Delete a C# script by URI."""
+ """
+ Delete a C# script identified by a URI under the Assets/ directory.
+
+ Parameters:
+ uri (str): Path or URI pointing to a script inside the project Assets (examples: "unity://path/Assets/...", "file://...", or "Assets/...").
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict: Operation result from the Unity toolbridge. On success includes `success: True` and any returned data; on failure includes `success: False` and error details.
+ """
ctx.info(f"Processing delete_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@@ -412,8 +466,26 @@ def validate_script(
level: Annotated[Literal['basic', 'standard'],
"Validation level"] = "basic",
include_diagnostics: Annotated[bool,
- "Include full diagnostics and summary"] = False
+ "Include full diagnostics and summary"] = False,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Validate a C# script under the Assets/ tree and optionally return diagnostics.
+
+ Parameters:
+ ctx (Context): Request context / logger.
+ uri (str): URI or path resolving to a script under Assets/ (e.g. unity://path/Assets/..., file://..., or Assets/...).
+ level (Literal['basic','standard']): Validation thoroughness; must be 'basic' or 'standard'.
+ include_diagnostics (bool): If True, include the full diagnostics list and a warnings/errors summary; otherwise return only the summary counts.
+ unity_instance (str|None): Optional target Unity instance identifier; if omitted the default instance is used.
+
+ Returns:
+ dict: On success, a dict with "success": True and "data" containing either:
+ - {"diagnostics": [...], "summary": {"warnings": int, "errors": int}} when include_diagnostics is True, or
+ - {"warnings": int, "errors": int} when include_diagnostics is False.
+ On failure, a dict with "success": False and an error code/message (for example "path_outside_assets" or "bad_level"), or the raw response from the Unity bridge if it indicates an error.
+ """
ctx.info(f"Processing validate_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
@@ -426,7 +498,7 @@ def validate_script(
"path": directory,
"level": level,
}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(
@@ -450,7 +522,25 @@ def manage_script(
script_type: Annotated[str, "Script type (e.g., 'C#')",
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Route create/read/delete script operations to the Unity toolbridge and normalize encoded contents in responses.
+
+ Parameters:
+ ctx (Context): Execution context for logging and tool calls.
+ action (Literal['create','read','delete']): Operation to perform on the script.
+ name (str): Script base name without the `.cs` extension.
+ path (str): Asset-relative path for the script (for example, "Assets/Scripts/My.cs").
+ contents (str | None): Script contents to send for create or update operations.
+ script_type (str | None): Optional script type hint (for example, "MonoBehaviour").
+ namespace (str | None): Optional namespace to assign to the script.
+ unity_instance (str | None): Optional target Unity instance identifier; if omitted the default instance is used.
+
+ Returns:
+ dict[str, Any]: Operation result. On success includes `"success": True`, a `"message"`, and optional `"data"` (when present, any encoded contents are decoded to `data["contents"]`). On failure returns a dict with `"success": False` and a `"message"`.
+ """
ctx.info(f"Processing manage_script: {action}")
try:
# Prepare parameters for Unity
@@ -473,7 +563,7 @@ def manage_script(
params = {k: v for k, v in params.items() if v is not None}
- response = unity_connection.send_command_with_retry("manage_script", params)
+ response = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(response, dict):
if response.get("success"):
@@ -535,13 +625,26 @@ def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
@mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
def get_sha(
ctx: Context,
- uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
+ uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Retrieve the SHA-256 hash and byte length for a script located under the Assets/ directory.
+
+ Parameters:
+ uri (str): Path or URI identifying the script (e.g., "unity://path/Assets/...", "file://...", or "Assets/...").
+ unity_instance (str | None): Optional Unity instance identifier (project name, hash, or "Name@hash"); when omitted the default instance is used.
+
+ Returns:
+ dict: On success, {"success": True, "data": {"sha256": , "lengthBytes": }}.
+ On failure, either the original response dict from Unity or {"success": False, "message": }.
+ """
ctx.info(f"Processing get_sha: {uri}")
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
- resp = unity_connection.send_command_with_retry("manage_script", params)
+ resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get(
@@ -549,4 +652,4 @@ def get_sha(
return {"success": True, "data": minimal}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
except Exception as e:
- return {"success": False, "message": f"get_sha error: {e}"}
+ return {"success": False, "message": f"get_sha error: {e}"}
\ No newline at end of file
diff --git a/Server/tools/manage_shader.py b/Server/tools/manage_shader.py
index 19b94550..cc871556 100644
--- a/Server/tools/manage_shader.py
+++ b/Server/tools/manage_shader.py
@@ -16,7 +16,26 @@ def manage_shader(
path: Annotated[str, "Asset path (default: \"Assets/\")"],
contents: Annotated[str,
"Shader code for 'create'/'update'"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Manage a shader asset in a Unity project by performing create, read, update, or delete operations.
+
+ Parameters:
+ ctx (Context): Execution context used for logging and propagation.
+ action (Literal['create','read','update','delete']): CRUD operation to perform on the shader.
+ name (str): Shader name without the file extension.
+ path (str): Asset path in the Unity project (default: "Assets/").
+ contents (str | None): Shader source code for 'create' or 'update'; ignored for other actions unless provided for informational purposes.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+
+ Returns:
+ dict[str, Any]: A result dictionary with:
+ - `success` (bool): `true` if the operation succeeded, `false` otherwise.
+ - `message` (str): Human-readable status or error message.
+ - `data` (optional): Operation-specific payload; when present, `data["contents"]` will contain decoded shader source if the Unity response included encoded contents.
+ """
ctx.info(f"Processing manage_shader: {action}")
try:
# Prepare parameters for Unity
@@ -39,8 +58,8 @@ def manage_shader(
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
- # Send command via centralized retry helper
- response = send_command_with_retry("manage_shader", params)
+ # Send command via centralized retry helper with instance routing
+ response = send_command_with_retry("manage_shader", params, instance_id=unity_instance)
# Process response from Unity
if isinstance(response, dict) and response.get("success"):
@@ -57,4 +76,4 @@ def manage_shader(
except Exception as e:
# Handle Python-side errors (e.g., connection issues)
- return {"success": False, "message": f"Python error managing shader: {str(e)}"}
+ return {"success": False, "message": f"Python error managing shader: {str(e)}"}
\ No newline at end of file
diff --git a/Server/tools/read_console.py b/Server/tools/read_console.py
index d922982c..9e731b15 100644
--- a/Server/tools/read_console.py
+++ b/Server/tools/read_console.py
@@ -23,8 +23,27 @@ def read_console(
format: Annotated[Literal['plain', 'detailed',
'json'], "Output format"] | None = None,
include_stacktrace: Annotated[bool | str,
- "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None
+ "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None
) -> dict[str, Any]:
+ """
+ Retrieve or clear Unity Editor console messages and return the tool response.
+
+ Parameters:
+ ctx (Context): Execution context (passed by the MCP runtime).
+ action (Literal['get','clear'] | None): "get" to fetch messages or "clear" to clear the console; defaults to "get".
+ types (list[str] | None): List of message types to include: "error", "warning", "log", or "all"; defaults to ["error", "warning", "log"].
+ count (int | str | None): Maximum number of messages to return; accepts integers or numeric strings. If None, the request explicitly sends null (interpreted by the receiver as no limit).
+ filter_text (str | None): Substring filter for message text.
+ since_timestamp (str | None): ISO 8601 timestamp; only messages after this time are returned.
+ format (Literal['plain','detailed','json'] | None): Output format; defaults to "detailed".
+ include_stacktrace (bool | str | None): Whether to include stack traces; accepts booleans or string equivalents ("true"/"false"); defaults to True. When False, returned message entries will not contain stacktrace fields.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); when omitted, the default instance is used.
+
+ Returns:
+ dict[str, Any]: The response object from the Unity command. On success, contains a "data" key with console lines; on failure, contains "success": False and a "message" describing the error.
+ """
ctx.info(f"Processing read_console: {action}")
# Set defaults if values are None
action = action if action is not None else 'get'
@@ -87,8 +106,8 @@ def _coerce_int(value, default=None):
if 'count' not in params_dict:
params_dict['count'] = None
- # Use centralized retry helper
- resp = send_command_with_retry("read_console", params_dict)
+ # Use centralized retry helper with instance routing
+ resp = send_command_with_retry("read_console", params_dict, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present
try:
@@ -98,4 +117,4 @@ def _coerce_int(value, default=None):
line.pop("stacktrace", None)
except Exception:
pass
- return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
\ No newline at end of file
diff --git a/Server/tools/resource_tools.py b/Server/tools/resource_tools.py
index d84bf7be..aa770e2c 100644
--- a/Server/tools/resource_tools.py
+++ b/Server/tools/resource_tools.py
@@ -18,8 +18,16 @@
def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None:
- """Safely coerce various inputs (str/float/etc.) to an int.
- Returns default on failure; clamps to minimum when provided.
+ """
+ Coerce a value to an integer, returning a fallback when conversion is not possible.
+
+ Parameters:
+ value (Any): Value to convert to int. Booleans are treated as invalid and will yield the `default`.
+ default (int | None): Value to return when `value` is None or cannot be converted.
+ minimum (int | None): If provided, ensure the returned integer is at least this value (clamped).
+
+ Returns:
+ int | None: The coerced integer (possibly clamped to `minimum`), or `default` if conversion fails.
"""
if value is None:
return default
@@ -42,8 +50,20 @@ def _coerce_int(value: Any, default: int | None = None, minimum: int | None = No
return default
-def _resolve_project_root(override: str | None) -> Path:
+def _resolve_project_root(override: str | None, unity_instance: str | None = None) -> Path:
# 1) Explicit override
+ """
+ Determine the Unity project root directory.
+
+ Attempts to resolve a filesystem path that represents the Unity project root by using, in order: an explicit override path, the UNITY_PROJECT_ROOT environment variable, a query to the Unity editor identified by `unity_instance`, an upward search from the current working directory for a folder containing both `Assets` and `ProjectSettings`, and a shallow downward search from the repository root. If none of these yield a valid project root, the current working directory is returned.
+
+ Parameters:
+ override (str | None): Optional explicit path to use as the project root.
+ unity_instance (str | None): Optional Unity instance identifier to query for the project root.
+
+ Returns:
+ pathlib.Path: The resolved project root directory. When possible, the returned path will contain an `Assets` subdirectory; otherwise the current working directory is returned as a fallback.
+ """
if override:
pr = Path(override).expanduser().resolve()
if (pr / "Assets").exists():
@@ -60,7 +80,7 @@ def _resolve_project_root(override: str | None) -> Path:
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry(
- "manage_editor", {"action": "get_project_root"})
+ "manage_editor", {"action": "get_project_root"}, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get(
"projectRoot", "")).expanduser().resolve()
@@ -141,10 +161,27 @@ async def list_resources(
"Folder under project root, default is Assets"] = "Assets",
limit: Annotated[int, "Page limit"] = 200,
project_root: Annotated[str, "Project path"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ List Unity C# script resources under a project folder and return their unity:// URIs.
+
+ Parameters:
+ ctx (Context): Request context for logging and telemetry.
+ pattern (str | None): Filename glob to filter results (default: "*.cs"); only files with a `.cs` extension are returned.
+ under (str): Subfolder under the project root to search (default: "Assets"); listing is restricted to the Assets subtree.
+ limit (int): Maximum number of results to return (default: 200); values are coerced to an integer and clamped to at least 1.
+ project_root (str | None): Override path to the Unity project root; if None the project root is resolved automatically.
+ unity_instance (str | None): Target Unity instance identifier (name, hash, or "Name@hash"); if omitted the default instance is used.
+
+ Returns:
+ dict: On success, {"success": True, "data": {"uris": [], "count": }}.
+ On failure, {"success": False, "error": ""}.
+ """
ctx.info(f"Processing list_resources: {pattern}")
try:
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
base = (project / under).resolve()
try:
base.relative_to(project)
@@ -201,7 +238,33 @@ async def read_resource(
project_root: Annotated[str,
"The project root directory"] | None = None,
request: Annotated[str, "The request ID"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Read a Unity project resource under Assets/ with optional byte- or line-based slicing and return file metadata and optionally a text selection.
+
+ Parameters:
+ uri (str): The resource URI to read (supports forms like `unity://path/...`, `file://...`, or `Assets/...`). A special canonical value `spec/script-edits` (with or without scheme) returns a predefined JSON spec.
+ start_line (int | float | str | None): Starting line number (1-based as accepted in requests; will be coerced to int). Used together with `line_count` to select a line window.
+ line_count (int | float | str | None): Number of lines to read starting at `start_line`.
+ head_bytes (int | float | str | None): Number of bytes to read from the start of the file; takes highest precedence when specified.
+ tail_lines (int | float | str | None): Number of lines to read from the end of the file; used when `head_bytes` is not specified.
+ project_root (str | None): Path or override for the Unity project root; if omitted, the project root will be resolved automatically.
+ request (str | None): Natural-language convenience hints (e.g., "last 120 lines", "first 200 lines", "show 40 lines around MethodName") that can set selection parameters.
+ unity_instance (str | None): Target Unity instance identifier (project name, hash, or "Name@hash"); if omitted, the default instance is used.
+
+ Returns:
+ dict: On success, returns {"success": True, "data": ...} where "data" contains either:
+ - "metadata": {"sha256": , "lengthBytes": } (when no selection requested), or
+ - "text": , "metadata": {...} (when a byte/line selection or `request` hint caused text extraction).
+ On failure, returns {"success": False, "error": ""}.
+
+ Notes:
+ - Reads are restricted to the Assets/ subtree; attempts to read outside Assets/ return an error.
+ - Selection precedence: `head_bytes` (highest), then `tail_lines`, then `start_line` + `line_count`; if none are specified only metadata is returned.
+ - Numeric inputs are coerced from strings/floats where applicable; invalid numeric inputs are treated as absent.
+ """
ctx.info(f"Processing read_resource: {uri}")
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
@@ -266,7 +329,7 @@ async def read_resource(
sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest()
return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}}
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
@@ -356,10 +419,29 @@ async def find_in_file(
"The project root directory"] | None = None,
max_results: Annotated[int,
"Cap results to avoid huge payloads"] = 200,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Search a file addressed by a Unity/file URI for lines that match a regular expression and return matching line/column ranges.
+
+ Parameters:
+ ctx (Context): Execution context (log/client handle).
+ uri (str): Resource identifier; supports unity://..., file://..., or Assets/... paths resolved relative to the Unity project.
+ pattern (str): Regular expression to search for.
+ ignore_case (bool | str | None): Case-insensitive search flag; accepts booleans or string values like "true"/"false"/"1"/"0". Defaults to True.
+ project_root (str | None): Optional override for the Unity project root directory.
+ max_results (int): Maximum number of matches to return; used to limit payload size.
+ unity_instance (str | None): Optional target Unity instance identifier (name, hash, or "Name@hash").
+
+ Returns:
+ dict: On success: {"success": True, "data": {"matches": [match, ...], "count": n}}
+ Each match is {"startLine": int, "startCol": int, "endLine": int, "endCol": int} with 1-based indices.
+ On failure: {"success": False, "error": ""}
+ """
ctx.info(f"Processing find_in_file: {uri}")
try:
- project = _resolve_project_root(project_root)
+ project = _resolve_project_root(project_root, unity_instance)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
@@ -403,4 +485,4 @@ def _coerce_bool(val, default=None):
return {"success": True, "data": {"matches": results, "count": len(results)}}
except Exception as e:
- return {"success": False, "error": str(e)}
+ return {"success": False, "error": str(e)}
\ No newline at end of file
diff --git a/Server/tools/run_tests.py b/Server/tools/run_tests.py
index e70fd00c..6366d8b3 100644
--- a/Server/tools/run_tests.py
+++ b/Server/tools/run_tests.py
@@ -45,7 +45,17 @@ async def run_tests(
description="Unity test mode to run")] = "edit",
timeout_seconds: Annotated[str, Field(
description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> RunTestsResponse:
+ """
+ Run Unity Test Runner suites for the specified mode and return the parsed test run results.
+
+ @param mode: Test mode to run, either "edit" or "play".
+ @param timeout_seconds: Optional timeout in seconds for the test run. Accepts numeric values or string representations (e.g., "30"); values that cannot be interpreted as an integer are ignored.
+ @param unity_instance: Optional identifier of the target Unity instance (project name, hash, or "Name@hash"). If omitted, the default instance is used.
+ @returns: `RunTestsResponse` containing the test run summary and individual results, or the original response value if it was not a dictionary.
+ """
await ctx.info(f"Processing run_tests: mode={mode}")
# Coerce timeout defensively (string/float -> int)
@@ -69,6 +79,6 @@ def _coerce_int(value, default=None):
if ts is not None:
params["timeoutSeconds"] = ts
- response = await async_send_command_with_retry("run_tests", params)
+ response = await async_send_command_with_retry("run_tests", params, instance_id=unity_instance)
await ctx.info(f'Response {response}')
- return RunTestsResponse(**response) if isinstance(response, dict) else response
+ return RunTestsResponse(**response) if isinstance(response, dict) else response
\ No newline at end of file
diff --git a/Server/tools/script_apply_edits.py b/Server/tools/script_apply_edits.py
index e339a754..be84b8b4 100644
--- a/Server/tools/script_apply_edits.py
+++ b/Server/tools/script_apply_edits.py
@@ -365,7 +365,32 @@ def script_apply_edits(
"Type of the script to edit"] = "MonoBehaviour",
namespace: Annotated[str,
"Namespace of the script to edit"] | None = None,
+ unity_instance: Annotated[str,
+ "Target Unity instance (project name, hash, or 'Name@hash'). If not specified, uses default instance."] | None = None,
) -> dict[str, Any]:
+ """
+ Apply a list of edits to a Unity C# script, performing local preview, validation, and/or sending appropriate structured or text edit requests to the Unity project.
+
+ This function normalizes the script locator and edit shapes, decides whether edits should be handled as structured (class/method/anchor) operations or text-based edits (prepend/append/replace_range/regex_replace), and then either:
+ - forwards structured edits to Unity's structured editor, or
+ - computes line/column edit spans and sends them as atomic text edits with a SHA-256 precondition, or
+ - applies edits locally to produce a preview diff without writing when requested.
+
+ It supports mixed batches by applying text edits first and then structured edits, honors preview/confirm options for regex replacements, and accepts an optional Unity instance selector to target a specific Unity editor/process.
+
+ Parameters:
+ ctx: Execution context (logging/tracing) used by the caller.
+ name (str): Script name or locator (may include extensions or Unity URI forms); normalized before use.
+ path (str): Path hint for the script under the Assets/ tree; used to resolve the final script locator.
+ edits (list[dict]): List of edit descriptors. Supported high-level ops include structured ops (replace_method/insert_method/delete_method/replace_class/delete_class/anchor_*) and text ops (prepend/append/replace_range/regex_replace). Various aliases and LSP-like ranges are accepted and normalized.
+ options (dict|None): Operation options such as preview (bool), confirm (bool), validate, refresh, and applyMode. See call sites for supported keys.
+ script_type (str): Script classification (default "MonoBehaviour"); forwarded to Unity for validation and editor actions.
+ namespace (str|None): Optional C# namespace to use when applying structured edits.
+ unity_instance (str|None): Optional target Unity instance identifier (project name, hash, or "Name@hash"); when provided, all Unity RPCs are scoped to that instance.
+
+ Returns:
+ dict: A structured response payload indicating success or failure. On success the payload may include data such as diffs (for preview), normalized edits echo, or Unity response data. On failure the payload contains machine-parsable error codes and hints describing required fields or regex/anchor problems.
+ """
ctx.info(f"Processing script_apply_edits: {name}")
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
@@ -586,7 +611,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
"options": opts2,
}
resp_struct = send_command_with_retry(
- "manage_script", params_struct)
+ "manage_script", params_struct, instance_id=unity_instance)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
@@ -598,7 +623,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
"path": path,
"namespace": namespace,
"scriptType": script_type,
- })
+ }, instance_id=unity_instance)
if not isinstance(read_resp, dict) or not read_resp.get("success"):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
@@ -722,7 +747,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
}
resp_text = send_command_with_retry(
- "manage_script", params_text)
+ "manage_script", params_text, instance_id=unity_instance)
if not (isinstance(resp_text, dict) and resp_text.get("success")):
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
# Optional sentinel reload removed (deprecated)
@@ -743,7 +768,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": opts2
}
resp_struct = send_command_with_retry(
- "manage_script", params_struct)
+ "manage_script", params_struct, instance_id=unity_instance)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
@@ -871,7 +896,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
- resp = send_command_with_retry("manage_script", params)
+ resp = send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@@ -955,7 +980,7 @@ def _expand_dollars(rep: str, _m=m) -> str:
"options": options or {"validate": "standard", "refresh": "debounced"},
}
- write_resp = send_command_with_retry("manage_script", params)
+ write_resp = send_command_with_retry("manage_script", params, instance_id=unity_instance)
if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@@ -963,4 +988,4 @@ def _expand_dollars(rep: str, _m=m) -> str:
else {"success": False, "message": str(write_resp)},
normalized_for_echo,
routing="text",
- )
+ )
\ No newline at end of file
diff --git a/Server/unity_connection.py b/Server/unity_connection.py
index f0e06b76..9ad6ca3c 100644
--- a/Server/unity_connection.py
+++ b/Server/unity_connection.py
@@ -4,6 +4,7 @@
import errno
import json
import logging
+import os
from pathlib import Path
from port_discovery import PortDiscovery
import random
@@ -11,9 +12,9 @@
import struct
import threading
import time
-from typing import Any, Dict
+from typing import Any, Dict, Optional, List
-from models import MCPResponse
+from models import MCPResponse, UnityInstanceInfo
# Configure logging using settings from config
@@ -37,9 +38,14 @@ class UnityConnection:
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
use_framing: bool = False # Negotiated per-connection
+ instance_id: str | None = None # Instance identifier for reconnection
def __post_init__(self):
- """Set port from discovery if not explicitly provided"""
+ """
+ Ensure the connection has a port and initialize per-instance locks.
+
+ If `port` is None, discover and assign the Unity port. Create `_io_lock` for serializing send/receive I/O and `_conn_lock` for guarding connection lifecycle operations.
+ """
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
self._io_lock = threading.Lock()
@@ -224,7 +230,20 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
raise
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
- """Send a command with retry/backoff and port rediscovery. Pings only when requested."""
+ """
+ Send a command to the Unity Editor, handling framing negotiation, retries with backoff, and per-instance port rediscovery.
+
+ Parameters:
+ command_type (str): The MCP command name to invoke (e.g., "ping").
+ params (Dict[str, Any] | None): Parameters to include with the command. If `None` this function returns an MCPResponse indicating a placeholder/invalid call.
+
+ Returns:
+ dict: The parsed `result` object from Unity's successful response (e.g., {"message": "pong"} for a ping). In the early placeholder case (`params is None`) an MCPResponse with success=False and an error message is returned.
+
+ Raises:
+ ValueError: If `command_type` is empty.
+ Exception: If communication repeatedly fails or Unity returns an error; underlying socket/JSON errors may be propagated after retries.
+ """
# Defensive guard: catch empty/placeholder invocations early
if not command_type:
raise ValueError("MCP call missing command_type")
@@ -328,9 +347,28 @@ def read_status_file() -> dict | None:
finally:
self.sock = None
- # Re-discover port each time
+ # Re-discover the port for this specific instance
try:
- new_port = PortDiscovery.discover_unity_port()
+ new_port: int | None = None
+ if self.instance_id:
+ # Try to rediscover the specific instance
+ pool = get_unity_connection_pool()
+ refreshed = pool.discover_all_instances(force_refresh=True)
+ match = next((inst for inst in refreshed if inst.id == self.instance_id), None)
+ if match:
+ new_port = match.port
+ logger.debug(f"Rediscovered instance {self.instance_id} on port {new_port}")
+ else:
+ logger.warning(f"Instance {self.instance_id} not found during reconnection")
+
+ # Fallback to generic port discovery if instance-specific discovery failed
+ if new_port is None:
+ if self.instance_id:
+ raise ConnectionError(
+ f"Unity instance '{self.instance_id}' could not be rediscovered"
+ ) from e
+ new_port = PortDiscovery.discover_unity_port()
+
if new_port != self.port:
logger.info(
f"Unity port changed {self.port} -> {new_port}")
@@ -371,32 +409,272 @@ def read_status_file() -> dict | None:
raise
-# Global Unity connection
-_unity_connection = None
+# -----------------------------
+# Connection Pool for Multiple Unity Instances
+# -----------------------------
+
+class UnityConnectionPool:
+ """Manages connections to multiple Unity Editor instances"""
+
+ def __init__(self):
+ """
+ Initialize a UnityConnectionPool, preparing internal caches, synchronization primitives, and an optional default instance identifier from the environment.
+
+ Initializes:
+ - a mapping of instance IDs to active UnityConnection objects,
+ - a mapping of discovered UnityInstanceInfo objects,
+ - a timestamp and interval used to cache discovery results,
+ - a lock to guard pool operations,
+ - an optional default instance id taken from the UNITY_MCP_DEFAULT_INSTANCE environment variable when present.
+ """
+ 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: Optional[str] = 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]:
+ """
+ Return discovered Unity Editor instances, using cached results unless a fresh scan is requested.
+
+ Parameters:
+ force_refresh (bool): If True, bypass the cache and perform an immediate discovery scan.
+
+ Returns:
+ instances (List[UnityInstanceInfo]): A list of discovered 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: Optional[str], instances: List[UnityInstanceInfo]) -> UnityInstanceInfo:
+ """
+ Resolve a user-supplied identifier to a specific Unity instance.
+
+ Accepts identifiers in any of these forms: instance id, project name, hash (full or prefix),
+ "Name@Hash", "Name@Port", filesystem path, or port number. If no identifier is provided,
+ uses the pool's default instance if configured, otherwise returns the most recently heartbeated instance.
+
+ Parameters:
+ instance_identifier (Optional[str]): Identifier to resolve (see accepted forms above).
+ instances (List[UnityInstanceInfo]): Available Unity instances to search.
+
+ Returns:
+ UnityInstanceInfo: The matching Unity instance.
+
+ Raises:
+ ConnectionError: If no instances are available or the identifier cannot be unambiguously 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 epoch as sentinel)
+ from datetime import datetime
+ sorted_instances = sorted(instances, key=lambda i: i.last_heartbeat or datetime.fromtimestamp(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 list_unity_instances() to see all instances."
+ )
+
+ def get_connection(self, instance_identifier: Optional[str] = None) -> UnityConnection:
+ """
+ Return a UnityConnection for the specified Unity instance, creating and connecting one if necessary.
+
+ Parameters:
+ instance_identifier (Optional[str]): Identifier for selecting a Unity instance. May be an instance id, project name, hash, composite forms like "Name@Hash" or "Name@Port", port string, or filesystem path. If None, uses the pool's default instance or the most recently heartbeated instance.
+
+ Returns:
+ UnityConnection: A connected UnityConnection for the resolved instance.
+
+ Raises:
+ ConnectionError: If the specified instance cannot be resolved or if a new connection cannot be established.
+ """
+ # 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 = 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):
+ """
+ Close and remove all UnityConnection instances held by the pool.
+
+ Attempts to gracefully disconnect each active connection while holding the pool lock,
+ logs any errors encountered per-instance, and clears the pool's internal connection mapping.
+ """
+ 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: Optional[UnityConnectionPool] = None
+_pool_init_lock = threading.Lock()
-def get_unity_connection() -> UnityConnection:
- """Retrieve or establish a persistent Unity connection.
- Note: Do NOT ping on every retrieval to avoid connection storms. Rely on
- send_command() exceptions to detect broken sockets and reconnect there.
+def get_unity_connection_pool() -> UnityConnectionPool:
"""
- global _unity_connection
- if _unity_connection is not None:
- return _unity_connection
-
- # Double-checked locking to avoid concurrent socket creation
- with _connection_lock:
- if _unity_connection is not None:
- return _unity_connection
- logger.info("Creating new Unity connection")
- _unity_connection = UnityConnection()
- if not _unity_connection.connect():
- _unity_connection = None
- raise ConnectionError(
- "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
- logger.info("Connected to Unity on startup")
- return _unity_connection
+ Return the global UnityConnectionPool, creating and caching it on first access.
+
+ Initializes the pool in a thread-safe manner if it does not already exist.
+
+ Returns:
+ UnityConnectionPool: The singleton global UnityConnectionPool instance.
+ """
+ global _unity_connection_pool
+
+ if _unity_connection_pool is not None:
+ return _unity_connection_pool
+
+ with _pool_init_lock:
+ if _unity_connection_pool is not None:
+ return _unity_connection_pool
+
+ logger.info("Initializing Unity connection pool")
+ _unity_connection_pool = UnityConnectionPool()
+ return _unity_connection_pool
+
+
+# Backwards compatibility: keep old single-connection function
+def get_unity_connection(instance_identifier: Optional[str] = None) -> UnityConnection:
+ """
+ Get a UnityConnection for a specific Unity Editor instance or the default/recent instance.
+
+ Parameters:
+ instance_identifier (Optional[str]): Identifier to select the target instance. May be an instance id, project name, instance hash, "Name@Hash" / "Name@Port" composite, port number, or project path. If omitted, uses the environment default (if set) or the most recently heartbeated instance.
+
+ Returns:
+ UnityConnection: Connection object for the resolved Unity instance.
+ """
+ pool = get_unity_connection_pool()
+ return pool.get_connection(instance_identifier)
# -----------------------------
@@ -404,7 +682,15 @@ def get_unity_connection() -> UnityConnection:
# -----------------------------
def _is_reloading_response(resp: dict) -> bool:
- """Return True if the Unity response indicates the editor is reloading."""
+ """
+ Determine whether a Unity response indicates the editor is reloading.
+
+ Parameters:
+ resp (dict): The parsed response object from Unity to inspect.
+
+ Returns:
+ bool: `True` if the response indicates Unity is reloading (`state` == "reloading" or the `message`/`error` text contains "reload"), `False` otherwise.
+ """
if not isinstance(resp, dict):
return False
if resp.get("state") == "reloading":
@@ -413,13 +699,30 @@ def _is_reloading_response(resp: dict) -> bool:
return "reload" in message_text
-def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
- """Send a command via the shared connection, waiting politely through Unity reloads.
-
- Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
- structured failure if retries are exhausted.
+def send_command_with_retry(
+ command_type: str,
+ params: Dict[str, Any],
+ *,
+ instance_id: Optional[str] = None,
+ max_retries: int | None = None,
+ retry_ms: int | None = None
+) -> Dict[str, Any]:
"""
- conn = get_unity_connection()
+ Send a command to a Unity instance and retry while the editor reports reloading.
+
+ Retries the command until Unity stops reporting a reload state or the retry limit is reached. If a response contains `retry_after_ms`, that value is used as the delay before the next attempt; otherwise `retry_ms` (or the config default) is used.
+
+ Parameters:
+ command_type (str): The command type to send.
+ params (dict): Command parameters.
+ instance_id (Optional[str]): Optional Unity instance identifier (id, name, hash, `name@hash`, port, or path) used to target a specific editor.
+ max_retries (int | None): Maximum number of retry attempts; when `None` uses `config.reload_max_retries`.
+ retry_ms (int | None): Milliseconds to wait between retries when not specified by the response; when `None` uses `config.reload_retry_ms`.
+
+ Returns:
+ dict: The response dictionary returned by Unity.
+ """
+ conn = get_unity_connection(instance_id)
if max_retries is None:
max_retries = getattr(config, "reload_max_retries", 40)
if retry_ms is None:
@@ -436,8 +739,28 @@ def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_re
return response
-async def async_send_command_with_retry(command_type: str, params: dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> dict[str, Any] | MCPResponse:
- """Async wrapper that runs the blocking retry helper in a thread pool."""
+async def async_send_command_with_retry(
+ command_type: str,
+ params: dict[str, Any],
+ *,
+ instance_id: Optional[str] = None,
+ loop=None,
+ max_retries: int | None = None,
+ retry_ms: int | None = None
+) -> dict[str, Any] | MCPResponse:
+ """Async wrapper that runs the blocking retry helper in a thread pool.
+
+ Args:
+ command_type: The command type to send
+ params: Command parameters
+ instance_id: Optional Unity instance identifier
+ loop: Optional asyncio event loop
+ max_retries: Maximum number of retries for reload states
+ retry_ms: Delay between retries in milliseconds
+
+ Returns:
+ Response dictionary or MCPResponse on error
+ """
try:
import asyncio # local import to avoid mandatory asyncio dependency for sync callers
if loop is None:
@@ -445,7 +768,7 @@ async def async_send_command_with_retry(command_type: str, params: dict[str, Any
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(
- command_type, params, max_retries=max_retries, retry_ms=retry_ms),
+ command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
)
except Exception as e:
- return MCPResponse(success=False, error=str(e))
+ return MCPResponse(success=False, error=str(e))
\ No newline at end of file