mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
@@ -0,0 +1,182 @@
1
+ """In-memory registry for connected Unity plugin sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+
8
+ import asyncio
9
+
10
+ from core.config import config
11
+ from models.models import ToolDefinitionModel
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class PluginSession:
16
+ """Represents a single Unity plugin connection."""
17
+
18
+ session_id: str
19
+ project_name: str
20
+ project_hash: str
21
+ unity_version: str
22
+ registered_at: datetime
23
+ connected_at: datetime
24
+ tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
25
+ project_id: str | None = None
26
+ # Full path to project root (for focus nudging)
27
+ project_path: str | None = None
28
+ user_id: str | None = None # Associated user id (None for local mode)
29
+
30
+
31
+ class PluginRegistry:
32
+ """Stores active plugin sessions in-memory.
33
+
34
+ The registry is optimised for quick lookup by either ``session_id`` or
35
+ ``project_hash`` (which is used as the canonical "instance id" across the
36
+ HTTP command routing stack).
37
+
38
+ In remote-hosted mode, sessions are scoped by (user_id, project_hash) composite key
39
+ to ensure session isolation between users.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ self._sessions: dict[str, PluginSession] = {}
44
+ # In local mode: project_hash -> session_id
45
+ # In remote mode: (user_id, project_hash) -> session_id
46
+ self._hash_to_session: dict[str, str] = {}
47
+ self._user_hash_to_session: dict[tuple[str, str], str] = {}
48
+ self._lock = asyncio.Lock()
49
+
50
+ async def register(
51
+ self,
52
+ session_id: str,
53
+ project_name: str,
54
+ project_hash: str,
55
+ unity_version: str,
56
+ project_path: str | None = None,
57
+ user_id: str | None = None,
58
+ ) -> PluginSession:
59
+ """Register (or replace) a plugin session.
60
+
61
+ If an existing session already claims the same ``project_hash`` (and ``user_id``
62
+ in remote-hosted mode) it will be replaced, ensuring that reconnect scenarios
63
+ always map to the latest WebSocket connection.
64
+ """
65
+ if config.http_remote_hosted and not user_id:
66
+ raise ValueError("user_id is required in remote-hosted mode")
67
+
68
+ async with self._lock:
69
+ now = datetime.now(timezone.utc)
70
+ session = PluginSession(
71
+ session_id=session_id,
72
+ project_name=project_name,
73
+ project_hash=project_hash,
74
+ unity_version=unity_version,
75
+ registered_at=now,
76
+ connected_at=now,
77
+ project_path=project_path,
78
+ user_id=user_id,
79
+ )
80
+
81
+ # Remove old mapping for this hash if it existed under a different session
82
+ if user_id:
83
+ # Remote-hosted mode: use composite key (user_id, project_hash)
84
+ composite_key = (user_id, project_hash)
85
+ previous_session_id = self._user_hash_to_session.get(
86
+ composite_key)
87
+ if previous_session_id and previous_session_id != session_id:
88
+ self._sessions.pop(previous_session_id, None)
89
+ self._user_hash_to_session[composite_key] = session_id
90
+ else:
91
+ # Local mode: use project_hash only
92
+ previous_session_id = self._hash_to_session.get(project_hash)
93
+ if previous_session_id and previous_session_id != session_id:
94
+ self._sessions.pop(previous_session_id, None)
95
+ self._hash_to_session[project_hash] = session_id
96
+
97
+ self._sessions[session_id] = session
98
+ return session
99
+
100
+ async def touch(self, session_id: str) -> None:
101
+ """Update the ``connected_at`` timestamp when a heartbeat is received."""
102
+
103
+ async with self._lock:
104
+ session = self._sessions.get(session_id)
105
+ if session:
106
+ session.connected_at = datetime.now(timezone.utc)
107
+
108
+ async def unregister(self, session_id: str) -> None:
109
+ """Remove a plugin session from the registry."""
110
+
111
+ async with self._lock:
112
+ session = self._sessions.pop(session_id, None)
113
+ if session:
114
+ # Clean up hash mappings
115
+ if session.project_hash in self._hash_to_session:
116
+ mapped = self._hash_to_session.get(session.project_hash)
117
+ if mapped == session_id:
118
+ del self._hash_to_session[session.project_hash]
119
+
120
+ # Clean up user-scoped mappings
121
+ if session.user_id:
122
+ composite_key = (session.user_id, session.project_hash)
123
+ if composite_key in self._user_hash_to_session:
124
+ mapped = self._user_hash_to_session.get(composite_key)
125
+ if mapped == session_id:
126
+ del self._user_hash_to_session[composite_key]
127
+
128
+ async def register_tools_for_session(self, session_id: str, tools: list[ToolDefinitionModel]) -> None:
129
+ """Register tools for a specific session."""
130
+ async with self._lock:
131
+ session = self._sessions.get(session_id)
132
+ if session:
133
+ # Replace existing tools or merge? Usually replace for "set state".
134
+ # We will replace the dict but keep the field.
135
+ session.tools.clear()
136
+ for tool in tools:
137
+ session.tools[tool.name] = tool
138
+
139
+ async def get_session(self, session_id: str) -> PluginSession | None:
140
+ """Fetch a session by its ``session_id``."""
141
+
142
+ async with self._lock:
143
+ return self._sessions.get(session_id)
144
+
145
+ async def get_session_id_by_hash(self, project_hash: str, user_id: str | None = None) -> str | None:
146
+ """Resolve a ``project_hash`` (Unity instance id) to a session id."""
147
+
148
+ if user_id:
149
+ async with self._lock:
150
+ return self._user_hash_to_session.get((user_id, project_hash))
151
+ else:
152
+ async with self._lock:
153
+ return self._hash_to_session.get(project_hash)
154
+
155
+ async def list_sessions(self, user_id: str | None = None) -> dict[str, PluginSession]:
156
+ """Return a shallow copy of sessions.
157
+
158
+ Args:
159
+ user_id: If provided, only return sessions for this user (remote-hosted mode).
160
+ If None, return all sessions (local mode only).
161
+
162
+ Raises:
163
+ ValueError: If ``user_id`` is None while running in remote-hosted mode.
164
+ This prevents accidentally leaking sessions across users.
165
+ """
166
+ if user_id is None and config.http_remote_hosted:
167
+ raise ValueError(
168
+ "list_sessions requires user_id in remote-hosted mode"
169
+ )
170
+
171
+ async with self._lock:
172
+ if user_id is None:
173
+ return dict(self._sessions)
174
+ else:
175
+ return {
176
+ sid: session
177
+ for sid, session in self._sessions.items()
178
+ if session.user_id == user_id
179
+ }
180
+
181
+
182
+ __all__ = ["PluginRegistry", "PluginSession"]
@@ -0,0 +1,262 @@
1
+ """
2
+ Middleware for managing Unity instance selection per session.
3
+
4
+ This middleware intercepts all tool calls and injects the active Unity instance
5
+ into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
6
+ """
7
+ from threading import RLock
8
+ import logging
9
+
10
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
11
+
12
+ from core.config import config
13
+ from transport.plugin_hub import PluginHub
14
+
15
+ logger = logging.getLogger("mcp-for-unity-server")
16
+
17
+ # Store a global reference to the middleware instance so tools can interact
18
+ # with it to set or clear the active unity instance.
19
+ _unity_instance_middleware = None
20
+ _middleware_lock = RLock()
21
+
22
+
23
+ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
24
+ """Get the global Unity instance middleware."""
25
+ global _unity_instance_middleware
26
+ if _unity_instance_middleware is None:
27
+ with _middleware_lock:
28
+ if _unity_instance_middleware is None:
29
+ # Auto-initialize if not set (lazy singleton) to handle import order or test cases
30
+ _unity_instance_middleware = UnityInstanceMiddleware()
31
+
32
+ return _unity_instance_middleware
33
+
34
+
35
+ def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
36
+ """Replace the global middleware instance.
37
+
38
+ This is a test seam: production code uses ``get_unity_instance_middleware()``
39
+ which lazy-initialises the singleton. Tests call this function to inject a
40
+ mock or pre-configured middleware before exercising tool/resource code.
41
+ """
42
+ global _unity_instance_middleware
43
+ _unity_instance_middleware = middleware
44
+
45
+
46
+ class UnityInstanceMiddleware(Middleware):
47
+ """
48
+ Middleware that manages per-session Unity instance selection.
49
+
50
+ Stores active instance per session_id and injects it into request state
51
+ for all tool and resource calls.
52
+ """
53
+
54
+ def __init__(self):
55
+ super().__init__()
56
+ self._active_by_key: dict[str, str] = {}
57
+ self._lock = RLock()
58
+
59
+ def get_session_key(self, ctx) -> str:
60
+ """
61
+ Derive a stable key for the calling session.
62
+
63
+ Prioritizes client_id for stability.
64
+ In remote-hosted mode, falls back to user_id for session isolation.
65
+ Otherwise falls back to 'global' (assuming single-user local mode).
66
+ """
67
+ client_id = getattr(ctx, "client_id", None)
68
+ if isinstance(client_id, str) and client_id:
69
+ return client_id
70
+
71
+ # In remote-hosted mode, use user_id so different users get isolated instance selections
72
+ user_id = ctx.get_state("user_id")
73
+ if isinstance(user_id, str) and user_id:
74
+ return f"user:{user_id}"
75
+
76
+ # Fallback to global for local dev stability
77
+ return "global"
78
+
79
+ def set_active_instance(self, ctx, instance_id: str) -> None:
80
+ """Store the active instance for this session."""
81
+ key = self.get_session_key(ctx)
82
+ with self._lock:
83
+ self._active_by_key[key] = instance_id
84
+
85
+ def get_active_instance(self, ctx) -> str | None:
86
+ """Retrieve the active instance for this session."""
87
+ key = self.get_session_key(ctx)
88
+ with self._lock:
89
+ return self._active_by_key.get(key)
90
+
91
+ def clear_active_instance(self, ctx) -> None:
92
+ """Clear the stored instance for this session."""
93
+ key = self.get_session_key(ctx)
94
+ with self._lock:
95
+ self._active_by_key.pop(key, None)
96
+
97
+ async def _maybe_autoselect_instance(self, ctx) -> str | None:
98
+ """
99
+ Auto-select the sole Unity instance when no active instance is set.
100
+
101
+ Note: This method both *discovers* and *persists* the selection via
102
+ `set_active_instance` as a side-effect, since callers expect the selection
103
+ to stick for subsequent tool/resource calls in the same session.
104
+ """
105
+ try:
106
+ transport = (config.transport_mode or "stdio").lower()
107
+ # This implicit behavior works well for solo-users, but is dangerous for multi-user setups
108
+ if transport == "http" and config.http_remote_hosted:
109
+ return None
110
+ if PluginHub.is_configured():
111
+ try:
112
+ sessions_data = await PluginHub.get_sessions()
113
+ sessions = sessions_data.sessions or {}
114
+ ids: list[str] = []
115
+ for session_info in sessions.values():
116
+ project = getattr(
117
+ session_info, "project", None) or "Unknown"
118
+ hash_value = getattr(session_info, "hash", None)
119
+ if hash_value:
120
+ ids.append(f"{project}@{hash_value}")
121
+ if len(ids) == 1:
122
+ chosen = ids[0]
123
+ self.set_active_instance(ctx, chosen)
124
+ logger.info(
125
+ "Auto-selected sole Unity instance via PluginHub: %s",
126
+ chosen,
127
+ )
128
+ return chosen
129
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
130
+ logger.debug(
131
+ "PluginHub auto-select probe failed (%s); falling back to stdio",
132
+ type(exc).__name__,
133
+ exc_info=True,
134
+ )
135
+ except Exception as exc:
136
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
137
+ raise
138
+ logger.debug(
139
+ "PluginHub auto-select probe failed with unexpected error (%s); falling back to stdio",
140
+ type(exc).__name__,
141
+ exc_info=True,
142
+ )
143
+
144
+ if transport != "http":
145
+ try:
146
+ # Import here to avoid circular imports in legacy transport paths.
147
+ from transport.legacy.unity_connection import get_unity_connection_pool
148
+
149
+ pool = get_unity_connection_pool()
150
+ instances = pool.discover_all_instances(force_refresh=True)
151
+ ids = [getattr(inst, "id", None) for inst in instances]
152
+ ids = [inst_id for inst_id in ids if inst_id]
153
+ if len(ids) == 1:
154
+ chosen = ids[0]
155
+ self.set_active_instance(ctx, chosen)
156
+ logger.info(
157
+ "Auto-selected sole Unity instance via stdio discovery: %s",
158
+ chosen,
159
+ )
160
+ return chosen
161
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
162
+ logger.debug(
163
+ "Stdio auto-select probe failed (%s)",
164
+ type(exc).__name__,
165
+ exc_info=True,
166
+ )
167
+ except Exception as exc:
168
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
169
+ raise
170
+ logger.debug(
171
+ "Stdio auto-select probe failed with unexpected error (%s)",
172
+ type(exc).__name__,
173
+ exc_info=True,
174
+ )
175
+ except Exception as exc:
176
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
177
+ raise
178
+ logger.debug(
179
+ "Auto-select path encountered an unexpected error (%s)",
180
+ type(exc).__name__,
181
+ exc_info=True,
182
+ )
183
+
184
+ return None
185
+
186
+ async def _resolve_user_id(self) -> str | None:
187
+ """Extract user_id from the current HTTP request's API key."""
188
+ if not config.http_remote_hosted:
189
+ return None
190
+ # Lazy import to avoid circular dependencies (same pattern as _maybe_autoselect_instance).
191
+ from transport.unity_transport import _resolve_user_id_from_request
192
+ return await _resolve_user_id_from_request()
193
+
194
+ async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
195
+ """Inject active Unity instance and user_id into context if available."""
196
+ ctx = context.fastmcp_context
197
+
198
+ # Resolve user_id from the HTTP request's API key header
199
+ user_id = await self._resolve_user_id()
200
+ if config.http_remote_hosted and user_id is None:
201
+ raise RuntimeError(
202
+ "API key authentication required. Provide a valid X-API-Key header."
203
+ )
204
+ if user_id:
205
+ ctx.set_state("user_id", user_id)
206
+
207
+ active_instance = self.get_active_instance(ctx)
208
+ if not active_instance:
209
+ active_instance = await self._maybe_autoselect_instance(ctx)
210
+ if active_instance:
211
+ # If using HTTP transport (PluginHub configured), validate session
212
+ # But for stdio transport (no PluginHub needed or maybe partially configured),
213
+ # we should be careful not to clear instance just because PluginHub can't resolve it.
214
+ # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
215
+
216
+ session_id: str | None = None
217
+ # Only validate via PluginHub if we are actually using HTTP transport.
218
+ # For stdio transport, skip PluginHub entirely - we only need the instance ID.
219
+ from transport.unity_transport import _is_http_transport
220
+ if _is_http_transport() and PluginHub.is_configured():
221
+ try:
222
+ # resolving session_id might fail if the plugin disconnected
223
+ # We only need session_id for HTTP transport routing.
224
+ # For stdio, we just need the instance ID.
225
+ # Pass user_id for remote-hosted mode session isolation
226
+ session_id = await PluginHub._resolve_session_id(active_instance, user_id=user_id)
227
+ except (ConnectionError, ValueError, KeyError, TimeoutError) as exc:
228
+ # If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
229
+ # If we are in stdio mode, this might still be fine if the user is just setting state?
230
+ # But usually if PluginHub is configured, we expect it to work.
231
+ # Let's LOG the error but NOT clear the instance immediately to avoid flickering,
232
+ # or at least debug why it's failing.
233
+ logger.debug(
234
+ "PluginHub session resolution failed for %s: %s; leaving active_instance unchanged",
235
+ active_instance,
236
+ exc,
237
+ exc_info=True,
238
+ )
239
+ except Exception as exc:
240
+ # Re-raise unexpected system exceptions to avoid swallowing critical failures
241
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
242
+ raise
243
+ logger.error(
244
+ "Unexpected error during PluginHub session resolution for %s: %s",
245
+ active_instance,
246
+ exc,
247
+ exc_info=True
248
+ )
249
+
250
+ ctx.set_state("unity_instance", active_instance)
251
+ if session_id is not None:
252
+ ctx.set_state("unity_session_id", session_id)
253
+
254
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
255
+ """Inject active Unity instance into tool context if available."""
256
+ await self._inject_unity_instance(context)
257
+ return await call_next(context)
258
+
259
+ async def on_read_resource(self, context: MiddlewareContext, call_next):
260
+ """Inject active Unity instance into resource context if available."""
261
+ await self._inject_unity_instance(context)
262
+ return await call_next(context)
@@ -0,0 +1,94 @@
1
+ """Transport helpers for routing commands to Unity."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Awaitable, Callable, TypeVar
6
+
7
+ from transport.plugin_hub import PluginHub
8
+ from core.config import config
9
+ from core.constants import API_KEY_HEADER
10
+ from services.api_key_service import ApiKeyService
11
+ from models.models import MCPResponse
12
+ from models.unity_response import normalize_unity_response
13
+
14
+ logger = logging.getLogger(__name__)
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _is_http_transport() -> bool:
19
+ return config.transport_mode.lower() == "http"
20
+
21
+
22
+ async def _resolve_user_id_from_request() -> str | None:
23
+ """Extract user_id from the current HTTP request's API key header."""
24
+ if not config.http_remote_hosted:
25
+ return None
26
+ if not ApiKeyService.is_initialized():
27
+ return None
28
+ try:
29
+ from fastmcp.server.dependencies import get_http_headers
30
+ headers = get_http_headers(include_all=True)
31
+ api_key = headers.get(API_KEY_HEADER.lower())
32
+ if not api_key:
33
+ return None
34
+ service = ApiKeyService.get_instance()
35
+ result = await service.validate(api_key)
36
+ return result.user_id if result.valid else None
37
+ except Exception as e:
38
+ logger.debug("Failed to resolve user_id from HTTP request: %s", e)
39
+ return None
40
+
41
+
42
+ async def send_with_unity_instance(
43
+ send_fn: Callable[..., Awaitable[T]],
44
+ unity_instance: str | None,
45
+ *args,
46
+ user_id: str | None = None,
47
+ **kwargs,
48
+ ) -> T:
49
+ if _is_http_transport():
50
+ if not args:
51
+ raise ValueError("HTTP transport requires command arguments")
52
+ command_type = args[0]
53
+ params = args[1] if len(args) > 1 else kwargs.get("params")
54
+ if params is None:
55
+ params = {}
56
+ if not isinstance(params, dict):
57
+ raise TypeError(
58
+ "Command parameters must be a dict for HTTP transport")
59
+
60
+ # Auto-resolve user_id from HTTP request API key (remote-hosted mode)
61
+ if user_id is None:
62
+ user_id = await _resolve_user_id_from_request()
63
+
64
+ # Auth check
65
+ if config.http_remote_hosted and not user_id:
66
+ return normalize_unity_response(
67
+ MCPResponse(
68
+ success=False,
69
+ error="auth_required",
70
+ message="API key required",
71
+ ).model_dump()
72
+ )
73
+
74
+ try:
75
+ raw = await PluginHub.send_command_for_instance(
76
+ unity_instance,
77
+ command_type,
78
+ params,
79
+ user_id=user_id,
80
+ )
81
+ return normalize_unity_response(raw)
82
+ except Exception as exc:
83
+ # NOTE: asyncio.TimeoutError has an empty str() by default, which is confusing for clients.
84
+ err = str(exc) or f"{type(exc).__name__}"
85
+ # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
86
+ # The client can decide whether retrying is appropriate for the command.
87
+ return normalize_unity_response(
88
+ MCPResponse(success=False, error=err,
89
+ hint="retry").model_dump()
90
+ )
91
+
92
+ if unity_instance:
93
+ kwargs.setdefault("instance_id", unity_instance)
94
+ return await send_fn(*args, **kwargs)