mcpforunityserver 8.2.3__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 (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. utils/reload_sentinel.py +9 -0
transport/models.py ADDED
@@ -0,0 +1,62 @@
1
+ from typing import Any
2
+ from pydantic import BaseModel, Field
3
+ from models.models import ToolDefinitionModel
4
+
5
+ # Outgoing (Server -> Plugin)
6
+
7
+
8
+ class WelcomeMessage(BaseModel):
9
+ type: str = "welcome"
10
+ serverTimeout: int
11
+ keepAliveInterval: int
12
+
13
+
14
+ class RegisteredMessage(BaseModel):
15
+ type: str = "registered"
16
+ session_id: str
17
+
18
+
19
+ class ExecuteCommandMessage(BaseModel):
20
+ type: str = "execute"
21
+ id: str
22
+ name: str
23
+ params: dict[str, Any]
24
+ timeout: float
25
+
26
+ # Incoming (Plugin -> Server)
27
+
28
+
29
+ class RegisterMessage(BaseModel):
30
+ type: str = "register"
31
+ project_name: str = "Unknown Project"
32
+ project_hash: str
33
+ unity_version: str = "Unknown"
34
+
35
+
36
+ class RegisterToolsMessage(BaseModel):
37
+ type: str = "register_tools"
38
+ tools: list[ToolDefinitionModel]
39
+
40
+
41
+ class PongMessage(BaseModel):
42
+ type: str = "pong"
43
+ session_id: str | None = None
44
+
45
+
46
+ class CommandResultMessage(BaseModel):
47
+ type: str = "command_result"
48
+ id: str
49
+ result: dict[str, Any] = Field(default_factory=dict)
50
+
51
+ # Session Info (API response)
52
+
53
+
54
+ class SessionDetails(BaseModel):
55
+ project: str
56
+ hash: str
57
+ unity_version: str
58
+ connected_at: str
59
+
60
+
61
+ class SessionList(BaseModel):
62
+ sessions: dict[str, SessionDetails]
@@ -0,0 +1,412 @@
1
+ """WebSocket hub for Unity plugin communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from typing import Any
10
+
11
+ from starlette.endpoints import WebSocketEndpoint
12
+ from starlette.websockets import WebSocket
13
+
14
+ from core.config import config
15
+ from transport.plugin_registry import PluginRegistry
16
+ from transport.models import (
17
+ WelcomeMessage,
18
+ RegisteredMessage,
19
+ ExecuteCommandMessage,
20
+ RegisterMessage,
21
+ RegisterToolsMessage,
22
+ PongMessage,
23
+ CommandResultMessage,
24
+ SessionList,
25
+ SessionDetails,
26
+ )
27
+
28
+ logger = logging.getLogger("mcp-for-unity-server")
29
+
30
+
31
+ class PluginHub(WebSocketEndpoint):
32
+ """Manages persistent WebSocket connections to Unity plugins."""
33
+
34
+ encoding = "json"
35
+ KEEP_ALIVE_INTERVAL = 15
36
+ SERVER_TIMEOUT = 30
37
+ COMMAND_TIMEOUT = 30
38
+
39
+ _registry: PluginRegistry | None = None
40
+ _connections: dict[str, WebSocket] = {}
41
+ _pending: dict[str, asyncio.Future] = {}
42
+ _lock: asyncio.Lock | None = None
43
+ _loop: asyncio.AbstractEventLoop | None = None
44
+
45
+ @classmethod
46
+ def configure(
47
+ cls,
48
+ registry: PluginRegistry,
49
+ loop: asyncio.AbstractEventLoop | None = None,
50
+ ) -> None:
51
+ cls._registry = registry
52
+ cls._loop = loop or asyncio.get_running_loop()
53
+ # Ensure coordination primitives are bound to the configured loop
54
+ cls._lock = asyncio.Lock()
55
+
56
+ @classmethod
57
+ def is_configured(cls) -> bool:
58
+ return cls._registry is not None and cls._lock is not None
59
+
60
+ async def on_connect(self, websocket: WebSocket) -> None:
61
+ await websocket.accept()
62
+ msg = WelcomeMessage(
63
+ serverTimeout=self.SERVER_TIMEOUT,
64
+ keepAliveInterval=self.KEEP_ALIVE_INTERVAL,
65
+ )
66
+ await websocket.send_json(msg.model_dump())
67
+
68
+ async def on_receive(self, websocket: WebSocket, data: Any) -> None:
69
+ if not isinstance(data, dict):
70
+ logger.warning(f"Received non-object payload from plugin: {data}")
71
+ return
72
+
73
+ message_type = data.get("type")
74
+ try:
75
+ if message_type == "register":
76
+ await self._handle_register(websocket, RegisterMessage(**data))
77
+ elif message_type == "register_tools":
78
+ await self._handle_register_tools(websocket, RegisterToolsMessage(**data))
79
+ elif message_type == "pong":
80
+ await self._handle_pong(PongMessage(**data))
81
+ elif message_type == "command_result":
82
+ await self._handle_command_result(CommandResultMessage(**data))
83
+ else:
84
+ logger.debug(f"Ignoring plugin message: {data}")
85
+ except Exception as e:
86
+ logger.error(f"Error handling message type {message_type}: {e}")
87
+
88
+ async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
89
+ cls = type(self)
90
+ lock = cls._lock
91
+ if lock is None:
92
+ return
93
+ async with lock:
94
+ session_id = next(
95
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
96
+ if session_id:
97
+ cls._connections.pop(session_id, None)
98
+ if cls._registry:
99
+ await cls._registry.unregister(session_id)
100
+ logger.info(
101
+ f"Plugin session {session_id} disconnected ({close_code})")
102
+
103
+ # ------------------------------------------------------------------
104
+ # Public API
105
+ # ------------------------------------------------------------------
106
+ @classmethod
107
+ async def send_command(cls, session_id: str, command_type: str, params: dict[str, Any]) -> dict[str, Any]:
108
+ websocket = await cls._get_connection(session_id)
109
+ command_id = str(uuid.uuid4())
110
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
111
+
112
+ lock = cls._lock
113
+ if lock is None:
114
+ raise RuntimeError("PluginHub not configured")
115
+
116
+ async with lock:
117
+ if command_id in cls._pending:
118
+ raise RuntimeError(
119
+ f"Duplicate command id generated: {command_id}")
120
+ cls._pending[command_id] = future
121
+
122
+ try:
123
+ msg = ExecuteCommandMessage(
124
+ id=command_id,
125
+ name=command_type,
126
+ params=params,
127
+ timeout=cls.COMMAND_TIMEOUT,
128
+ )
129
+ await websocket.send_json(msg.model_dump())
130
+ result = await asyncio.wait_for(future, timeout=cls.COMMAND_TIMEOUT)
131
+ return result
132
+ finally:
133
+ async with lock:
134
+ cls._pending.pop(command_id, None)
135
+
136
+ @classmethod
137
+ async def get_sessions(cls) -> SessionList:
138
+ if cls._registry is None:
139
+ return SessionList(sessions={})
140
+ sessions = await cls._registry.list_sessions()
141
+ return SessionList(
142
+ sessions={
143
+ session_id: SessionDetails(
144
+ project=session.project_name,
145
+ hash=session.project_hash,
146
+ unity_version=session.unity_version,
147
+ connected_at=session.connected_at.isoformat(),
148
+ )
149
+ for session_id, session in sessions.items()
150
+ }
151
+ )
152
+
153
+ @classmethod
154
+ async def get_tools_for_project(cls, project_hash: str) -> list[Any]:
155
+ """Retrieve tools registered for a active project hash."""
156
+ if cls._registry is None:
157
+ return []
158
+
159
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
160
+ if not session_id:
161
+ return []
162
+
163
+ session = await cls._registry.get_session(session_id)
164
+ if not session:
165
+ return []
166
+
167
+ return list(session.tools.values())
168
+
169
+ @classmethod
170
+ async def get_tool_definition(cls, project_hash: str, tool_name: str) -> Any | None:
171
+ """Retrieve a specific tool definition for an active project hash."""
172
+ if cls._registry is None:
173
+ return None
174
+
175
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
176
+ if not session_id:
177
+ return None
178
+
179
+ session = await cls._registry.get_session(session_id)
180
+ if not session:
181
+ return None
182
+
183
+ return session.tools.get(tool_name)
184
+
185
+ # ------------------------------------------------------------------
186
+ # Internal helpers
187
+ # ------------------------------------------------------------------
188
+ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) -> None:
189
+ cls = type(self)
190
+ registry = cls._registry
191
+ lock = cls._lock
192
+ if registry is None or lock is None:
193
+ await websocket.close(code=1011)
194
+ raise RuntimeError("PluginHub not configured")
195
+
196
+ project_name = payload.project_name
197
+ project_hash = payload.project_hash
198
+ unity_version = payload.unity_version
199
+
200
+ if not project_hash:
201
+ await websocket.close(code=4400)
202
+ raise ValueError(
203
+ "Plugin registration missing project_hash")
204
+
205
+ session_id = str(uuid.uuid4())
206
+ # Inform the plugin of its assigned session ID
207
+ response = RegisteredMessage(session_id=session_id)
208
+ await websocket.send_json(response.model_dump())
209
+
210
+ session = await registry.register(session_id, project_name, project_hash, unity_version)
211
+ async with lock:
212
+ cls._connections[session.session_id] = websocket
213
+ logger.info(f"Plugin registered: {project_name} ({project_hash})")
214
+
215
+ async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
216
+ cls = type(self)
217
+ registry = cls._registry
218
+ lock = cls._lock
219
+ if registry is None or lock is None:
220
+ return
221
+
222
+ # Find session_id for this websocket
223
+ async with lock:
224
+ session_id = next(
225
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
226
+
227
+ if not session_id:
228
+ logger.warning("Received register_tools from unknown connection")
229
+ return
230
+
231
+ await registry.register_tools_for_session(session_id, payload.tools)
232
+ logger.info(
233
+ f"Registered {len(payload.tools)} tools for session {session_id}")
234
+
235
+ async def _handle_command_result(self, payload: CommandResultMessage) -> None:
236
+ cls = type(self)
237
+ lock = cls._lock
238
+ if lock is None:
239
+ return
240
+ command_id = payload.id
241
+ result = payload.result
242
+
243
+ if not command_id:
244
+ logger.warning(f"Command result missing id: {payload}")
245
+ return
246
+
247
+ async with lock:
248
+ future = cls._pending.get(command_id)
249
+ if future and not future.done():
250
+ future.set_result(result)
251
+
252
+ async def _handle_pong(self, payload: PongMessage) -> None:
253
+ cls = type(self)
254
+ registry = cls._registry
255
+ if registry is None:
256
+ return
257
+ session_id = payload.session_id
258
+ if session_id:
259
+ await registry.touch(session_id)
260
+
261
+ @classmethod
262
+ async def _get_connection(cls, session_id: str) -> WebSocket:
263
+ lock = cls._lock
264
+ if lock is None:
265
+ raise RuntimeError("PluginHub not configured")
266
+ async with lock:
267
+ websocket = cls._connections.get(session_id)
268
+ if websocket is None:
269
+ raise RuntimeError(f"Plugin session {session_id} not connected")
270
+ return websocket
271
+
272
+ # ------------------------------------------------------------------
273
+ # Session resolution helpers
274
+ # ------------------------------------------------------------------
275
+ @classmethod
276
+ async def _resolve_session_id(cls, unity_instance: str | None) -> str:
277
+ """Resolve a project hash (Unity instance id) to an active plugin session.
278
+
279
+ During Unity domain reloads the plugin's WebSocket session is torn down
280
+ and reconnected shortly afterwards. Instead of failing immediately when
281
+ no sessions are available, we wait for a bounded period for a plugin
282
+ to reconnect so in-flight MCP calls can succeed transparently.
283
+ """
284
+ if cls._registry is None:
285
+ raise RuntimeError("Plugin registry not configured")
286
+
287
+ # Use the same defaults as the stdio transport reload handling so that
288
+ # HTTP/WebSocket and TCP behave consistently without per-project env.
289
+ max_retries = max(1, int(getattr(config, "reload_max_retries", 40)))
290
+ retry_ms = float(getattr(config, "reload_retry_ms", 250))
291
+ sleep_seconds = max(0.05, retry_ms / 1000.0)
292
+
293
+ # Allow callers to provide either just the hash or Name@hash
294
+ target_hash: str | None = None
295
+ if unity_instance:
296
+ if "@" in unity_instance:
297
+ _, _, suffix = unity_instance.rpartition("@")
298
+ target_hash = suffix or None
299
+ else:
300
+ target_hash = unity_instance
301
+
302
+ async def _try_once() -> tuple[str | None, int]:
303
+ # Prefer a specific Unity instance if one was requested
304
+ if target_hash:
305
+ session_id = await cls._registry.get_session_id_by_hash(target_hash)
306
+ sessions = await cls._registry.list_sessions()
307
+ return session_id, len(sessions)
308
+
309
+ # No target provided: determine if we can auto-select
310
+ sessions = await cls._registry.list_sessions()
311
+ count = len(sessions)
312
+ if count == 0:
313
+ return None, count
314
+ if count == 1:
315
+ return next(iter(sessions.keys())), count
316
+ # Multiple sessions but no explicit target is ambiguous
317
+ return None, count
318
+
319
+ session_id, session_count = await _try_once()
320
+ deadline = time.monotonic() + (max_retries * sleep_seconds)
321
+ wait_started = None
322
+
323
+ # If there is no active plugin yet (e.g., Unity starting up or reloading),
324
+ # wait politely for a session to appear before surfacing an error.
325
+ while session_id is None and time.monotonic() < deadline:
326
+ if not target_hash and session_count > 1:
327
+ raise RuntimeError(
328
+ "Multiple Unity instances are connected. "
329
+ "Call set_active_instance with Name@hash from unity://instances."
330
+ )
331
+ if wait_started is None:
332
+ wait_started = time.monotonic()
333
+ logger.debug(
334
+ f"No plugin session available (instance={unity_instance or 'default'}); waiting up to {deadline - wait_started:.2f}s",
335
+ )
336
+ await asyncio.sleep(sleep_seconds)
337
+ session_id, session_count = await _try_once()
338
+
339
+ if session_id is not None and wait_started is not None:
340
+ logger.debug(
341
+ f"Plugin session restored after {time.monotonic() - wait_started:.3f}s (instance={unity_instance or 'default'})",
342
+ )
343
+ if session_id is None and not target_hash and session_count > 1:
344
+ raise RuntimeError(
345
+ "Multiple Unity instances are connected. "
346
+ "Call set_active_instance with Name@hash from unity://instances."
347
+ )
348
+
349
+ if session_id is None:
350
+ logger.warning(
351
+ f"No Unity plugin reconnected within {max_retries * sleep_seconds:.2f}s (instance={unity_instance or 'default'})",
352
+ )
353
+ # At this point we've given the plugin ample time to reconnect; surface
354
+ # a clear error so the client can prompt the user to open Unity.
355
+ raise RuntimeError("No Unity plugins are currently connected")
356
+
357
+ return session_id
358
+
359
+ @classmethod
360
+ async def send_command_for_instance(
361
+ cls,
362
+ unity_instance: str | None,
363
+ command_type: str,
364
+ params: dict[str, Any],
365
+ ) -> dict[str, Any]:
366
+ session_id = await cls._resolve_session_id(unity_instance)
367
+ return await cls.send_command(session_id, command_type, params)
368
+
369
+ # ------------------------------------------------------------------
370
+ # Blocking helpers for synchronous tool code
371
+ # ------------------------------------------------------------------
372
+ @classmethod
373
+ def _run_coroutine_sync(cls, coro: "asyncio.Future[Any]") -> Any:
374
+ if cls._loop is None:
375
+ raise RuntimeError("PluginHub event loop not configured")
376
+ loop = cls._loop
377
+ if loop.is_running():
378
+ try:
379
+ running_loop = asyncio.get_running_loop()
380
+ except RuntimeError:
381
+ running_loop = None
382
+ else:
383
+ if running_loop is loop:
384
+ raise RuntimeError(
385
+ "Cannot wait synchronously for PluginHub coroutine from within the event loop"
386
+ )
387
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
388
+ return future.result()
389
+
390
+ @classmethod
391
+ def send_command_blocking(
392
+ cls,
393
+ unity_instance: str | None,
394
+ command_type: str,
395
+ params: dict[str, Any],
396
+ ) -> dict[str, Any]:
397
+ return cls._run_coroutine_sync(
398
+ cls.send_command_for_instance(unity_instance, command_type, params)
399
+ )
400
+
401
+ @classmethod
402
+ def list_sessions_sync(cls) -> SessionList:
403
+ return cls._run_coroutine_sync(cls.get_sessions())
404
+
405
+
406
+ def send_command_to_plugin(
407
+ *,
408
+ unity_instance: str | None,
409
+ command_type: str,
410
+ params: dict[str, Any],
411
+ ) -> dict[str, Any]:
412
+ return PluginHub.send_command_blocking(unity_instance, command_type, params)
@@ -0,0 +1,123 @@
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 models.models import ToolDefinitionModel
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class PluginSession:
15
+ """Represents a single Unity plugin connection."""
16
+
17
+ session_id: str
18
+ project_name: str
19
+ project_hash: str
20
+ unity_version: str
21
+ registered_at: datetime
22
+ connected_at: datetime
23
+ tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
24
+ project_id: str | None = None
25
+
26
+
27
+ class PluginRegistry:
28
+ """Stores active plugin sessions in-memory.
29
+
30
+ The registry is optimised for quick lookup by either ``session_id`` or
31
+ ``project_hash`` (which is used as the canonical "instance id" across the
32
+ HTTP command routing stack).
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._sessions: dict[str, PluginSession] = {}
37
+ self._hash_to_session: dict[str, str] = {}
38
+ self._lock = asyncio.Lock()
39
+
40
+ async def register(
41
+ self,
42
+ session_id: str,
43
+ project_name: str,
44
+ project_hash: str,
45
+ unity_version: str,
46
+ ) -> PluginSession:
47
+ """Register (or replace) a plugin session.
48
+
49
+ If an existing session already claims the same ``project_hash`` it will be
50
+ replaced, ensuring that reconnect scenarios always map to the latest
51
+ WebSocket connection.
52
+ """
53
+
54
+ async with self._lock:
55
+ now = datetime.now(timezone.utc)
56
+ session = PluginSession(
57
+ session_id=session_id,
58
+ project_name=project_name,
59
+ project_hash=project_hash,
60
+ unity_version=unity_version,
61
+ registered_at=now,
62
+ connected_at=now,
63
+ )
64
+
65
+ # Remove old mapping for this hash if it existed under a different session
66
+ previous_session_id = self._hash_to_session.get(project_hash)
67
+ if previous_session_id and previous_session_id != session_id:
68
+ self._sessions.pop(previous_session_id, None)
69
+
70
+ self._sessions[session_id] = session
71
+ self._hash_to_session[project_hash] = session_id
72
+ return session
73
+
74
+ async def touch(self, session_id: str) -> None:
75
+ """Update the ``connected_at`` timestamp when a heartbeat is received."""
76
+
77
+ async with self._lock:
78
+ session = self._sessions.get(session_id)
79
+ if session:
80
+ session.connected_at = datetime.now(timezone.utc)
81
+
82
+ async def unregister(self, session_id: str) -> None:
83
+ """Remove a plugin session from the registry."""
84
+
85
+ async with self._lock:
86
+ session = self._sessions.pop(session_id, None)
87
+ if session and session.project_hash in self._hash_to_session:
88
+ # Only delete the mapping if it still points at the removed session.
89
+ mapped = self._hash_to_session.get(session.project_hash)
90
+ if mapped == session_id:
91
+ del self._hash_to_session[session.project_hash]
92
+
93
+ async def register_tools_for_session(self, session_id: str, tools: list[ToolDefinitionModel]) -> None:
94
+ """Register tools for a specific session."""
95
+ async with self._lock:
96
+ session = self._sessions.get(session_id)
97
+ if session:
98
+ # Replace existing tools or merge? Usually replace for "set state".
99
+ # We will replace the dict but keep the field.
100
+ session.tools.clear()
101
+ for tool in tools:
102
+ session.tools[tool.name] = tool
103
+
104
+ async def get_session(self, session_id: str) -> PluginSession | None:
105
+ """Fetch a session by its ``session_id``."""
106
+
107
+ async with self._lock:
108
+ return self._sessions.get(session_id)
109
+
110
+ async def get_session_id_by_hash(self, project_hash: str) -> str | None:
111
+ """Resolve a ``project_hash`` (Unity instance id) to a session id."""
112
+
113
+ async with self._lock:
114
+ return self._hash_to_session.get(project_hash)
115
+
116
+ async def list_sessions(self) -> dict[str, PluginSession]:
117
+ """Return a shallow copy of all known sessions."""
118
+
119
+ async with self._lock:
120
+ return dict(self._sessions)
121
+
122
+
123
+ __all__ = ["PluginRegistry", "PluginSession"]