From 0cb5a0d7683dc9e469c1327b6e5490fce0f55b6f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 17 Feb 2026 18:00:09 -0800 Subject: [PATCH] Add per-call unity_instance routing via middleware argument interception Any tool call can now include a unity_instance parameter to route that specific call to a target Unity instance without changing the session default and without requiring a set_active_instance call first. The middleware pops unity_instance from tool call arguments before Pydantic validation runs, resolves it (port number, hash prefix, or Name@hash), and injects it into request-scoped state for that call only. - Port numbers resolve to the matching Name@hash via status file lookup rather than synthetic direct:{port} IDs, so the transport layer can route them correctly - HTTP mode rejects port-based targeting with a clear error - set_active_instance now also accepts port numbers for consistency - Multi-instance scenarios log available instances with ports when auto-select cannot choose - _discover_instances() helper DRYs up transport-aware instance discovery previously duplicated across the codebase - Server instructions updated to document both routing approaches - 18 new tests covering pop behaviour, per-call vs session routing, port resolution, transport modes, and edge cases Closes #697 Co-Authored-By: Claude Sonnet 4.6 --- Server/src/main.py | 3 +- Server/src/services/tools/batch_execute.py | 7 + .../src/services/tools/set_active_instance.py | 37 +- .../transport/unity_instance_middleware.py | 148 ++++++- .../integration/test_inline_unity_instance.py | 410 ++++++++++++++++++ 5 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 Server/tests/integration/test_inline_unity_instance.py diff --git a/Server/src/main.py b/Server/src/main.py index 2cf162c04..c0415ecc6 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -268,7 +268,8 @@ def _build_instructions(project_scoped_tools: bool) -> str: Targeting Unity instances: - Use the resource mcpforunity://instances to list active Unity sessions (Name@hash). -- When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources. The server will error if multiple are connected and no active instance is set. +- When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources to pin routing for the whole session. The server will error if multiple are connected and no active instance is set. +- Alternatively, pass unity_instance as a parameter on any individual tool call to route just that call (e.g. unity_instance="MyGame@abc123", unity_instance="abc" for a hash prefix, or unity_instance="6401" for a port number in stdio mode). This does not change the session default. Important Workflows: diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index 4e41da364..3849dae2a 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -116,6 +116,13 @@ async def batch_execute( raise ValueError( f"Command '{tool_name}' must specify parameters as an object/dict") + if "unity_instance" in params: + raise ValueError( + f"Command '{tool_name}' at index {index} contains 'unity_instance'. " + "Per-command instance routing is not supported inside batch_execute. " + "Set unity_instance on the outer batch_execute call to route the entire batch." + ) + normalized_commands.append({ "tool": tool_name, "params": params, diff --git a/Server/src/services/tools/set_active_instance.py b/Server/src/services/tools/set_active_instance.py index 30582867f..ecdfb3a5c 100644 --- a/Server/src/services/tools/set_active_instance.py +++ b/Server/src/services/tools/set_active_instance.py @@ -13,17 +13,50 @@ @mcp_for_unity_tool( unity_target=None, - description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.", + description="Set the active Unity instance for this client/session. Accepts Name@hash, hash prefix, or port number (stdio only).", annotations=ToolAnnotations( title="Set Active Instance", ), ) async def set_active_instance( ctx: Context, - instance: Annotated[str, "Target instance (Name@hash or hash prefix)"] + instance: Annotated[str, "Target instance (Name@hash, hash prefix, or port number in stdio mode)"] ) -> dict[str, Any]: transport = (config.transport_mode or "stdio").lower() + # Port number shorthand (stdio only) — resolve to Name@hash via pool discovery + value = (instance or "").strip() + if value.isdigit(): + if transport == "http": + return { + "success": False, + "error": f"Port-based targeting ('{value}') is not supported in HTTP transport mode. " + "Use Name@hash or a hash prefix. Read mcpforunity://instances for available instances." + } + port_int = int(value) + pool = get_unity_connection_pool() + instances = pool.discover_all_instances(force_refresh=True) + match = next((inst for inst in instances if getattr(inst, "port", None) == port_int), None) + if match is None: + available = ", ".join( + f"{inst.id} (port {getattr(inst, 'port', '?')})" for inst in instances + ) or "none" + return { + "success": False, + "error": f"No Unity instance found on port {value}. Available: {available}." + } + resolved_id = match.id + 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, + "session_key": middleware.get_session_key(ctx), + }, + } + # Discover running instances based on transport if transport == "http": # In remote-hosted mode, filter sessions by user_id diff --git a/Server/src/transport/unity_instance_middleware.py b/Server/src/transport/unity_instance_middleware.py index 41b4e8baf..e8aea6625 100644 --- a/Server/src/transport/unity_instance_middleware.py +++ b/Server/src/transport/unity_instance_middleware.py @@ -104,6 +104,124 @@ def clear_active_instance(self, ctx) -> None: with self._lock: self._active_by_key.pop(key, None) + async def _discover_instances(self, ctx) -> list: + """ + Return running Unity instances across both HTTP (PluginHub) and stdio transports. + + Returns a list of objects with .id (Name@hash) and .hash attributes. + """ + from types import SimpleNamespace + transport = (config.transport_mode or "stdio").lower() + results: list = [] + + if PluginHub.is_configured(): + try: + user_id = None + get_state_fn = getattr(ctx, "get_state", None) + if callable(get_state_fn) and config.http_remote_hosted: + user_id = get_state_fn("user_id") + sessions_data = await PluginHub.get_sessions(user_id=user_id) + sessions = sessions_data.sessions or {} + for session_info in sessions.values(): + project = getattr(session_info, "project", None) or "Unknown" + hash_value = getattr(session_info, "hash", None) + if hash_value: + results.append(SimpleNamespace( + id=f"{project}@{hash_value}", + hash=hash_value, + name=project, + )) + except Exception as exc: + if isinstance(exc, (SystemExit, KeyboardInterrupt)): + raise + logger.debug("PluginHub instance discovery failed (%s)", type(exc).__name__, exc_info=True) + + if not results and transport != "http": + try: + from transport.legacy.unity_connection import get_unity_connection_pool + pool = get_unity_connection_pool() + results = pool.discover_all_instances(force_refresh=True) + except Exception as exc: + if isinstance(exc, (SystemExit, KeyboardInterrupt)): + raise + logger.debug("Stdio instance discovery failed (%s)", type(exc).__name__, exc_info=True) + + return results + + async def _resolve_instance_value(self, value: str, ctx) -> str: + """ + Resolve a unity_instance string to a validated instance identifier. + + Accepts: + - Bare port number like "6401" (stdio only) -> resolved Name@hash + - "Name@hash" exact match + - Hash prefix (unique prefix match against running instances) + + Raises ValueError with a user-friendly message on failure. + """ + value = value.strip() + if not value: + raise ValueError("unity_instance value must not be empty.") + + transport = (config.transport_mode or "stdio").lower() + + # Port number (stdio only) — resolve to Name@hash via status file lookup + if value.isdigit(): + if transport == "http": + raise ValueError( + f"Port-based targeting ('{value}') is not supported in HTTP transport mode. " + "Use Name@hash or a hash prefix. Read mcpforunity://instances for available instances." + ) + port_int = int(value) + instances = await self._discover_instances(ctx) + for inst in instances: + if getattr(inst, "port", None) == port_int: + return inst.id + available = ", ".join( + f"{getattr(i, 'id', '?')} (port {getattr(i, 'port', '?')})" + for i in instances + ) or "none" + raise ValueError( + f"No Unity instance found on port {value}. Available: {available}." + ) + + instances = await self._discover_instances(ctx) + ids = { + getattr(inst, "id", None): inst + for inst in instances + if getattr(inst, "id", None) + } + + # Exact Name@hash match + if "@" in value: + if value in ids: + return value + available = ", ".join(ids) or "none" + raise ValueError( + f"Instance '{value}' not found. Available: {available}. " + "Read mcpforunity://instances for current sessions." + ) + + # Hash prefix match + lookup = value.lower() + matches = [ + inst for inst in instances + if getattr(inst, "hash", "") and getattr(inst, "hash", "").lower().startswith(lookup) + ] + if len(matches) == 1: + return matches[0].id + if len(matches) > 1: + ambiguous = ", ".join(getattr(m, "id", "?") for m in matches) + raise ValueError( + f"Hash prefix '{value}' is ambiguous ({ambiguous}). " + "Provide the full Name@hash from mcpforunity://instances." + ) + available = ", ".join(ids) or "none" + raise ValueError( + f"No running Unity instance matches '{value}'. Available: {available}. " + "Read mcpforunity://instances for current sessions." + ) + async def _maybe_autoselect_instance(self, ctx) -> str | None: """ Auto-select the sole Unity instance when no active instance is set. @@ -136,6 +254,12 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: chosen, ) return chosen + if len(ids) > 1: + logger.info( + "Multiple Unity instances found (%d). Pass unity_instance on any tool call " + "or call set_active_instance to choose one. Available: %s", + len(ids), ", ".join(ids), + ) except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc: logger.debug( "PluginHub auto-select probe failed (%s); falling back to stdio", @@ -168,6 +292,12 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: chosen, ) return chosen + if len(ids) > 1: + logger.info( + "Multiple Unity instances found (%d). Pass unity_instance on any tool call " + "or call set_active_instance to choose one. Available: %s", + len(ids), ", ".join(ids), + ) except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc: logger.debug( "Stdio auto-select probe failed (%s)", @@ -214,7 +344,23 @@ async def _inject_unity_instance(self, context: MiddlewareContext) -> None: if user_id: ctx.set_state("user_id", user_id) - active_instance = self.get_active_instance(ctx) + # Per-call routing: check if this tool call explicitly specifies unity_instance. + # context.message.arguments is a mutable dict on CallToolRequestParams; resource + # reads use ReadResourceRequestParams which has no .arguments, so this is a no-op for them. + # We pop the key here so Pydantic's type_adapter.validate_python() never sees it. + active_instance: str | None = None + msg_args = getattr(getattr(context, "message", None), "arguments", None) + if isinstance(msg_args, dict) and "unity_instance" in msg_args: + raw = msg_args.pop("unity_instance") + if raw is not None: + raw_str = str(raw).strip() + if raw_str: + # Raises ValueError with a user-friendly message on invalid input. + active_instance = await self._resolve_instance_value(raw_str, ctx) + logger.debug("Per-call unity_instance resolved to: %s", active_instance) + + if not active_instance: + active_instance = self.get_active_instance(ctx) if not active_instance: active_instance = await self._maybe_autoselect_instance(ctx) if active_instance: diff --git a/Server/tests/integration/test_inline_unity_instance.py b/Server/tests/integration/test_inline_unity_instance.py new file mode 100644 index 000000000..129e67a52 --- /dev/null +++ b/Server/tests/integration/test_inline_unity_instance.py @@ -0,0 +1,410 @@ +""" +Tests for per-call unity_instance routing via middleware argument interception. + +When a tool call includes unity_instance in its arguments, the middleware: + 1. Pops the key before Pydantic validation sees it + 2. Resolves it to a validated instance identifier + 3. Sets it in request-scoped state for that call only (does NOT persist to session) +""" +import asyncio +import sys +import types +from types import SimpleNamespace + +import pytest + +from .test_helpers import DummyContext +from core.config import config + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class DummyMiddlewareContext: + """Minimal MiddlewareContext stand-in with a mutable arguments dict.""" + + def __init__(self, ctx, arguments: dict | None = None): + self.fastmcp_context = ctx + self.message = SimpleNamespace(arguments=arguments if arguments is not None else {}) + + +def _make_middleware(monkeypatch, *, transport="stdio", plugin_hub_configured=False, sessions=None, pool_instances=None): + """ + Build a UnityInstanceMiddleware with patched transport dependencies. + + sessions: dict of session_id -> SimpleNamespace(project=..., hash=...) + pool_instances: list of SimpleNamespace(id=..., hash=...) + """ + plugin_hub_mod = types.ModuleType("transport.plugin_hub") + + _sessions = sessions or {} + _configured = plugin_hub_configured + + class FakePluginHub: + @classmethod + def is_configured(cls): + return _configured + + @classmethod + async def get_sessions(cls, user_id=None): + return SimpleNamespace(sessions=_sessions) + + @classmethod + async def _resolve_session_id(cls, instance, user_id=None): + return None + + plugin_hub_mod.PluginHub = FakePluginHub + monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub_mod) + monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False) + + from transport.unity_instance_middleware import UnityInstanceMiddleware + + middleware = UnityInstanceMiddleware() + monkeypatch.setattr(config, "transport_mode", transport) + monkeypatch.setattr(config, "http_remote_hosted", False) + + if pool_instances is not None: + async def fake_discover(ctx): + return pool_instances + monkeypatch.setattr(middleware, "_discover_instances", fake_discover) + + return middleware + + +# --------------------------------------------------------------------------- +# Pop behaviour +# --------------------------------------------------------------------------- + +def test_unity_instance_is_popped_from_arguments(monkeypatch): + """unity_instance key must be removed from arguments before the tool function sees them.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + args = {"action": "get_active", "unity_instance": "abc123"} + mw_ctx = DummyMiddlewareContext(ctx, arguments=args) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert "unity_instance" not in args + assert "action" in args # other keys untouched + + +def test_arguments_without_unity_instance_untouched(monkeypatch): + """When unity_instance is absent, arguments dict is left completely untouched.""" + mw = _make_middleware(monkeypatch, pool_instances=[SimpleNamespace(id="Proj@abc123", hash="abc123")]) + + ctx = DummyContext() + ctx.client_id = "client-1" + # Seed a persisted instance so auto-select isn't needed + mw.set_active_instance(ctx, "Proj@abc123") + + args = {"action": "get_active", "name": "Test"} + mw_ctx = DummyMiddlewareContext(ctx, arguments=args) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert args == {"action": "get_active", "name": "Test"} + + +# --------------------------------------------------------------------------- +# Per-call routing (no persistence) +# --------------------------------------------------------------------------- + +def test_inline_routes_to_specified_instance(monkeypatch): + """Per-call unity_instance sets request state to the resolved instance.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc123"}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_inline_does_not_persist_to_session(monkeypatch): + """Per-call unity_instance must not change the session-persisted instance.""" + instances = [ + SimpleNamespace(id="ProjA@aaa111", hash="aaa111"), + SimpleNamespace(id="ProjB@bbb222", hash="bbb222"), + ] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw.set_active_instance(ctx, "ProjA@aaa111") + + # Call 1: inline override to ProjB + mw_ctx1 = DummyMiddlewareContext(ctx, arguments={"unity_instance": "bbb222"}) + asyncio.run(mw._inject_unity_instance(mw_ctx1)) + assert ctx.get_state("unity_instance") == "ProjB@bbb222" + + # Call 2: no inline — must revert to session-persisted ProjA + mw_ctx2 = DummyMiddlewareContext(ctx, arguments={}) + asyncio.run(mw._inject_unity_instance(mw_ctx2)) + assert ctx.get_state("unity_instance") == "ProjA@aaa111" + + +def test_inline_overrides_session_persisted_instance(monkeypatch): + """Inline unity_instance takes precedence over session-persisted instance.""" + instances = [ + SimpleNamespace(id="ProjA@aaa111", hash="aaa111"), + SimpleNamespace(id="ProjB@bbb222", hash="bbb222"), + ] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw.set_active_instance(ctx, "ProjA@aaa111") + + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "ProjB@bbb222"}) + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "ProjB@bbb222" + # Session still pinned to ProjA + assert mw.get_active_instance(ctx) == "ProjA@aaa111" + + +# --------------------------------------------------------------------------- +# Port number resolution (stdio) +# --------------------------------------------------------------------------- + +def test_port_number_resolves_to_name_hash_stdio(monkeypatch): + """Bare port number resolves to the matching Name@hash in stdio mode.""" + instances = [ + SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401), + SimpleNamespace(id="Other@def456", hash="def456", port=6402), + ] + mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_port_number_not_found_raises(monkeypatch): + """Port number with no matching instance raises ValueError.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401)] + mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "9999"}) + + with pytest.raises(ValueError, match="No Unity instance found on port 9999"): + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + +def test_port_number_errors_in_http_mode(monkeypatch): + """Bare port number raises ValueError in HTTP transport mode.""" + mw = _make_middleware(monkeypatch, transport="http") + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"}) + + with pytest.raises(ValueError, match="not supported in HTTP transport mode"): + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + +# --------------------------------------------------------------------------- +# Name@hash and hash prefix resolution +# --------------------------------------------------------------------------- + +def test_name_at_hash_resolves_exactly(monkeypatch): + """Full Name@hash resolves directly without discovery.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Proj@abc123"}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_unknown_name_at_hash_raises(monkeypatch): + """Unknown Name@hash raises ValueError.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Ghost@deadbeef"}) + + with pytest.raises(ValueError, match="not found"): + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + +def test_hash_prefix_resolves_unique(monkeypatch): + """Unique hash prefix resolves to the full Name@hash.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_ambiguous_hash_prefix_raises(monkeypatch): + """Ambiguous hash prefix raises ValueError.""" + instances = [ + SimpleNamespace(id="ProjA@abc111", hash="abc111"), + SimpleNamespace(id="ProjB@abc222", hash="abc222"), + ] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"}) + + with pytest.raises(ValueError, match="ambiguous"): + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + +def test_no_match_raises(monkeypatch): + """Hash prefix matching nothing raises ValueError.""" + instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")] + mw = _make_middleware(monkeypatch, pool_instances=instances) + + ctx = DummyContext() + ctx.client_id = "client-1" + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "xyz"}) + + with pytest.raises(ValueError, match="No running Unity instance"): + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +def test_none_unity_instance_falls_through_to_session(monkeypatch): + """None value for unity_instance falls through to session-persisted instance.""" + mw = _make_middleware(monkeypatch) + ctx = DummyContext() + ctx.client_id = "client-1" + mw.set_active_instance(ctx, "Proj@abc123") + + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": None, "action": "x"}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_empty_string_unity_instance_falls_through_to_session(monkeypatch): + """Empty string unity_instance falls through to session-persisted instance.""" + mw = _make_middleware(monkeypatch) + ctx = DummyContext() + ctx.client_id = "client-1" + mw.set_active_instance(ctx, "Proj@abc123") + + mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": " "}) + + asyncio.run(mw._inject_unity_instance(mw_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +def test_resource_read_unaffected(monkeypatch): + """on_read_resource with no .arguments attribute routes via session state normally.""" + mw = _make_middleware(monkeypatch) + ctx = DummyContext() + ctx.client_id = "client-1" + mw.set_active_instance(ctx, "Proj@abc123") + + # ReadResourceRequestParams has .uri not .arguments + resource_ctx = SimpleNamespace( + fastmcp_context=ctx, + message=SimpleNamespace(uri="mcpforunity://scene/active"), + ) + + asyncio.run(mw._inject_unity_instance(resource_ctx)) + + assert ctx.get_state("unity_instance") == "Proj@abc123" + + +# --------------------------------------------------------------------------- +# set_active_instance tool: port number support +# --------------------------------------------------------------------------- + +def test_set_active_instance_port_stdio(monkeypatch): + """set_active_instance accepts a port number in stdio mode and resolves to Name@hash.""" + monkeypatch.setattr(config, "transport_mode", "stdio") + monkeypatch.setattr(config, "http_remote_hosted", False) + + from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware + mw = UnityInstanceMiddleware() + set_unity_instance_middleware(mw) + + pool_instance = SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401) + + class FakePool: + def discover_all_instances(self, force_refresh=False): + return [pool_instance] + + import services.tools.set_active_instance as sat + monkeypatch.setattr(sat, "get_unity_connection_pool", lambda: FakePool()) + + from services.tools.set_active_instance import set_active_instance + + ctx = DummyContext() + ctx.client_id = "client-1" + + result = asyncio.run(set_active_instance(ctx, instance="6401")) + + assert result["success"] is True + assert result["data"]["instance"] == "Proj@abc123" + assert mw.get_active_instance(ctx) == "Proj@abc123" + + +def test_set_active_instance_port_http_errors(monkeypatch): + """set_active_instance rejects port numbers in HTTP mode.""" + monkeypatch.setattr(config, "transport_mode", "http") + monkeypatch.setattr(config, "http_remote_hosted", False) + + from services.tools.set_active_instance import set_active_instance + + ctx = DummyContext() + ctx.client_id = "client-1" + + result = asyncio.run(set_active_instance(ctx, instance="6401")) + + assert result["success"] is False + assert "not supported in HTTP transport mode" in result["error"] + + +# --------------------------------------------------------------------------- +# batch_execute rejects inner unity_instance +# --------------------------------------------------------------------------- + +def test_batch_execute_rejects_inner_unity_instance(): + """batch_execute raises ValueError when an inner command contains unity_instance.""" + from services.tools.batch_execute import batch_execute + + ctx = DummyContext() + ctx.client_id = "client-1" + ctx._state["unity_instance"] = "Proj@abc123" + + commands = [ + {"tool": "manage_scene", "params": {"action": "get_active", "unity_instance": "6402"}}, + ] + + with pytest.raises(ValueError, match="Per-command instance routing is not supported inside batch_execute"): + asyncio.run(batch_execute(ctx, commands=commands))