unity-mcp/MCPForUnity/Editor/Helpers/PortManager.cs

323 lines
12 KiB
C#
Raw Normal View History

using System;
using System.IO;
using UnityEditor;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Manages dynamic port allocation and persistent storage for MCP for Unity
/// </summary>
public static class PortManager
{
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); }
catch { return false; }
}
private const int DefaultPort = 6400;
private const int MaxPortAttempts = 100;
private const string RegistryFileName = "unity-mcp-port.json";
[Serializable]
public class PortConfig
{
public int unity_port;
public string created_date;
public string project_path;
}
/// <summary>
/// Get the port to use - either from storage or discover a new one
/// Will try stored port first, then fallback to discovering new port
/// </summary>
/// <returns>Port number to use</returns>
public static int GetPortWithFallback()
{
// Try to load stored port first, but only if it's from the current project
var storedConfig = GetStoredPortConfig();
if (storedConfig != null &&
storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
IsPortAvailable(storedConfig.unity_port))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using stored port {storedConfig.unity_port} for current project");
return storedConfig.unity_port;
}
// If stored port exists but is currently busy, wait briefly for release
if (storedConfig != null && storedConfig.unity_port > 0)
{
if (WaitForPortRelease(storedConfig.unity_port, 1500))
{
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;
}
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>
2025-11-06 01:43:36 +08:00
// 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
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>
2025-11-06 01:43:36 +08:00
int foundPort = FindAvailablePort();
SavePort(foundPort);
return foundPort;
}
/// <summary>
/// Discover and save a new available port (used by Auto-Connect button)
/// </summary>
/// <returns>New available port</returns>
public static int DiscoverNewPort()
{
int newPort = FindAvailablePort();
SavePort(newPort);
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Discovered and saved new port: {newPort}");
return newPort;
}
/// <summary>
/// Find an available port starting from the default port
/// </summary>
/// <returns>Available port number</returns>
private static int FindAvailablePort()
{
// Always try default port first
if (IsPortAvailable(DefaultPort))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using default port {DefaultPort}");
return DefaultPort;
}
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Default port {DefaultPort} is in use, searching for alternative...");
// Search for alternatives
for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++)
{
if (IsPortAvailable(port))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Found available port {port}");
return port;
}
}
throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}");
}
/// <summary>
/// Check if a specific port is available for binding
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port is available</returns>
public static bool IsPortAvailable(int port)
{
try
{
var testListener = new TcpListener(IPAddress.Loopback, port);
testListener.Start();
testListener.Stop();
return true;
}
catch (SocketException)
{
return false;
}
}
/// <summary>
/// Check if a port is currently being used by MCP for Unity
/// This helps avoid unnecessary port changes when Unity itself is using the port
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port appears to be used by MCP for Unity</returns>
public static bool IsPortUsedByMCPForUnity(int port)
{
try
{
// Try to make a quick connection to see if it's an MCP for Unity server
using var client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
if (connectTask.Wait(100)) // 100ms timeout
{
// If connection succeeded, it's likely the MCP for Unity server
return client.Connected;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Wait for a port to become available for a limited amount of time.
/// Used to bridge the gap during domain reload when the old listener
/// hasn't released the socket yet.
/// </summary>
private static bool WaitForPortRelease(int port, int timeoutMs)
{
int waited = 0;
const int step = 100;
while (waited < timeoutMs)
{
if (IsPortAvailable(port))
{
return true;
}
// If the port is in use by an MCP instance, continue waiting briefly
if (!IsPortUsedByMCPForUnity(port))
{
// In use by something else; don't keep waiting
return false;
}
Thread.Sleep(step);
waited += step;
}
return IsPortAvailable(port);
}
/// <summary>
/// Save port to persistent storage
/// </summary>
/// <param name="port">Port to save</param>
private static void SavePort(int port)
{
try
{
var portConfig = new PortConfig
{
unity_port = port,
created_date = DateTime.UtcNow.ToString("O"),
project_path = Application.dataPath
};
string registryDir = GetRegistryDirectory();
Directory.CreateDirectory(registryDir);
string registryFile = GetRegistryFilePath();
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
// Write to hashed, project-scoped file
File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false));
// Also write to legacy stable filename to avoid hash/case drift across reloads
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false));
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Saved port {port} to storage");
}
catch (Exception ex)
{
Debug.LogWarning($"Could not save port to storage: {ex.Message}");
}
}
/// <summary>
/// Load port from persistent storage
/// </summary>
/// <returns>Stored port number, or 0 if not found</returns>
private static int LoadStoredPort()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file name
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return 0;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
var portConfig = JsonConvert.DeserializeObject<PortConfig>(json);
return portConfig?.unity_port ?? 0;
}
catch (Exception ex)
{
Debug.LogWarning($"Could not load port from storage: {ex.Message}");
return 0;
}
}
/// <summary>
/// Get the current stored port configuration
/// </summary>
/// <returns>Port configuration if exists, null otherwise</returns>
public static PortConfig GetStoredPortConfig()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return null;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
return JsonConvert.DeserializeObject<PortConfig>(json);
}
catch (Exception ex)
{
Debug.LogWarning($"Could not load port config: {ex.Message}");
return null;
}
}
private static string GetRegistryDirectory()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
private static string GetRegistryFilePath()
{
string dir = GetRegistryDirectory();
string hash = ComputeProjectHash(Application.dataPath);
string fileName = $"unity-mcp-port-{hash}.json";
return Path.Combine(dir, fileName);
}
private static string ComputeProjectHash(string input)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8]; // short, sufficient for filenames
}
catch
{
return "default";
}
}
}
}