mcpforunityserver 8.5.0__py3-none-any.whl → 8.6.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.
- main.py +30 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/METADATA +2 -2
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/RECORD +13 -13
- services/resources/editor_state.py +10 -1
- services/tools/read_console.py +12 -2
- services/tools/run_tests.py +9 -1
- transport/plugin_hub.py +118 -7
- transport/unity_instance_middleware.py +90 -0
- transport/unity_transport.py +16 -6
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.6.0.dist-info}/top_level.txt +0 -0
main.py
CHANGED
|
@@ -391,6 +391,22 @@ Examples:
|
|
|
391
391
|
help="HTTP server port (overrides URL port). "
|
|
392
392
|
"Overrides UNITY_MCP_HTTP_PORT environment variable."
|
|
393
393
|
)
|
|
394
|
+
parser.add_argument(
|
|
395
|
+
"--unity-instance-token",
|
|
396
|
+
type=str,
|
|
397
|
+
default=None,
|
|
398
|
+
metavar="TOKEN",
|
|
399
|
+
help="Optional per-launch token set by Unity for deterministic lifecycle management. "
|
|
400
|
+
"Used by Unity to validate it is stopping the correct process."
|
|
401
|
+
)
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--pidfile",
|
|
404
|
+
type=str,
|
|
405
|
+
default=None,
|
|
406
|
+
metavar="PATH",
|
|
407
|
+
help="Optional path where the server will write its PID on startup. "
|
|
408
|
+
"Used by Unity to stop the exact process it launched when running in a terminal."
|
|
409
|
+
)
|
|
394
410
|
|
|
395
411
|
args = parser.parse_args()
|
|
396
412
|
|
|
@@ -418,6 +434,20 @@ Examples:
|
|
|
418
434
|
os.environ["UNITY_MCP_HTTP_HOST"] = http_host
|
|
419
435
|
os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
|
|
420
436
|
|
|
437
|
+
# Optional lifecycle handshake for Unity-managed terminal launches
|
|
438
|
+
if args.unity_instance_token:
|
|
439
|
+
os.environ["UNITY_MCP_INSTANCE_TOKEN"] = args.unity_instance_token
|
|
440
|
+
if args.pidfile:
|
|
441
|
+
try:
|
|
442
|
+
pid_dir = os.path.dirname(args.pidfile)
|
|
443
|
+
if pid_dir:
|
|
444
|
+
os.makedirs(pid_dir, exist_ok=True)
|
|
445
|
+
with open(args.pidfile, "w", encoding="ascii") as f:
|
|
446
|
+
f.write(str(os.getpid()))
|
|
447
|
+
except Exception as exc:
|
|
448
|
+
logger.warning(
|
|
449
|
+
"Failed to write pidfile '%s': %s", args.pidfile, exc)
|
|
450
|
+
|
|
421
451
|
if args.http_url != "http://localhost:8080":
|
|
422
452
|
logger.info(f"HTTP URL set to: {http_url}")
|
|
423
453
|
if args.http_host:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcpforunityserver
|
|
3
|
-
Version: 8.
|
|
3
|
+
Version: 8.6.0
|
|
4
4
|
Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
|
|
5
5
|
Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -108,7 +108,7 @@ Use this to run the latest released version from the repository. Change the vers
|
|
|
108
108
|
"command": "uvx",
|
|
109
109
|
"args": [
|
|
110
110
|
"--from",
|
|
111
|
-
"git+https://github.com/CoplayDev/unity-mcp@v8.
|
|
111
|
+
"git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server",
|
|
112
112
|
"mcp-for-unity",
|
|
113
113
|
"--transport",
|
|
114
114
|
"stdio"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
main.py,sha256=
|
|
2
|
+
main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
|
|
3
3
|
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
|
|
5
5
|
core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
|
|
6
6
|
core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
|
|
7
7
|
core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
|
|
8
|
-
mcpforunityserver-8.
|
|
8
|
+
mcpforunityserver-8.6.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
|
|
9
9
|
models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
|
|
10
10
|
models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
|
|
11
11
|
models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
|
|
@@ -18,7 +18,7 @@ services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kj
|
|
|
18
18
|
services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
|
|
19
19
|
services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
|
|
20
20
|
services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
|
|
21
|
-
services/resources/editor_state.py,sha256=
|
|
21
|
+
services/resources/editor_state.py,sha256=8hrNnskSFdsvdKagAYEeZGJ0Oz9QRlkWJjpM4q0XeNo,2013
|
|
22
22
|
services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
|
|
23
23
|
services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
|
|
24
24
|
services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
|
|
@@ -43,24 +43,24 @@ services/tools/manage_scene.py,sha256=3BhIsbbtGiMNqBMQMqEsB4ajYmtx-VwWl-krOkFR_B
|
|
|
43
43
|
services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
|
|
44
44
|
services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
|
|
45
45
|
services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
|
|
46
|
-
services/tools/read_console.py,sha256=
|
|
47
|
-
services/tools/run_tests.py,sha256=
|
|
46
|
+
services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
|
|
47
|
+
services/tools/run_tests.py,sha256=eeHwFmBxbKHaL_RMxoDN6qKsmBp2qTrnG7FxnRQR5mQ,3709
|
|
48
48
|
services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
|
|
49
49
|
services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
|
|
50
50
|
services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
|
|
51
51
|
transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
52
|
transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
|
|
53
|
-
transport/plugin_hub.py,sha256=
|
|
53
|
+
transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
|
|
54
54
|
transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
|
|
55
|
-
transport/unity_instance_middleware.py,sha256=
|
|
56
|
-
transport/unity_transport.py,sha256=
|
|
55
|
+
transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
|
|
56
|
+
transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
|
|
57
57
|
transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
|
|
58
58
|
transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
|
|
59
59
|
transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
|
|
60
60
|
utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
|
|
61
61
|
utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
|
|
62
|
-
mcpforunityserver-8.
|
|
63
|
-
mcpforunityserver-8.
|
|
64
|
-
mcpforunityserver-8.
|
|
65
|
-
mcpforunityserver-8.
|
|
66
|
-
mcpforunityserver-8.
|
|
62
|
+
mcpforunityserver-8.6.0.dist-info/METADATA,sha256=mhrVzwZHOC4hsY1aNMsgBK3wLZuFNyE05hJ0xN94k18,5712
|
|
63
|
+
mcpforunityserver-8.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
64
|
+
mcpforunityserver-8.6.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
|
|
65
|
+
mcpforunityserver-8.6.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
|
|
66
|
+
mcpforunityserver-8.6.0.dist-info/RECORD,,
|
|
@@ -39,4 +39,13 @@ async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
|
|
|
39
39
|
"get_editor_state",
|
|
40
40
|
{}
|
|
41
41
|
)
|
|
42
|
-
|
|
42
|
+
# When Unity is reloading/unresponsive (often when unfocused), transports may return
|
|
43
|
+
# a retryable MCPResponse payload with success=false and no data. Do not attempt to
|
|
44
|
+
# coerce that into EditorStateResponse (it would fail validation); return it as-is.
|
|
45
|
+
if isinstance(response, dict):
|
|
46
|
+
if not response.get("success", True):
|
|
47
|
+
return MCPResponse(**response)
|
|
48
|
+
if response.get("data") is None:
|
|
49
|
+
return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response)
|
|
50
|
+
return EditorStateResponse(**response)
|
|
51
|
+
return response
|
services/tools/read_console.py
CHANGED
|
@@ -45,8 +45,18 @@ async def read_console(
|
|
|
45
45
|
if isinstance(action, str):
|
|
46
46
|
action = action.lower()
|
|
47
47
|
|
|
48
|
-
# Coerce count defensively (string/float -> int)
|
|
49
|
-
count
|
|
48
|
+
# Coerce count defensively (string/float -> int).
|
|
49
|
+
# Important: leaving count unset previously meant "return all console entries", which can be extremely slow
|
|
50
|
+
# (and can exceed the plugin command timeout when Unity has a large console).
|
|
51
|
+
# To keep the tool responsive by default, we cap the default to a reasonable number of most-recent entries.
|
|
52
|
+
# If a client truly wants everything, it can pass count="all" (or count="*") explicitly.
|
|
53
|
+
if isinstance(count, str) and count.strip().lower() in ("all", "*"):
|
|
54
|
+
count = None
|
|
55
|
+
else:
|
|
56
|
+
count = coerce_int(count)
|
|
57
|
+
|
|
58
|
+
if action == "get" and count is None:
|
|
59
|
+
count = 200
|
|
50
60
|
|
|
51
61
|
# Prepare parameters for the C# handler
|
|
52
62
|
params_dict = {
|
services/tools/run_tests.py
CHANGED
|
@@ -34,7 +34,7 @@ class RunTestsTestResult(BaseModel):
|
|
|
34
34
|
class RunTestsResult(BaseModel):
|
|
35
35
|
mode: str
|
|
36
36
|
summary: RunTestsSummary
|
|
37
|
-
results: list[RunTestsTestResult]
|
|
37
|
+
results: list[RunTestsTestResult] | None = None
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class RunTestsResponse(MCPResponse):
|
|
@@ -52,6 +52,8 @@ async def run_tests(
|
|
|
52
52
|
group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
|
|
53
53
|
category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
|
|
54
54
|
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
|
55
|
+
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
|
56
|
+
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
|
55
57
|
) -> RunTestsResponse:
|
|
56
58
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
57
59
|
|
|
@@ -88,6 +90,12 @@ async def run_tests(
|
|
|
88
90
|
if assembly_names_list:
|
|
89
91
|
params["assemblyNames"] = assembly_names_list
|
|
90
92
|
|
|
93
|
+
# Add verbosity parameters
|
|
94
|
+
if include_failed_tests:
|
|
95
|
+
params["includeFailedTests"] = True
|
|
96
|
+
if include_details:
|
|
97
|
+
params["includeDetails"] = True
|
|
98
|
+
|
|
91
99
|
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
|
|
92
100
|
await ctx.info(f'Response {response}')
|
|
93
101
|
return RunTestsResponse(**response) if isinstance(response, dict) else response
|
transport/plugin_hub.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
9
10
|
from typing import Any
|
|
@@ -12,6 +13,7 @@ from starlette.endpoints import WebSocketEndpoint
|
|
|
12
13
|
from starlette.websockets import WebSocket
|
|
13
14
|
|
|
14
15
|
from core.config import config
|
|
16
|
+
from models.models import MCPResponse
|
|
15
17
|
from transport.plugin_registry import PluginRegistry
|
|
16
18
|
from transport.models import (
|
|
17
19
|
WelcomeMessage,
|
|
@@ -28,6 +30,10 @@ from transport.models import (
|
|
|
28
30
|
logger = logging.getLogger("mcp-for-unity-server")
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
class PluginDisconnectedError(RuntimeError):
|
|
34
|
+
"""Raised when a plugin WebSocket disconnects while commands are in flight."""
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
class PluginHub(WebSocketEndpoint):
|
|
32
38
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
33
39
|
|
|
@@ -35,10 +41,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
35
41
|
KEEP_ALIVE_INTERVAL = 15
|
|
36
42
|
SERVER_TIMEOUT = 30
|
|
37
43
|
COMMAND_TIMEOUT = 30
|
|
44
|
+
# Fast-path commands should never block the client for long; return a retry hint instead.
|
|
45
|
+
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
|
46
|
+
# or is throttled while unfocused.
|
|
47
|
+
_FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
|
|
38
48
|
|
|
39
49
|
_registry: PluginRegistry | None = None
|
|
40
50
|
_connections: dict[str, WebSocket] = {}
|
|
41
|
-
|
|
51
|
+
# command_id -> {"future": Future, "session_id": str}
|
|
52
|
+
_pending: dict[str, dict[str, Any]] = {}
|
|
42
53
|
_lock: asyncio.Lock | None = None
|
|
43
54
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
44
55
|
|
|
@@ -95,6 +106,21 @@ class PluginHub(WebSocketEndpoint):
|
|
|
95
106
|
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
|
96
107
|
if session_id:
|
|
97
108
|
cls._connections.pop(session_id, None)
|
|
109
|
+
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
|
|
110
|
+
pending_ids = [
|
|
111
|
+
command_id
|
|
112
|
+
for command_id, entry in cls._pending.items()
|
|
113
|
+
if entry.get("session_id") == session_id
|
|
114
|
+
]
|
|
115
|
+
for command_id in pending_ids:
|
|
116
|
+
entry = cls._pending.get(command_id)
|
|
117
|
+
future = entry.get("future") if isinstance(entry, dict) else None
|
|
118
|
+
if future and not future.done():
|
|
119
|
+
future.set_exception(
|
|
120
|
+
PluginDisconnectedError(
|
|
121
|
+
f"Unity plugin session {session_id} disconnected while awaiting command_result"
|
|
122
|
+
)
|
|
123
|
+
)
|
|
98
124
|
if cls._registry:
|
|
99
125
|
await cls._registry.unregister(session_id)
|
|
100
126
|
logger.info(
|
|
@@ -108,6 +134,39 @@ class PluginHub(WebSocketEndpoint):
|
|
|
108
134
|
websocket = await cls._get_connection(session_id)
|
|
109
135
|
command_id = str(uuid.uuid4())
|
|
110
136
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
137
|
+
# Compute a per-command timeout:
|
|
138
|
+
# - fast-path commands: short timeout (encourage retry)
|
|
139
|
+
# - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
|
|
140
|
+
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
|
141
|
+
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
|
142
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
143
|
+
try:
|
|
144
|
+
fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
|
|
145
|
+
except Exception:
|
|
146
|
+
fast_timeout = 3.0
|
|
147
|
+
unity_timeout_s = fast_timeout
|
|
148
|
+
server_wait_s = fast_timeout
|
|
149
|
+
else:
|
|
150
|
+
# Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
|
|
151
|
+
requested = None
|
|
152
|
+
try:
|
|
153
|
+
if isinstance(params, dict):
|
|
154
|
+
requested = params.get("timeout_seconds", None)
|
|
155
|
+
if requested is None:
|
|
156
|
+
requested = params.get("timeoutSeconds", None)
|
|
157
|
+
except Exception:
|
|
158
|
+
requested = None
|
|
159
|
+
|
|
160
|
+
if requested is not None:
|
|
161
|
+
try:
|
|
162
|
+
requested_s = float(requested)
|
|
163
|
+
# Clamp to a sane upper bound to avoid accidental infinite hangs.
|
|
164
|
+
requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
|
|
165
|
+
unity_timeout_s = max(unity_timeout_s, requested_s)
|
|
166
|
+
# Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
|
|
167
|
+
server_wait_s = max(server_wait_s, requested_s + 5.0)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
111
170
|
|
|
112
171
|
lock = cls._lock
|
|
113
172
|
if lock is None:
|
|
@@ -117,18 +176,35 @@ class PluginHub(WebSocketEndpoint):
|
|
|
117
176
|
if command_id in cls._pending:
|
|
118
177
|
raise RuntimeError(
|
|
119
178
|
f"Duplicate command id generated: {command_id}")
|
|
120
|
-
cls._pending[command_id] = future
|
|
179
|
+
cls._pending[command_id] = {"future": future, "session_id": session_id}
|
|
121
180
|
|
|
122
181
|
try:
|
|
123
182
|
msg = ExecuteCommandMessage(
|
|
124
183
|
id=command_id,
|
|
125
184
|
name=command_type,
|
|
126
185
|
params=params,
|
|
127
|
-
timeout=
|
|
186
|
+
timeout=unity_timeout_s,
|
|
128
187
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
188
|
+
try:
|
|
189
|
+
await websocket.send_json(msg.model_dump())
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
# If send fails (socket already closing), fail the future so callers don't hang.
|
|
192
|
+
if not future.done():
|
|
193
|
+
future.set_exception(exc)
|
|
194
|
+
raise
|
|
195
|
+
try:
|
|
196
|
+
result = await asyncio.wait_for(future, timeout=server_wait_s)
|
|
197
|
+
return result
|
|
198
|
+
except PluginDisconnectedError as exc:
|
|
199
|
+
return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
202
|
+
return MCPResponse(
|
|
203
|
+
success=False,
|
|
204
|
+
error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
|
|
205
|
+
hint="retry",
|
|
206
|
+
).model_dump()
|
|
207
|
+
raise
|
|
132
208
|
finally:
|
|
133
209
|
async with lock:
|
|
134
210
|
cls._pending.pop(command_id, None)
|
|
@@ -245,7 +321,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
245
321
|
return
|
|
246
322
|
|
|
247
323
|
async with lock:
|
|
248
|
-
|
|
324
|
+
entry = cls._pending.get(command_id)
|
|
325
|
+
future = entry.get("future") if isinstance(entry, dict) else None
|
|
249
326
|
if future and not future.done():
|
|
250
327
|
future.set_result(result)
|
|
251
328
|
|
|
@@ -364,6 +441,40 @@ class PluginHub(WebSocketEndpoint):
|
|
|
364
441
|
params: dict[str, Any],
|
|
365
442
|
) -> dict[str, Any]:
|
|
366
443
|
session_id = await cls._resolve_session_id(unity_instance)
|
|
444
|
+
|
|
445
|
+
# During domain reload / immediate reconnect windows, the plugin may be connected but not yet
|
|
446
|
+
# ready to process execute commands on the Unity main thread (which can be further delayed when
|
|
447
|
+
# the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
|
|
448
|
+
# a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
|
|
449
|
+
# register_tools (which can be delayed by EditorApplication.delayCall).
|
|
450
|
+
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
|
451
|
+
try:
|
|
452
|
+
max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
|
453
|
+
except Exception:
|
|
454
|
+
max_wait_s = 6.0
|
|
455
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
456
|
+
if max_wait_s > 0:
|
|
457
|
+
deadline = time.monotonic() + max_wait_s
|
|
458
|
+
while time.monotonic() < deadline:
|
|
459
|
+
try:
|
|
460
|
+
probe = await cls.send_command(session_id, "ping", {})
|
|
461
|
+
except Exception:
|
|
462
|
+
probe = None
|
|
463
|
+
|
|
464
|
+
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
|
465
|
+
if isinstance(probe, dict) and probe.get("status") == "success":
|
|
466
|
+
result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
|
|
467
|
+
if result.get("message") == "pong":
|
|
468
|
+
break
|
|
469
|
+
await asyncio.sleep(0.1)
|
|
470
|
+
else:
|
|
471
|
+
# Not ready within the bounded window: return retry hint without sending.
|
|
472
|
+
return MCPResponse(
|
|
473
|
+
success=False,
|
|
474
|
+
error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
|
|
475
|
+
hint="retry",
|
|
476
|
+
).model_dump()
|
|
477
|
+
|
|
367
478
|
return await cls.send_command(session_id, command_type, params)
|
|
368
479
|
|
|
369
480
|
# ------------------------------------------------------------------
|
|
@@ -83,11 +83,101 @@ class UnityInstanceMiddleware(Middleware):
|
|
|
83
83
|
with self._lock:
|
|
84
84
|
self._active_by_key.pop(key, None)
|
|
85
85
|
|
|
86
|
+
async def _maybe_autoselect_instance(self, ctx) -> str | None:
|
|
87
|
+
"""
|
|
88
|
+
Auto-select the sole Unity instance when no active instance is set.
|
|
89
|
+
|
|
90
|
+
Note: This method both *discovers* and *persists* the selection via
|
|
91
|
+
`set_active_instance` as a side-effect, since callers expect the selection
|
|
92
|
+
to stick for subsequent tool/resource calls in the same session.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
# Import here to avoid circular dependencies / optional transport modules.
|
|
96
|
+
from transport.unity_transport import _current_transport
|
|
97
|
+
|
|
98
|
+
transport = _current_transport()
|
|
99
|
+
if PluginHub.is_configured():
|
|
100
|
+
try:
|
|
101
|
+
sessions_data = await PluginHub.get_sessions()
|
|
102
|
+
sessions = sessions_data.sessions or {}
|
|
103
|
+
ids: list[str] = []
|
|
104
|
+
for session_info in sessions.values():
|
|
105
|
+
project = getattr(session_info, "project", None) or "Unknown"
|
|
106
|
+
hash_value = getattr(session_info, "hash", None)
|
|
107
|
+
if hash_value:
|
|
108
|
+
ids.append(f"{project}@{hash_value}")
|
|
109
|
+
if len(ids) == 1:
|
|
110
|
+
chosen = ids[0]
|
|
111
|
+
self.set_active_instance(ctx, chosen)
|
|
112
|
+
logger.info(
|
|
113
|
+
"Auto-selected sole Unity instance via PluginHub: %s",
|
|
114
|
+
chosen,
|
|
115
|
+
)
|
|
116
|
+
return chosen
|
|
117
|
+
except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
|
|
118
|
+
logger.debug(
|
|
119
|
+
"PluginHub auto-select probe failed (%s); falling back to stdio",
|
|
120
|
+
type(exc).__name__,
|
|
121
|
+
exc_info=True,
|
|
122
|
+
)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
|
|
125
|
+
raise
|
|
126
|
+
logger.debug(
|
|
127
|
+
"PluginHub auto-select probe failed with unexpected error (%s); falling back to stdio",
|
|
128
|
+
type(exc).__name__,
|
|
129
|
+
exc_info=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if transport != "http":
|
|
133
|
+
try:
|
|
134
|
+
# Import here to avoid circular imports in legacy transport paths.
|
|
135
|
+
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
136
|
+
|
|
137
|
+
pool = get_unity_connection_pool()
|
|
138
|
+
instances = pool.discover_all_instances(force_refresh=True)
|
|
139
|
+
ids = [getattr(inst, "id", None) for inst in instances]
|
|
140
|
+
ids = [inst_id for inst_id in ids if inst_id]
|
|
141
|
+
if len(ids) == 1:
|
|
142
|
+
chosen = ids[0]
|
|
143
|
+
self.set_active_instance(ctx, chosen)
|
|
144
|
+
logger.info(
|
|
145
|
+
"Auto-selected sole Unity instance via stdio discovery: %s",
|
|
146
|
+
chosen,
|
|
147
|
+
)
|
|
148
|
+
return chosen
|
|
149
|
+
except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
|
|
150
|
+
logger.debug(
|
|
151
|
+
"Stdio auto-select probe failed (%s)",
|
|
152
|
+
type(exc).__name__,
|
|
153
|
+
exc_info=True,
|
|
154
|
+
)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
|
|
157
|
+
raise
|
|
158
|
+
logger.debug(
|
|
159
|
+
"Stdio auto-select probe failed with unexpected error (%s)",
|
|
160
|
+
type(exc).__name__,
|
|
161
|
+
exc_info=True,
|
|
162
|
+
)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
|
|
165
|
+
raise
|
|
166
|
+
logger.debug(
|
|
167
|
+
"Auto-select path encountered an unexpected error (%s)",
|
|
168
|
+
type(exc).__name__,
|
|
169
|
+
exc_info=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return None
|
|
173
|
+
|
|
86
174
|
async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
|
|
87
175
|
"""Inject active Unity instance into context if available."""
|
|
88
176
|
ctx = context.fastmcp_context
|
|
89
177
|
|
|
90
178
|
active_instance = self.get_active_instance(ctx)
|
|
179
|
+
if not active_instance:
|
|
180
|
+
active_instance = await self._maybe_autoselect_instance(ctx)
|
|
91
181
|
if active_instance:
|
|
92
182
|
# If using HTTP transport (PluginHub configured), validate session
|
|
93
183
|
# But for stdio transport (no PluginHub needed or maybe partially configured),
|
transport/unity_transport.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Awaitable, Callable, TypeVar
|
|
|
9
9
|
from fastmcp import Context
|
|
10
10
|
|
|
11
11
|
from transport.plugin_hub import PluginHub
|
|
12
|
+
from models.models import MCPResponse
|
|
12
13
|
from models.unity_response import normalize_unity_response
|
|
13
14
|
from services.tools import get_unity_instance_from_context
|
|
14
15
|
|
|
@@ -91,12 +92,21 @@ async def send_with_unity_instance(
|
|
|
91
92
|
if not isinstance(params, dict):
|
|
92
93
|
raise TypeError(
|
|
93
94
|
"Command parameters must be a dict for HTTP transport")
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
try:
|
|
96
|
+
raw = await PluginHub.send_command_for_instance(
|
|
97
|
+
unity_instance,
|
|
98
|
+
command_type,
|
|
99
|
+
params,
|
|
100
|
+
)
|
|
101
|
+
return normalize_unity_response(raw)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
# NOTE: asyncio.TimeoutError has an empty str() by default, which is confusing for clients.
|
|
104
|
+
err = str(exc) or f"{type(exc).__name__}"
|
|
105
|
+
# Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
|
|
106
|
+
# The client can decide whether retrying is appropriate for the command.
|
|
107
|
+
return normalize_unity_response(
|
|
108
|
+
MCPResponse(success=False, error=err, hint="retry").model_dump()
|
|
109
|
+
)
|
|
100
110
|
|
|
101
111
|
if unity_instance:
|
|
102
112
|
kwargs.setdefault("instance_id", unity_instance)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|