mcpforunityserver 8.7.0__py3-none-any.whl → 9.1.0__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/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +177 -62
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
- services/custom_tool_service.py +179 -19
- services/resources/__init__.py +6 -1
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +2 -2
- services/resources/editor_state.py +283 -30
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +1 -1
- services/resources/prefab_stage.py +1 -1
- services/resources/project_info.py +1 -1
- services/resources/selection.py +1 -1
- services/resources/tags.py +1 -1
- services/resources/unity_instances.py +1 -1
- services/resources/windows.py +1 -1
- services/state/external_changes_scanner.py +3 -4
- services/tools/__init__.py +6 -1
- services/tools/batch_execute.py +24 -9
- services/tools/debug_request_context.py +8 -2
- services/tools/execute_custom_tool.py +6 -1
- services/tools/execute_menu_item.py +6 -3
- services/tools/find_gameobjects.py +89 -0
- services/tools/find_in_file.py +26 -19
- services/tools/manage_asset.py +13 -44
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +115 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +30 -13
- services/tools/manage_script.py +62 -19
- services/tools/manage_scriptable_object.py +22 -10
- services/tools/manage_shader.py +8 -1
- services/tools/manage_vfx.py +738 -0
- services/tools/preflight.py +15 -12
- services/tools/read_console.py +70 -17
- services/tools/refresh_unity.py +92 -29
- services/tools/run_tests.py +187 -53
- services/tools/script_apply_edits.py +15 -7
- services/tools/set_active_instance.py +12 -7
- services/tools/utils.py +60 -6
- transport/legacy/port_discovery.py +2 -2
- transport/legacy/unity_connection.py +129 -26
- transport/plugin_hub.py +85 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
- routes/__init__.py +0 -0
- services/resources/editor_state_v2.py +0 -270
- services/tools/test_jobs.py +0 -94
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
services/custom_tool_service.py
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
3
4
|
import time
|
|
4
5
|
from hashlib import sha256
|
|
5
6
|
from typing import Optional
|
|
6
7
|
|
|
7
|
-
from fastmcp import FastMCP
|
|
8
|
+
from fastmcp import Context, FastMCP
|
|
8
9
|
from pydantic import BaseModel, Field, ValidationError
|
|
9
10
|
from starlette.requests import Request
|
|
10
11
|
from starlette.responses import JSONResponse
|
|
11
12
|
|
|
12
13
|
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
|
|
14
|
+
from core.logging_decorator import log_execution
|
|
15
|
+
from core.telemetry_decorator import telemetry_tool
|
|
13
16
|
from transport.unity_transport import send_with_unity_instance
|
|
14
17
|
from transport.legacy.unity_connection import (
|
|
15
18
|
async_send_command_with_retry,
|
|
16
19
|
get_unity_connection_pool,
|
|
17
20
|
)
|
|
18
21
|
from transport.plugin_hub import PluginHub
|
|
22
|
+
from services.tools import get_unity_instance_from_context
|
|
23
|
+
from services.registry import get_registered_tools
|
|
19
24
|
|
|
20
25
|
logger = logging.getLogger("mcp-for-unity-server")
|
|
21
26
|
|
|
@@ -39,11 +44,13 @@ class ToolRegistrationResponse(BaseModel):
|
|
|
39
44
|
class CustomToolService:
|
|
40
45
|
_instance: "CustomToolService | None" = None
|
|
41
46
|
|
|
42
|
-
def __init__(self, mcp: FastMCP):
|
|
47
|
+
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
|
|
43
48
|
CustomToolService._instance = self
|
|
44
49
|
self._mcp = mcp
|
|
50
|
+
self._project_scoped_tools = project_scoped_tools
|
|
45
51
|
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
|
|
46
52
|
self._hash_to_project: dict[str, str] = {}
|
|
53
|
+
self._global_tools: dict[str, ToolDefinitionModel] = {}
|
|
47
54
|
self._register_http_routes()
|
|
48
55
|
|
|
49
56
|
@classmethod
|
|
@@ -61,17 +68,8 @@ class CustomToolService:
|
|
|
61
68
|
except ValidationError as exc:
|
|
62
69
|
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
|
|
63
70
|
|
|
64
|
-
registered
|
|
65
|
-
|
|
66
|
-
for tool in payload.tools:
|
|
67
|
-
if self._is_registered(payload.project_id, tool.name):
|
|
68
|
-
replaced.append(tool.name)
|
|
69
|
-
self._register_tool(payload.project_id, tool)
|
|
70
|
-
registered.append(tool.name)
|
|
71
|
-
|
|
72
|
-
if payload.project_hash:
|
|
73
|
-
self._hash_to_project[payload.project_hash.lower(
|
|
74
|
-
)] = payload.project_id
|
|
71
|
+
registered, replaced = self._register_project_tools(
|
|
72
|
+
payload.project_id, payload.tools, project_hash=payload.project_hash)
|
|
75
73
|
|
|
76
74
|
message = f"Registered {len(registered)} tool(s)"
|
|
77
75
|
if replaced:
|
|
@@ -266,15 +264,171 @@ class CustomToolService:
|
|
|
266
264
|
return None
|
|
267
265
|
return {"message": str(response)}
|
|
268
266
|
|
|
269
|
-
def
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
def _register_project_tools(
|
|
268
|
+
self,
|
|
269
|
+
project_id: str,
|
|
270
|
+
tools: list[ToolDefinitionModel],
|
|
271
|
+
project_hash: str | None = None,
|
|
272
|
+
) -> tuple[list[str], list[str]]:
|
|
273
|
+
registered: list[str] = []
|
|
274
|
+
replaced: list[str] = []
|
|
275
|
+
for tool in tools:
|
|
276
|
+
if self._is_registered(project_id, tool.name):
|
|
277
|
+
replaced.append(tool.name)
|
|
278
|
+
self._register_tool(project_id, tool)
|
|
279
|
+
registered.append(tool.name)
|
|
280
|
+
if not self._project_scoped_tools:
|
|
281
|
+
self._register_global_tool(tool)
|
|
282
|
+
|
|
283
|
+
if project_hash:
|
|
284
|
+
self._hash_to_project[project_hash.lower()] = project_id
|
|
285
|
+
|
|
286
|
+
return registered, replaced
|
|
287
|
+
|
|
288
|
+
def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
|
|
289
|
+
if self._project_scoped_tools:
|
|
290
|
+
return
|
|
291
|
+
builtin_names = self._get_builtin_tool_names()
|
|
292
|
+
for tool in tools:
|
|
293
|
+
if tool.name in builtin_names:
|
|
294
|
+
logger.info(
|
|
295
|
+
"Skipping global custom tool registration for built-in tool '%s'",
|
|
296
|
+
tool.name,
|
|
297
|
+
)
|
|
298
|
+
continue
|
|
299
|
+
self._register_global_tool(tool)
|
|
300
|
+
|
|
301
|
+
def _get_builtin_tool_names(self) -> set[str]:
|
|
302
|
+
return {tool["name"] for tool in get_registered_tools()}
|
|
303
|
+
|
|
304
|
+
def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
|
|
305
|
+
existing = self._global_tools.get(definition.name)
|
|
306
|
+
if existing:
|
|
307
|
+
if existing.model_dump() != definition.model_dump():
|
|
308
|
+
logger.warning(
|
|
309
|
+
"Custom tool '%s' already registered with a different schema; keeping existing definition.",
|
|
310
|
+
definition.name,
|
|
311
|
+
)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
handler = self._build_global_tool_handler(definition)
|
|
315
|
+
wrapped = log_execution(definition.name, "Tool")(handler)
|
|
316
|
+
wrapped = telemetry_tool(definition.name)(wrapped)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
wrapped = self._mcp.tool(
|
|
320
|
+
name=definition.name,
|
|
321
|
+
description=definition.description,
|
|
322
|
+
)(wrapped)
|
|
323
|
+
except Exception as exc: # pragma: no cover - defensive against tool conflicts
|
|
324
|
+
logger.warning(
|
|
325
|
+
"Failed to register custom tool '%s' globally: %s",
|
|
326
|
+
definition.name,
|
|
327
|
+
exc,
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
self._global_tools[definition.name] = definition
|
|
332
|
+
|
|
333
|
+
def _build_global_tool_handler(self, definition: ToolDefinitionModel):
|
|
334
|
+
async def _handler(ctx: Context, **kwargs) -> MCPResponse:
|
|
335
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
336
|
+
if not unity_instance:
|
|
337
|
+
return MCPResponse(
|
|
338
|
+
success=False,
|
|
339
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
343
|
+
if project_id is None:
|
|
344
|
+
return MCPResponse(
|
|
345
|
+
success=False,
|
|
346
|
+
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
params = {k: v for k, v in kwargs.items() if v is not None}
|
|
350
|
+
service = CustomToolService.get_instance()
|
|
351
|
+
return await service.execute_tool(project_id, definition.name, unity_instance, params)
|
|
352
|
+
|
|
353
|
+
_handler.__name__ = f"custom_tool_{definition.name}"
|
|
354
|
+
_handler.__doc__ = definition.description or ""
|
|
355
|
+
_handler.__signature__ = self._build_signature(definition)
|
|
356
|
+
_handler.__annotations__ = self._build_annotations(definition)
|
|
357
|
+
return _handler
|
|
358
|
+
|
|
359
|
+
def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
|
|
360
|
+
params: list[inspect.Parameter] = [
|
|
361
|
+
inspect.Parameter(
|
|
362
|
+
"ctx",
|
|
363
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
364
|
+
annotation=Context,
|
|
365
|
+
)
|
|
366
|
+
]
|
|
367
|
+
for param in definition.parameters:
|
|
368
|
+
if not param.name.isidentifier():
|
|
369
|
+
logger.warning(
|
|
370
|
+
"Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
|
|
371
|
+
definition.name,
|
|
372
|
+
param.name,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
default = inspect._empty if param.required else self._coerce_default(
|
|
376
|
+
param.default_value, param.type)
|
|
377
|
+
params.append(
|
|
378
|
+
inspect.Parameter(
|
|
379
|
+
param.name,
|
|
380
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
381
|
+
default=default,
|
|
382
|
+
annotation=self._map_param_type(param),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return inspect.Signature(parameters=params)
|
|
386
|
+
|
|
387
|
+
def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
|
|
388
|
+
annotations: dict[str, object] = {"ctx": Context}
|
|
389
|
+
for param in definition.parameters:
|
|
390
|
+
if not param.name.isidentifier():
|
|
391
|
+
continue
|
|
392
|
+
annotations[param.name] = self._map_param_type(param)
|
|
393
|
+
return annotations
|
|
394
|
+
|
|
395
|
+
def _map_param_type(self, param: ToolParameterModel):
|
|
396
|
+
ptype = (param.type or "string").lower()
|
|
397
|
+
if ptype in ("integer", "int"):
|
|
398
|
+
return int
|
|
399
|
+
if ptype in ("number", "float", "double"):
|
|
400
|
+
return float
|
|
401
|
+
if ptype in ("bool", "boolean"):
|
|
402
|
+
return bool
|
|
403
|
+
if ptype in ("array", "list"):
|
|
404
|
+
return list
|
|
405
|
+
if ptype in ("object", "dict"):
|
|
406
|
+
return dict
|
|
407
|
+
return str
|
|
408
|
+
|
|
409
|
+
def _coerce_default(self, value: str | None, param_type: str | None):
|
|
410
|
+
if value is None:
|
|
273
411
|
return None
|
|
274
|
-
|
|
412
|
+
try:
|
|
413
|
+
ptype = (param_type or "string").lower()
|
|
414
|
+
if ptype in ("integer", "int"):
|
|
415
|
+
return int(value)
|
|
416
|
+
if ptype in ("number", "float", "double"):
|
|
417
|
+
return float(value)
|
|
418
|
+
if ptype in ("bool", "boolean"):
|
|
419
|
+
return str(value).lower() in ("1", "true", "yes", "on")
|
|
420
|
+
return value
|
|
421
|
+
except Exception:
|
|
422
|
+
return value
|
|
275
423
|
|
|
276
424
|
|
|
277
425
|
def compute_project_id(project_name: str, project_path: str) -> str:
|
|
426
|
+
"""
|
|
427
|
+
DEPRECATED: Computes a SHA256-based project ID.
|
|
428
|
+
This function is no longer used as of the multi-session fix.
|
|
429
|
+
Unity instances now use their native project_hash (SHA1-based) for consistency
|
|
430
|
+
across stdio and WebSocket transports.
|
|
431
|
+
"""
|
|
278
432
|
combined = f"{project_name}:{project_path}"
|
|
279
433
|
return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
|
|
280
434
|
|
|
@@ -307,7 +461,13 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
|
|
|
307
461
|
)
|
|
308
462
|
|
|
309
463
|
if target:
|
|
310
|
-
|
|
464
|
+
# Return the project_hash from Unity (not a computed SHA256 hash).
|
|
465
|
+
# This matches the hash Unity uses when registering tools via WebSocket.
|
|
466
|
+
if target.hash:
|
|
467
|
+
return target.hash
|
|
468
|
+
logger.warning(
|
|
469
|
+
f"Unity instance {target.id} has empty hash; cannot resolve project ID")
|
|
470
|
+
return None
|
|
311
471
|
except Exception:
|
|
312
472
|
logger.debug(
|
|
313
473
|
f"Failed to resolve project id via connection pool for {unity_instance}")
|
services/resources/__init__.py
CHANGED
|
@@ -18,7 +18,7 @@ logger = logging.getLogger("mcp-for-unity-server")
|
|
|
18
18
|
__all__ = ['register_all_resources']
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def register_all_resources(mcp: FastMCP):
|
|
21
|
+
def register_all_resources(mcp: FastMCP, *, project_scoped_tools: bool = True):
|
|
22
22
|
"""
|
|
23
23
|
Auto-discover and register all resources in the resources/ directory.
|
|
24
24
|
|
|
@@ -46,6 +46,11 @@ def register_all_resources(mcp: FastMCP):
|
|
|
46
46
|
description = resource_info['description']
|
|
47
47
|
kwargs = resource_info['kwargs']
|
|
48
48
|
|
|
49
|
+
if not project_scoped_tools and resource_name == "custom_tools":
|
|
50
|
+
logger.info(
|
|
51
|
+
"Skipping custom_tools resource registration (project-scoped tools disabled)")
|
|
52
|
+
continue
|
|
53
|
+
|
|
49
54
|
# Check if URI contains query parameters (e.g., {?unity_instance})
|
|
50
55
|
has_query_params = '{?' in uri
|
|
51
56
|
|
|
@@ -31,7 +31,7 @@ class ActiveToolResponse(MCPResponse):
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
@mcp_for_unity_resource(
|
|
34
|
-
uri="
|
|
34
|
+
uri="mcpforunity://editor/active-tool",
|
|
35
35
|
name="editor_active_tool",
|
|
36
36
|
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings."
|
|
37
37
|
)
|
|
@@ -22,7 +22,7 @@ class CustomToolsResourceResponse(MCPResponse):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@mcp_for_unity_resource(
|
|
25
|
-
uri="
|
|
25
|
+
uri="mcpforunity://custom-tools",
|
|
26
26
|
name="custom_tools",
|
|
27
27
|
description="Lists custom tools available for the active Unity project.",
|
|
28
28
|
)
|
|
@@ -31,7 +31,7 @@ async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPRes
|
|
|
31
31
|
if not unity_instance:
|
|
32
32
|
return MCPResponse(
|
|
33
33
|
success=False,
|
|
34
|
-
message="No active Unity instance. Call set_active_instance with Name@hash from
|
|
34
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
@@ -1,51 +1,304 @@
|
|
|
1
|
-
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
2
5
|
from fastmcp import Context
|
|
6
|
+
from pydantic import BaseModel
|
|
3
7
|
|
|
4
8
|
from models import MCPResponse
|
|
5
9
|
from services.registry import mcp_for_unity_resource
|
|
6
10
|
from services.tools import get_unity_instance_from_context
|
|
7
|
-
from
|
|
11
|
+
from services.state.external_changes_scanner import external_changes_scanner
|
|
12
|
+
import transport.unity_transport as unity_transport
|
|
8
13
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
14
|
|
|
10
15
|
|
|
16
|
+
class EditorStateUnity(BaseModel):
|
|
17
|
+
instance_id: str | None = None
|
|
18
|
+
unity_version: str | None = None
|
|
19
|
+
project_id: str | None = None
|
|
20
|
+
platform: str | None = None
|
|
21
|
+
is_batch_mode: bool | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EditorStatePlayMode(BaseModel):
|
|
25
|
+
is_playing: bool | None = None
|
|
26
|
+
is_paused: bool | None = None
|
|
27
|
+
is_changing: bool | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EditorStateActiveScene(BaseModel):
|
|
31
|
+
path: str | None = None
|
|
32
|
+
guid: str | None = None
|
|
33
|
+
name: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EditorStateEditor(BaseModel):
|
|
37
|
+
is_focused: bool | None = None
|
|
38
|
+
play_mode: EditorStatePlayMode | None = None
|
|
39
|
+
active_scene: EditorStateActiveScene | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EditorStateActivity(BaseModel):
|
|
43
|
+
phase: str | None = None
|
|
44
|
+
since_unix_ms: int | None = None
|
|
45
|
+
reasons: list[str] | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EditorStateCompilation(BaseModel):
|
|
49
|
+
is_compiling: bool | None = None
|
|
50
|
+
is_domain_reload_pending: bool | None = None
|
|
51
|
+
last_compile_started_unix_ms: int | None = None
|
|
52
|
+
last_compile_finished_unix_ms: int | None = None
|
|
53
|
+
last_domain_reload_before_unix_ms: int | None = None
|
|
54
|
+
last_domain_reload_after_unix_ms: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EditorStateRefresh(BaseModel):
|
|
58
|
+
is_refresh_in_progress: bool | None = None
|
|
59
|
+
last_refresh_requested_unix_ms: int | None = None
|
|
60
|
+
last_refresh_finished_unix_ms: int | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EditorStateAssets(BaseModel):
|
|
64
|
+
is_updating: bool | None = None
|
|
65
|
+
external_changes_dirty: bool | None = None
|
|
66
|
+
external_changes_last_seen_unix_ms: int | None = None
|
|
67
|
+
external_changes_dirty_since_unix_ms: int | None = None
|
|
68
|
+
external_changes_last_cleared_unix_ms: int | None = None
|
|
69
|
+
refresh: EditorStateRefresh | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EditorStateLastRun(BaseModel):
|
|
73
|
+
finished_unix_ms: int | None = None
|
|
74
|
+
result: str | None = None
|
|
75
|
+
counts: Any | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EditorStateTests(BaseModel):
|
|
79
|
+
is_running: bool | None = None
|
|
80
|
+
mode: str | None = None
|
|
81
|
+
current_job_id: str | None = None
|
|
82
|
+
started_unix_ms: int | None = None
|
|
83
|
+
started_by: str | None = None
|
|
84
|
+
last_run: EditorStateLastRun | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EditorStateTransport(BaseModel):
|
|
88
|
+
unity_bridge_connected: bool | None = None
|
|
89
|
+
last_message_unix_ms: int | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EditorStateAdvice(BaseModel):
|
|
93
|
+
ready_for_tools: bool | None = None
|
|
94
|
+
blocking_reasons: list[str] | None = None
|
|
95
|
+
recommended_retry_after_ms: int | None = None
|
|
96
|
+
recommended_next_action: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class EditorStateStaleness(BaseModel):
|
|
100
|
+
age_ms: int | None = None
|
|
101
|
+
is_stale: bool | None = None
|
|
102
|
+
|
|
103
|
+
|
|
11
104
|
class EditorStateData(BaseModel):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
105
|
+
schema_version: str
|
|
106
|
+
observed_at_unix_ms: int
|
|
107
|
+
sequence: int
|
|
108
|
+
unity: EditorStateUnity | None = None
|
|
109
|
+
editor: EditorStateEditor | None = None
|
|
110
|
+
activity: EditorStateActivity | None = None
|
|
111
|
+
compilation: EditorStateCompilation | None = None
|
|
112
|
+
assets: EditorStateAssets | None = None
|
|
113
|
+
tests: EditorStateTests | None = None
|
|
114
|
+
transport: EditorStateTransport | None = None
|
|
115
|
+
advice: EditorStateAdvice | None = None
|
|
116
|
+
staleness: EditorStateStaleness | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _now_unix_ms() -> int:
|
|
120
|
+
return int(time.time() * 1000)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _in_pytest() -> bool:
|
|
124
|
+
# Avoid instance-discovery side effects during the Python integration test suite.
|
|
125
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def infer_single_instance_id(ctx: Context) -> str | None:
|
|
129
|
+
"""
|
|
130
|
+
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
|
|
131
|
+
This makes editor_state outputs self-describing even when no explicit active instance is set.
|
|
132
|
+
"""
|
|
133
|
+
await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
transport = unity_transport._current_transport()
|
|
137
|
+
except Exception:
|
|
138
|
+
transport = None
|
|
139
|
+
|
|
140
|
+
if transport == "http":
|
|
141
|
+
# HTTP/WebSocket transport: derive from PluginHub sessions.
|
|
142
|
+
try:
|
|
143
|
+
from transport.plugin_hub import PluginHub
|
|
144
|
+
|
|
145
|
+
sessions_data = await PluginHub.get_sessions()
|
|
146
|
+
sessions = sessions_data.sessions if hasattr(
|
|
147
|
+
sessions_data, "sessions") else {}
|
|
148
|
+
if isinstance(sessions, dict) and len(sessions) == 1:
|
|
149
|
+
session = next(iter(sessions.values()))
|
|
150
|
+
project = getattr(session, "project", None)
|
|
151
|
+
project_hash = getattr(session, "hash", None)
|
|
152
|
+
if project and project_hash:
|
|
153
|
+
return f"{project}@{project_hash}"
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Stdio/TCP transport: derive from connection pool discovery.
|
|
159
|
+
try:
|
|
160
|
+
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
161
|
+
|
|
162
|
+
pool = get_unity_connection_pool()
|
|
163
|
+
instances = pool.discover_all_instances(force_refresh=False)
|
|
164
|
+
if isinstance(instances, list) and len(instances) == 1:
|
|
165
|
+
inst = instances[0]
|
|
166
|
+
inst_id = getattr(inst, "id", None)
|
|
167
|
+
return str(inst_id) if inst_id else None
|
|
168
|
+
except Exception:
|
|
169
|
+
return None
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
|
|
174
|
+
now_ms = _now_unix_ms()
|
|
175
|
+
observed = state_v2.get("observed_at_unix_ms")
|
|
176
|
+
try:
|
|
177
|
+
observed_ms = int(observed)
|
|
178
|
+
except Exception:
|
|
179
|
+
observed_ms = now_ms
|
|
21
180
|
|
|
181
|
+
age_ms = max(0, now_ms - observed_ms)
|
|
182
|
+
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
|
|
183
|
+
is_stale = age_ms > 2000
|
|
22
184
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
185
|
+
compilation = state_v2.get("compilation") or {}
|
|
186
|
+
tests = state_v2.get("tests") or {}
|
|
187
|
+
assets = state_v2.get("assets") or {}
|
|
188
|
+
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
|
|
189
|
+
|
|
190
|
+
blocking: list[str] = []
|
|
191
|
+
if compilation.get("is_compiling") is True:
|
|
192
|
+
blocking.append("compiling")
|
|
193
|
+
if compilation.get("is_domain_reload_pending") is True:
|
|
194
|
+
blocking.append("domain_reload")
|
|
195
|
+
if tests.get("is_running") is True:
|
|
196
|
+
blocking.append("running_tests")
|
|
197
|
+
if refresh.get("is_refresh_in_progress") is True:
|
|
198
|
+
blocking.append("asset_refresh")
|
|
199
|
+
if is_stale:
|
|
200
|
+
blocking.append("stale_status")
|
|
201
|
+
|
|
202
|
+
ready_for_tools = len(blocking) == 0
|
|
203
|
+
|
|
204
|
+
state_v2["advice"] = {
|
|
205
|
+
"ready_for_tools": ready_for_tools,
|
|
206
|
+
"blocking_reasons": blocking,
|
|
207
|
+
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
|
|
208
|
+
"recommended_next_action": "none" if ready_for_tools else "retry_later",
|
|
209
|
+
}
|
|
210
|
+
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
|
|
211
|
+
return state_v2
|
|
26
212
|
|
|
27
213
|
|
|
28
214
|
@mcp_for_unity_resource(
|
|
29
|
-
uri="
|
|
215
|
+
uri="mcpforunity://editor/state",
|
|
30
216
|
name="editor_state",
|
|
31
|
-
description="
|
|
217
|
+
description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.",
|
|
32
218
|
)
|
|
33
|
-
async def get_editor_state(ctx: Context) ->
|
|
34
|
-
"""Get current editor runtime state."""
|
|
219
|
+
async def get_editor_state(ctx: Context) -> MCPResponse:
|
|
35
220
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
36
|
-
|
|
221
|
+
|
|
222
|
+
response = await unity_transport.send_with_unity_instance(
|
|
37
223
|
async_send_command_with_retry,
|
|
38
224
|
unity_instance,
|
|
39
225
|
"get_editor_state",
|
|
40
|
-
{}
|
|
226
|
+
{},
|
|
41
227
|
)
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
228
|
+
|
|
229
|
+
# If Unity returns a structured retry hint or error, surface it directly.
|
|
230
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
231
|
+
return MCPResponse(**response)
|
|
232
|
+
|
|
233
|
+
state_v2 = response.get("data") if isinstance(
|
|
234
|
+
response, dict) and isinstance(response.get("data"), dict) else {}
|
|
235
|
+
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
|
|
236
|
+
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
|
|
237
|
+
state_v2.setdefault("sequence", 0)
|
|
238
|
+
|
|
239
|
+
# Ensure the returned snapshot is clearly associated with the targeted instance.
|
|
240
|
+
unity_section = state_v2.get("unity")
|
|
241
|
+
if not isinstance(unity_section, dict):
|
|
242
|
+
unity_section = {}
|
|
243
|
+
state_v2["unity"] = unity_section
|
|
244
|
+
current_instance_id = unity_section.get("instance_id")
|
|
245
|
+
if current_instance_id in (None, ""):
|
|
246
|
+
if unity_instance:
|
|
247
|
+
unity_section["instance_id"] = unity_instance
|
|
248
|
+
else:
|
|
249
|
+
inferred = await infer_single_instance_id(ctx)
|
|
250
|
+
if inferred:
|
|
251
|
+
unity_section["instance_id"] = inferred
|
|
252
|
+
|
|
253
|
+
# External change detection (server-side): compute per instance based on project root path.
|
|
254
|
+
try:
|
|
255
|
+
instance_id = unity_section.get("instance_id")
|
|
256
|
+
if isinstance(instance_id, str) and instance_id.strip():
|
|
257
|
+
from services.resources.project_info import get_project_info
|
|
258
|
+
|
|
259
|
+
proj_resp = await get_project_info(ctx)
|
|
260
|
+
proj = proj_resp.model_dump() if hasattr(
|
|
261
|
+
proj_resp, "model_dump") else proj_resp
|
|
262
|
+
proj_data = proj.get("data") if isinstance(proj, dict) else None
|
|
263
|
+
project_root = proj_data.get("projectRoot") if isinstance(
|
|
264
|
+
proj_data, dict) else None
|
|
265
|
+
if isinstance(project_root, str) and project_root.strip():
|
|
266
|
+
external_changes_scanner.set_project_root(
|
|
267
|
+
instance_id, project_root)
|
|
268
|
+
|
|
269
|
+
ext = external_changes_scanner.update_and_get(instance_id)
|
|
270
|
+
|
|
271
|
+
assets = state_v2.get("assets")
|
|
272
|
+
if not isinstance(assets, dict):
|
|
273
|
+
assets = {}
|
|
274
|
+
state_v2["assets"] = assets
|
|
275
|
+
assets["external_changes_dirty"] = bool(
|
|
276
|
+
ext.get("external_changes_dirty", False))
|
|
277
|
+
assets["external_changes_last_seen_unix_ms"] = ext.get(
|
|
278
|
+
"external_changes_last_seen_unix_ms")
|
|
279
|
+
assets["external_changes_dirty_since_unix_ms"] = ext.get(
|
|
280
|
+
"dirty_since_unix_ms")
|
|
281
|
+
assets["external_changes_last_cleared_unix_ms"] = ext.get(
|
|
282
|
+
"last_cleared_unix_ms")
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
state_v2 = _enrich_advice_and_staleness(state_v2)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
if hasattr(EditorStateData, "model_validate"):
|
|
290
|
+
validated = EditorStateData.model_validate(state_v2)
|
|
291
|
+
else:
|
|
292
|
+
validated = EditorStateData.parse_obj(
|
|
293
|
+
state_v2) # type: ignore[attr-defined]
|
|
294
|
+
data = validated.model_dump() if hasattr(
|
|
295
|
+
validated, "model_dump") else validated.dict()
|
|
296
|
+
except Exception as e:
|
|
297
|
+
return MCPResponse(
|
|
298
|
+
success=False,
|
|
299
|
+
error="invalid_editor_state",
|
|
300
|
+
message=f"Editor state payload failed validation: {e}",
|
|
301
|
+
data={"raw": state_v2},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return MCPResponse(success=True, message="Retrieved editor state.", data=data)
|