Feature/session based instance routing (#369)

* Add support for multiple Unity instances

* fix port detection

* add missing unity_instance parameter

* add instance params for resources

* Fix CodeRabbit review feedback

- Fix partial framed response handling in port discovery
  Add _recv_exact() helper to ensure complete frame reading
  Prevents healthy Unity instances from being misidentified as offline

- Remove unused default_conn variables in server.py (2 files)
  Fixes Ruff F841 lint error that would block CI/CD

- Preserve sync/async nature of resources in wrapper
  Check if original function is coroutine before wrapping
  Prevents 'dict object is not awaitable' runtime errors

- Fix reconnection to preserve instance_id
  Add instance_id tracking to UnityConnection dataclass
  Reconnection now targets the same Unity instance instead of any available one
  Prevents operations from being applied to wrong project

- Add instance logging to manage_asset for debugging
  Helps troubleshoot multi-instance scenarios

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>

* Fix CodeRabbit feedback: reconnection fallback and annotations safety

Address 3 CodeRabbit review comments:

1. Critical: Guard reconnection fallback to prevent wrong instance routing
   - When instance_id is set but rediscovery fails, now raises ConnectionError
   - Added 'from e' to preserve exception chain for better debugging
   - Prevents silently connecting to different Unity instance
   - Ensures multi-instance routing integrity

2. Minor: Guard __annotations__ access in resource registration
   - Use getattr(func, '__annotations__', {}) instead of direct access
   - Prevents AttributeError for functions without type hints

3. Minor: Remove unused get_type_hints import
   - Clean up unused import in resources/__init__.py

All changes applied to both Server/ and MCPForUnity/ directories.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix instance sorting and logging issues

- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update uv.lock to prepare for merging into main

* Restore Python 3.10 lockfiles and package list_unity_instances tool

* Deduplicate Unity instance discovery by port

* Scope status-file reload checks to the active instance

* refactor: implement FastMCP middleware for session-based instance routing

Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.

Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)

Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs

* fix: convert resource templates to static resources for discoverability

Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.

Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)

Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.

Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.

Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.

Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.

* feat: improve material properties and sync Server resources

Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting

This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.

Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()

* fix: repair instance routing and simplify get_unity_instance_from_context

PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests 
Script2 (intended: UnityMCPTests) -> went to ramble 

ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.

FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only

TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate

Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite

* refactor: standardize instance extraction and remove dead imports

- Standardize all 18 tools to use get_unity_instance_from_context() helper
  instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
  that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation

* fix: critical timezone and import bugs from code review

- Remove incorrect port safety check that treated reclaimed ports as errors
  (GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
  (use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
  (file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
  (was causing NameError at runtime on lines 108 and 488)

All 88 tests pass (76 passed + 5 skipped + 7 xpassed)

---------

Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
main
dsarno 2025-11-05 09:43:36 -08:00 committed by GitHub
parent 5e4b554e70
commit f667582505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3018 additions and 408 deletions

View File

@ -60,16 +60,19 @@ namespace MCPForUnity.Editor.Helpers
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} became available after short wait");
return storedConfig.unity_port;
}
// Prefer sticking to the same port; let the caller handle bind retries/fallbacks
return storedConfig.unity_port;
}
// If no valid stored port, find a new one and save it
// Port is still busy after waiting - find a new available port instead
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative...");
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
}
// If no valid stored port, find a new one and save it
int foundPort = FindAvailablePort();
SavePort(foundPort);
return foundPort;
}
/// <summary>
/// Discover and save a new available port (used by Auto-Connect button)
/// </summary>

View File

@ -362,7 +362,24 @@ namespace MCPForUnity.Editor
}
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();
// GetPortWithFallback() may return the same port if it became available during wait
// or a different port if switching to an alternative
if (IsDebugEnabled())
{
if (currentUnityPort == oldPort)
{
McpLog.Info($"Port {oldPort} became available, proceeding");
}
else
{
McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}");
}
}
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Server.SetSocketOption(
SocketOptionLevel.Socket,
@ -474,6 +491,22 @@ namespace MCPForUnity.Editor
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.");
}
@ -1184,6 +1217,29 @@ namespace MCPForUnity.Editor
}
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 +1247,8 @@ namespace MCPForUnity.Editor
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));

View File

@ -911,7 +911,7 @@ namespace MCPForUnity.Editor.Tools
// Example: Set color property
if (properties["color"] is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); // Auto-detect if not specified
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
@ -922,12 +922,22 @@ namespace MCPForUnity.Editor.Tools
colArr[2].ToObject<float>(),
colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
catch (Exception ex)
{
Debug.LogWarning(
@ -938,7 +948,8 @@ namespace MCPForUnity.Editor.Tools
}
else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
string propName = "_Color";
// Auto-detect the main color property for the shader
string propName = GetMainColorPropertyName(mat);
try
{
if (colorArr.Count >= 3)
@ -949,12 +960,22 @@ namespace MCPForUnity.Editor.Tools
colorArr[2].ToObject<float>(),
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
}
catch (Exception ex)
{
@ -1140,6 +1161,27 @@ namespace MCPForUnity.Editor.Tools
return modified;
}
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc.
/// </summary>
private static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";
// Try common color property names in order of likelihood
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}
// Fallback to _Color if none found
return "_Color";
}
/// <summary>
/// Applies properties from JObject to a PhysicsMaterial.
/// </summary>

View File

@ -1,4 +1,5 @@
from typing import Any
from datetime import datetime
from pydantic import BaseModel
@ -7,3 +8,28 @@ class MCPResponse(BaseModel):
message: str | None = None
error: str | None = None
data: Any | None = None
class UnityInstanceInfo(BaseModel):
"""Information about a Unity Editor instance"""
id: str # "ProjectName@hash" or fallback to hash
name: str # Project name extracted from path
path: str # Full project path (Assets folder)
hash: str # 8-char hash of project path
port: int # TCP port
status: str # "running", "reloading", "offline"
last_heartbeat: datetime | None = None
unity_version: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
"id": self.id,
"name": self.name,
"path": self.path,
"hash": self.hash,
"port": self.port,
"status": self.status,
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
"unity_version": self.unity_version
}

View File

@ -14,9 +14,14 @@ What changed and why:
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 typing import Optional, List, Dict
from models import UnityInstanceInfo
logger = logging.getLogger("mcp-for-unity-server")
@ -56,21 +61,54 @@ class PortDiscovery:
@staticmethod
def _try_probe_unity_mcp(port: int) -> bool:
"""Quickly check if a MCP for Unity listener is on this port.
Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message.
Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed pong.
"""
try:
with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s:
s.settimeout(PortDiscovery.CONNECT_TIMEOUT)
try:
# 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)
# 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:
return data and b'"message":"pong"' in data
# 2. Send framed ping command
# Frame format: 8-byte length header (big-endian uint64) + payload
payload = b"ping"
header = struct.pack('>Q', len(payload))
s.sendall(header + payload)
# 3. Receive framed response
# Helper to receive exact number of bytes
def _recv_exact(expected: int) -> bytes | None:
chunks = bytearray()
while len(chunks) < expected:
chunk = s.recv(expected - len(chunks))
if not chunk:
return None
chunks.extend(chunk)
return bytes(chunks)
response_header = _recv_exact(8)
if response_header is None:
return False
except Exception:
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 as e:
logger.debug(f"Connection failed for port {port}: {e}")
return False
@staticmethod
@ -158,3 +196,112 @@ class PortDiscovery:
logger.warning(
f"Could not read port configuration {path}: {e}")
return None
@staticmethod
def _extract_project_name(project_path: str) -> str:
"""Extract project name from Assets path.
Examples:
/Users/sakura/Projects/MyGame/Assets -> MyGame
C:\\Projects\\TestProject\\Assets -> TestProject
"""
if not project_path:
return "Unknown"
try:
# Remove trailing /Assets or \Assets
path = project_path.rstrip('/\\')
if path.endswith('Assets'):
path = path[:-6].rstrip('/\\')
# Get the last directory name
name = os.path.basename(path)
return name if name else "Unknown"
except Exception:
return "Unknown"
@staticmethod
def discover_all_unity_instances() -> List[UnityInstanceInfo]:
"""
Discover all running Unity Editor instances by scanning status files.
Returns:
List of UnityInstanceInfo objects for all discovered instances
"""
instances_by_port: Dict[int, tuple[UnityInstanceInfo, datetime]] = {}
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:
status_path = Path(status_file_path)
file_mtime = datetime.fromtimestamp(status_path.stat().st_mtime)
with status_path.open('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
freshness = last_heartbeat or file_mtime
existing = instances_by_port.get(port)
if existing:
_, existing_time = existing
if existing_time >= freshness:
logger.debug(
"Skipping stale status entry %s in favor of more recent data for port %s",
status_path.name,
port,
)
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_by_port[port] = (instance, freshness)
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
deduped_instances = [entry[0] for entry in sorted(instances_by_port.values(), key=lambda item: item[1], reverse=True)]
logger.info(f"Discovered {len(deduped_instances)} Unity instances (after de-duplication by port)")
return deduped_instances

View File

@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.27.2",
"fastmcp>=2.12.5",
"fastmcp>=2.13.0",
"mcp>=1.16.0",
"pydantic>=2.12.0",
"tomli>=2.3.0",

View File

@ -1,6 +1,7 @@
"""
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import inspect
import logging
from pathlib import Path
@ -36,6 +37,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 +45,30 @@ def register_all_resources(mcp: FastMCP):
description = resource_info['description']
kwargs = resource_info['kwargs']
# Apply the @mcp.resource decorator and telemetry
# Check if URI contains query parameters (e.g., {?unity_instance})
has_query_params = '{?' in uri
if has_query_params:
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
resource_info['func'] = wrapped_template
else:
wrapped = telemetry_resource(resource_name)(func)
wrapped = mcp.resource(uri=uri, name=resource_name,
description=description, **kwargs)(wrapped)
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)")

View File

@ -1,5 +1,8 @@
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -12,14 +15,19 @@ class GetMenuItemsResponse(MCPResponse):
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(ctx: Context) -> GetMenuItemsResponse:
"""Provides a list of all menu items.
"""
unity_instance = get_unity_instance_from_context(ctx)
params = {
"refresh": True,
"search": "",
}
response = await async_send_command_with_retry("get_menu_items", params)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_menu_items",
params,
)
return GetMenuItemsResponse(**response) if isinstance(response, dict) else response

View File

@ -1,8 +1,11 @@
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -18,14 +21,34 @@ class GetTestsResponse(MCPResponse):
@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", {})
async def get_tests(ctx: Context) -> GetTestsResponse:
"""Provides a list of all tests.
"""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests",
{},
)
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})
async def get_tests_for_mode(
ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
) -> GetTestsResponse:
"""Provides a list of tests for a specific mode.
Args:
mode: The test mode to filter by (EditMode or PlayMode).
"""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests_for_mode",
{"mode": mode},
)
return GetTestsResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,67 @@
"""
Resource to list all available Unity Editor instances.
"""
from typing import Any
from fastmcp import Context
from registry import mcp_for_unity_resource
from unity_connection import get_unity_connection_pool
@mcp_for_unity_resource(
uri="unity://instances",
name="unity_instances",
description="Lists all running Unity Editor instances with their details."
)
def unity_instances(ctx: Context) -> dict[str, Any]:
"""
List all available Unity Editor instances.
Returns information about each instance including:
- id: Unique identifier (ProjectName@hash)
- name: Project name
- path: Full project path
- hash: 8-character hash of project path
- port: TCP port number
- status: Current status (running, reloading, etc.)
- last_heartbeat: Last heartbeat timestamp
- unity_version: Unity version (if available)
Returns:
Dictionary containing list of instances and metadata
"""
ctx.info("Listing Unity instances")
try:
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
# 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": []
}

View File

@ -3,12 +3,14 @@ from fastmcp import FastMCP
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
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
import time
# Configure logging using settings from config
@ -61,14 +63,14 @@ try:
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
global _unity_connection_pool
logger.info("MCP for Unity Server starting up")
# Record server startup telemetry
@ -101,8 +103,17 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
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")
# 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
@ -111,12 +122,16 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
{
"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 +147,6 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
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 +159,12 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
)).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
@ -179,6 +192,12 @@ Available tools:\n
"""
)
# Initialize and register middleware for session-based Unity instance routing
unity_middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(unity_middleware)
mcp.add_middleware(unity_middleware)
logger.info("Registered Unity instance middleware for session-based routing")
# Register all tools
register_all_tools(mcp)
@ -188,6 +207,38 @@ register_all_resources(mcp)
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')

View File

@ -3,8 +3,9 @@ MCP Tools package - Auto-discovers and registers all tools in this directory.
"""
import logging
from pathlib import Path
from typing import Any, Awaitable, Callable, TypeVar
from fastmcp import FastMCP
from fastmcp import Context, FastMCP
from telemetry_decorator import telemetry_tool
from registry import get_registered_tools
@ -12,8 +13,16 @@ from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
# Export decorator for easy imports within tools
__all__ = ['register_all_tools']
# Export decorator and helpers for easy imports within tools
__all__ = [
"register_all_tools",
"get_unity_instance_from_context",
"send_with_unity_instance",
"async_send_with_unity_instance",
"with_unity_instance",
]
T = TypeVar("T")
def register_all_tools(mcp: FastMCP):
@ -50,3 +59,115 @@ def register_all_tools(mcp: FastMCP):
logger.debug(f"Registered tool: {tool_name} - {description}")
logger.info(f"Registered {len(tools)} MCP tools")
def get_unity_instance_from_context(
ctx: Context,
key: str = "unity_instance",
) -> str | None:
"""Extract the unity_instance value from middleware state.
The instance is set via the set_active_instance tool and injected into
request state by UnityInstanceMiddleware.
"""
get_state_fn = getattr(ctx, "get_state", None)
if callable(get_state_fn):
try:
return get_state_fn(key)
except Exception: # pragma: no cover - defensive
pass
return None
def send_with_unity_instance(
send_fn: Callable[..., T],
unity_instance: str | None,
*args,
**kwargs,
) -> T:
"""Call a transport function, attaching instance_id only when provided."""
if unity_instance:
kwargs.setdefault("instance_id", unity_instance)
return send_fn(*args, **kwargs)
async def async_send_with_unity_instance(
send_fn: Callable[..., Awaitable[T]],
unity_instance: str | None,
*args,
**kwargs,
) -> T:
"""Async variant of send_with_unity_instance."""
if unity_instance:
kwargs.setdefault("instance_id", unity_instance)
return await send_fn(*args, **kwargs)
def with_unity_instance(
log: str | Callable[[Context, tuple, dict, str | None], str] | None = None,
*,
kwarg_name: str = "unity_instance",
):
"""Decorator to extract unity_instance, perform standard logging, and pass the
instance to the wrapped tool via kwarg.
- log: a format string (using `{unity_instance}`) or a callable returning a message.
- kwarg_name: name of the kwarg to inject (default: "unity_instance").
"""
def _decorate(fn: Callable[..., T]):
import asyncio
import inspect
is_coro = asyncio.iscoroutinefunction(fn)
def _compose_message(ctx: Context, a: tuple, k: dict, inst: str | None) -> str | None:
if log is None:
return None
if callable(log):
try:
return log(ctx, a, k, inst)
except Exception:
return None
try:
return str(log).format(unity_instance=inst or "default")
except Exception:
return str(log)
if is_coro:
async def _wrapper(ctx: Context, *args, **kwargs):
inst = get_unity_instance_from_context(ctx)
msg = _compose_message(ctx, args, kwargs, inst)
if msg:
try:
result = ctx.info(msg)
if inspect.isawaitable(result):
await result
except Exception:
pass
kwargs.setdefault(kwarg_name, inst)
return await fn(ctx, *args, **kwargs)
else:
def _wrapper(ctx: Context, *args, **kwargs):
inst = get_unity_instance_from_context(ctx)
msg = _compose_message(ctx, args, kwargs, inst)
if msg:
try:
result = ctx.info(msg)
if inspect.isawaitable(result):
try:
loop = asyncio.get_running_loop()
loop.create_task(result)
except RuntimeError:
pass
except Exception:
pass
kwargs.setdefault(kwarg_name, inst)
return fn(ctx, *args, **kwargs)
from functools import wraps
return wraps(fn)(_wrapper) # type: ignore[arg-type]
return _decorate

View File

@ -0,0 +1,61 @@
from typing import Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from unity_instance_middleware import get_unity_instance_middleware
@mcp_for_unity_tool(
description="Return the current FastMCP request context details (client_id, session_id, and meta dump)."
)
def debug_request_context(ctx: Context) -> dict[str, Any]:
# Check request_context properties
rc = getattr(ctx, "request_context", None)
rc_client_id = getattr(rc, "client_id", None)
rc_session_id = getattr(rc, "session_id", None)
meta = getattr(rc, "meta", None)
# Check direct ctx properties (per latest FastMCP docs)
ctx_session_id = getattr(ctx, "session_id", None)
ctx_client_id = getattr(ctx, "client_id", None)
meta_dump = None
if meta is not None:
try:
dump_fn = getattr(meta, "model_dump", None)
if callable(dump_fn):
meta_dump = dump_fn(exclude_none=False)
elif isinstance(meta, dict):
meta_dump = dict(meta)
except Exception as e:
meta_dump = {"_error": str(e)}
# List all ctx attributes for debugging
ctx_attrs = [attr for attr in dir(ctx) if not attr.startswith("_")]
# Get session state info via middleware
middleware = get_unity_instance_middleware()
derived_key = middleware._get_session_key(ctx)
active_instance = middleware.get_active_instance(ctx)
return {
"success": True,
"data": {
"request_context": {
"client_id": rc_client_id,
"session_id": rc_session_id,
"meta": meta_dump,
},
"direct_properties": {
"session_id": ctx_session_id,
"client_id": ctx_client_id,
},
"session_state": {
"derived_key": derived_key,
"active_instance": active_instance,
},
"available_attributes": ctx_attrs,
},
}

View File

@ -7,6 +7,7 @@ from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -18,8 +19,10 @@ async def execute_menu_item(
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
) -> MCPResponse:
await ctx.info(f"Processing execute_menu_item: {menu_path}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
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)
result = await async_send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
return MCPResponse(**result) if isinstance(result, dict) else result

View File

@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -31,9 +32,11 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing manage_asset: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Coerce 'properties' from JSON string to dict for client compatibility
if isinstance(properties, str):
try:
@ -86,7 +89,7 @@ async def manage_asset(
# 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_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -3,6 +3,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from telemetry import is_telemetry_enabled, record_tool_usage
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -22,7 +23,8 @@ def manage_editor(
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_editor: {action}")
# Get active instance from request state (injected by middleware)
unity_instance = get_unity_instance_from_context(ctx)
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
def _coerce_bool(value, default=None):
@ -62,8 +64,8 @@ def manage_editor(
}
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_editor", params)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):

View File

@ -3,15 +3,16 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(
description="Manage GameObjects. For booleans, send true/false; if your client only sends strings, 'true'/'false' are accepted. Vectors may be [x,y,z] or a string like '[x,y,z]'. For 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data."
description="Performs CRUD operations on GameObjects and components."
)
def manage_gameobject(
ctx: Context,
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."],
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."],
target: Annotated[str,
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
@ -65,7 +66,9 @@ def manage_gameobject(
includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_gameobject: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Coercers to tolerate stringified booleans and vectors
def _coerce_bool(value, default=None):
@ -195,8 +198,8 @@ def manage_gameobject(
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_gameobject", params)
# Check if the response indicates success
# If the response is not successful, raise an exception with the error message

View File

@ -2,20 +2,16 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(
description="Bridge for prefab management commands (stage control and creation)."
description="Performs prefab operations (create, modify, delete, etc.)."
)
def manage_prefabs(
ctx: Context,
action: Annotated[Literal[
"open_stage",
"close_stage",
"save_open_stage",
"create_from_gameobject",
], "Manage prefabs (stage control and creation)."],
action: Annotated[Literal["create", "modify", "delete", "get_components"], "Perform prefab operations."],
prefab_path: Annotated[str,
"Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None,
mode: Annotated[str,
@ -28,8 +24,11 @@ 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,
component_properties: Annotated[str, "Component properties in JSON format"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_prefabs: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
params: dict[str, Any] = {"action": action}
@ -45,7 +44,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_with_unity_instance(send_command_with_retry, unity_instance, "manage_prefabs", params)
if isinstance(response, dict) and response.get("success"):
return {

View File

@ -2,21 +2,23 @@ from typing import Annotated, Literal, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(description="Manage Unity scenes. Tip: For broad client compatibility, pass build_index as a quoted string (e.g., '0').")
@mcp_for_unity_tool(
description="Performs CRUD operations on Unity scenes."
)
def manage_scene(
ctx: Context,
action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
name: Annotated[str,
"Scene name. Not required get_active/get_build_settings"] | None = None,
path: Annotated[str,
"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,
name: Annotated[str, "Scene name."] | None = None,
path: Annotated[str, "Scene path."] | None = None,
build_index: Annotated[int | str, "Unity build index (quote as string, e.g., '0')."] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_scene: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
# Coerce numeric inputs defensively
def _coerce_int(value, default=None):
@ -44,8 +46,8 @@ def manage_scene(
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_scene", params)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):

View File

@ -6,6 +6,7 @@ from urllib.parse import urlparse, unquote
from fastmcp import FastMCP, Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
import unity_connection
@ -86,7 +87,8 @@ def apply_text_edits(
options: Annotated[dict[str, Any],
"Optional options, used to pass additional options to the script editor"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing apply_text_edits: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing apply_text_edits: {uri} (unity_instance={unity_instance or 'default'})")
name, directory = _split_uri(uri)
# Normalize common aliases/misuses for resilience:
@ -103,11 +105,16 @@ def apply_text_edits(
warnings: list[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = unity_connection.send_command_with_retry("manage_script", {
read_resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
{
"action": "read",
"name": name,
"path": directory,
})
},
)
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 +311,12 @@ def apply_text_edits(
"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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
@ -341,6 +353,7 @@ def apply_text_edits(
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
instance_id=unity_instance,
)
except Exception:
pass
@ -360,7 +373,8 @@ def create_script(
script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing create_script: {path}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing create_script: {path} (unity_instance={unity_instance or 'default'})")
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
# Local validation to avoid round-trips on obviously bad input
@ -386,22 +400,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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
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/..."],
) -> dict[str, Any]:
"""Delete a C# script by URI."""
ctx.info(f"Processing delete_script: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing delete_script: {uri} (unity_instance={unity_instance or 'default'})")
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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -412,9 +437,10 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing validate_script: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing validate_script: {uri} (unity_instance={unity_instance or 'default'})")
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/'."}
@ -426,7 +452,12 @@ def validate_script(
"path": directory,
"level": level,
}
resp = unity_connection.send_command_with_retry("manage_script", params)
resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(
@ -451,7 +482,8 @@ def manage_script(
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_script: {action}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing manage_script: {action} (unity_instance={unity_instance or 'default'})")
try:
# Prepare parameters for Unity
params = {
@ -473,7 +505,12 @@ 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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(response, dict):
if response.get("success"):
@ -535,13 +572,19 @@ 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/..."],
) -> dict[str, Any]:
ctx.info(f"Processing get_sha: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing get_sha: {uri} (unity_instance={unity_instance or 'default'})")
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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get(

View File

@ -3,6 +3,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -17,7 +18,9 @@ def manage_shader(
contents: Annotated[str,
"Shader code for 'create'/'update'"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_shader: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
# Prepare parameters for Unity
params = {
@ -39,8 +42,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_with_unity_instance(send_command_with_retry, unity_instance, "manage_shader", params)
# Process response from Unity
if isinstance(response, dict) and response.get("success"):

View File

@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -23,9 +24,11 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing read_console: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Set defaults if values are None
action = action if action is not None else 'get'
types = types if types is not None else ['error', 'warning', 'log']
@ -87,8 +90,8 @@ def read_console(
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_with_unity_instance(send_command_with_retry, unity_instance, "read_console", params_dict)
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present
try:

View File

@ -14,6 +14,7 @@ from urllib.parse import urlparse, unquote
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance, async_send_with_unity_instance
from unity_connection import send_command_with_retry
@ -42,7 +43,8 @@ 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(ctx: Context, override: str | None) -> Path:
unity_instance = get_unity_instance_from_context(ctx)
# 1) Explicit override
if override:
pr = Path(override).expanduser().resolve()
@ -59,10 +61,14 @@ def _resolve_project_root(override: str | None) -> Path:
return pr
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry(
"manage_editor", {"action": "get_project_root"})
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get(
response = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_editor",
{"action": "get_project_root"},
)
if isinstance(response, dict) and response.get("success"):
pr = Path(response.get("data", {}).get(
"projectRoot", "")).expanduser().resolve()
if pr and (pr / "Assets").exists():
return pr
@ -142,9 +148,10 @@ async def list_resources(
limit: Annotated[int, "Page limit"] = 200,
project_root: Annotated[str, "Project path"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing list_resources: {pattern}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing list_resources: {pattern} (unity_instance={unity_instance or 'default'})")
try:
project = _resolve_project_root(project_root)
project = _resolve_project_root(ctx, project_root)
base = (project / under).resolve()
try:
base.relative_to(project)
@ -202,7 +209,8 @@ async def read_resource(
"The project root directory"] | None = None,
request: Annotated[str, "The request ID"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing read_resource: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing read_resource: {uri} (unity_instance={unity_instance or 'default'})")
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"):
@ -266,7 +274,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(ctx, project_root)
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}"}
@ -357,9 +365,10 @@ async def find_in_file(
max_results: Annotated[int,
"Cap results to avoid huge payloads"] = 200,
) -> dict[str, Any]:
ctx.info(f"Processing find_in_file: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
try:
project = _resolve_project_root(project_root)
project = _resolve_project_root(ctx, project_root)
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}"}

View File

@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from models import MCPResponse
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -38,15 +39,17 @@ class RunTestsResponse(MCPResponse):
data: RunTestsResult | None = None
@mcp_for_unity_tool(description="Runs Unity tests for the specified mode")
@mcp_for_unity_tool(
description="Runs Unity tests for the specified mode"
)
async def run_tests(
ctx: Context,
mode: Annotated[Literal["edit", "play"], Field(
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,
) -> RunTestsResponse:
await ctx.info(f"Processing run_tests: mode={mode}")
mode: Annotated[Literal["edit", "play"], "Unity test mode to run"] = "edit",
timeout_seconds: Annotated[int | str | None, "Optional timeout in seconds for the Unity test run (string, e.g. '30')"] = None,
) -> dict[str, Any]:
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Coerce timeout defensively (string/float -> int)
def _coerce_int(value, default=None):
@ -69,6 +72,6 @@ async def run_tests(
if ts is not None:
params["timeoutSeconds"] = ts
response = await async_send_command_with_retry("run_tests", params)
response = await async_send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
await ctx.info(f'Response {response}')
return RunTestsResponse(**response) if isinstance(response, dict) else response

View File

@ -6,6 +6,7 @@ from typing import Annotated, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -366,7 +367,8 @@ def script_apply_edits(
namespace: Annotated[str,
"Namespace of the script to edit"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing script_apply_edits: {name}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
# Normalize unsupported or aliased ops to known structured/text paths
@ -585,8 +587,12 @@ def script_apply_edits(
"edits": edits,
"options": opts2,
}
resp_struct = send_command_with_retry(
"manage_script", params_struct)
resp_struct = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_struct,
)
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 +604,7 @@ def script_apply_edits(
"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)}
@ -721,8 +727,12 @@ def script_apply_edits(
"precondition_sha256": sha,
"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)
resp_text = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_text,
)
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)
@ -742,8 +752,12 @@ def script_apply_edits(
"edits": struct_edits,
"options": opts2
}
resp_struct = send_command_with_retry(
"manage_script", params_struct)
resp_struct = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_struct,
)
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 +885,12 @@ def script_apply_edits(
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
resp = send_command_with_retry("manage_script", params)
resp = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@ -955,7 +974,12 @@ def script_apply_edits(
"options": options or {"validate": "standard", "refresh": "debounced"},
}
write_resp = send_command_with_retry("manage_script", params)
write_resp = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(

View File

@ -0,0 +1,45 @@
from typing import Annotated, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from unity_connection import get_unity_connection_pool
from unity_instance_middleware import get_unity_instance_middleware
@mcp_for_unity_tool(
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
)
def set_active_instance(
ctx: Context,
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
) -> dict[str, Any]:
# Discover running instances
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=True)
ids = {inst.id: inst for inst in instances}
hashes = {}
for inst in instances:
# exact hash and prefix map; last write wins but we'll detect ambiguity
hashes.setdefault(inst.hash, inst)
# Disallow plain names to ensure determinism
value = instance.strip()
resolved = None
if "@" in value:
resolved = ids.get(value)
if resolved is None:
return {"success": False, "error": f"Instance '{value}' not found. Check unity://instances resource."}
else:
# Treat as hash/prefix; require unique match
candidates = [inst for inst in instances if inst.hash.startswith(value)]
if len(candidates) == 1:
resolved = candidates[0]
elif len(candidates) == 0:
return {"success": False, "error": f"No instance with hash '{value}'."}
else:
return {"success": False, "error": f"Hash '{value}' matches multiple instances: {[c.id for c in candidates]}"}
# Store selection in middleware (session-scoped)
middleware = get_unity_instance_middleware()
middleware.set_active_instance(ctx, resolved.id)
return {"success": True, "message": f"Active instance set to {resolved.id}", "data": {"instance": resolved.id}}

View File

@ -4,6 +4,7 @@ from dataclasses import dataclass
import errno
import json
import logging
import os
from pathlib import Path
from port_discovery import PortDiscovery
import random
@ -11,9 +12,9 @@ import socket
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,6 +38,7 @@ 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"""
@ -233,23 +235,39 @@ class UnityConnection:
attempts = max(config.max_retries, 5)
base_backoff = max(0.5, config.retry_delay)
def read_status_file() -> dict | None:
def read_status_file(target_hash: str | None = None) -> dict | None:
try:
status_files = sorted(Path.home().joinpath(
'.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
base_path = Path.home().joinpath('.unity-mcp')
status_files = sorted(
base_path.glob('unity-mcp-status-*.json'),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not status_files:
return None
latest = status_files[0]
with latest.open('r') as f:
if target_hash:
for status_path in status_files:
if status_path.stem.endswith(target_hash):
with status_path.open('r') as f:
return json.load(f)
# Fallback: return most recent regardless of hash
with status_files[0].open('r') as f:
return json.load(f)
except Exception:
return None
last_short_timeout = None
# Extract hash suffix from instance id (e.g., Project@hash)
target_hash: str | None = None
if self.instance_id and '@' in self.instance_id:
maybe_hash = self.instance_id.split('@', 1)[1].strip()
if maybe_hash:
target_hash = maybe_hash
# Preflight: if Unity reports reloading, return a structured hint so clients can retry politely
try:
status = read_status_file()
status = read_status_file(target_hash)
if status and (status.get('reloading') or status.get('reason') == 'reloading'):
return MCPResponse(
success=False,
@ -328,9 +346,28 @@ class UnityConnection:
finally:
self.sock = None
# Re-discover port each time
# Re-discover the port for this specific instance
try:
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}")
@ -340,7 +377,7 @@ class UnityConnection:
if attempt < attempts:
# Heartbeat-aware, jittered backoff
status = read_status_file()
status = read_status_file(target_hash)
# Base exponential backoff
backoff = base_backoff * (2 ** attempt)
# Decorrelated jitter multiplier
@ -371,32 +408,252 @@ class UnityConnection:
raise
# Global Unity connection
_unity_connection = None
# -----------------------------
# Connection Pool for Multiple Unity Instances
# -----------------------------
class UnityConnectionPool:
"""Manages connections to multiple Unity Editor instances"""
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection.
def __init__(self):
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
Note: Do NOT ping on every retrieval to avoid connection storms. Rely on
send_command() exceptions to detect broken sockets and reconnect there.
# 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]:
"""
global _unity_connection
if _unity_connection is not None:
return _unity_connection
Discover all running Unity Editor instances.
# 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
Args:
force_refresh: If True, bypass cache and scan immediately
Returns:
List of UnityInstanceInfo objects
"""
now = time.time()
# Return cached results if valid
if not force_refresh and (now - self._last_full_scan) < self._scan_interval:
logger.debug(f"Returning cached Unity instances (age: {now - self._last_full_scan:.1f}s)")
return list(self._known_instances.values())
# Scan for instances
logger.debug("Scanning for Unity instances...")
instances = PortDiscovery.discover_all_unity_instances()
# Update cache
with self._pool_lock:
self._known_instances = {inst.id: inst for inst in instances}
self._last_full_scan = now
logger.info(f"Found {len(instances)} Unity instances: {[inst.id for inst in instances]}")
return instances
def _resolve_instance_id(self, instance_identifier: Optional[str], instances: List[UnityInstanceInfo]) -> UnityInstanceInfo:
"""
Resolve an instance identifier to a specific Unity instance.
Args:
instance_identifier: User-provided identifier (name, hash, name@hash, path, port, or None)
instances: List of available instances
Returns:
Resolved UnityInstanceInfo
Raises:
ConnectionError: If instance cannot be resolved
"""
if not instances:
raise ConnectionError(
"Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
logger.info("Connected to Unity on startup")
return _unity_connection
"No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
)
# Use default instance if no identifier provided
if instance_identifier is None:
if self._default_instance_id:
instance_identifier = self._default_instance_id
logger.debug(f"Using default instance: {instance_identifier}")
else:
# Use the most recently active instance
# Instances with no heartbeat (None) should be sorted last (use 0 as sentinel)
sorted_instances = sorted(
instances,
key=lambda inst: inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0,
reverse=True,
)
logger.info(f"No instance specified, using most recent: {sorted_instances[0].id}")
return sorted_instances[0]
identifier = instance_identifier.strip()
# Try exact ID match first
for inst in instances:
if inst.id == identifier:
return inst
# Try project name match
name_matches = [inst for inst in instances if inst.name == identifier]
if len(name_matches) == 1:
return name_matches[0]
elif len(name_matches) > 1:
# Multiple projects with same name - return helpful error
suggestions = [
{
"id": inst.id,
"path": inst.path,
"port": inst.port,
"suggest": f"Use unity_instance='{inst.id}'"
}
for inst in name_matches
]
raise ConnectionError(
f"Project name '{identifier}' matches {len(name_matches)} instances. "
f"Please use the full format (e.g., '{name_matches[0].id}'). "
f"Available instances: {suggestions}"
)
# Try hash match
hash_matches = [inst for inst in instances if inst.hash == identifier or inst.hash.startswith(identifier)]
if len(hash_matches) == 1:
return hash_matches[0]
elif len(hash_matches) > 1:
raise ConnectionError(
f"Hash '{identifier}' matches multiple instances: {[inst.id for inst in hash_matches]}"
)
# Try composite format: Name@Hash or Name@Port
if "@" in identifier:
name_part, hint_part = identifier.split("@", 1)
composite_matches = [
inst for inst in instances
if inst.name == name_part and (
inst.hash.startswith(hint_part) or str(inst.port) == hint_part
)
]
if len(composite_matches) == 1:
return composite_matches[0]
# Try port match (as string)
try:
port_num = int(identifier)
port_matches = [inst for inst in instances if inst.port == port_num]
if len(port_matches) == 1:
return port_matches[0]
except ValueError:
pass
# Try path match
path_matches = [inst for inst in instances if inst.path == identifier]
if len(path_matches) == 1:
return path_matches[0]
# Nothing matched
available_ids = [inst.id for inst in instances]
raise ConnectionError(
f"Unity instance '{identifier}' not found. "
f"Available instances: {available_ids}. "
f"Check unity://instances resource for 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):
"""Disconnect all active connections"""
with self._pool_lock:
for instance_id, conn in self._connections.items():
try:
logger.info(f"Disconnecting from Unity instance: {instance_id}")
conn.disconnect()
except Exception:
logger.exception(f"Error disconnecting from {instance_id}")
self._connections.clear()
# Global Unity connection pool
_unity_connection_pool: Optional[UnityConnectionPool] = None
_pool_init_lock = threading.Lock()
def get_unity_connection_pool() -> UnityConnectionPool:
"""Get or create the global Unity connection pool"""
global _unity_connection_pool
if _unity_connection_pool is 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:
"""Retrieve or establish a Unity connection.
Args:
instance_identifier: Optional identifier for specific Unity instance.
If None, uses default or most recent instance.
Returns:
UnityConnection to the specified or default Unity instance
Note: This function now uses the connection pool internally.
"""
pool = get_unity_connection_pool()
return pool.get_connection(instance_identifier)
# -----------------------------
@ -413,13 +670,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.
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, waiting politely through Unity reloads.
Args:
command_type: The command type to send
params: Command parameters
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds
Returns:
Response dictionary from Unity
Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
structured failure if retries are exhausted.
"""
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 +710,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 +739,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))

View File

@ -0,0 +1,85 @@
"""
Middleware for managing Unity instance selection per session.
This middleware intercepts all tool calls and injects the active Unity instance
into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
"""
from threading import RLock
from typing import Optional
from fastmcp.server.middleware import Middleware, MiddlewareContext
# Global instance for access from tools
_unity_instance_middleware: Optional['UnityInstanceMiddleware'] = None
def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
"""Get the global Unity instance middleware."""
if _unity_instance_middleware is None:
raise RuntimeError("UnityInstanceMiddleware not initialized. Call set_unity_instance_middleware first.")
return _unity_instance_middleware
def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
"""Set the global Unity instance middleware (called during server initialization)."""
global _unity_instance_middleware
_unity_instance_middleware = middleware
class UnityInstanceMiddleware(Middleware):
"""
Middleware that manages per-session Unity instance selection.
Stores active instance per session_id and injects it into request state
for all tool calls.
"""
def __init__(self):
super().__init__()
self._active_by_key: dict[str, str] = {}
self._lock = RLock()
def _get_session_key(self, ctx) -> str:
"""
Derive a stable key for the calling session.
Uses ctx.session_id if available, falls back to 'global'.
"""
session_id = getattr(ctx, "session_id", None)
if isinstance(session_id, str) and session_id:
return session_id
client_id = getattr(ctx, "client_id", None)
if isinstance(client_id, str) and client_id:
return client_id
return "global"
def set_active_instance(self, ctx, instance_id: str) -> None:
"""Store the active instance for this session."""
key = self._get_session_key(ctx)
with self._lock:
self._active_by_key[key] = instance_id
def get_active_instance(self, ctx) -> Optional[str]:
"""Retrieve the active instance for this session."""
key = self._get_session_key(ctx)
with self._lock:
return self._active_by_key.get(key)
async def on_call_tool(self, context: MiddlewareContext, call_next):
"""
Intercept tool calls and inject the active Unity instance into request state.
"""
# Get the FastMCP context
ctx = context.fastmcp_context
# Look up the active instance for this session
active_instance = self.get_active_instance(ctx)
# Inject into request-scoped state (accessible via ctx.get_state)
if active_instance is not None:
ctx.set_state("unity_instance", active_instance)
# Continue with tool execution
return await call_next(context)

View File

@ -52,6 +52,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
* `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries.
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes.
* `run_test`: Runs a tests in the Unity Editor.
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running).
</details>
@ -60,6 +61,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
Your LLM can retrieve the following resources:
* `unity_instances`: Lists all running Unity Editor instances with their details (name, path, port, status).
* `menu_items`: Retrieves all available menu items in the Unity Editor.
* `tests`: Retrieves all available tests in the Unity Editor. Can select tests of a specific type (e.g., "EditMode", "PlayMode").
</details>
@ -277,6 +279,28 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
Example Prompt: `Create a 3D player controller`, `Create a tic-tac-toe game in 3D`, `Create a cool shader and apply to a cube`.
### Working with Multiple Unity Instances
MCP for Unity supports multiple Unity Editor instances simultaneously. Each instance is isolated per MCP client session.
**To direct tool calls to a specific instance:**
1. List available instances: Ask your LLM to check the `unity_instances` resource
2. Set the active instance: Use `set_active_instance` with the instance name (e.g., `MyProject@abc123`)
3. All subsequent tools route to that instance until changed
**Example:**
```
User: "List all Unity instances"
LLM: [Shows ProjectA@abc123 and ProjectB@def456]
User: "Set active instance to ProjectA@abc123"
LLM: [Calls set_active_instance("ProjectA@abc123")]
User: "Create a red cube"
LLM: [Creates cube in ProjectA]
```
---
## Development & Contributing 🛠️

View File

@ -1,4 +1,5 @@
from typing import Any
from datetime import datetime
from pydantic import BaseModel
@ -7,3 +8,28 @@ class MCPResponse(BaseModel):
message: str | None = None
error: str | None = None
data: Any | None = None
class UnityInstanceInfo(BaseModel):
"""Information about a Unity Editor instance"""
id: str # "ProjectName@hash" or fallback to hash
name: str # Project name extracted from path
path: str # Full project path (Assets folder)
hash: str # 8-char hash of project path
port: int # TCP port
status: str # "running", "reloading", "offline"
last_heartbeat: datetime | None = None
unity_version: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
"id": self.id,
"name": self.name,
"path": self.path,
"hash": self.hash,
"port": self.port,
"status": self.status,
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
"unity_version": self.unity_version
}

View File

@ -14,9 +14,14 @@ What changed and why:
import glob
import json
import logging
import os
import struct
from datetime import datetime, timezone
from pathlib import Path
import socket
from typing import Optional, List
from typing import Optional, List, Dict
from models import UnityInstanceInfo
logger = logging.getLogger("mcp-for-unity-server")
@ -56,21 +61,54 @@ class PortDiscovery:
@staticmethod
def _try_probe_unity_mcp(port: int) -> bool:
"""Quickly check if a MCP for Unity listener is on this port.
Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message.
Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed pong.
"""
try:
with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s:
s.settimeout(PortDiscovery.CONNECT_TIMEOUT)
try:
# 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)
# 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:
return data and b'"message":"pong"' in data
# 2. Send framed ping command
# Frame format: 8-byte length header (big-endian uint64) + payload
payload = b"ping"
header = struct.pack('>Q', len(payload))
s.sendall(header + payload)
# 3. Receive framed response
# Helper to receive exact number of bytes
def _recv_exact(expected: int) -> bytes | None:
chunks = bytearray()
while len(chunks) < expected:
chunk = s.recv(expected - len(chunks))
if not chunk:
return None
chunks.extend(chunk)
return bytes(chunks)
response_header = _recv_exact(8)
if response_header is None:
return False
except Exception:
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 as e:
logger.debug(f"Connection failed for port {port}: {e}")
return False
@staticmethod
@ -158,3 +196,117 @@ class PortDiscovery:
logger.warning(
f"Could not read port configuration {path}: {e}")
return None
@staticmethod
def _extract_project_name(project_path: str) -> str:
"""Extract project name from Assets path.
Examples:
/Users/sakura/Projects/MyGame/Assets -> MyGame
C:\\Projects\\TestProject\\Assets -> TestProject
"""
if not project_path:
return "Unknown"
try:
# Remove trailing /Assets or \Assets
path = project_path.rstrip('/\\')
if path.endswith('Assets'):
path = path[:-6].rstrip('/\\')
# Get the last directory name
name = os.path.basename(path)
return name if name else "Unknown"
except Exception:
return "Unknown"
@staticmethod
def discover_all_unity_instances() -> List[UnityInstanceInfo]:
"""
Discover all running Unity Editor instances by scanning status files.
Returns:
List of UnityInstanceInfo objects for all discovered instances
"""
instances_by_port: Dict[int, tuple[UnityInstanceInfo, datetime]] = {}
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:
status_path = Path(status_file_path)
file_mtime = datetime.fromtimestamp(status_path.stat().st_mtime, tz=timezone.utc)
with status_path.open('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:
parsed = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00'))
# Normalize to UTC for consistent comparison
if parsed.tzinfo is None:
last_heartbeat = parsed.replace(tzinfo=timezone.utc)
else:
last_heartbeat = parsed.astimezone(timezone.utc)
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
freshness = last_heartbeat or file_mtime
existing = instances_by_port.get(port)
if existing:
_, existing_time = existing
if existing_time >= freshness:
logger.debug(
"Skipping stale status entry %s in favor of more recent data for port %s",
status_path.name,
port,
)
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_by_port[port] = (instance, freshness)
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
deduped_instances = [entry[0] for entry in sorted(instances_by_port.values(), key=lambda item: item[1], reverse=True)]
logger.info(f"Discovered {len(deduped_instances)} Unity instances (after de-duplication by port)")
return deduped_instances

View File

@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.27.2",
"fastmcp>=2.12.5",
"fastmcp>=2.13.0",
"mcp>=1.16.0",
"pydantic>=2.12.0",
"tomli>=2.3.0",

View File

@ -1,6 +1,7 @@
"""
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import inspect
import logging
from pathlib import Path
@ -36,6 +37,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 +45,32 @@ def register_all_resources(mcp: FastMCP):
description = resource_info['description']
kwargs = resource_info['kwargs']
# Apply the @mcp.resource decorator and telemetry
# Check if URI contains query parameters (e.g., {?unity_instance})
has_query_params = '{?' in uri
if has_query_params:
# Register template with query parameter support
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
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)
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)")

View File

@ -1,5 +1,8 @@
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -12,14 +15,19 @@ class GetMenuItemsResponse(MCPResponse):
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(ctx: Context) -> GetMenuItemsResponse:
"""Provides a list of all menu items.
"""
unity_instance = get_unity_instance_from_context(ctx)
params = {
"refresh": True,
"search": "",
}
response = await async_send_command_with_retry("get_menu_items", params)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_menu_items",
params,
)
return GetMenuItemsResponse(**response) if isinstance(response, dict) else response

View File

@ -1,8 +1,11 @@
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -18,14 +21,34 @@ class GetTestsResponse(MCPResponse):
@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", {})
async def get_tests(ctx: Context) -> GetTestsResponse:
"""Provides a list of all tests.
"""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests",
{},
)
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})
async def get_tests_for_mode(
ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
) -> GetTestsResponse:
"""Provides a list of tests for a specific mode.
Args:
mode: The test mode to filter by (EditMode or PlayMode).
"""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests_for_mode",
{"mode": mode},
)
return GetTestsResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,67 @@
"""
Resource to list all available Unity Editor instances.
"""
from typing import Any
from fastmcp import Context
from registry import mcp_for_unity_resource
from unity_connection import get_unity_connection_pool
@mcp_for_unity_resource(
uri="unity://instances",
name="unity_instances",
description="Lists all running Unity Editor instances with their details."
)
def unity_instances(ctx: Context) -> dict[str, Any]:
"""
List all available Unity Editor instances.
Returns information about each instance including:
- id: Unique identifier (ProjectName@hash)
- name: Project name
- path: Full project path
- hash: 8-character hash of project path
- port: TCP port number
- status: Current status (running, reloading, etc.)
- last_heartbeat: Last heartbeat timestamp
- unity_version: Unity version (if available)
Returns:
Dictionary containing list of instances and metadata
"""
ctx.info("Listing Unity instances")
try:
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
# 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": []
}

View File

@ -3,12 +3,14 @@ from fastmcp import FastMCP
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
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
import time
# Configure logging using settings from config
@ -61,14 +63,14 @@ try:
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
global _unity_connection_pool
logger.info("MCP for Unity Server starting up")
# Record server startup telemetry
@ -101,8 +103,17 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
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")
# 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
@ -111,12 +122,16 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
{
"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 +147,6 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
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 +159,12 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
)).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
@ -179,6 +192,12 @@ Available tools:\n
"""
)
# Initialize and register middleware for session-based Unity instance routing
unity_middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(unity_middleware)
mcp.add_middleware(unity_middleware)
logger.info("Registered Unity instance middleware for session-based routing")
# Register all tools
register_all_tools(mcp)
@ -188,6 +207,38 @@ register_all_resources(mcp)
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')

View File

@ -3,8 +3,9 @@ MCP Tools package - Auto-discovers and registers all tools in this directory.
"""
import logging
from pathlib import Path
from typing import Any, Awaitable, Callable, TypeVar
from fastmcp import FastMCP
from fastmcp import Context, FastMCP
from telemetry_decorator import telemetry_tool
from registry import get_registered_tools
@ -12,8 +13,16 @@ from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
# Export decorator for easy imports within tools
__all__ = ['register_all_tools']
# Export decorator and helpers for easy imports within tools
__all__ = [
"register_all_tools",
"get_unity_instance_from_context",
"send_with_unity_instance",
"async_send_with_unity_instance",
"with_unity_instance",
]
T = TypeVar("T")
def register_all_tools(mcp: FastMCP):
@ -50,3 +59,117 @@ def register_all_tools(mcp: FastMCP):
logger.debug(f"Registered tool: {tool_name} - {description}")
logger.info(f"Registered {len(tools)} MCP tools")
def get_unity_instance_from_context(
ctx: Context,
key: str = "unity_instance",
) -> str | None:
"""Extract the unity_instance value from middleware state.
The instance is set via the set_active_instance tool and injected into
request state by UnityInstanceMiddleware.
"""
get_state_fn = getattr(ctx, "get_state", None)
if callable(get_state_fn):
try:
return get_state_fn(key)
except Exception: # pragma: no cover - defensive
pass
return None
def send_with_unity_instance(
send_fn: Callable[..., T],
unity_instance: str | None,
*args,
**kwargs,
) -> T:
"""Call a transport function, attaching instance_id only when provided."""
if unity_instance:
kwargs.setdefault("instance_id", unity_instance)
return send_fn(*args, **kwargs)
async def async_send_with_unity_instance(
send_fn: Callable[..., Awaitable[T]],
unity_instance: str | None,
*args,
**kwargs,
) -> T:
"""Async variant of send_with_unity_instance."""
if unity_instance:
kwargs.setdefault("instance_id", unity_instance)
return await send_fn(*args, **kwargs)
def with_unity_instance(
log: str | Callable[[Context, tuple, dict, str | None], str] | None = None,
*,
kwarg_name: str = "unity_instance",
):
"""Decorator to extract unity_instance, perform standard logging, and pass the
instance to the wrapped tool via kwarg.
- log: a format string (using `{unity_instance}`) or a callable returning a message.
- kwarg_name: name of the kwarg to inject (default: "unity_instance").
"""
def _decorate(fn: Callable[..., T]):
import asyncio
import inspect
is_coro = asyncio.iscoroutinefunction(fn)
def _compose_message(ctx: Context, a: tuple, k: dict, inst: str | None) -> str | None:
if log is None:
return None
if callable(log):
try:
return log(ctx, a, k, inst)
except Exception:
return None
try:
return str(log).format(unity_instance=inst or "default")
except Exception:
return str(log)
if is_coro:
async def _wrapper(ctx: Context, *args, **kwargs):
inst = get_unity_instance_from_context(ctx)
msg = _compose_message(ctx, args, kwargs, inst)
if msg:
try:
result = ctx.info(msg)
if inspect.isawaitable(result):
await result
except Exception:
pass
# Inject kwarg only if function accepts it or downstream ignores extras
kwargs.setdefault(kwarg_name, inst)
return await fn(ctx, *args, **kwargs)
else:
def _wrapper(ctx: Context, *args, **kwargs):
inst = get_unity_instance_from_context(ctx)
msg = _compose_message(ctx, args, kwargs, inst)
if msg:
try:
result = ctx.info(msg)
if inspect.isawaitable(result):
try:
loop = asyncio.get_running_loop()
loop.create_task(result)
except RuntimeError:
# No running event loop; skip awaiting to avoid warnings
pass
except Exception:
pass
kwargs.setdefault(kwarg_name, inst)
return fn(ctx, *args, **kwargs)
from functools import wraps
return wraps(fn)(_wrapper) # type: ignore[arg-type]
return _decorate

View File

@ -7,6 +7,7 @@ from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -18,8 +19,10 @@ async def execute_menu_item(
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
) -> MCPResponse:
await ctx.info(f"Processing execute_menu_item: {menu_path}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
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)
result = await async_send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
return MCPResponse(**result) if isinstance(result, dict) else result

View File

@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -31,9 +32,11 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing manage_asset: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Coerce 'properties' from JSON string to dict for client compatibility
if isinstance(properties, str):
try:
@ -86,7 +89,7 @@ async def manage_asset(
# 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_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -3,6 +3,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from telemetry import is_telemetry_enabled, record_tool_usage
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -22,7 +23,8 @@ def manage_editor(
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_editor: {action}")
# Get active instance from request state (injected by middleware)
unity_instance = get_unity_instance_from_context(ctx)
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
def _coerce_bool(value, default=None):
@ -62,8 +64,8 @@ def manage_editor(
}
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_editor", params)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):

View File

@ -3,15 +3,16 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(
description="Manage GameObjects. For booleans, send true/false; if your client only sends strings, 'true'/'false' are accepted. Vectors may be [x,y,z] or a string like '[x,y,z]'. For 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data."
description="Performs CRUD operations on GameObjects and components."
)
def manage_gameobject(
ctx: Context,
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."],
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."],
target: Annotated[str,
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
@ -65,7 +66,8 @@ def manage_gameobject(
includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_gameobject: {action}")
# Get active instance from session-scoped middleware state
unity_instance = get_unity_instance_from_context(ctx)
# Coercers to tolerate stringified booleans and vectors
def _coerce_bool(value, default=None):
@ -195,8 +197,8 @@ def manage_gameobject(
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_gameobject", params)
# Check if the response indicates success
# If the response is not successful, raise an exception with the error message

View File

@ -2,20 +2,16 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(
description="Bridge for prefab management commands (stage control and creation)."
description="Performs prefab operations (create, modify, delete, etc.)."
)
def manage_prefabs(
ctx: Context,
action: Annotated[Literal[
"open_stage",
"close_stage",
"save_open_stage",
"create_from_gameobject",
], "Manage prefabs (stage control and creation)."],
action: Annotated[Literal["create", "modify", "delete", "get_components"], "Perform prefab operations."],
prefab_path: Annotated[str,
"Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None,
mode: Annotated[str,
@ -28,8 +24,11 @@ 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,
component_properties: Annotated[str, "Component properties in JSON format"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_prefabs: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
params: dict[str, Any] = {"action": action}
@ -45,7 +44,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_with_unity_instance(send_command_with_retry, unity_instance, "manage_prefabs", params)
if isinstance(response, dict) and response.get("success"):
return {

View File

@ -2,21 +2,23 @@ from typing import Annotated, Literal, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@mcp_for_unity_tool(description="Manage Unity scenes. Tip: For broad client compatibility, pass build_index as a quoted string (e.g., '0').")
@mcp_for_unity_tool(
description="Performs CRUD operations on Unity scenes."
)
def manage_scene(
ctx: Context,
action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
name: Annotated[str,
"Scene name. Not required get_active/get_build_settings"] | None = None,
path: Annotated[str,
"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,
name: Annotated[str, "Scene name."] | None = None,
path: Annotated[str, "Scene path."] | None = None,
build_index: Annotated[int | str, "Unity build index (quote as string, e.g., '0')."] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_scene: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
# Coerce numeric inputs defensively
def _coerce_int(value, default=None):
@ -44,8 +46,8 @@ def manage_scene(
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_with_unity_instance(send_command_with_retry, unity_instance, "manage_scene", params)
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):

View File

@ -6,6 +6,7 @@ from urllib.parse import urlparse, unquote
from fastmcp import FastMCP, Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
import unity_connection
@ -86,7 +87,8 @@ def apply_text_edits(
options: Annotated[dict[str, Any],
"Optional options, used to pass additional options to the script editor"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing apply_text_edits: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing apply_text_edits: {uri} (unity_instance={unity_instance or 'default'})")
name, directory = _split_uri(uri)
# Normalize common aliases/misuses for resilience:
@ -103,11 +105,16 @@ def apply_text_edits(
warnings: list[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = unity_connection.send_command_with_retry("manage_script", {
read_resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
{
"action": "read",
"name": name,
"path": directory,
})
},
)
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 +311,7 @@ def apply_text_edits(
"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)
@ -341,6 +348,7 @@ def apply_text_edits(
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
instance_id=unity_instance,
)
except Exception:
pass
@ -360,7 +368,8 @@ def create_script(
script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing create_script: {path}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing create_script: {path} (unity_instance={unity_instance or 'default'})")
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
# Local validation to avoid round-trips on obviously bad input
@ -386,22 +395,23 @@ 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/..."],
) -> dict[str, Any]:
"""Delete a C# script by URI."""
ctx.info(f"Processing delete_script: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing delete_script: {uri} (unity_instance={unity_instance or 'default'})")
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,9 +422,10 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing validate_script: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing validate_script: {uri} (unity_instance={unity_instance or 'default'})")
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/'."}
@ -426,7 +437,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(
@ -451,7 +462,8 @@ def manage_script(
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_script: {action}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing manage_script: {action} (unity_instance={unity_instance or 'default'})")
try:
# Prepare parameters for Unity
params = {
@ -473,7 +485,12 @@ 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 = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(response, dict):
if response.get("success"):
@ -535,13 +552,14 @@ 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/..."],
) -> dict[str, Any]:
ctx.info(f"Processing get_sha: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing get_sha: {uri} (unity_instance={unity_instance or 'default'})")
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(

View File

@ -3,6 +3,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -17,7 +18,9 @@ def manage_shader(
contents: Annotated[str,
"Shader code for 'create'/'update'"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_shader: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
try:
# Prepare parameters for Unity
params = {
@ -39,8 +42,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_with_unity_instance(send_command_with_retry, unity_instance, "manage_shader", params)
# Process response from Unity
if isinstance(response, dict) and response.get("success"):

View File

@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -23,9 +24,11 @@ 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,
) -> dict[str, Any]:
ctx.info(f"Processing read_console: {action}")
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Set defaults if values are None
action = action if action is not None else 'get'
types = types if types is not None else ['error', 'warning', 'log']
@ -87,8 +90,8 @@ def read_console(
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_with_unity_instance(send_command_with_retry, unity_instance, "read_console", params_dict)
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present
try:

View File

@ -14,6 +14,7 @@ from urllib.parse import urlparse, unquote
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance, async_send_with_unity_instance
from unity_connection import send_command_with_retry
@ -42,7 +43,8 @@ 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(ctx: Context, override: str | None) -> Path:
unity_instance = get_unity_instance_from_context(ctx)
# 1) Explicit override
if override:
pr = Path(override).expanduser().resolve()
@ -59,10 +61,14 @@ def _resolve_project_root(override: str | None) -> Path:
return pr
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry(
"manage_editor", {"action": "get_project_root"})
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get(
response = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_editor",
{"action": "get_project_root"},
)
if isinstance(response, dict) and response.get("success"):
pr = Path(response.get("data", {}).get(
"projectRoot", "")).expanduser().resolve()
if pr and (pr / "Assets").exists():
return pr
@ -142,9 +148,10 @@ async def list_resources(
limit: Annotated[int, "Page limit"] = 200,
project_root: Annotated[str, "Project path"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing list_resources: {pattern}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing list_resources: {pattern} (unity_instance={unity_instance or 'default'})")
try:
project = _resolve_project_root(project_root)
project = _resolve_project_root(ctx, project_root)
base = (project / under).resolve()
try:
base.relative_to(project)
@ -202,7 +209,8 @@ async def read_resource(
"The project root directory"] | None = None,
request: Annotated[str, "The request ID"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing read_resource: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing read_resource: {uri} (unity_instance={unity_instance or 'default'})")
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"):
@ -266,7 +274,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(ctx, project_root)
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}"}
@ -357,9 +365,10 @@ async def find_in_file(
max_results: Annotated[int,
"Cap results to avoid huge payloads"] = 200,
) -> dict[str, Any]:
ctx.info(f"Processing find_in_file: {uri}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
try:
project = _resolve_project_root(project_root)
project = _resolve_project_root(ctx, project_root)
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}"}

View File

@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from models import MCPResponse
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
@ -38,15 +39,17 @@ class RunTestsResponse(MCPResponse):
data: RunTestsResult | None = None
@mcp_for_unity_tool(description="Runs Unity tests for the specified mode")
@mcp_for_unity_tool(
description="Runs Unity tests for the specified mode"
)
async def run_tests(
ctx: Context,
mode: Annotated[Literal["edit", "play"], Field(
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,
) -> RunTestsResponse:
await ctx.info(f"Processing run_tests: mode={mode}")
mode: Annotated[Literal["edit", "play"], "Unity test mode to run"] = "edit",
timeout_seconds: Annotated[int | str | None, "Optional timeout in seconds for the Unity test run (string, e.g. '30')"] = None,
) -> dict[str, Any]:
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)
# Coerce timeout defensively (string/float -> int)
def _coerce_int(value, default=None):
@ -69,6 +72,6 @@ async def run_tests(
if ts is not None:
params["timeoutSeconds"] = ts
response = await async_send_command_with_retry("run_tests", params)
response = await async_send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
await ctx.info(f'Response {response}')
return RunTestsResponse(**response) if isinstance(response, dict) else response

View File

@ -6,6 +6,7 @@ from typing import Annotated, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from tools import get_unity_instance_from_context, send_with_unity_instance
from unity_connection import send_command_with_retry
@ -366,7 +367,8 @@ def script_apply_edits(
namespace: Annotated[str,
"Namespace of the script to edit"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing script_apply_edits: {name}")
unity_instance = get_unity_instance_from_context(ctx)
ctx.info(f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
# Normalize unsupported or aliased ops to known structured/text paths
@ -585,8 +587,12 @@ def script_apply_edits(
"edits": edits,
"options": opts2,
}
resp_struct = send_command_with_retry(
"manage_script", params_struct)
resp_struct = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_struct,
)
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 +604,7 @@ def script_apply_edits(
"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)}
@ -721,8 +727,12 @@ def script_apply_edits(
"precondition_sha256": sha,
"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)
resp_text = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_text,
)
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)
@ -742,8 +752,12 @@ def script_apply_edits(
"edits": struct_edits,
"options": opts2
}
resp_struct = send_command_with_retry(
"manage_script", params_struct)
resp_struct = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params_struct,
)
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 +885,12 @@ def script_apply_edits(
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
resp = send_command_with_retry("manage_script", params)
resp = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
@ -955,7 +974,12 @@ def script_apply_edits(
"options": options or {"validate": "standard", "refresh": "debounced"},
}
write_resp = send_command_with_retry("manage_script", params)
write_resp = send_with_unity_instance(
send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(

View File

@ -0,0 +1,45 @@
from typing import Annotated, Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from unity_connection import get_unity_connection_pool
from unity_instance_middleware import get_unity_instance_middleware
@mcp_for_unity_tool(
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
)
def set_active_instance(
ctx: Context,
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
) -> dict[str, Any]:
# Discover running instances
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=True)
ids = {inst.id: inst for inst in instances}
hashes = {}
for inst in instances:
# exact hash and prefix map; last write wins but we'll detect ambiguity
hashes.setdefault(inst.hash, inst)
# Disallow plain names to ensure determinism
value = instance.strip()
resolved = None
if "@" in value:
resolved = ids.get(value)
if resolved is None:
return {"success": False, "error": f"Instance '{value}' not found. Check unity://instances resource."}
else:
# Treat as hash/prefix; require unique match
candidates = [inst for inst in instances if inst.hash.startswith(value)]
if len(candidates) == 1:
resolved = candidates[0]
elif len(candidates) == 0:
return {"success": False, "error": f"No instance with hash '{value}'."}
else:
return {"success": False, "error": f"Hash '{value}' matches multiple instances: {[c.id for c in candidates]}"}
# Store selection in middleware (session-scoped)
middleware = get_unity_instance_middleware()
middleware.set_active_instance(ctx, resolved.id)
return {"success": True, "message": f"Active instance set to {resolved.id}", "data": {"instance": resolved.id}}

View File

@ -4,6 +4,7 @@ from dataclasses import dataclass
import errno
import json
import logging
import os
from pathlib import Path
from port_discovery import PortDiscovery
import random
@ -11,9 +12,9 @@ import socket
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,6 +38,7 @@ 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"""
@ -233,23 +235,39 @@ class UnityConnection:
attempts = max(config.max_retries, 5)
base_backoff = max(0.5, config.retry_delay)
def read_status_file() -> dict | None:
def read_status_file(target_hash: str | None = None) -> dict | None:
try:
status_files = sorted(Path.home().joinpath(
'.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
base_path = Path.home().joinpath('.unity-mcp')
status_files = sorted(
base_path.glob('unity-mcp-status-*.json'),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not status_files:
return None
latest = status_files[0]
with latest.open('r') as f:
if target_hash:
for status_path in status_files:
if status_path.stem.endswith(target_hash):
with status_path.open('r') as f:
return json.load(f)
# Fallback: return most recent regardless of hash
with status_files[0].open('r') as f:
return json.load(f)
except Exception:
return None
last_short_timeout = None
# Extract hash suffix from instance id (e.g., Project@hash)
target_hash: str | None = None
if self.instance_id and '@' in self.instance_id:
maybe_hash = self.instance_id.split('@', 1)[1].strip()
if maybe_hash:
target_hash = maybe_hash
# Preflight: if Unity reports reloading, return a structured hint so clients can retry politely
try:
status = read_status_file()
status = read_status_file(target_hash)
if status and (status.get('reloading') or status.get('reason') == 'reloading'):
return MCPResponse(
success=False,
@ -328,9 +346,28 @@ class UnityConnection:
finally:
self.sock = None
# Re-discover port each time
# Re-discover the port for this specific instance
try:
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}")
@ -340,7 +377,7 @@ class UnityConnection:
if attempt < attempts:
# Heartbeat-aware, jittered backoff
status = read_status_file()
status = read_status_file(target_hash)
# Base exponential backoff
backoff = base_backoff * (2 ** attempt)
# Decorrelated jitter multiplier
@ -371,32 +408,252 @@ class UnityConnection:
raise
# Global Unity connection
_unity_connection = None
# -----------------------------
# Connection Pool for Multiple Unity Instances
# -----------------------------
class UnityConnectionPool:
"""Manages connections to multiple Unity Editor instances"""
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection.
def __init__(self):
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
Note: Do NOT ping on every retrieval to avoid connection storms. Rely on
send_command() exceptions to detect broken sockets and reconnect there.
# 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]:
"""
global _unity_connection
if _unity_connection is not None:
return _unity_connection
Discover all running Unity Editor instances.
# 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
Args:
force_refresh: If True, bypass cache and scan immediately
Returns:
List of UnityInstanceInfo objects
"""
now = time.time()
# Return cached results if valid
if not force_refresh and (now - self._last_full_scan) < self._scan_interval:
logger.debug(f"Returning cached Unity instances (age: {now - self._last_full_scan:.1f}s)")
return list(self._known_instances.values())
# Scan for instances
logger.debug("Scanning for Unity instances...")
instances = PortDiscovery.discover_all_unity_instances()
# Update cache
with self._pool_lock:
self._known_instances = {inst.id: inst for inst in instances}
self._last_full_scan = now
logger.info(f"Found {len(instances)} Unity instances: {[inst.id for inst in instances]}")
return instances
def _resolve_instance_id(self, instance_identifier: Optional[str], instances: List[UnityInstanceInfo]) -> UnityInstanceInfo:
"""
Resolve an instance identifier to a specific Unity instance.
Args:
instance_identifier: User-provided identifier (name, hash, name@hash, path, port, or None)
instances: List of available instances
Returns:
Resolved UnityInstanceInfo
Raises:
ConnectionError: If instance cannot be resolved
"""
if not instances:
raise ConnectionError(
"Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
logger.info("Connected to Unity on startup")
return _unity_connection
"No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
)
# Use default instance if no identifier provided
if instance_identifier is None:
if self._default_instance_id:
instance_identifier = self._default_instance_id
logger.debug(f"Using default instance: {instance_identifier}")
else:
# Use the most recently active instance
# Instances with no heartbeat (None) should be sorted last (use 0 as sentinel)
sorted_instances = sorted(
instances,
key=lambda inst: inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0,
reverse=True,
)
logger.info(f"No instance specified, using most recent: {sorted_instances[0].id}")
return sorted_instances[0]
identifier = instance_identifier.strip()
# Try exact ID match first
for inst in instances:
if inst.id == identifier:
return inst
# Try project name match
name_matches = [inst for inst in instances if inst.name == identifier]
if len(name_matches) == 1:
return name_matches[0]
elif len(name_matches) > 1:
# Multiple projects with same name - return helpful error
suggestions = [
{
"id": inst.id,
"path": inst.path,
"port": inst.port,
"suggest": f"Use unity_instance='{inst.id}'"
}
for inst in name_matches
]
raise ConnectionError(
f"Project name '{identifier}' matches {len(name_matches)} instances. "
f"Please use the full format (e.g., '{name_matches[0].id}'). "
f"Available instances: {suggestions}"
)
# Try hash match
hash_matches = [inst for inst in instances if inst.hash == identifier or inst.hash.startswith(identifier)]
if len(hash_matches) == 1:
return hash_matches[0]
elif len(hash_matches) > 1:
raise ConnectionError(
f"Hash '{identifier}' matches multiple instances: {[inst.id for inst in hash_matches]}"
)
# Try composite format: Name@Hash or Name@Port
if "@" in identifier:
name_part, hint_part = identifier.split("@", 1)
composite_matches = [
inst for inst in instances
if inst.name == name_part and (
inst.hash.startswith(hint_part) or str(inst.port) == hint_part
)
]
if len(composite_matches) == 1:
return composite_matches[0]
# Try port match (as string)
try:
port_num = int(identifier)
port_matches = [inst for inst in instances if inst.port == port_num]
if len(port_matches) == 1:
return port_matches[0]
except ValueError:
pass
# Try path match
path_matches = [inst for inst in instances if inst.path == identifier]
if len(path_matches) == 1:
return path_matches[0]
# Nothing matched
available_ids = [inst.id for inst in instances]
raise ConnectionError(
f"Unity instance '{identifier}' not found. "
f"Available instances: {available_ids}. "
f"Check unity://instances resource for 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):
"""Disconnect all active connections"""
with self._pool_lock:
for instance_id, conn in self._connections.items():
try:
logger.info(f"Disconnecting from Unity instance: {instance_id}")
conn.disconnect()
except Exception:
logger.exception(f"Error disconnecting from {instance_id}")
self._connections.clear()
# Global Unity connection pool
_unity_connection_pool: Optional[UnityConnectionPool] = None
_pool_init_lock = threading.Lock()
def get_unity_connection_pool() -> UnityConnectionPool:
"""Get or create the global Unity connection pool"""
global _unity_connection_pool
if _unity_connection_pool is 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:
"""Retrieve or establish a Unity connection.
Args:
instance_identifier: Optional identifier for specific Unity instance.
If None, uses default or most recent instance.
Returns:
UnityConnection to the specified or default Unity instance
Note: This function now uses the connection pool internally.
"""
pool = get_unity_connection_pool()
return pool.get_connection(instance_identifier)
# -----------------------------
@ -413,13 +670,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.
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, waiting politely through Unity reloads.
Args:
command_type: The command type to send
params: Command parameters
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds
Returns:
Response dictionary from Unity
Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
structured failure if retries are exhausted.
"""
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 +710,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 +739,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))

View File

@ -0,0 +1,85 @@
"""
Middleware for managing Unity instance selection per session.
This middleware intercepts all tool calls and injects the active Unity instance
into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
"""
from threading import RLock
from typing import Optional
from fastmcp.server.middleware import Middleware, MiddlewareContext
# Global instance for access from tools
_unity_instance_middleware: Optional['UnityInstanceMiddleware'] = None
def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
"""Get the global Unity instance middleware."""
if _unity_instance_middleware is None:
raise RuntimeError("UnityInstanceMiddleware not initialized. Call set_unity_instance_middleware first.")
return _unity_instance_middleware
def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
"""Set the global Unity instance middleware (called during server initialization)."""
global _unity_instance_middleware
_unity_instance_middleware = middleware
class UnityInstanceMiddleware(Middleware):
"""
Middleware that manages per-session Unity instance selection.
Stores active instance per session_id and injects it into request state
for all tool calls.
"""
def __init__(self):
super().__init__()
self._active_by_key: dict[str, str] = {}
self._lock = RLock()
def _get_session_key(self, ctx) -> str:
"""
Derive a stable key for the calling session.
Uses ctx.session_id if available, falls back to 'global'.
"""
session_id = getattr(ctx, "session_id", None)
if isinstance(session_id, str) and session_id:
return session_id
client_id = getattr(ctx, "client_id", None)
if isinstance(client_id, str) and client_id:
return client_id
return "global"
def set_active_instance(self, ctx, instance_id: str) -> None:
"""Store the active instance for this session."""
key = self._get_session_key(ctx)
with self._lock:
self._active_by_key[key] = instance_id
def get_active_instance(self, ctx) -> Optional[str]:
"""Retrieve the active instance for this session."""
key = self._get_session_key(ctx)
with self._lock:
return self._active_by_key.get(key)
async def on_call_tool(self, context: MiddlewareContext, call_next):
"""
Intercept tool calls and inject the active Unity instance into request state.
"""
# Get the FastMCP context
ctx = context.fastmcp_context
# Look up the active instance for this session
active_instance = self.get_active_instance(ctx)
# Inject into request-scoped state (accessible via ctx.get_state)
if active_instance is not None:
ctx.set_state("unity_instance", active_instance)
# Continue with tool execution
return await call_next(context)

View File

@ -1,10 +1,55 @@
class _DummyMeta(dict):
def __getattr__(self, item):
try:
return self[item]
except KeyError as exc:
raise AttributeError(item) from exc
model_extra = property(lambda self: self)
def model_dump(self, exclude_none=True):
if not exclude_none:
return dict(self)
return {k: v for k, v in self.items() if v is not None}
class DummyContext:
"""Mock context object for testing"""
def __init__(self, **meta):
import uuid
self.log_info = []
self.log_warning = []
self.log_error = []
self._meta = _DummyMeta(meta)
# Give each context a unique session_id to avoid state leakage between tests
self.session_id = str(uuid.uuid4())
# Add state storage to mimic FastMCP context state
self._state = {}
class _RequestContext:
def __init__(self, meta):
self.meta = meta
self.request_context = _RequestContext(self._meta)
def info(self, message):
pass
self.log_info.append(message)
def warning(self, message):
pass
self.log_warning.append(message)
# Some code paths call warn(); treat it as an alias of warning()
def warn(self, message):
self.warning(message)
def error(self, message):
pass
self.log_error.append(message)
def set_state(self, key, value):
"""Set state value (mimics FastMCP context.set_state)"""
self._state[key] = value
def get_state(self, key, default=None):
"""Get state value (mimics FastMCP context.get_state)"""
return self._state.get(key, default)

View File

@ -0,0 +1,344 @@
"""
Comprehensive test suite for Unity instance routing.
These tests validate that set_active_instance correctly routes subsequent
tool calls to the intended Unity instance across ALL tool categories.
DESIGN: Single source of truth via middleware state:
- set_active_instance tool stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools (GameObject, Script, Asset, etc.) use get_unity_instance_from_context()
"""
import sys
import pathlib
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock, patch
from fastmcp import Context
# Add Server source to path
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "Server"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from unity_instance_middleware import UnityInstanceMiddleware
from tools import get_unity_instance_from_context
class TestInstanceRoutingBasics:
"""Test basic middleware functionality."""
def test_middleware_stores_and_retrieves_instance(self):
"""Middleware should store and retrieve instance per session."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session-1"
# Set active instance
middleware.set_active_instance(ctx, "TestProject@abc123")
# Retrieve should return same instance
assert middleware.get_active_instance(ctx) == "TestProject@abc123"
def test_middleware_isolates_sessions(self):
"""Different sessions should have independent instance selections."""
middleware = UnityInstanceMiddleware()
ctx1 = Mock(spec=Context)
ctx1.session_id = "session-1"
ctx1.client_id = "client-1"
ctx2 = Mock(spec=Context)
ctx2.session_id = "session-2"
ctx2.client_id = "client-2"
# Set different instances for different sessions
middleware.set_active_instance(ctx1, "Project1@aaa")
middleware.set_active_instance(ctx2, "Project2@bbb")
# Each session should retrieve its own instance
assert middleware.get_active_instance(ctx1) == "Project1@aaa"
assert middleware.get_active_instance(ctx2) == "Project2@bbb"
def test_middleware_fallback_to_client_id(self):
"""When session_id unavailable, should use client_id."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = None
ctx.client_id = "client-123"
middleware.set_active_instance(ctx, "Project@xyz")
assert middleware.get_active_instance(ctx) == "Project@xyz"
def test_middleware_fallback_to_global(self):
"""When no session/client id, should use 'global' key."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = None
ctx.client_id = None
middleware.set_active_instance(ctx, "Project@global")
assert middleware.get_active_instance(ctx) == "Project@global"
class TestInstanceRoutingIntegration:
"""Test that instance routing works end-to-end for all tool categories."""
@pytest.mark.asyncio
async def test_middleware_injects_state_into_context(self):
"""Middleware on_call_tool should inject instance into ctx state."""
middleware = UnityInstanceMiddleware()
# Create mock context with state management
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Create middleware context
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
# Set active instance
middleware.set_active_instance(ctx, "TestProject@abc123")
# Mock call_next
async def mock_call_next(ctx):
return {"success": True}
# Execute middleware
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# Verify state was injected
ctx.set_state.assert_called_once_with("unity_instance", "TestProject@abc123")
def test_get_unity_instance_from_context_checks_state(self):
"""get_unity_instance_from_context must read from ctx.get_state()."""
ctx = Mock(spec=Context)
# Set up state storage (only source of truth now)
state_storage = {"unity_instance": "Project@state123"}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Call and verify
result = get_unity_instance_from_context(ctx)
assert result == "Project@state123", \
"get_unity_instance_from_context must read from ctx.get_state()!"
def test_get_unity_instance_returns_none_when_not_set(self):
"""Should return None when no instance is set."""
ctx = Mock(spec=Context)
# Empty state storage
state_storage = {}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
result = get_unity_instance_from_context(ctx)
assert result is None
class TestInstanceRoutingToolCategories:
"""Test instance routing for each tool category."""
def _create_mock_context_with_instance(self, instance_id: str):
"""Helper to create a mock context with instance set via middleware."""
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
# Set up state storage (only source of truth)
state_storage = {"unity_instance": instance_id}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
return ctx
@pytest.mark.parametrize("tool_category,tool_names", [
("GameObject", ["manage_gameobject"]),
("Asset", ["manage_asset"]),
("Scene", ["manage_scene"]),
("Editor", ["manage_editor"]),
("Console", ["read_console"]),
("Menu", ["execute_menu_item"]),
("Shader", ["manage_shader"]),
("Prefab", ["manage_prefabs"]),
("Tests", ["run_tests"]),
("Script", ["create_script", "delete_script", "apply_text_edits", "script_apply_edits"]),
("Resources", ["unity_instances", "menu_items", "tests"]),
])
def test_tool_category_respects_active_instance(self, tool_category, tool_names):
"""All tool categories must respect set_active_instance."""
# This is a specification test - individual tools need separate implementation tests
pass # Placeholder for category-level test
class TestInstanceRoutingRaceConditions:
"""Test for race conditions and timing issues."""
@pytest.mark.asyncio
async def test_rapid_instance_switching(self):
"""Rapidly switching instances should not cause routing errors."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
instances = ["Project1@aaa", "Project2@bbb", "Project3@ccc"]
for instance in instances:
middleware.set_active_instance(ctx, instance)
# Create middleware context
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_call_next(ctx):
return {"success": True}
# Execute middleware
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# Verify correct instance is set
assert state_storage.get("unity_instance") == instance
@pytest.mark.asyncio
async def test_set_then_immediate_create_script(self):
"""Setting instance then immediately creating script should route correctly."""
# This reproduces the bug: set_active_instance → create_script went to wrong instance
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
ctx.request_context = None
# Set active instance
middleware.set_active_instance(ctx, "ramble@8e29de57")
# Simulate middleware intercepting create_script call
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_create_script_call(ctx):
# This simulates what create_script does
instance = get_unity_instance_from_context(ctx)
return {"success": True, "routed_to": instance}
# Inject state via middleware
await middleware.on_call_tool(middleware_ctx, mock_create_script_call)
# Verify create_script would route to correct instance
result = await mock_create_script_call(ctx)
assert result["routed_to"] == "ramble@8e29de57", \
"create_script must route to the instance set by set_active_instance"
class TestInstanceRoutingSequentialOperations:
"""Test the exact failure scenario from user report."""
@pytest.mark.asyncio
async def test_four_script_creation_sequence(self):
"""
Reproduce the exact failure:
1. set_active(ramble) create_script1 should go to ramble
2. set_active(UnityMCPTests) create_script2 should go to UnityMCPTests
3. set_active(ramble) create_script3 should go to ramble
4. set_active(UnityMCPTests) create_script4 should go to UnityMCPTests
ACTUAL BEHAVIOR:
- Script1 went to UnityMCPTests (WRONG)
- Script2 went to ramble (WRONG)
- Script3 went to ramble (CORRECT)
- Script4 went to UnityMCPTests (CORRECT)
"""
middleware = UnityInstanceMiddleware()
# Track which instance each script was created in
script_routes = {}
async def simulate_create_script(ctx, script_name, expected_instance):
# Inject state via middleware
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_tool_call(middleware_ctx):
# The middleware passes the middleware_ctx, we need the fastmcp_context
tool_ctx = middleware_ctx.fastmcp_context
instance = get_unity_instance_from_context(tool_ctx)
script_routes[script_name] = instance
return {"success": True}
await middleware.on_call_tool(middleware_ctx, mock_tool_call)
return expected_instance
# Session context
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Execute sequence
middleware.set_active_instance(ctx, "ramble@8e29de57")
expected1 = await simulate_create_script(ctx, "Script1", "ramble@8e29de57")
middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected2 = await simulate_create_script(ctx, "Script2", "UnityMCPTests@cc8756d4")
middleware.set_active_instance(ctx, "ramble@8e29de57")
expected3 = await simulate_create_script(ctx, "Script3", "ramble@8e29de57")
middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected4 = await simulate_create_script(ctx, "Script4", "UnityMCPTests@cc8756d4")
# Assertions - these will FAIL until the bug is fixed
assert script_routes.get("Script1") == expected1, \
f"Script1 should route to {expected1}, got {script_routes.get('Script1')}"
assert script_routes.get("Script2") == expected2, \
f"Script2 should route to {expected2}, got {script_routes.get('Script2')}"
assert script_routes.get("Script3") == expected3, \
f"Script3 should route to {expected3}, got {script_routes.get('Script3')}"
assert script_routes.get("Script4") == expected4, \
f"Script4 should route to {expected4}, got {script_routes.get('Script4')}"
# Test regimen summary
"""
COMPREHENSIVE TEST REGIMEN FOR INSTANCE ROUTING
Prerequisites:
- Two Unity instances running (e.g., ramble, UnityMCPTests)
- MCP server connected to both instances
Test Categories:
1. Middleware State Management (4 tests)
2. Middleware Integration (2 tests)
3. get_unity_instance_from_context (2 tests)
4. Tool Category Coverage (11 categories)
5. Race Conditions (2 tests)
6. Sequential Operations (1 test - reproduces exact user bug)
Total: 21 tests
DESIGN:
Single source of truth via middleware state:
- set_active_instance stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools use get_unity_instance_from_context()
This ensures consistent routing across ALL tool categories (Script, GameObject, Asset, etc.)
"""

View File

@ -0,0 +1,87 @@
import sys
import pathlib
from tests.test_helpers import DummyContext
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
def test_manage_gameobject_uses_session_state(monkeypatch):
"""Test that tools use session-stored active instance via middleware"""
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware and set a session-scoped active instance
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
middleware.set_active_instance(ctx, "SessionProj@AAAA1111")
assert middleware.get_active_instance(ctx) == "SessionProj@AAAA1111"
# Simulate middleware injection into request state
ctx.set_state("unity_instance", "SessionProj@AAAA1111")
captured = {}
# Monkeypatch transport to capture the resolved instance_id
def fake_send(command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import tools.manage_gameobject as mg
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
# Act: call tool - should use session state from context
res = mg.manage_gameobject(
ctx,
action="create",
name="SessionSphere",
primitive_type="Sphere",
)
# Assert: uses session-stored instance
assert res.get("success") is True
assert captured.get("command_type") == "manage_gameobject"
assert captured.get("instance_id") == "SessionProj@AAAA1111"
def test_manage_gameobject_without_active_instance(monkeypatch):
"""Test that tools work with no active instance set (uses None/default)"""
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware with no active instance set
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
assert middleware.get_active_instance(ctx) is None
# Don't set any state in context
captured = {}
def fake_send(command_type, params, **kwargs):
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import tools.manage_gameobject as mg
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
# Act: call without active instance
res = mg.manage_gameobject(
ctx,
action="create",
name="DefaultSphere",
primitive_type="Sphere",
)
# Assert: uses None (connection pool will pick default)
assert res.get("success") is True
assert captured.get("instance_id") is None

View File

@ -3,7 +3,8 @@ Tests for JSON string parameter parsing in manage_asset tool.
"""
import pytest
import json
from unittest.mock import Mock, AsyncMock
from tests.test_helpers import DummyContext
from tools.manage_asset import manage_asset
@ -14,12 +15,10 @@ class TestManageAssetJsonParsing:
async def test_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string properties are correctly parsed to dict."""
# Mock context
ctx = Mock()
ctx.info = Mock()
ctx.warning = Mock()
ctx = DummyContext()
# Patch Unity transport
async def fake_async(cmd, params, loop=None):
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully", "data": {"path": "Assets/Test.mat"}}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
@ -33,7 +32,7 @@ class TestManageAssetJsonParsing:
)
# Verify JSON parsing was logged
ctx.info.assert_any_call("manage_asset: coerced properties from JSON string to dict")
assert "manage_asset: coerced properties from JSON string to dict" in ctx.log_info
# Verify the result
assert result["success"] is True
@ -42,11 +41,9 @@ class TestManageAssetJsonParsing:
@pytest.mark.asyncio
async def test_properties_invalid_json_string(self, monkeypatch):
"""Test handling of invalid JSON string properties."""
ctx = Mock()
ctx.info = Mock()
ctx.warning = Mock()
ctx = DummyContext()
async def fake_async(cmd, params, loop=None):
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
@ -60,16 +57,15 @@ class TestManageAssetJsonParsing:
)
# Verify behavior: no coercion log for invalid JSON; warning may be emitted by some runtimes
assert not any("coerced properties" in str(c) for c in ctx.info.call_args_list)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result.get("success") is True
@pytest.mark.asyncio
async def test_properties_dict_unchanged(self, monkeypatch):
"""Test that dict properties are passed through unchanged."""
ctx = Mock()
ctx.info = Mock()
ctx = DummyContext()
async def fake_async(cmd, params, loop=None):
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
@ -85,16 +81,15 @@ class TestManageAssetJsonParsing:
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in str(c) for c in ctx.info.call_args_list)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
@pytest.mark.asyncio
async def test_properties_none_handling(self, monkeypatch):
"""Test that None properties are handled correctly."""
ctx = Mock()
ctx.info = Mock()
ctx = DummyContext()
async def fake_async(cmd, params, loop=None):
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
@ -108,7 +103,7 @@ class TestManageAssetJsonParsing:
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in str(c) for c in ctx.info.call_args_list)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
@ -120,11 +115,9 @@ class TestManageGameObjectJsonParsing:
"""Test that JSON string component_properties are correctly parsed."""
from tools.manage_gameobject import manage_gameobject
ctx = Mock()
ctx.info = Mock()
ctx.warning = Mock()
ctx = DummyContext()
def fake_send(cmd, params):
def fake_send(cmd, params, **kwargs):
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
@ -137,7 +130,7 @@ class TestManageGameObjectJsonParsing:
)
# Verify JSON parsing was logged
ctx.info.assert_called_with("manage_gameobject: coerced component_properties from JSON string to dict")
assert "manage_gameobject: coerced component_properties from JSON string to dict" in ctx.log_info
# Verify the result
assert result["success"] is True

View File

@ -46,7 +46,7 @@ def test_manage_asset_pagination_coercion(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, loop=None):
async def fake_async_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}