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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Resources package - Auto-discovers and registers all resources in this directory.
|
|
3
|
+
"""
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from core.telemetry_decorator import telemetry_resource
|
|
10
|
+
from core.logging_decorator import log_execution
|
|
11
|
+
|
|
12
|
+
from services.registry import get_registered_resources
|
|
13
|
+
from utils.module_discovery import discover_modules
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
16
|
+
|
|
17
|
+
# Export decorator for easy imports within tools
|
|
18
|
+
__all__ = ['register_all_resources']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_all_resources(mcp: FastMCP, *, project_scoped_tools: bool = True):
|
|
22
|
+
"""
|
|
23
|
+
Auto-discover and register all resources in the resources/ directory.
|
|
24
|
+
|
|
25
|
+
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
|
|
26
|
+
functions will be automatically registered.
|
|
27
|
+
"""
|
|
28
|
+
logger.info("Auto-discovering MCP for Unity Server resources...")
|
|
29
|
+
# Dynamic import of all modules in this directory
|
|
30
|
+
resources_dir = Path(__file__).parent
|
|
31
|
+
|
|
32
|
+
# Discover and import all modules
|
|
33
|
+
list(discover_modules(resources_dir, __package__))
|
|
34
|
+
|
|
35
|
+
resources = get_registered_resources()
|
|
36
|
+
|
|
37
|
+
if not resources:
|
|
38
|
+
logger.warning("No MCP resources registered!")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
registered_count = 0
|
|
42
|
+
for resource_info in resources:
|
|
43
|
+
func = resource_info['func']
|
|
44
|
+
uri = resource_info['uri']
|
|
45
|
+
resource_name = resource_info['name']
|
|
46
|
+
description = resource_info['description']
|
|
47
|
+
kwargs = resource_info['kwargs']
|
|
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
|
+
|
|
54
|
+
# Check if URI contains query parameters (e.g., {?unity_instance})
|
|
55
|
+
has_query_params = '{?' in uri
|
|
56
|
+
|
|
57
|
+
if has_query_params:
|
|
58
|
+
wrapped_template = log_execution(resource_name, "Resource")(func)
|
|
59
|
+
wrapped_template = telemetry_resource(
|
|
60
|
+
resource_name)(wrapped_template)
|
|
61
|
+
wrapped_template = mcp.resource(
|
|
62
|
+
uri=uri,
|
|
63
|
+
name=resource_name,
|
|
64
|
+
description=description,
|
|
65
|
+
**kwargs,
|
|
66
|
+
)(wrapped_template)
|
|
67
|
+
logger.debug(
|
|
68
|
+
f"Registered resource template: {resource_name} - {uri}")
|
|
69
|
+
registered_count += 1
|
|
70
|
+
resource_info['func'] = wrapped_template
|
|
71
|
+
else:
|
|
72
|
+
wrapped = log_execution(resource_name, "Resource")(func)
|
|
73
|
+
wrapped = telemetry_resource(resource_name)(wrapped)
|
|
74
|
+
wrapped = mcp.resource(
|
|
75
|
+
uri=uri,
|
|
76
|
+
name=resource_name,
|
|
77
|
+
description=description,
|
|
78
|
+
**kwargs,
|
|
79
|
+
)(wrapped)
|
|
80
|
+
resource_info['func'] = wrapped
|
|
81
|
+
logger.debug(
|
|
82
|
+
f"Registered resource: {resource_name} - {description}")
|
|
83
|
+
registered_count += 1
|
|
84
|
+
|
|
85
|
+
logger.info(
|
|
86
|
+
f"Registered {registered_count} MCP resources ({len(resources)} unique)")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from models.unity_response import parse_resource_response
|
|
6
|
+
from services.registry import mcp_for_unity_resource
|
|
7
|
+
from services.tools import get_unity_instance_from_context
|
|
8
|
+
from transport.unity_transport import send_with_unity_instance
|
|
9
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Vector3(BaseModel):
|
|
13
|
+
"""3D vector."""
|
|
14
|
+
x: float = 0.0
|
|
15
|
+
y: float = 0.0
|
|
16
|
+
z: float = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ActiveToolData(BaseModel):
|
|
20
|
+
"""Active tool data fields."""
|
|
21
|
+
activeTool: str = ""
|
|
22
|
+
isCustom: bool = False
|
|
23
|
+
pivotMode: str = ""
|
|
24
|
+
pivotRotation: str = ""
|
|
25
|
+
handleRotation: Vector3 = Vector3()
|
|
26
|
+
handlePosition: Vector3 = Vector3()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActiveToolResponse(MCPResponse):
|
|
30
|
+
"""Information about the currently active editor tool."""
|
|
31
|
+
data: ActiveToolData = ActiveToolData()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp_for_unity_resource(
|
|
35
|
+
uri="mcpforunity://editor/active-tool",
|
|
36
|
+
name="editor_active_tool",
|
|
37
|
+
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.\n\nURI: mcpforunity://editor/active-tool"
|
|
38
|
+
)
|
|
39
|
+
async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
|
|
40
|
+
"""Get active editor tool information."""
|
|
41
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
42
|
+
response = await send_with_unity_instance(
|
|
43
|
+
async_send_command_with_retry,
|
|
44
|
+
unity_instance,
|
|
45
|
+
"get_active_tool",
|
|
46
|
+
{}
|
|
47
|
+
)
|
|
48
|
+
return parse_resource_response(response, ActiveToolResponse)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from fastmcp import Context
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.custom_tool_service import (
|
|
6
|
+
CustomToolService,
|
|
7
|
+
resolve_project_id_for_unity_instance,
|
|
8
|
+
ToolDefinitionModel,
|
|
9
|
+
)
|
|
10
|
+
from services.registry import mcp_for_unity_resource
|
|
11
|
+
from services.tools import get_unity_instance_from_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomToolsData(BaseModel):
|
|
15
|
+
project_id: str
|
|
16
|
+
tool_count: int
|
|
17
|
+
tools: list[ToolDefinitionModel]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CustomToolsResourceResponse(MCPResponse):
|
|
21
|
+
data: CustomToolsData | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp_for_unity_resource(
|
|
25
|
+
uri="mcpforunity://custom-tools",
|
|
26
|
+
name="custom_tools",
|
|
27
|
+
description="Lists custom tools available for the active Unity project.\n\nURI: mcpforunity://custom-tools",
|
|
28
|
+
)
|
|
29
|
+
async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPResponse:
|
|
30
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
31
|
+
if not unity_instance:
|
|
32
|
+
return MCPResponse(
|
|
33
|
+
success=False,
|
|
34
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
38
|
+
if project_id is None:
|
|
39
|
+
return MCPResponse(
|
|
40
|
+
success=False,
|
|
41
|
+
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
service = CustomToolService.get_instance()
|
|
45
|
+
tools = await service.list_registered_tools(project_id)
|
|
46
|
+
|
|
47
|
+
data = CustomToolsData(
|
|
48
|
+
project_id=project_id,
|
|
49
|
+
tool_count=len(tools),
|
|
50
|
+
tools=tools,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return CustomToolsResourceResponse(
|
|
54
|
+
success=True,
|
|
55
|
+
message="Custom tools retrieved successfully.",
|
|
56
|
+
data=data,
|
|
57
|
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp import Context
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from core.config import config
|
|
9
|
+
from models import MCPResponse
|
|
10
|
+
from services.registry import mcp_for_unity_resource
|
|
11
|
+
from services.tools import get_unity_instance_from_context
|
|
12
|
+
from services.state.external_changes_scanner import external_changes_scanner
|
|
13
|
+
import transport.unity_transport as unity_transport
|
|
14
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
from transport.plugin_hub import PluginHub
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EditorStateUnity(BaseModel):
|
|
19
|
+
instance_id: str | None = None
|
|
20
|
+
unity_version: str | None = None
|
|
21
|
+
project_id: str | None = None
|
|
22
|
+
platform: str | None = None
|
|
23
|
+
is_batch_mode: bool | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EditorStatePlayMode(BaseModel):
|
|
27
|
+
is_playing: bool | None = None
|
|
28
|
+
is_paused: bool | None = None
|
|
29
|
+
is_changing: bool | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EditorStateActiveScene(BaseModel):
|
|
33
|
+
path: str | None = None
|
|
34
|
+
guid: str | None = None
|
|
35
|
+
name: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EditorStateEditor(BaseModel):
|
|
39
|
+
is_focused: bool | None = None
|
|
40
|
+
play_mode: EditorStatePlayMode | None = None
|
|
41
|
+
active_scene: EditorStateActiveScene | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EditorStateActivity(BaseModel):
|
|
45
|
+
phase: str | None = None
|
|
46
|
+
since_unix_ms: int | None = None
|
|
47
|
+
reasons: list[str] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EditorStateCompilation(BaseModel):
|
|
51
|
+
is_compiling: bool | None = None
|
|
52
|
+
is_domain_reload_pending: bool | None = None
|
|
53
|
+
last_compile_started_unix_ms: int | None = None
|
|
54
|
+
last_compile_finished_unix_ms: int | None = None
|
|
55
|
+
last_domain_reload_before_unix_ms: int | None = None
|
|
56
|
+
last_domain_reload_after_unix_ms: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EditorStateRefresh(BaseModel):
|
|
60
|
+
is_refresh_in_progress: bool | None = None
|
|
61
|
+
last_refresh_requested_unix_ms: int | None = None
|
|
62
|
+
last_refresh_finished_unix_ms: int | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EditorStateAssets(BaseModel):
|
|
66
|
+
is_updating: bool | None = None
|
|
67
|
+
external_changes_dirty: bool | None = None
|
|
68
|
+
external_changes_last_seen_unix_ms: int | None = None
|
|
69
|
+
external_changes_dirty_since_unix_ms: int | None = None
|
|
70
|
+
external_changes_last_cleared_unix_ms: int | None = None
|
|
71
|
+
refresh: EditorStateRefresh | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EditorStateLastRun(BaseModel):
|
|
75
|
+
finished_unix_ms: int | None = None
|
|
76
|
+
result: str | None = None
|
|
77
|
+
counts: Any | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EditorStateTests(BaseModel):
|
|
81
|
+
is_running: bool | None = None
|
|
82
|
+
mode: str | None = None
|
|
83
|
+
current_job_id: str | None = None
|
|
84
|
+
started_unix_ms: int | None = None
|
|
85
|
+
started_by: str | None = None
|
|
86
|
+
last_run: EditorStateLastRun | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EditorStateTransport(BaseModel):
|
|
90
|
+
unity_bridge_connected: bool | None = None
|
|
91
|
+
last_message_unix_ms: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EditorStateAdvice(BaseModel):
|
|
95
|
+
ready_for_tools: bool | None = None
|
|
96
|
+
blocking_reasons: list[str] | None = None
|
|
97
|
+
recommended_retry_after_ms: int | None = None
|
|
98
|
+
recommended_next_action: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EditorStateStaleness(BaseModel):
|
|
102
|
+
age_ms: int | None = None
|
|
103
|
+
is_stale: bool | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class EditorStateData(BaseModel):
|
|
107
|
+
schema_version: str
|
|
108
|
+
observed_at_unix_ms: int
|
|
109
|
+
sequence: int
|
|
110
|
+
unity: EditorStateUnity | None = None
|
|
111
|
+
editor: EditorStateEditor | None = None
|
|
112
|
+
activity: EditorStateActivity | None = None
|
|
113
|
+
compilation: EditorStateCompilation | None = None
|
|
114
|
+
assets: EditorStateAssets | None = None
|
|
115
|
+
tests: EditorStateTests | None = None
|
|
116
|
+
transport: EditorStateTransport | None = None
|
|
117
|
+
advice: EditorStateAdvice | None = None
|
|
118
|
+
staleness: EditorStateStaleness | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _now_unix_ms() -> int:
|
|
122
|
+
return int(time.time() * 1000)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _in_pytest() -> bool:
|
|
126
|
+
# Avoid instance-discovery side effects during the Python integration test suite.
|
|
127
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def infer_single_instance_id(ctx: Context) -> str | None:
|
|
131
|
+
"""
|
|
132
|
+
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
|
|
133
|
+
This makes editor_state outputs self-describing even when no explicit active instance is set.
|
|
134
|
+
"""
|
|
135
|
+
await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
|
|
136
|
+
|
|
137
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
138
|
+
|
|
139
|
+
if transport == "http":
|
|
140
|
+
# HTTP/WebSocket transport: derive from PluginHub sessions.
|
|
141
|
+
try:
|
|
142
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
143
|
+
user_id = ctx.get_state(
|
|
144
|
+
"user_id") if config.http_remote_hosted else None
|
|
145
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
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
|
|
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
|
|
184
|
+
|
|
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
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@mcp_for_unity_resource(
|
|
215
|
+
uri="mcpforunity://editor/state",
|
|
216
|
+
name="editor_state",
|
|
217
|
+
description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.\n\nURI: mcpforunity://editor/state",
|
|
218
|
+
)
|
|
219
|
+
async def get_editor_state(ctx: Context) -> MCPResponse:
|
|
220
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
221
|
+
|
|
222
|
+
response = await unity_transport.send_with_unity_instance(
|
|
223
|
+
async_send_command_with_retry,
|
|
224
|
+
unity_instance,
|
|
225
|
+
"get_editor_state",
|
|
226
|
+
{},
|
|
227
|
+
)
|
|
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)
|