Skip to content

Commit 4055fe5

Browse files
dsarnoclaude
andauthored
Add per-call unity_instance routing via middleware argument interception (#772)
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 <noreply@anthropic.com>
1 parent f241538 commit 4055fe5

5 files changed

Lines changed: 601 additions & 4 deletions

File tree

‎Server/src/main.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ def _build_instructions(project_scoped_tools: bool) -> str:
268268
269269
Targeting Unity instances:
270270
- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
271-
- 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.
271+
- 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.
272+
- 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.
272273
273274
Important Workflows:
274275

‎Server/src/services/tools/batch_execute.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ async def batch_execute(
116116
raise ValueError(
117117
f"Command '{tool_name}' must specify parameters as an object/dict")
118118

119+
if "unity_instance" in params:
120+
raise ValueError(
121+
f"Command '{tool_name}' at index {index} contains 'unity_instance'. "
122+
"Per-command instance routing is not supported inside batch_execute. "
123+
"Set unity_instance on the outer batch_execute call to route the entire batch."
124+
)
125+
119126
normalized_commands.append({
120127
"tool": tool_name,
121128
"params": params,

‎Server/src/services/tools/set_active_instance.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,50 @@
1313

1414
@mcp_for_unity_tool(
1515
unity_target=None,
16-
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
16+
description="Set the active Unity instance for this client/session. Accepts Name@hash, hash prefix, or port number (stdio only).",
1717
annotations=ToolAnnotations(
1818
title="Set Active Instance",
1919
),
2020
)
2121
async def set_active_instance(
2222
ctx: Context,
23-
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
23+
instance: Annotated[str, "Target instance (Name@hash, hash prefix, or port number in stdio mode)"]
2424
) -> dict[str, Any]:
2525
transport = (config.transport_mode or "stdio").lower()
2626

27+
# Port number shorthand (stdio only) — resolve to Name@hash via pool discovery
28+
value = (instance or "").strip()
29+
if value.isdigit():
30+
if transport == "http":
31+
return {
32+
"success": False,
33+
"error": f"Port-based targeting ('{value}') is not supported in HTTP transport mode. "
34+
"Use Name@hash or a hash prefix. Read mcpforunity://instances for available instances."
35+
}
36+
port_int = int(value)
37+
pool = get_unity_connection_pool()
38+
instances = pool.discover_all_instances(force_refresh=True)
39+
match = next((inst for inst in instances if getattr(inst, "port", None) == port_int), None)
40+
if match is None:
41+
available = ", ".join(
42+
f"{inst.id} (port {getattr(inst, 'port', '?')})" for inst in instances
43+
) or "none"
44+
return {
45+
"success": False,
46+
"error": f"No Unity instance found on port {value}. Available: {available}."
47+
}
48+
resolved_id = match.id
49+
middleware = get_unity_instance_middleware()
50+
middleware.set_active_instance(ctx, resolved_id)
51+
return {
52+
"success": True,
53+
"message": f"Active instance set to {resolved_id}",
54+
"data": {
55+
"instance": resolved_id,
56+
"session_key": middleware.get_session_key(ctx),
57+
},
58+
}
59+
2760
# Discover running instances based on transport
2861
if transport == "http":
2962
# In remote-hosted mode, filter sessions by user_id

‎Server/src/transport/unity_instance_middleware.py‎

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,124 @@ def clear_active_instance(self, ctx) -> None:
104104
with self._lock:
105105
self._active_by_key.pop(key, None)
106106

107+
async def _discover_instances(self, ctx) -> list:
108+
"""
109+
Return running Unity instances across both HTTP (PluginHub) and stdio transports.
110+
111+
Returns a list of objects with .id (Name@hash) and .hash attributes.
112+
"""
113+
from types import SimpleNamespace
114+
transport = (config.transport_mode or "stdio").lower()
115+
results: list = []
116+
117+
if PluginHub.is_configured():
118+
try:
119+
user_id = None
120+
get_state_fn = getattr(ctx, "get_state", None)
121+
if callable(get_state_fn) and config.http_remote_hosted:
122+
user_id = get_state_fn("user_id")
123+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
124+
sessions = sessions_data.sessions or {}
125+
for session_info in sessions.values():
126+
project = getattr(session_info, "project", None) or "Unknown"
127+
hash_value = getattr(session_info, "hash", None)
128+
if hash_value:
129+
results.append(SimpleNamespace(
130+
id=f"{project}@{hash_value}",
131+
hash=hash_value,
132+
name=project,
133+
))
134+
except Exception as exc:
135+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
136+
raise
137+
logger.debug("PluginHub instance discovery failed (%s)", type(exc).__name__, exc_info=True)
138+
139+
if not results and transport != "http":
140+
try:
141+
from transport.legacy.unity_connection import get_unity_connection_pool
142+
pool = get_unity_connection_pool()
143+
results = pool.discover_all_instances(force_refresh=True)
144+
except Exception as exc:
145+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
146+
raise
147+
logger.debug("Stdio instance discovery failed (%s)", type(exc).__name__, exc_info=True)
148+
149+
return results
150+
151+
async def _resolve_instance_value(self, value: str, ctx) -> str:
152+
"""
153+
Resolve a unity_instance string to a validated instance identifier.
154+
155+
Accepts:
156+
- Bare port number like "6401" (stdio only) -> resolved Name@hash
157+
- "Name@hash" exact match
158+
- Hash prefix (unique prefix match against running instances)
159+
160+
Raises ValueError with a user-friendly message on failure.
161+
"""
162+
value = value.strip()
163+
if not value:
164+
raise ValueError("unity_instance value must not be empty.")
165+
166+
transport = (config.transport_mode or "stdio").lower()
167+
168+
# Port number (stdio only) — resolve to Name@hash via status file lookup
169+
if value.isdigit():
170+
if transport == "http":
171+
raise ValueError(
172+
f"Port-based targeting ('{value}') is not supported in HTTP transport mode. "
173+
"Use Name@hash or a hash prefix. Read mcpforunity://instances for available instances."
174+
)
175+
port_int = int(value)
176+
instances = await self._discover_instances(ctx)
177+
for inst in instances:
178+
if getattr(inst, "port", None) == port_int:
179+
return inst.id
180+
available = ", ".join(
181+
f"{getattr(i, 'id', '?')} (port {getattr(i, 'port', '?')})"
182+
for i in instances
183+
) or "none"
184+
raise ValueError(
185+
f"No Unity instance found on port {value}. Available: {available}."
186+
)
187+
188+
instances = await self._discover_instances(ctx)
189+
ids = {
190+
getattr(inst, "id", None): inst
191+
for inst in instances
192+
if getattr(inst, "id", None)
193+
}
194+
195+
# Exact Name@hash match
196+
if "@" in value:
197+
if value in ids:
198+
return value
199+
available = ", ".join(ids) or "none"
200+
raise ValueError(
201+
f"Instance '{value}' not found. Available: {available}. "
202+
"Read mcpforunity://instances for current sessions."
203+
)
204+
205+
# Hash prefix match
206+
lookup = value.lower()
207+
matches = [
208+
inst for inst in instances
209+
if getattr(inst, "hash", "") and getattr(inst, "hash", "").lower().startswith(lookup)
210+
]
211+
if len(matches) == 1:
212+
return matches[0].id
213+
if len(matches) > 1:
214+
ambiguous = ", ".join(getattr(m, "id", "?") for m in matches)
215+
raise ValueError(
216+
f"Hash prefix '{value}' is ambiguous ({ambiguous}). "
217+
"Provide the full Name@hash from mcpforunity://instances."
218+
)
219+
available = ", ".join(ids) or "none"
220+
raise ValueError(
221+
f"No running Unity instance matches '{value}'. Available: {available}. "
222+
"Read mcpforunity://instances for current sessions."
223+
)
224+
107225
async def _maybe_autoselect_instance(self, ctx) -> str | None:
108226
"""
109227
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:
136254
chosen,
137255
)
138256
return chosen
257+
if len(ids) > 1:
258+
logger.info(
259+
"Multiple Unity instances found (%d). Pass unity_instance on any tool call "
260+
"or call set_active_instance to choose one. Available: %s",
261+
len(ids), ", ".join(ids),
262+
)
139263
except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
140264
logger.debug(
141265
"PluginHub auto-select probe failed (%s); falling back to stdio",
@@ -168,6 +292,12 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None:
168292
chosen,
169293
)
170294
return chosen
295+
if len(ids) > 1:
296+
logger.info(
297+
"Multiple Unity instances found (%d). Pass unity_instance on any tool call "
298+
"or call set_active_instance to choose one. Available: %s",
299+
len(ids), ", ".join(ids),
300+
)
171301
except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
172302
logger.debug(
173303
"Stdio auto-select probe failed (%s)",
@@ -214,7 +344,23 @@ async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
214344
if user_id:
215345
ctx.set_state("user_id", user_id)
216346

217-
active_instance = self.get_active_instance(ctx)
347+
# Per-call routing: check if this tool call explicitly specifies unity_instance.
348+
# context.message.arguments is a mutable dict on CallToolRequestParams; resource
349+
# reads use ReadResourceRequestParams which has no .arguments, so this is a no-op for them.
350+
# We pop the key here so Pydantic's type_adapter.validate_python() never sees it.
351+
active_instance: str | None = None
352+
msg_args = getattr(getattr(context, "message", None), "arguments", None)
353+
if isinstance(msg_args, dict) and "unity_instance" in msg_args:
354+
raw = msg_args.pop("unity_instance")
355+
if raw is not None:
356+
raw_str = str(raw).strip()
357+
if raw_str:
358+
# Raises ValueError with a user-friendly message on invalid input.
359+
active_instance = await self._resolve_instance_value(raw_str, ctx)
360+
logger.debug("Per-call unity_instance resolved to: %s", active_instance)
361+
362+
if not active_instance:
363+
active_instance = self.get_active_instance(ctx)
218364
if not active_instance:
219365
active_instance = await self._maybe_autoselect_instance(ctx)
220366
if active_instance:

0 commit comments

Comments
 (0)