mcpforunityserver 8.7.1__py3-none-any.whl → 9.0.1__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.
- main.py +4 -3
- {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/METADATA +2 -2
- mcpforunityserver-9.0.1.dist-info/RECORD +72 -0
- {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/top_level.txt +0 -1
- services/custom_tool_service.py +13 -8
- 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/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 +11 -4
- services/tools/refresh_unity.py +24 -14
- services/tools/run_tests.py +162 -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 +1 -1
- transport/plugin_hub.py +24 -16
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- mcpforunityserver-8.7.1.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.1.dist-info → mcpforunityserver-9.0.1.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/licenses/LICENSE +0 -0
services/tools/utils.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
_TRUTHY = {"true", "1", "yes", "on"}
|
|
9
9
|
_FALSY = {"false", "0", "no", "off"}
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
|
12
13
|
"""Attempt to coerce a loosely-typed value to a boolean."""
|
|
13
14
|
if value is None:
|
|
@@ -27,26 +28,26 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
|
|
27
28
|
def parse_json_payload(value: Any) -> Any:
|
|
28
29
|
"""
|
|
29
30
|
Attempt to parse a value that might be a JSON string into its native object.
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
This is a tolerant parser used to handle cases where MCP clients or LLMs
|
|
32
33
|
serialize complex objects (lists, dicts) into strings. It also handles
|
|
33
34
|
scalar values like numbers, booleans, and null.
|
|
34
|
-
|
|
35
|
+
|
|
35
36
|
Args:
|
|
36
37
|
value: The input value (can be str, list, dict, etc.)
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
Returns:
|
|
39
40
|
The parsed JSON object/list if the input was a valid JSON string,
|
|
40
41
|
or the original value if parsing failed or wasn't necessary.
|
|
41
42
|
"""
|
|
42
43
|
if not isinstance(value, str):
|
|
43
44
|
return value
|
|
44
|
-
|
|
45
|
+
|
|
45
46
|
val_trimmed = value.strip()
|
|
46
|
-
|
|
47
|
+
|
|
47
48
|
# Fast path: if it doesn't look like JSON structure, return as is
|
|
48
49
|
if not (
|
|
49
|
-
(val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
|
|
50
|
+
(val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
|
|
50
51
|
(val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
|
|
51
52
|
val_trimmed in ("true", "false", "null") or
|
|
52
53
|
(val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
|
|
@@ -75,3 +76,56 @@ def coerce_int(value: Any, default: int | None = None) -> int | None:
|
|
|
75
76
|
return int(float(s))
|
|
76
77
|
except Exception:
|
|
77
78
|
return default
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def coerce_float(value: Any, default: float | None = None) -> float | None:
|
|
82
|
+
"""Attempt to coerce a loosely-typed value to a float-like number."""
|
|
83
|
+
if value is None:
|
|
84
|
+
return default
|
|
85
|
+
try:
|
|
86
|
+
# Treat booleans as invalid numeric input instead of coercing to 0/1.
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return default
|
|
89
|
+
if isinstance(value, (int, float)):
|
|
90
|
+
return float(value)
|
|
91
|
+
s = str(value).strip()
|
|
92
|
+
if s.lower() in ("", "none", "null"):
|
|
93
|
+
return default
|
|
94
|
+
return float(s)
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return default
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
|
100
|
+
"""
|
|
101
|
+
Robustly normalize a properties parameter to a dict.
|
|
102
|
+
|
|
103
|
+
Handles various input formats from MCP clients/LLMs:
|
|
104
|
+
- None -> (None, None)
|
|
105
|
+
- dict -> (dict, None)
|
|
106
|
+
- JSON string -> (parsed_dict, None) or (None, error_message)
|
|
107
|
+
- Invalid values -> (None, error_message)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
|
111
|
+
"""
|
|
112
|
+
if value is None:
|
|
113
|
+
return None, None
|
|
114
|
+
|
|
115
|
+
# Already a dict - return as-is
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
return value, None
|
|
118
|
+
|
|
119
|
+
# Try parsing as string
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
# Check for obviously invalid values from serialization bugs
|
|
122
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
123
|
+
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
|
|
124
|
+
|
|
125
|
+
parsed = parse_json_payload(value)
|
|
126
|
+
if isinstance(parsed, dict):
|
|
127
|
+
return parsed, None
|
|
128
|
+
|
|
129
|
+
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
|
130
|
+
|
|
131
|
+
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
|
@@ -279,9 +279,9 @@ class PortDiscovery:
|
|
|
279
279
|
if freshness.tzinfo:
|
|
280
280
|
from datetime import timezone
|
|
281
281
|
now = datetime.now(timezone.utc)
|
|
282
|
-
|
|
282
|
+
|
|
283
283
|
age_s = (now - freshness).total_seconds()
|
|
284
|
-
|
|
284
|
+
|
|
285
285
|
if is_reloading and age_s < 60:
|
|
286
286
|
pass # keep it, status="reloading"
|
|
287
287
|
else:
|
|
@@ -584,7 +584,7 @@ class UnityConnectionPool:
|
|
|
584
584
|
raise ConnectionError(
|
|
585
585
|
f"Unity instance '{identifier}' not found. "
|
|
586
586
|
f"Available instances: {available_ids}. "
|
|
587
|
-
f"Check
|
|
587
|
+
f"Check mcpforunity://instances resource for all instances."
|
|
588
588
|
)
|
|
589
589
|
|
|
590
590
|
def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
|
transport/plugin_hub.py
CHANGED
|
@@ -45,10 +45,14 @@ class PluginHub(WebSocketEndpoint):
|
|
|
45
45
|
KEEP_ALIVE_INTERVAL = 15
|
|
46
46
|
SERVER_TIMEOUT = 30
|
|
47
47
|
COMMAND_TIMEOUT = 30
|
|
48
|
+
# Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
|
|
49
|
+
# Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
|
|
50
|
+
FAST_FAIL_TIMEOUT = 2.0
|
|
48
51
|
# Fast-path commands should never block the client for long; return a retry hint instead.
|
|
49
52
|
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
|
50
53
|
# or is throttled while unfocused.
|
|
51
|
-
_FAST_FAIL_COMMANDS: set[str] = {
|
|
54
|
+
_FAST_FAIL_COMMANDS: set[str] = {
|
|
55
|
+
"read_console", "get_editor_state", "ping"}
|
|
52
56
|
|
|
53
57
|
_registry: PluginRegistry | None = None
|
|
54
58
|
_connections: dict[str, WebSocket] = {}
|
|
@@ -118,7 +122,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
118
122
|
]
|
|
119
123
|
for command_id in pending_ids:
|
|
120
124
|
entry = cls._pending.get(command_id)
|
|
121
|
-
future = entry.get("future") if isinstance(
|
|
125
|
+
future = entry.get("future") if isinstance(
|
|
126
|
+
entry, dict) else None
|
|
122
127
|
if future and not future.done():
|
|
123
128
|
future.set_exception(
|
|
124
129
|
PluginDisconnectedError(
|
|
@@ -140,18 +145,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
140
145
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
141
146
|
# Compute a per-command timeout:
|
|
142
147
|
# - fast-path commands: short timeout (encourage retry)
|
|
143
|
-
# - long-running commands
|
|
148
|
+
# - long-running commands: allow caller to request a longer timeout via params
|
|
144
149
|
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
|
145
150
|
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
|
146
151
|
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
147
|
-
|
|
148
|
-
fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
|
|
149
|
-
except Exception:
|
|
150
|
-
fast_timeout = 3.0
|
|
152
|
+
fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
|
|
151
153
|
unity_timeout_s = fast_timeout
|
|
152
154
|
server_wait_s = fast_timeout
|
|
153
155
|
else:
|
|
154
|
-
# Common tools pass a requested timeout in seconds (e.g.,
|
|
156
|
+
# Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
|
|
155
157
|
requested = None
|
|
156
158
|
try:
|
|
157
159
|
if isinstance(params, dict):
|
|
@@ -180,7 +182,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
180
182
|
if command_id in cls._pending:
|
|
181
183
|
raise RuntimeError(
|
|
182
184
|
f"Duplicate command id generated: {command_id}")
|
|
183
|
-
cls._pending[command_id] = {
|
|
185
|
+
cls._pending[command_id] = {
|
|
186
|
+
"future": future, "session_id": session_id}
|
|
184
187
|
|
|
185
188
|
try:
|
|
186
189
|
msg = ExecuteCommandMessage(
|
|
@@ -370,7 +373,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
370
373
|
max_wait_s = float(
|
|
371
374
|
os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
|
|
372
375
|
except ValueError as e:
|
|
373
|
-
raw_val = os.environ.get(
|
|
376
|
+
raw_val = os.environ.get(
|
|
377
|
+
"UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
|
|
374
378
|
logger.warning(
|
|
375
379
|
"Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
376
380
|
raw_val, e)
|
|
@@ -416,7 +420,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
416
420
|
if not target_hash and session_count > 1:
|
|
417
421
|
raise RuntimeError(
|
|
418
422
|
"Multiple Unity instances are connected. "
|
|
419
|
-
"Call set_active_instance with Name@hash from
|
|
423
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
420
424
|
)
|
|
421
425
|
if wait_started is None:
|
|
422
426
|
wait_started = time.monotonic()
|
|
@@ -437,7 +441,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
437
441
|
if session_id is None and not target_hash and session_count > 1:
|
|
438
442
|
raise RuntimeError(
|
|
439
443
|
"Multiple Unity instances are connected. "
|
|
440
|
-
"Call set_active_instance with Name@hash from
|
|
444
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
441
445
|
)
|
|
442
446
|
|
|
443
447
|
if session_id is None:
|
|
@@ -448,7 +452,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
448
452
|
)
|
|
449
453
|
# At this point we've given the plugin ample time to reconnect; surface
|
|
450
454
|
# a clear error so the client can prompt the user to open Unity.
|
|
451
|
-
raise NoUnitySessionError(
|
|
455
|
+
raise NoUnitySessionError(
|
|
456
|
+
"No Unity plugins are currently connected")
|
|
452
457
|
|
|
453
458
|
return session_id
|
|
454
459
|
|
|
@@ -481,9 +486,11 @@ class PluginHub(WebSocketEndpoint):
|
|
|
481
486
|
# register_tools (which can be delayed by EditorApplication.delayCall).
|
|
482
487
|
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
|
483
488
|
try:
|
|
484
|
-
max_wait_s = float(os.environ.get(
|
|
489
|
+
max_wait_s = float(os.environ.get(
|
|
490
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
|
485
491
|
except ValueError as e:
|
|
486
|
-
raw_val = os.environ.get(
|
|
492
|
+
raw_val = os.environ.get(
|
|
493
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
|
|
487
494
|
logger.warning(
|
|
488
495
|
"Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
|
|
489
496
|
raw_val, e)
|
|
@@ -499,7 +506,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
499
506
|
|
|
500
507
|
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
|
501
508
|
if isinstance(probe, dict) and probe.get("status") == "success":
|
|
502
|
-
result = probe.get("result") if isinstance(
|
|
509
|
+
result = probe.get("result") if isinstance(
|
|
510
|
+
probe.get("result"), dict) else {}
|
|
503
511
|
if result.get("message") == "pong":
|
|
504
512
|
break
|
|
505
513
|
await asyncio.sleep(0.1)
|
|
@@ -27,7 +27,7 @@ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
|
|
|
27
27
|
if _unity_instance_middleware is None:
|
|
28
28
|
# Auto-initialize if not set (lazy singleton) to handle import order or test cases
|
|
29
29
|
_unity_instance_middleware = UnityInstanceMiddleware()
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
return _unity_instance_middleware
|
|
32
32
|
|
|
33
33
|
|
|
@@ -102,7 +102,8 @@ class UnityInstanceMiddleware(Middleware):
|
|
|
102
102
|
sessions = sessions_data.sessions or {}
|
|
103
103
|
ids: list[str] = []
|
|
104
104
|
for session_info in sessions.values():
|
|
105
|
-
project = getattr(
|
|
105
|
+
project = getattr(
|
|
106
|
+
session_info, "project", None) or "Unknown"
|
|
106
107
|
hash_value = getattr(session_info, "hash", None)
|
|
107
108
|
if hash_value:
|
|
108
109
|
ids.append(f"{project}@{hash_value}")
|
|
@@ -183,7 +184,7 @@ class UnityInstanceMiddleware(Middleware):
|
|
|
183
184
|
# But for stdio transport (no PluginHub needed or maybe partially configured),
|
|
184
185
|
# we should be careful not to clear instance just because PluginHub can't resolve it.
|
|
185
186
|
# The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
|
|
186
|
-
|
|
187
|
+
|
|
187
188
|
session_id: str | None = None
|
|
188
189
|
# Only validate via PluginHub if we are actually using HTTP transport
|
|
189
190
|
# OR if we want to support hybrid mode. For now, let's be permissive.
|
transport/unity_transport.py
CHANGED
|
@@ -105,7 +105,8 @@ async def send_with_unity_instance(
|
|
|
105
105
|
# Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
|
|
106
106
|
# The client can decide whether retrying is appropriate for the command.
|
|
107
107
|
return normalize_unity_response(
|
|
108
|
-
MCPResponse(success=False, error=err,
|
|
108
|
+
MCPResponse(success=False, error=err,
|
|
109
|
+
hint="retry").model_dump()
|
|
109
110
|
)
|
|
110
111
|
|
|
111
112
|
if unity_instance:
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
|
|
3
|
-
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
|
|
5
|
-
core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
|
|
6
|
-
core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
|
|
7
|
-
core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
|
|
8
|
-
mcpforunityserver-8.7.1.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
|
|
9
|
-
models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
|
|
10
|
-
models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
|
|
11
|
-
models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
|
|
12
|
-
routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
services/custom_tool_service.py,sha256=ZOb6LsOQc3C9n3iLn5b7QaG3WJ25M-Id3WG_iE1YhMY,11960
|
|
15
|
-
services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
|
|
16
|
-
services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
|
|
17
|
-
services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
|
|
18
|
-
services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
|
|
19
|
-
services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
|
|
20
|
-
services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
|
|
21
|
-
services/resources/editor_state.py,sha256=8hrNnskSFdsvdKagAYEeZGJ0Oz9QRlkWJjpM4q0XeNo,2013
|
|
22
|
-
services/resources/editor_state_v2.py,sha256=zgss1EEhJo7oZeHnjOXsdJPuFQsHLwpsZmzcDy3ybq0,10874
|
|
23
|
-
services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
|
|
24
|
-
services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
|
|
25
|
-
services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
|
|
26
|
-
services/resources/project_info.py,sha256=gSVSFfwP0u2FmxSowOkdbNoSSQHxfQtLfndvoCXTVSw,1323
|
|
27
|
-
services/resources/selection.py,sha256=4rI5Bdkes4uxtMc_5jQhUaqUl-iprhIiTWqnOJl8tmg,1839
|
|
28
|
-
services/resources/tags.py,sha256=7EhmQjMotz85DSSr7cVKYIy7LPT5mmPfrEySr1mTE6w,1049
|
|
29
|
-
services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
|
|
30
|
-
services/resources/unity_instances.py,sha256=fR0cVopGQnmF41IFDycwlo2XniKstfJWLGobgJeiabE,4348
|
|
31
|
-
services/resources/windows.py,sha256=--QVsb0oyoBpSjK2D4kPcZFSe2zdR-t_KSHP-e2QNoY,1427
|
|
32
|
-
services/state/external_changes_scanner.py,sha256=qwdiriHR1D11aPiLUbpS7COXtfVOjNj9DpzcSDK067o,9042
|
|
33
|
-
services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
|
|
34
|
-
services/tools/batch_execute.py,sha256=_ByjffeXQB9j64mcjaxJmrnbSJrMn0f9_6Zh9BBI_2c,2898
|
|
35
|
-
services/tools/debug_request_context.py,sha256=WQBtQdXSH5stw2MAwIM32H6jGwUVQOgU2r35VUWLlYo,2765
|
|
36
|
-
services/tools/execute_custom_tool.py,sha256=K2qaO4-FTPz0_3j53hhDP9idjC002ugc8C03FtHGTbY,1376
|
|
37
|
-
services/tools/execute_menu_item.py,sha256=FAC-1v_TwOcy6GSxkogDsVxeRtdap0DsPlIngf8uJdU,1184
|
|
38
|
-
services/tools/find_in_file.py,sha256=xp80lqRN2cdZc3XGJWlCpeQEy6WnwyKOj2l5WiHNx0Q,6379
|
|
39
|
-
services/tools/manage_asset.py,sha256=6YjWOl2b58vRwjp-9XbQE9e1l3ajhGookVY8ncQN0wo,7877
|
|
40
|
-
services/tools/manage_editor.py,sha256=_HZRT-_hBakH0g6p7BpxTv3gWpxsaV6KNGRol-qknwo,3243
|
|
41
|
-
services/tools/manage_gameobject.py,sha256=OgFIsoPGiWHOj6-d3Lmtp3xlAW9Tr0c38tV4atAaFAU,14400
|
|
42
|
-
services/tools/manage_material.py,sha256=wZB2H4orhL6wG9TTnmnk-Lj2Gj_zvg7koxW3t319BLU,3545
|
|
43
|
-
services/tools/manage_prefabs.py,sha256=73XzznjFNOm1SazW_Y7l6uGIE7wosMpAIVQs8xpvK9A,3037
|
|
44
|
-
services/tools/manage_scene.py,sha256=oJ1qDX0T06mINZ1hX2AcDp3ItHo-oUz7Uck0yujI9eA,4834
|
|
45
|
-
services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
|
|
46
|
-
services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
|
|
47
|
-
services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
|
|
48
|
-
services/tools/preflight.py,sha256=VJn61h-9pvoVaCyKL7DTKLfbpoZfNK4fnRmj91c2o8M,4093
|
|
49
|
-
services/tools/read_console.py,sha256=ELryGXGhZi56pqe4cSdrNDaZGBlUydQIeJ5q86fq_Uo,5201
|
|
50
|
-
services/tools/refresh_unity.py,sha256=xlmqMeAxmYEa5l4OduYdDYWSKJPm2QoJg5CrxCJY8_A,3859
|
|
51
|
-
services/tools/run_tests.py,sha256=8CqmgRN6Bata666ytF_S9no4gaFmHCmeZM82ZwNQJ68,4666
|
|
52
|
-
services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
|
|
53
|
-
services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
|
|
54
|
-
services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4059
|
|
55
|
-
services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
|
|
56
|
-
transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
57
|
-
transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
|
|
58
|
-
transport/plugin_hub.py,sha256=77xYWunNw8hHRJuimx3Qrxu9wqlKURpuunkUqkqx9kE,22845
|
|
59
|
-
transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
|
|
60
|
-
transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
|
|
61
|
-
transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
|
|
62
|
-
transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
|
|
63
|
-
transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
|
|
64
|
-
transport/legacy/unity_connection.py,sha256=psMwMDiUXKUSrpP4UO9Lgu5PH-Mu6QPY-r5o81WIkVg,35802
|
|
65
|
-
utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
|
|
66
|
-
utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
|
|
67
|
-
mcpforunityserver-8.7.1.dist-info/METADATA,sha256=OzxsNyocEXg1K-ND-AesGUKXM8pQx_7CGUrW_XsKXMg,5712
|
|
68
|
-
mcpforunityserver-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
69
|
-
mcpforunityserver-8.7.1.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
|
|
70
|
-
mcpforunityserver-8.7.1.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
|
|
71
|
-
mcpforunityserver-8.7.1.dist-info/RECORD,,
|
routes/__init__.py
DELETED
|
File without changes
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
import os
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
from fastmcp import Context
|
|
6
|
-
|
|
7
|
-
from models import MCPResponse
|
|
8
|
-
from services.registry import mcp_for_unity_resource
|
|
9
|
-
from services.tools import get_unity_instance_from_context
|
|
10
|
-
import transport.unity_transport as unity_transport
|
|
11
|
-
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
12
|
-
from services.state.external_changes_scanner import external_changes_scanner
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _now_unix_ms() -> int:
|
|
16
|
-
return int(time.time() * 1000)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _in_pytest() -> bool:
|
|
20
|
-
# Avoid instance-discovery side effects during the Python integration test suite.
|
|
21
|
-
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async def _infer_single_instance_id(ctx: Context) -> str | None:
|
|
25
|
-
"""
|
|
26
|
-
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
|
|
27
|
-
This makes editor_state outputs self-describing even when no explicit active instance is set.
|
|
28
|
-
"""
|
|
29
|
-
if _in_pytest():
|
|
30
|
-
return None
|
|
31
|
-
|
|
32
|
-
try:
|
|
33
|
-
transport = unity_transport._current_transport()
|
|
34
|
-
except Exception:
|
|
35
|
-
transport = None
|
|
36
|
-
|
|
37
|
-
if transport == "http":
|
|
38
|
-
# HTTP/WebSocket transport: derive from PluginHub sessions.
|
|
39
|
-
try:
|
|
40
|
-
from transport.plugin_hub import PluginHub
|
|
41
|
-
|
|
42
|
-
sessions_data = await PluginHub.get_sessions()
|
|
43
|
-
sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {}
|
|
44
|
-
if isinstance(sessions, dict) and len(sessions) == 1:
|
|
45
|
-
session = next(iter(sessions.values()))
|
|
46
|
-
project = getattr(session, "project", None)
|
|
47
|
-
project_hash = getattr(session, "hash", None)
|
|
48
|
-
if project and project_hash:
|
|
49
|
-
return f"{project}@{project_hash}"
|
|
50
|
-
except Exception:
|
|
51
|
-
return None
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
# Stdio/TCP transport: derive from connection pool discovery.
|
|
55
|
-
try:
|
|
56
|
-
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
57
|
-
|
|
58
|
-
pool = get_unity_connection_pool()
|
|
59
|
-
instances = pool.discover_all_instances(force_refresh=False)
|
|
60
|
-
if isinstance(instances, list) and len(instances) == 1:
|
|
61
|
-
inst = instances[0]
|
|
62
|
-
inst_id = getattr(inst, "id", None)
|
|
63
|
-
return str(inst_id) if inst_id else None
|
|
64
|
-
except Exception:
|
|
65
|
-
return None
|
|
66
|
-
return None
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
-
"""
|
|
71
|
-
Best-effort mapping from legacy get_editor_state payload into the v2 contract.
|
|
72
|
-
Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...}
|
|
73
|
-
"""
|
|
74
|
-
now_ms = _now_unix_ms()
|
|
75
|
-
# legacy may arrive already wrapped as MCPResponse-like {success,data:{...}}
|
|
76
|
-
state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {}
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
"schema_version": "unity-mcp/editor_state@2",
|
|
80
|
-
"observed_at_unix_ms": now_ms,
|
|
81
|
-
"sequence": 0,
|
|
82
|
-
"unity": {
|
|
83
|
-
"instance_id": None,
|
|
84
|
-
"unity_version": None,
|
|
85
|
-
"project_id": None,
|
|
86
|
-
"platform": None,
|
|
87
|
-
"is_batch_mode": None,
|
|
88
|
-
},
|
|
89
|
-
"editor": {
|
|
90
|
-
"is_focused": None,
|
|
91
|
-
"play_mode": {
|
|
92
|
-
"is_playing": bool(state.get("isPlaying", False)),
|
|
93
|
-
"is_paused": bool(state.get("isPaused", False)),
|
|
94
|
-
"is_changing": None,
|
|
95
|
-
},
|
|
96
|
-
"active_scene": {
|
|
97
|
-
"path": None,
|
|
98
|
-
"guid": None,
|
|
99
|
-
"name": state.get("activeSceneName", "") or "",
|
|
100
|
-
},
|
|
101
|
-
"selection": {
|
|
102
|
-
"count": int(state.get("selectionCount", 0) or 0),
|
|
103
|
-
"active_object_name": state.get("activeObjectName", None),
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
"activity": {
|
|
107
|
-
"phase": "unknown",
|
|
108
|
-
"since_unix_ms": now_ms,
|
|
109
|
-
"reasons": ["legacy_fallback"],
|
|
110
|
-
},
|
|
111
|
-
"compilation": {
|
|
112
|
-
"is_compiling": bool(state.get("isCompiling", False)),
|
|
113
|
-
"is_domain_reload_pending": None,
|
|
114
|
-
"last_compile_started_unix_ms": None,
|
|
115
|
-
"last_compile_finished_unix_ms": None,
|
|
116
|
-
},
|
|
117
|
-
"assets": {
|
|
118
|
-
"is_updating": bool(state.get("isUpdating", False)),
|
|
119
|
-
"external_changes_dirty": False,
|
|
120
|
-
"external_changes_last_seen_unix_ms": None,
|
|
121
|
-
"refresh": {
|
|
122
|
-
"is_refresh_in_progress": False,
|
|
123
|
-
"last_refresh_requested_unix_ms": None,
|
|
124
|
-
"last_refresh_finished_unix_ms": None,
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
"tests": {
|
|
128
|
-
"is_running": False,
|
|
129
|
-
"mode": None,
|
|
130
|
-
"started_unix_ms": None,
|
|
131
|
-
"started_by": "unknown",
|
|
132
|
-
"last_run": None,
|
|
133
|
-
},
|
|
134
|
-
"transport": {
|
|
135
|
-
"unity_bridge_connected": None,
|
|
136
|
-
"last_message_unix_ms": None,
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
|
|
142
|
-
now_ms = _now_unix_ms()
|
|
143
|
-
observed = state_v2.get("observed_at_unix_ms")
|
|
144
|
-
try:
|
|
145
|
-
observed_ms = int(observed)
|
|
146
|
-
except Exception:
|
|
147
|
-
observed_ms = now_ms
|
|
148
|
-
|
|
149
|
-
age_ms = max(0, now_ms - observed_ms)
|
|
150
|
-
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
|
|
151
|
-
is_stale = age_ms > 2000
|
|
152
|
-
|
|
153
|
-
compilation = state_v2.get("compilation") or {}
|
|
154
|
-
tests = state_v2.get("tests") or {}
|
|
155
|
-
assets = state_v2.get("assets") or {}
|
|
156
|
-
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
|
|
157
|
-
|
|
158
|
-
blocking: list[str] = []
|
|
159
|
-
if compilation.get("is_compiling") is True:
|
|
160
|
-
blocking.append("compiling")
|
|
161
|
-
if compilation.get("is_domain_reload_pending") is True:
|
|
162
|
-
blocking.append("domain_reload")
|
|
163
|
-
if tests.get("is_running") is True:
|
|
164
|
-
blocking.append("running_tests")
|
|
165
|
-
if refresh.get("is_refresh_in_progress") is True:
|
|
166
|
-
blocking.append("asset_refresh")
|
|
167
|
-
if is_stale:
|
|
168
|
-
blocking.append("stale_status")
|
|
169
|
-
|
|
170
|
-
ready_for_tools = len(blocking) == 0
|
|
171
|
-
|
|
172
|
-
state_v2["advice"] = {
|
|
173
|
-
"ready_for_tools": ready_for_tools,
|
|
174
|
-
"blocking_reasons": blocking,
|
|
175
|
-
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
|
|
176
|
-
"recommended_next_action": "none" if ready_for_tools else "retry_later",
|
|
177
|
-
}
|
|
178
|
-
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
|
|
179
|
-
return state_v2
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
@mcp_for_unity_resource(
|
|
183
|
-
uri="unity://editor_state",
|
|
184
|
-
name="editor_state_v2",
|
|
185
|
-
description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.",
|
|
186
|
-
)
|
|
187
|
-
async def get_editor_state_v2(ctx: Context) -> MCPResponse:
|
|
188
|
-
unity_instance = get_unity_instance_from_context(ctx)
|
|
189
|
-
|
|
190
|
-
# Try v2 snapshot first (Unity-side cache will make this fast once implemented).
|
|
191
|
-
response = await unity_transport.send_with_unity_instance(
|
|
192
|
-
async_send_command_with_retry,
|
|
193
|
-
unity_instance,
|
|
194
|
-
"get_editor_state_v2",
|
|
195
|
-
{},
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
# If Unity returns a structured retry hint or error, surface it directly.
|
|
199
|
-
if isinstance(response, dict) and not response.get("success", True):
|
|
200
|
-
return MCPResponse(**response)
|
|
201
|
-
|
|
202
|
-
# If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map.
|
|
203
|
-
if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")):
|
|
204
|
-
legacy = await unity_transport.send_with_unity_instance(
|
|
205
|
-
async_send_command_with_retry,
|
|
206
|
-
unity_instance,
|
|
207
|
-
"get_editor_state",
|
|
208
|
-
{},
|
|
209
|
-
)
|
|
210
|
-
if isinstance(legacy, dict) and not legacy.get("success", True):
|
|
211
|
-
return MCPResponse(**legacy)
|
|
212
|
-
state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {})
|
|
213
|
-
else:
|
|
214
|
-
state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {}
|
|
215
|
-
# Ensure required v2 marker exists even if Unity returns partial.
|
|
216
|
-
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
|
|
217
|
-
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
|
|
218
|
-
state_v2.setdefault("sequence", 0)
|
|
219
|
-
|
|
220
|
-
# Ensure the returned snapshot is clearly associated with the targeted instance.
|
|
221
|
-
# (This matters when multiple Unity instances are connected and the client is polling readiness.)
|
|
222
|
-
unity_section = state_v2.get("unity")
|
|
223
|
-
if not isinstance(unity_section, dict):
|
|
224
|
-
unity_section = {}
|
|
225
|
-
state_v2["unity"] = unity_section
|
|
226
|
-
current_instance_id = unity_section.get("instance_id")
|
|
227
|
-
if current_instance_id in (None, ""):
|
|
228
|
-
if unity_instance:
|
|
229
|
-
unity_section["instance_id"] = unity_instance
|
|
230
|
-
else:
|
|
231
|
-
inferred = await _infer_single_instance_id(ctx)
|
|
232
|
-
if inferred:
|
|
233
|
-
unity_section["instance_id"] = inferred
|
|
234
|
-
|
|
235
|
-
# External change detection (server-side): compute per instance based on project root path.
|
|
236
|
-
# This helps detect stale assets when external tools edit the filesystem.
|
|
237
|
-
try:
|
|
238
|
-
instance_id = unity_section.get("instance_id")
|
|
239
|
-
if isinstance(instance_id, str) and instance_id.strip():
|
|
240
|
-
from services.resources.project_info import get_project_info
|
|
241
|
-
|
|
242
|
-
# Cache the project root for this instance (best-effort).
|
|
243
|
-
proj_resp = await get_project_info(ctx)
|
|
244
|
-
proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp
|
|
245
|
-
proj_data = proj.get("data") if isinstance(proj, dict) else None
|
|
246
|
-
project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None
|
|
247
|
-
if isinstance(project_root, str) and project_root.strip():
|
|
248
|
-
external_changes_scanner.set_project_root(instance_id, project_root)
|
|
249
|
-
|
|
250
|
-
ext = external_changes_scanner.update_and_get(instance_id)
|
|
251
|
-
|
|
252
|
-
assets = state_v2.get("assets")
|
|
253
|
-
if not isinstance(assets, dict):
|
|
254
|
-
assets = {}
|
|
255
|
-
state_v2["assets"] = assets
|
|
256
|
-
# IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative
|
|
257
|
-
# for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner.
|
|
258
|
-
assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False))
|
|
259
|
-
assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms")
|
|
260
|
-
# Extra bookkeeping fields (server-only) are safe to add under assets.
|
|
261
|
-
assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms")
|
|
262
|
-
assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms")
|
|
263
|
-
except Exception:
|
|
264
|
-
# Best-effort; do not fail readiness resource if filesystem scan can't run.
|
|
265
|
-
pass
|
|
266
|
-
|
|
267
|
-
state_v2 = _enrich_advice_and_staleness(state_v2)
|
|
268
|
-
return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2)
|
|
269
|
-
|
|
270
|
-
|