mcpforunityserver 9.3.0b20260129104751__py3-none-any.whl → 9.3.0b20260131003150__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.
- cli/utils/connection.py +28 -32
- core/config.py +15 -0
- core/constants.py +4 -0
- main.py +306 -174
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/METADATA +117 -5
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/RECORD +30 -28
- models/__init__.py +2 -2
- models/unity_response.py +24 -1
- services/api_key_service.py +235 -0
- services/resources/active_tool.py +2 -1
- services/resources/editor_state.py +7 -7
- services/resources/layers.py +2 -1
- services/resources/menu_items.py +2 -1
- services/resources/prefab_stage.py +2 -1
- services/resources/project_info.py +2 -1
- services/resources/selection.py +2 -1
- services/resources/tags.py +2 -1
- services/resources/tests.py +3 -2
- services/resources/unity_instances.py +6 -3
- services/resources/windows.py +2 -1
- services/tools/manage_prefabs.py +35 -0
- services/tools/set_active_instance.py +6 -3
- transport/plugin_hub.py +124 -24
- transport/plugin_registry.py +75 -19
- transport/unity_instance_middleware.py +38 -9
- transport/unity_transport.py +41 -10
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/WHEEL +0 -0
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/top_level.txt +0 -0
services/resources/tags.py
CHANGED
|
@@ -2,6 +2,7 @@ from pydantic import Field
|
|
|
2
2
|
from fastmcp import Context
|
|
3
3
|
|
|
4
4
|
from models import MCPResponse
|
|
5
|
+
from models.unity_response import parse_resource_response
|
|
5
6
|
from services.registry import mcp_for_unity_resource
|
|
6
7
|
from services.tools import get_unity_instance_from_context
|
|
7
8
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -27,4 +28,4 @@ async def get_tags(ctx: Context) -> TagsResponse | MCPResponse:
|
|
|
27
28
|
"get_tags",
|
|
28
29
|
{}
|
|
29
30
|
)
|
|
30
|
-
return
|
|
31
|
+
return parse_resource_response(response, TagsResponse)
|
services/resources/tests.py
CHANGED
|
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
|
|
|
4
4
|
from fastmcp import Context
|
|
5
5
|
|
|
6
6
|
from models import MCPResponse
|
|
7
|
+
from models.unity_response import parse_resource_response
|
|
7
8
|
from services.registry import mcp_for_unity_resource
|
|
8
9
|
from services.tools import get_unity_instance_from_context
|
|
9
10
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -53,7 +54,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
|
|
|
53
54
|
"get_tests",
|
|
54
55
|
{},
|
|
55
56
|
)
|
|
56
|
-
return
|
|
57
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
@mcp_for_unity_resource(
|
|
@@ -84,4 +85,4 @@ async def get_tests_for_mode(
|
|
|
84
85
|
"get_tests_for_mode",
|
|
85
86
|
{"mode": mode},
|
|
86
87
|
)
|
|
87
|
-
return
|
|
88
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
@@ -7,7 +7,7 @@ from fastmcp import Context
|
|
|
7
7
|
from services.registry import mcp_for_unity_resource
|
|
8
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
9
|
from transport.plugin_hub import PluginHub
|
|
10
|
-
from
|
|
10
|
+
from core.config import config
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@mcp_for_unity_resource(
|
|
@@ -36,10 +36,13 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
|
|
|
36
36
|
await ctx.info("Listing Unity instances")
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
transport =
|
|
39
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
40
40
|
if transport == "http":
|
|
41
41
|
# HTTP/WebSocket transport: query PluginHub
|
|
42
|
-
|
|
42
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
43
|
+
user_id = ctx.get_state(
|
|
44
|
+
"user_id") if config.http_remote_hosted else None
|
|
45
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
43
46
|
sessions = sessions_data.sessions
|
|
44
47
|
|
|
45
48
|
instances = []
|
services/resources/windows.py
CHANGED
|
@@ -2,6 +2,7 @@ from pydantic import BaseModel
|
|
|
2
2
|
from fastmcp import Context
|
|
3
3
|
|
|
4
4
|
from models import MCPResponse
|
|
5
|
+
from models.unity_response import parse_resource_response
|
|
5
6
|
from services.registry import mcp_for_unity_resource
|
|
6
7
|
from services.tools import get_unity_instance_from_context
|
|
7
8
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -44,4 +45,4 @@ async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
|
|
|
44
45
|
"get_windows",
|
|
45
46
|
{}
|
|
46
47
|
)
|
|
47
|
-
return
|
|
48
|
+
return parse_resource_response(response, WindowsResponse)
|
services/tools/manage_prefabs.py
CHANGED
|
@@ -25,6 +25,10 @@ REQUIRED_PARAMS = {
|
|
|
25
25
|
"Manages Unity Prefab assets via headless operations (no UI, no prefab stages). "
|
|
26
26
|
"Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. "
|
|
27
27
|
"Use modify_contents for headless prefab editing - ideal for automated workflows. "
|
|
28
|
+
"Use create_child parameter with modify_contents to add child GameObjects to a prefab "
|
|
29
|
+
"(single object or array for batch creation in one save). "
|
|
30
|
+
"Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, "
|
|
31
|
+
"{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. "
|
|
28
32
|
"Use manage_asset action=search filterType=Prefab to list prefabs."
|
|
29
33
|
),
|
|
30
34
|
annotations=ToolAnnotations(
|
|
@@ -59,6 +63,7 @@ async def manage_prefabs(
|
|
|
59
63
|
parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None,
|
|
60
64
|
components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
|
|
61
65
|
components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
|
|
66
|
+
create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None,
|
|
62
67
|
) -> dict[str, Any]:
|
|
63
68
|
# Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both)
|
|
64
69
|
if action == "create_from_gameobject" and target is None and name is not None:
|
|
@@ -143,6 +148,36 @@ async def manage_prefabs(
|
|
|
143
148
|
params["componentsToAdd"] = components_to_add
|
|
144
149
|
if components_to_remove is not None:
|
|
145
150
|
params["componentsToRemove"] = components_to_remove
|
|
151
|
+
if create_child is not None:
|
|
152
|
+
# Normalize vector fields within create_child (handles single object or array)
|
|
153
|
+
def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]:
|
|
154
|
+
prefix = f"create_child[{index}]" if index is not None else "create_child"
|
|
155
|
+
if not isinstance(child, dict):
|
|
156
|
+
return None, f"{prefix} must be a dict with child properties (name, primitive_type, position, etc.), got {type(child).__name__}"
|
|
157
|
+
child_params = dict(child)
|
|
158
|
+
for vec_field in ("position", "rotation", "scale"):
|
|
159
|
+
if vec_field in child_params and child_params[vec_field] is not None:
|
|
160
|
+
vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}")
|
|
161
|
+
if vec_err:
|
|
162
|
+
return None, vec_err
|
|
163
|
+
child_params[vec_field] = vec_val
|
|
164
|
+
return child_params, None
|
|
165
|
+
|
|
166
|
+
if isinstance(create_child, list):
|
|
167
|
+
# Array of children
|
|
168
|
+
normalized_children = []
|
|
169
|
+
for i, child in enumerate(create_child):
|
|
170
|
+
child_params, err = normalize_child_params(child, i)
|
|
171
|
+
if err:
|
|
172
|
+
return {"success": False, "message": err}
|
|
173
|
+
normalized_children.append(child_params)
|
|
174
|
+
params["createChild"] = normalized_children
|
|
175
|
+
else:
|
|
176
|
+
# Single child object
|
|
177
|
+
child_params, err = normalize_child_params(create_child)
|
|
178
|
+
if err:
|
|
179
|
+
return {"success": False, "message": err}
|
|
180
|
+
params["createChild"] = child_params
|
|
146
181
|
|
|
147
182
|
# Send command to Unity
|
|
148
183
|
response = await send_with_unity_instance(
|
|
@@ -8,7 +8,7 @@ from services.registry import mcp_for_unity_tool
|
|
|
8
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
9
|
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
10
10
|
from transport.plugin_hub import PluginHub
|
|
11
|
-
from
|
|
11
|
+
from core.config import config
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@mcp_for_unity_tool(
|
|
@@ -21,11 +21,14 @@ async def set_active_instance(
|
|
|
21
21
|
ctx: Context,
|
|
22
22
|
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
|
|
23
23
|
) -> dict[str, Any]:
|
|
24
|
-
transport =
|
|
24
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
25
25
|
|
|
26
26
|
# Discover running instances based on transport
|
|
27
27
|
if transport == "http":
|
|
28
|
-
|
|
28
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
29
|
+
user_id = ctx.get_state(
|
|
30
|
+
"user_id") if config.http_remote_hosted else None
|
|
31
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
29
32
|
sessions = sessions_data.sessions
|
|
30
33
|
instances = []
|
|
31
34
|
for session_id, session in sessions.items():
|
transport/plugin_hub.py
CHANGED
|
@@ -13,8 +13,10 @@ from starlette.endpoints import WebSocketEndpoint
|
|
|
13
13
|
from starlette.websockets import WebSocket
|
|
14
14
|
|
|
15
15
|
from core.config import config
|
|
16
|
+
from core.constants import API_KEY_HEADER
|
|
16
17
|
from models.models import MCPResponse
|
|
17
18
|
from transport.plugin_registry import PluginRegistry
|
|
19
|
+
from services.api_key_service import ApiKeyService
|
|
18
20
|
from transport.models import (
|
|
19
21
|
WelcomeMessage,
|
|
20
22
|
RegisteredMessage,
|
|
@@ -38,6 +40,22 @@ class NoUnitySessionError(RuntimeError):
|
|
|
38
40
|
"""Raised when no Unity plugins are available."""
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
class InstanceSelectionRequiredError(RuntimeError):
|
|
44
|
+
"""Raised when the caller must explicitly select a Unity instance."""
|
|
45
|
+
|
|
46
|
+
_SELECTION_REQUIRED = (
|
|
47
|
+
"Unity instance selection is required. "
|
|
48
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
49
|
+
)
|
|
50
|
+
_MULTIPLE_INSTANCES = (
|
|
51
|
+
"Multiple Unity instances are connected. "
|
|
52
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str | None = None):
|
|
56
|
+
super().__init__(message or self._SELECTION_REQUIRED)
|
|
57
|
+
|
|
58
|
+
|
|
41
59
|
class PluginHub(WebSocketEndpoint):
|
|
42
60
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
43
61
|
|
|
@@ -77,6 +95,50 @@ class PluginHub(WebSocketEndpoint):
|
|
|
77
95
|
return cls._registry is not None and cls._lock is not None
|
|
78
96
|
|
|
79
97
|
async def on_connect(self, websocket: WebSocket) -> None:
|
|
98
|
+
# Validate API key in remote-hosted mode (fail closed)
|
|
99
|
+
if config.http_remote_hosted:
|
|
100
|
+
if not ApiKeyService.is_initialized():
|
|
101
|
+
logger.debug(
|
|
102
|
+
"WebSocket connection rejected: auth service not initialized")
|
|
103
|
+
await websocket.close(code=1013, reason="Try again later")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
api_key = websocket.headers.get(API_KEY_HEADER)
|
|
107
|
+
|
|
108
|
+
if not api_key:
|
|
109
|
+
logger.debug("WebSocket connection rejected: API key required")
|
|
110
|
+
await websocket.close(code=4401, reason="API key required")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
service = ApiKeyService.get_instance()
|
|
114
|
+
result = await service.validate(api_key)
|
|
115
|
+
|
|
116
|
+
if not result.valid:
|
|
117
|
+
# Transient auth failures are retryable (1013)
|
|
118
|
+
if result.error and any(
|
|
119
|
+
indicator in result.error.lower()
|
|
120
|
+
for indicator in ("unavailable", "timeout", "service error")
|
|
121
|
+
):
|
|
122
|
+
logger.debug(
|
|
123
|
+
"WebSocket connection rejected: auth service unavailable")
|
|
124
|
+
await websocket.close(code=1013, reason="Try again later")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
logger.debug("WebSocket connection rejected: invalid API key")
|
|
128
|
+
await websocket.close(code=4403, reason="Invalid API key")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Both valid and user_id must be present to accept
|
|
132
|
+
if not result.user_id:
|
|
133
|
+
logger.debug(
|
|
134
|
+
"WebSocket connection rejected: validated key missing user_id")
|
|
135
|
+
await websocket.close(code=4403, reason="Invalid API key")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Store user_id in websocket state for later use during registration
|
|
139
|
+
websocket.state.user_id = result.user_id
|
|
140
|
+
websocket.state.api_key_metadata = result.metadata
|
|
141
|
+
|
|
80
142
|
await websocket.accept()
|
|
81
143
|
msg = WelcomeMessage(
|
|
82
144
|
serverTimeout=self.SERVER_TIMEOUT,
|
|
@@ -217,10 +279,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
217
279
|
cls._pending.pop(command_id, None)
|
|
218
280
|
|
|
219
281
|
@classmethod
|
|
220
|
-
async def get_sessions(cls) -> SessionList:
|
|
282
|
+
async def get_sessions(cls, user_id: str | None = None) -> SessionList:
|
|
283
|
+
"""Get all active plugin sessions.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
user_id: If provided (remote-hosted mode), only return sessions for this user.
|
|
287
|
+
"""
|
|
221
288
|
if cls._registry is None:
|
|
222
289
|
return SessionList(sessions={})
|
|
223
|
-
sessions = await cls._registry.list_sessions()
|
|
290
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
224
291
|
return SessionList(
|
|
225
292
|
sessions={
|
|
226
293
|
session_id: SessionDetails(
|
|
@@ -286,15 +353,23 @@ class PluginHub(WebSocketEndpoint):
|
|
|
286
353
|
raise ValueError(
|
|
287
354
|
"Plugin registration missing project_hash")
|
|
288
355
|
|
|
356
|
+
# Get user_id from websocket state (set during API key validation)
|
|
357
|
+
user_id = getattr(websocket.state, "user_id", None)
|
|
358
|
+
|
|
289
359
|
session_id = str(uuid.uuid4())
|
|
290
360
|
# Inform the plugin of its assigned session ID
|
|
291
361
|
response = RegisteredMessage(session_id=session_id)
|
|
292
362
|
await websocket.send_json(response.model_dump())
|
|
293
363
|
|
|
294
|
-
session = await registry.register(session_id, project_name, project_hash, unity_version, project_path)
|
|
364
|
+
session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id)
|
|
295
365
|
async with lock:
|
|
296
366
|
cls._connections[session.session_id] = websocket
|
|
297
|
-
|
|
367
|
+
|
|
368
|
+
if user_id:
|
|
369
|
+
logger.info(
|
|
370
|
+
f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
|
|
371
|
+
else:
|
|
372
|
+
logger.info(f"Plugin registered: {project_name} ({project_hash})")
|
|
298
373
|
|
|
299
374
|
async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
|
|
300
375
|
cls = type(self)
|
|
@@ -375,13 +450,17 @@ class PluginHub(WebSocketEndpoint):
|
|
|
375
450
|
# Session resolution helpers
|
|
376
451
|
# ------------------------------------------------------------------
|
|
377
452
|
@classmethod
|
|
378
|
-
async def _resolve_session_id(cls, unity_instance: str | None) -> str:
|
|
453
|
+
async def _resolve_session_id(cls, unity_instance: str | None, user_id: str | None = None) -> str:
|
|
379
454
|
"""Resolve a project hash (Unity instance id) to an active plugin session.
|
|
380
455
|
|
|
381
456
|
During Unity domain reloads the plugin's WebSocket session is torn down
|
|
382
457
|
and reconnected shortly afterwards. Instead of failing immediately when
|
|
383
458
|
no sessions are available, we wait for a bounded period for a plugin
|
|
384
459
|
to reconnect so in-flight MCP calls can succeed transparently.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
unity_instance: Target instance (Name@hash or hash)
|
|
463
|
+
user_id: User ID from API key validation (for remote-hosted mode session isolation)
|
|
385
464
|
"""
|
|
386
465
|
if cls._registry is None:
|
|
387
466
|
raise RuntimeError("Plugin registry not configured")
|
|
@@ -411,24 +490,35 @@ class PluginHub(WebSocketEndpoint):
|
|
|
411
490
|
else:
|
|
412
491
|
target_hash = unity_instance
|
|
413
492
|
|
|
414
|
-
async def _try_once() -> tuple[str | None, int]:
|
|
493
|
+
async def _try_once() -> tuple[str | None, int, bool]:
|
|
494
|
+
explicit_required = config.http_remote_hosted
|
|
415
495
|
# Prefer a specific Unity instance if one was requested
|
|
416
496
|
if target_hash:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
497
|
+
# In remote-hosted mode with user_id, use user-scoped lookup
|
|
498
|
+
if config.http_remote_hosted and user_id:
|
|
499
|
+
session_id = await cls._registry.get_session_id_by_hash(target_hash, user_id)
|
|
500
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
501
|
+
else:
|
|
502
|
+
session_id = await cls._registry.get_session_id_by_hash(target_hash)
|
|
503
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
504
|
+
return session_id, len(sessions), explicit_required
|
|
420
505
|
|
|
421
506
|
# No target provided: determine if we can auto-select
|
|
422
|
-
sessions
|
|
507
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
508
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
423
509
|
count = len(sessions)
|
|
424
510
|
if count == 0:
|
|
425
|
-
return None, count
|
|
511
|
+
return None, count, explicit_required
|
|
512
|
+
if explicit_required:
|
|
513
|
+
return None, count, explicit_required
|
|
426
514
|
if count == 1:
|
|
427
|
-
return next(iter(sessions.keys())), count
|
|
515
|
+
return next(iter(sessions.keys())), count, explicit_required
|
|
428
516
|
# Multiple sessions but no explicit target is ambiguous
|
|
429
|
-
return None, count
|
|
517
|
+
return None, count, explicit_required
|
|
430
518
|
|
|
431
|
-
session_id, session_count = await _try_once()
|
|
519
|
+
session_id, session_count, explicit_required = await _try_once()
|
|
520
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
521
|
+
raise InstanceSelectionRequiredError()
|
|
432
522
|
deadline = time.monotonic() + max_wait_s
|
|
433
523
|
wait_started = None
|
|
434
524
|
|
|
@@ -436,10 +526,10 @@ class PluginHub(WebSocketEndpoint):
|
|
|
436
526
|
# wait politely for a session to appear before surfacing an error.
|
|
437
527
|
while session_id is None and time.monotonic() < deadline:
|
|
438
528
|
if not target_hash and session_count > 1:
|
|
439
|
-
raise
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
)
|
|
529
|
+
raise InstanceSelectionRequiredError(
|
|
530
|
+
InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
|
|
531
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
532
|
+
raise InstanceSelectionRequiredError()
|
|
443
533
|
if wait_started is None:
|
|
444
534
|
wait_started = time.monotonic()
|
|
445
535
|
logger.debug(
|
|
@@ -448,7 +538,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
448
538
|
max_wait_s,
|
|
449
539
|
)
|
|
450
540
|
await asyncio.sleep(sleep_seconds)
|
|
451
|
-
session_id, session_count = await _try_once()
|
|
541
|
+
session_id, session_count, explicit_required = await _try_once()
|
|
452
542
|
|
|
453
543
|
if session_id is not None and wait_started is not None:
|
|
454
544
|
logger.debug(
|
|
@@ -457,10 +547,11 @@ class PluginHub(WebSocketEndpoint):
|
|
|
457
547
|
unity_instance or "default",
|
|
458
548
|
)
|
|
459
549
|
if session_id is None and not target_hash and session_count > 1:
|
|
460
|
-
raise
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
550
|
+
raise InstanceSelectionRequiredError(
|
|
551
|
+
InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
|
|
552
|
+
|
|
553
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
554
|
+
raise InstanceSelectionRequiredError()
|
|
464
555
|
|
|
465
556
|
if session_id is None:
|
|
466
557
|
logger.warning(
|
|
@@ -481,9 +572,18 @@ class PluginHub(WebSocketEndpoint):
|
|
|
481
572
|
unity_instance: str | None,
|
|
482
573
|
command_type: str,
|
|
483
574
|
params: dict[str, Any],
|
|
575
|
+
user_id: str | None = None,
|
|
484
576
|
) -> dict[str, Any]:
|
|
577
|
+
"""Send a command to a Unity instance.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
unity_instance: Target instance (Name@hash or hash)
|
|
581
|
+
command_type: Command type to execute
|
|
582
|
+
params: Command parameters
|
|
583
|
+
user_id: User ID for session isolation in remote-hosted mode
|
|
584
|
+
"""
|
|
485
585
|
try:
|
|
486
|
-
session_id = await cls._resolve_session_id(unity_instance)
|
|
586
|
+
session_id = await cls._resolve_session_id(unity_instance, user_id=user_id)
|
|
487
587
|
except NoUnitySessionError:
|
|
488
588
|
logger.debug(
|
|
489
589
|
"Unity session unavailable; returning retry: command=%s instance=%s",
|
transport/plugin_registry.py
CHANGED
|
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
|
|
10
|
+
from core.config import config
|
|
10
11
|
from models.models import ToolDefinitionModel
|
|
11
12
|
|
|
12
13
|
|
|
@@ -22,7 +23,9 @@ class PluginSession:
|
|
|
22
23
|
connected_at: datetime
|
|
23
24
|
tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
|
|
24
25
|
project_id: str | None = None
|
|
25
|
-
|
|
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)
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
class PluginRegistry:
|
|
@@ -31,11 +34,17 @@ class PluginRegistry:
|
|
|
31
34
|
The registry is optimised for quick lookup by either ``session_id`` or
|
|
32
35
|
``project_hash`` (which is used as the canonical "instance id" across the
|
|
33
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.
|
|
34
40
|
"""
|
|
35
41
|
|
|
36
42
|
def __init__(self) -> None:
|
|
37
43
|
self._sessions: dict[str, PluginSession] = {}
|
|
44
|
+
# In local mode: project_hash -> session_id
|
|
45
|
+
# In remote mode: (user_id, project_hash) -> session_id
|
|
38
46
|
self._hash_to_session: dict[str, str] = {}
|
|
47
|
+
self._user_hash_to_session: dict[tuple[str, str], str] = {}
|
|
39
48
|
self._lock = asyncio.Lock()
|
|
40
49
|
|
|
41
50
|
async def register(
|
|
@@ -45,13 +54,16 @@ class PluginRegistry:
|
|
|
45
54
|
project_hash: str,
|
|
46
55
|
unity_version: str,
|
|
47
56
|
project_path: str | None = None,
|
|
57
|
+
user_id: str | None = None,
|
|
48
58
|
) -> PluginSession:
|
|
49
59
|
"""Register (or replace) a plugin session.
|
|
50
60
|
|
|
51
|
-
If an existing session already claims the same ``project_hash``
|
|
52
|
-
replaced, ensuring that reconnect scenarios
|
|
53
|
-
WebSocket connection.
|
|
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.
|
|
54
64
|
"""
|
|
65
|
+
if config.http_remote_hosted and not user_id:
|
|
66
|
+
raise ValueError("user_id is required in remote-hosted mode")
|
|
55
67
|
|
|
56
68
|
async with self._lock:
|
|
57
69
|
now = datetime.now(timezone.utc)
|
|
@@ -63,15 +75,26 @@ class PluginRegistry:
|
|
|
63
75
|
registered_at=now,
|
|
64
76
|
connected_at=now,
|
|
65
77
|
project_path=project_path,
|
|
78
|
+
user_id=user_id,
|
|
66
79
|
)
|
|
67
80
|
|
|
68
81
|
# Remove old mapping for this hash if it existed under a different session
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
72
96
|
|
|
73
97
|
self._sessions[session_id] = session
|
|
74
|
-
self._hash_to_session[project_hash] = session_id
|
|
75
98
|
return session
|
|
76
99
|
|
|
77
100
|
async def touch(self, session_id: str) -> None:
|
|
@@ -87,11 +110,20 @@ class PluginRegistry:
|
|
|
87
110
|
|
|
88
111
|
async with self._lock:
|
|
89
112
|
session = self._sessions.pop(session_id, None)
|
|
90
|
-
if session
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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]
|
|
95
127
|
|
|
96
128
|
async def register_tools_for_session(self, session_id: str, tools: list[ToolDefinitionModel]) -> None:
|
|
97
129
|
"""Register tools for a specific session."""
|
|
@@ -110,17 +142,41 @@ class PluginRegistry:
|
|
|
110
142
|
async with self._lock:
|
|
111
143
|
return self._sessions.get(session_id)
|
|
112
144
|
|
|
113
|
-
async def get_session_id_by_hash(self, project_hash: str) -> str | None:
|
|
145
|
+
async def get_session_id_by_hash(self, project_hash: str, user_id: str | None = None) -> str | None:
|
|
114
146
|
"""Resolve a ``project_hash`` (Unity instance id) to a session id."""
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
118
157
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
)
|
|
121
170
|
|
|
122
171
|
async with self._lock:
|
|
123
|
-
|
|
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
|
+
}
|
|
124
180
|
|
|
125
181
|
|
|
126
182
|
__all__ = ["PluginRegistry", "PluginSession"]
|