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
|
|
|
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}}
|