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.
Files changed (81) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +177 -62
  26. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -30
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +3 -4
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +13 -44
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +115 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +30 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +15 -12
  64. services/tools/read_console.py +70 -17
  65. services/tools/refresh_unity.py +92 -29
  66. services/tools/run_tests.py +187 -53
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +85 -24
  73. transport/unity_instance_middleware.py +4 -3
  74. transport/unity_transport.py +2 -1
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
  78. routes/__init__.py +0 -0
  79. services/resources/editor_state_v2.py +0 -270
  80. services/tools/test_jobs.py +0 -94
  81. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -42,11 +42,12 @@ async def preflight(
42
42
  if _in_pytest():
43
43
  return None
44
44
 
45
- # Load canonical v2 state (server enriches advice + staleness).
45
+ # Load canonical editor state (server enriches advice + staleness).
46
46
  try:
47
- from services.resources.editor_state_v2 import get_editor_state_v2
48
- state_resp = await get_editor_state_v2(ctx)
49
- state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
47
+ from services.resources.editor_state import get_editor_state
48
+ state_resp = await get_editor_state(ctx)
49
+ state = state_resp.model_dump() if hasattr(
50
+ state_resp, "model_dump") else state_resp
50
51
  except Exception:
51
52
  # If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
52
53
  return None
@@ -80,9 +81,12 @@ async def preflight(
80
81
  if wait_for_no_compile:
81
82
  deadline = time.monotonic() + float(max_wait_s)
82
83
  while True:
83
- compilation = data.get("compilation") if isinstance(data, dict) else None
84
- is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True
85
- is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True
84
+ compilation = data.get("compilation") if isinstance(
85
+ data, dict) else None
86
+ is_compiling = isinstance(compilation, dict) and compilation.get(
87
+ "is_compiling") is True
88
+ is_domain_reload_pending = isinstance(compilation, dict) and compilation.get(
89
+ "is_domain_reload_pending") is True
86
90
  if not is_compiling and not is_domain_reload_pending:
87
91
  break
88
92
  if time.monotonic() >= deadline:
@@ -91,9 +95,10 @@ async def preflight(
91
95
 
92
96
  # Refresh state for the next loop iteration.
93
97
  try:
94
- from services.resources.editor_state_v2 import get_editor_state_v2
95
- state_resp = await get_editor_state_v2(ctx)
96
- state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
98
+ from services.resources.editor_state import get_editor_state
99
+ state_resp = await get_editor_state(ctx)
100
+ state = state_resp.model_dump() if hasattr(
101
+ state_resp, "model_dump") else state_resp
97
102
  data = state.get("data") if isinstance(state, dict) else None
98
103
  if not isinstance(data, dict):
99
104
  return None
@@ -103,5 +108,3 @@ async def preflight(
103
108
  # Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
104
109
  # In future we may make this strict for some tools.
105
110
  return None
106
-
107
-
@@ -4,27 +4,44 @@ Defines the read_console tool for accessing Unity Editor console messages.
4
4
  from typing import Annotated, Any, Literal
5
5
 
6
6
  from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
8
+
7
9
  from services.registry import mcp_for_unity_tool
8
10
  from services.tools import get_unity_instance_from_context
9
- from services.tools.utils import coerce_int, coerce_bool
11
+ from services.tools.utils import coerce_int, coerce_bool, parse_json_payload
10
12
  from transport.unity_transport import send_with_unity_instance
11
13
  from transport.legacy.unity_connection import async_send_command_with_retry
12
14
 
13
15
 
16
+ def _strip_stacktrace_from_list(items: list) -> None:
17
+ """Remove stacktrace fields from a list of log entries."""
18
+ for item in items:
19
+ if isinstance(item, dict) and "stacktrace" in item:
20
+ item.pop("stacktrace", None)
21
+
22
+
14
23
  @mcp_for_unity_tool(
15
- description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
24
+ description="Gets messages from or clears the Unity Editor console. Defaults to 10 most recent entries. Use page_size/cursor for paging. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5'). The 'get' action is read-only; 'clear' modifies ephemeral UI state (not project data).",
25
+ annotations=ToolAnnotations(
26
+ title="Read Console",
27
+ ),
16
28
  )
17
29
  async def read_console(
18
30
  ctx: Context,
19
31
  action: Annotated[Literal['get', 'clear'],
20
32
  "Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None,
21
33
  types: Annotated[list[Literal['error', 'warning',
22
- 'log', 'all']], "Message types to get"] | None = None,
34
+ 'log', 'all']] | str,
35
+ "Message types to get (accepts list or JSON string)"] | None = None,
23
36
  count: Annotated[int | str,
24
- "Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
37
+ "Max messages to return in non-paging mode (accepts int or string, e.g., 5 or '5'). Ignored when paging with page_size/cursor."] | None = None,
25
38
  filter_text: Annotated[str, "Text filter for messages"] | None = None,
26
39
  since_timestamp: Annotated[str,
27
40
  "Get messages after this timestamp (ISO 8601)"] | None = None,
41
+ page_size: Annotated[int | str,
42
+ "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
43
+ cursor: Annotated[int | str,
44
+ "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
28
45
  format: Annotated[Literal['plain', 'detailed',
29
46
  'json'], "Output format"] | None = None,
30
47
  include_stacktrace: Annotated[bool | str,
@@ -35,11 +52,48 @@ async def read_console(
35
52
  unity_instance = get_unity_instance_from_context(ctx)
36
53
  # Set defaults if values are None
37
54
  action = action if action is not None else 'get'
38
- types = types if types is not None else ['error', 'warning', 'log']
39
- format = format if format is not None else 'detailed'
55
+
56
+ # Parse types if it's a JSON string (handles client compatibility issue #561)
57
+ if isinstance(types, str):
58
+ types = parse_json_payload(types)
59
+ # Validate types is a list after parsing
60
+ if types is not None and not isinstance(types, list):
61
+ return {
62
+ "success": False,
63
+ "message": (
64
+ f"types must be a list, got {type(types).__name__}. "
65
+ "If passing as JSON string, use format: '[\"error\", \"warning\"]'"
66
+ )
67
+ }
68
+ if types is not None:
69
+ allowed_types = {"error", "warning", "log", "all"}
70
+ normalized_types = []
71
+ for entry in types:
72
+ if not isinstance(entry, str):
73
+ return {
74
+ "success": False,
75
+ "message": f"types entries must be strings, got {type(entry).__name__}"
76
+ }
77
+ normalized = entry.strip().lower()
78
+ if normalized not in allowed_types:
79
+ return {
80
+ "success": False,
81
+ "message": (
82
+ f"invalid types entry '{entry}'. "
83
+ f"Allowed values: {sorted(allowed_types)}"
84
+ )
85
+ }
86
+ normalized_types.append(normalized)
87
+ types = normalized_types
88
+ else:
89
+ types = ['error', 'warning', 'log']
90
+
91
+ format = format if format is not None else 'plain'
40
92
  # Coerce booleans defensively (strings like 'true'/'false')
41
93
 
42
- include_stacktrace = coerce_bool(include_stacktrace, default=True)
94
+ include_stacktrace = coerce_bool(include_stacktrace, default=False)
95
+ coerced_page_size = coerce_int(page_size, default=None)
96
+ coerced_cursor = coerce_int(cursor, default=None)
43
97
 
44
98
  # Normalize action if it's a string
45
99
  if isinstance(action, str):
@@ -56,7 +110,7 @@ async def read_console(
56
110
  count = coerce_int(count)
57
111
 
58
112
  if action == "get" and count is None:
59
- count = 200
113
+ count = 10
60
114
 
61
115
  # Prepare parameters for the C# handler
62
116
  params_dict = {
@@ -65,6 +119,8 @@ async def read_console(
65
119
  "count": count,
66
120
  "filterText": filter_text,
67
121
  "sinceTimestamp": since_timestamp,
122
+ "pageSize": coerced_page_size,
123
+ "cursor": coerced_cursor,
68
124
  "format": format.lower() if isinstance(format, str) else format,
69
125
  "includeStacktrace": include_stacktrace
70
126
  }
@@ -83,16 +139,13 @@ async def read_console(
83
139
  # Strip stacktrace fields from returned lines if present
84
140
  try:
85
141
  data = resp.get("data")
86
- # Handle standard format: {"data": {"lines": [...]}}
87
- if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
88
- for line in data["lines"]:
89
- if isinstance(line, dict) and "stacktrace" in line:
90
- line.pop("stacktrace", None)
91
- # Handle legacy/direct list format if any
142
+ if isinstance(data, dict):
143
+ for key in ("lines", "items"):
144
+ if key in data and isinstance(data[key], list):
145
+ _strip_stacktrace_from_list(data[key])
146
+ break
92
147
  elif isinstance(data, list):
93
- for line in data:
94
- if isinstance(line, dict) and "stacktrace" in line:
95
- line.pop("stacktrace", None)
148
+ _strip_stacktrace_from_list(data)
96
149
  except Exception:
97
150
  pass
98
151
  return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@@ -1,28 +1,40 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
4
5
  import time
5
6
  from typing import Annotated, Any, Literal
6
7
 
7
8
  from fastmcp import Context
9
+ from mcp.types import ToolAnnotations
8
10
 
9
11
  from models import MCPResponse
10
12
  from services.registry import mcp_for_unity_tool
11
13
  from services.tools import get_unity_instance_from_context
12
14
  import transport.unity_transport as unity_transport
13
- from transport.legacy.unity_connection import async_send_command_with_retry
15
+ from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
14
16
  from services.state.external_changes_scanner import external_changes_scanner
17
+ import services.resources.editor_state as editor_state
18
+
19
+ logger = logging.getLogger(__name__)
15
20
 
16
21
 
17
22
  @mcp_for_unity_tool(
18
- description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
23
+ description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
24
+ annotations=ToolAnnotations(
25
+ title="Refresh Unity",
26
+ destructiveHint=True,
27
+ ),
19
28
  )
20
29
  async def refresh_unity(
21
30
  ctx: Context,
22
31
  mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
23
- scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all",
24
- compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none",
25
- wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
32
+ scope: Annotated[Literal["assets", "scripts", "all"],
33
+ "Refresh scope"] = "all",
34
+ compile: Annotated[Literal["none", "request"],
35
+ "Whether to request compilation"] = "none",
36
+ wait_for_ready: Annotated[bool,
37
+ "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
26
38
  ) -> MCPResponse | dict[str, Any]:
27
39
  unity_instance = get_unity_instance_from_context(ctx)
28
40
 
@@ -34,45 +46,98 @@ async def refresh_unity(
34
46
  }
35
47
 
36
48
  recovered_from_disconnect = False
49
+ # Don't retry on reload - refresh_unity triggers compilation/reload,
50
+ # so retrying would cause multiple reloads (issue #577)
37
51
  response = await unity_transport.send_with_unity_instance(
38
52
  async_send_command_with_retry,
39
53
  unity_instance,
40
54
  "refresh_unity",
41
55
  params,
56
+ retry_on_reload=False,
42
57
  )
43
58
 
44
- # Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
45
- # Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
46
- # interpret that as a hard failure (#503-style loops).
47
- if isinstance(response, dict) and not response.get("success", True):
48
- hint = response.get("hint")
49
- err = (response.get("error") or response.get("message") or "")
50
- is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
51
- if (not wait_for_ready) or (not is_retryable):
52
- return MCPResponse(**response)
53
- recovered_from_disconnect = True
59
+ # Handle connection errors during refresh/compile gracefully.
60
+ # Unity disconnects during domain reload, which is expected behavior - not a failure.
61
+ # If we sent the command and connection closed, the refresh was likely triggered successfully.
62
+ # Convert MCPResponse to dict if needed
63
+ response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
64
+ if not response_dict.get("success", True):
65
+ hint = response_dict.get("hint")
66
+ err = (response_dict.get("error") or response_dict.get("message") or "").lower()
67
+ reason = _extract_response_reason(response_dict)
68
+
69
+ # Connection closed/timeout during compile = refresh was triggered, Unity is reloading
70
+ # This is SUCCESS, not failure - don't return error to prevent Claude Code from retrying
71
+ is_connection_lost = (
72
+ "connection closed" in err
73
+ or "disconnected" in err
74
+ or "aborted" in err # WinError 10053: connection aborted
75
+ or "timeout" in err
76
+ or reason == "reloading"
77
+ )
78
+
79
+ if is_connection_lost and compile == "request":
80
+ # EXPECTED BEHAVIOR: When compile="request", Unity triggers domain reload which
81
+ # causes connection to close mid-command. This is NOT a failure - the refresh
82
+ # was successfully triggered. Treating this as success prevents Claude Code from
83
+ # retrying unnecessarily (which would cause multiple domain reloads - issue #577).
84
+ # The subsequent wait_for_ready loop (below) will verify Unity becomes ready.
85
+ logger.info("refresh_unity: Connection lost during compile (expected - domain reload triggered)")
86
+ recovered_from_disconnect = True
87
+ elif hint == "retry" or "could not connect" in err:
88
+ # Retryable error - proceed to wait loop if wait_for_ready
89
+ if not wait_for_ready:
90
+ return MCPResponse(**response_dict)
91
+ recovered_from_disconnect = True
92
+ else:
93
+ # Non-recoverable error - connection issue unrelated to domain reload
94
+ logger.warning(f"refresh_unity: Non-recoverable error (compile={compile}): {err[:100]}")
95
+ return MCPResponse(**response_dict)
54
96
 
55
97
  # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
56
- # poll the canonical editor_state v2 resource until ready or timeout.
98
+ # poll the canonical editor_state resource until ready or timeout.
99
+ ready_confirmed = False
57
100
  if wait_for_ready:
58
101
  timeout_s = 60.0
59
102
  start = time.monotonic()
60
- from services.resources.editor_state_v2 import get_editor_state_v2
103
+
104
+ # Blocking reasons that indicate Unity is actually busy (not just stale status)
105
+ # Must match activityPhase values from EditorStateCache.cs
106
+ real_blocking_reasons = {"compiling", "domain_reload", "running_tests", "asset_import"}
61
107
 
62
108
  while time.monotonic() - start < timeout_s:
63
- state_resp = await get_editor_state_v2(ctx)
64
- state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
65
- data = (state or {}).get("data") if isinstance(state, dict) else None
66
- advice = (data or {}).get("advice") if isinstance(data, dict) else None
67
- if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
68
- break
109
+ state_resp = await editor_state.get_editor_state(ctx)
110
+ state = state_resp.model_dump() if hasattr(
111
+ state_resp, "model_dump") else state_resp
112
+ data = (state or {}).get("data") if isinstance(
113
+ state, dict) else None
114
+ advice = (data or {}).get(
115
+ "advice") if isinstance(data, dict) else None
116
+ if isinstance(advice, dict):
117
+ # Exit if ready_for_tools is True
118
+ if advice.get("ready_for_tools") is True:
119
+ ready_confirmed = True
120
+ break
121
+ # Also exit if the only blocking reason is "stale_status" (Unity in background)
122
+ # Staleness means we can't confirm status, not that Unity is actually busy
123
+ blocking = set(advice.get("blocking_reasons") or [])
124
+ if not (blocking & real_blocking_reasons):
125
+ ready_confirmed = True # No real blocking reasons, consider ready
126
+ break
69
127
  await asyncio.sleep(0.25)
70
128
 
129
+ # If we timed out without confirming readiness, log and return failure
130
+ if not ready_confirmed:
131
+ logger.warning(f"refresh_unity: Timed out after {timeout_s}s waiting for editor to become ready")
132
+ return MCPResponse(
133
+ success=False,
134
+ message=f"Refresh triggered but timed out after {timeout_s}s waiting for editor readiness.",
135
+ data={"timeout": True, "wait_seconds": timeout_s},
136
+ )
137
+
71
138
  # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
72
139
  try:
73
- from services.resources.editor_state_v2 import _infer_single_instance_id
74
-
75
- inst = unity_instance or await _infer_single_instance_id(ctx)
140
+ inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
76
141
  if inst:
77
142
  external_changes_scanner.clear_dirty(inst)
78
143
  except Exception:
@@ -85,6 +150,4 @@ async def refresh_unity(
85
150
  data={"recovered_from_disconnect": True},
86
151
  )
87
152
 
88
- return MCPResponse(**response) if isinstance(response, dict) else response
89
-
90
-
153
+ return MCPResponse(**response_dict) if isinstance(response, dict) else response
@@ -1,16 +1,24 @@
1
- """Tool for executing Unity Test Runner suites."""
2
- from typing import Annotated, Literal, Any
1
+ """Async Unity Test Runner jobs: start + poll."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import time
7
+ from typing import Annotated, Any, Literal
3
8
 
4
9
  from fastmcp import Context
5
- from pydantic import BaseModel, Field
10
+ from mcp.types import ToolAnnotations
11
+ from pydantic import BaseModel
6
12
 
7
13
  from models import MCPResponse
8
14
  from services.registry import mcp_for_unity_tool
9
15
  from services.tools import get_unity_instance_from_context
10
- from services.tools.utils import coerce_int
11
- from transport.unity_transport import send_with_unity_instance
12
- from transport.legacy.unity_connection import async_send_command_with_retry
13
16
  from services.tools.preflight import preflight
17
+ import transport.unity_transport as unity_transport
18
+ from transport.legacy.unity_connection import async_send_command_with_retry
19
+ from utils.focus_nudge import nudge_unity_focus, should_nudge
20
+
21
+ logger = logging.getLogger(__name__)
14
22
 
15
23
 
16
24
  class RunTestsSummary(BaseModel):
@@ -38,31 +46,83 @@ class RunTestsResult(BaseModel):
38
46
  results: list[RunTestsTestResult] | None = None
39
47
 
40
48
 
41
- class RunTestsResponse(MCPResponse):
42
- data: RunTestsResult | None = None
49
+ class RunTestsStartData(BaseModel):
50
+ job_id: str
51
+ status: str
52
+ mode: str | None = None
53
+ include_details: bool | None = None
54
+ include_failed_tests: bool | None = None
55
+
56
+
57
+ class RunTestsStartResponse(MCPResponse):
58
+ data: RunTestsStartData | None = None
59
+
60
+
61
+ class TestJobFailure(BaseModel):
62
+ full_name: str | None = None
63
+ message: str | None = None
64
+
65
+
66
+ class TestJobProgress(BaseModel):
67
+ completed: int | None = None
68
+ total: int | None = None
69
+ current_test_full_name: str | None = None
70
+ current_test_started_unix_ms: int | None = None
71
+ last_finished_test_full_name: str | None = None
72
+ last_finished_unix_ms: int | None = None
73
+ stuck_suspected: bool | None = None
74
+ editor_is_focused: bool | None = None
75
+ blocked_reason: str | None = None
76
+ failures_so_far: list[TestJobFailure] | None = None
77
+ failures_capped: bool | None = None
78
+
79
+
80
+ class GetTestJobData(BaseModel):
81
+ job_id: str
82
+ status: str
83
+ mode: str | None = None
84
+ started_unix_ms: int | None = None
85
+ finished_unix_ms: int | None = None
86
+ last_update_unix_ms: int | None = None
87
+ progress: TestJobProgress | None = None
88
+ error: str | None = None
89
+ result: RunTestsResult | None = None
90
+
91
+
92
+ class GetTestJobResponse(MCPResponse):
93
+ data: GetTestJobData | None = None
43
94
 
44
95
 
45
96
  @mcp_for_unity_tool(
46
- description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
97
+ description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
98
+ annotations=ToolAnnotations(
99
+ title="Run Tests",
100
+ destructiveHint=True,
101
+ ),
47
102
  )
48
103
  async def run_tests(
49
104
  ctx: Context,
50
- mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
51
- timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None,
52
- test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None,
53
- group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
54
- category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
55
- assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
56
- include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
57
- include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
58
- ) -> RunTestsResponse | MCPResponse:
105
+ mode: Annotated[Literal["EditMode", "PlayMode"],
106
+ "Unity test mode to run"] = "EditMode",
107
+ test_names: Annotated[list[str] | str,
108
+ "Full names of specific tests to run"] | None = None,
109
+ group_names: Annotated[list[str] | str,
110
+ "Same as test_names, except it allows for Regex"] | None = None,
111
+ category_names: Annotated[list[str] | str,
112
+ "NUnit category names to filter by"] | None = None,
113
+ assembly_names: Annotated[list[str] | str,
114
+ "Assembly names to filter tests by"] | None = None,
115
+ include_failed_tests: Annotated[bool,
116
+ "Include details for failed/skipped tests only (default: false)"] = False,
117
+ include_details: Annotated[bool,
118
+ "Include details for all tests (default: false)"] = False,
119
+ ) -> RunTestsStartResponse | MCPResponse:
59
120
  unity_instance = get_unity_instance_from_context(ctx)
60
121
 
61
122
  gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
62
123
  if isinstance(gate, MCPResponse):
63
124
  return gate
64
125
 
65
- # Coerce string or list to list of strings
66
126
  def _coerce_string_list(value) -> list[str] | None:
67
127
  if value is None:
68
128
  return None
@@ -74,47 +134,121 @@ async def run_tests(
74
134
  return None
75
135
 
76
136
  params: dict[str, Any] = {"mode": mode}
77
- ts = coerce_int(timeout_seconds)
78
- if ts is not None:
79
- params["timeoutSeconds"] = ts
137
+ if (t := _coerce_string_list(test_names)):
138
+ params["testNames"] = t
139
+ if (g := _coerce_string_list(group_names)):
140
+ params["groupNames"] = g
141
+ if (c := _coerce_string_list(category_names)):
142
+ params["categoryNames"] = c
143
+ if (a := _coerce_string_list(assembly_names)):
144
+ params["assemblyNames"] = a
145
+ if include_failed_tests:
146
+ params["includeFailedTests"] = True
147
+ if include_details:
148
+ params["includeDetails"] = True
80
149
 
81
- # Add filter parameters if provided
82
- test_names_list = _coerce_string_list(test_names)
83
- if test_names_list:
84
- params["testNames"] = test_names_list
150
+ response = await unity_transport.send_with_unity_instance(
151
+ async_send_command_with_retry,
152
+ unity_instance,
153
+ "run_tests",
154
+ params,
155
+ )
85
156
 
86
- group_names_list = _coerce_string_list(group_names)
87
- if group_names_list:
88
- params["groupNames"] = group_names_list
157
+ if isinstance(response, dict):
158
+ if not response.get("success", True):
159
+ return MCPResponse(**response)
160
+ return RunTestsStartResponse(**response)
161
+ return MCPResponse(success=False, error=str(response))
89
162
 
90
- category_names_list = _coerce_string_list(category_names)
91
- if category_names_list:
92
- params["categoryNames"] = category_names_list
93
163
 
94
- assembly_names_list = _coerce_string_list(assembly_names)
95
- if assembly_names_list:
96
- params["assemblyNames"] = assembly_names_list
164
+ @mcp_for_unity_tool(
165
+ description="Polls an async Unity test job by job_id.",
166
+ annotations=ToolAnnotations(
167
+ title="Get Test Job",
168
+ readOnlyHint=True,
169
+ ),
170
+ )
171
+ async def get_test_job(
172
+ ctx: Context,
173
+ job_id: Annotated[str, "Job id returned by run_tests"],
174
+ include_failed_tests: Annotated[bool,
175
+ "Include details for failed/skipped tests only (default: false)"] = False,
176
+ include_details: Annotated[bool,
177
+ "Include details for all tests (default: false)"] = False,
178
+ wait_timeout: Annotated[int | None,
179
+ "If set, wait up to this many seconds for tests to complete before returning. "
180
+ "Reduces polling frequency and avoids client-side loop detection. "
181
+ "Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
182
+ ) -> GetTestJobResponse | MCPResponse:
183
+ unity_instance = get_unity_instance_from_context(ctx)
97
184
 
98
- # Add verbosity parameters
185
+ params: dict[str, Any] = {"job_id": job_id}
99
186
  if include_failed_tests:
100
187
  params["includeFailedTests"] = True
101
188
  if include_details:
102
189
  params["includeDetails"] = True
103
190
 
104
- response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
105
-
106
- # If Unity indicates a run is already active, return a structured "busy" response rather than
107
- # letting clients interpret this as a generic failure (avoids #503 retry loops).
108
- if isinstance(response, dict) and not response.get("success", True):
109
- err = (response.get("error") or response.get("message") or "").strip()
110
- if "test run is already in progress" in err.lower():
111
- return MCPResponse(
112
- success=False,
113
- error="tests_running",
114
- message=err,
115
- hint="retry",
116
- data={"reason": "tests_running", "retry_after_ms": 5000},
117
- )
118
- return MCPResponse(**response)
119
-
120
- return RunTestsResponse(**response) if isinstance(response, dict) else response
191
+ async def _fetch_status() -> dict[str, Any]:
192
+ return await unity_transport.send_with_unity_instance(
193
+ async_send_command_with_retry,
194
+ unity_instance,
195
+ "get_test_job",
196
+ params,
197
+ )
198
+
199
+ # If wait_timeout is specified, poll server-side until complete or timeout
200
+ if wait_timeout and wait_timeout > 0:
201
+ deadline = asyncio.get_event_loop().time() + wait_timeout
202
+ poll_interval = 2.0 # Poll Unity every 2 seconds
203
+
204
+ while True:
205
+ response = await _fetch_status()
206
+
207
+ if not isinstance(response, dict):
208
+ return MCPResponse(success=False, error=str(response))
209
+
210
+ if not response.get("success", True):
211
+ return MCPResponse(**response)
212
+
213
+ # Check if tests are done
214
+ data = response.get("data", {})
215
+ status = data.get("status", "")
216
+ if status in ("succeeded", "failed", "cancelled"):
217
+ return GetTestJobResponse(**response)
218
+
219
+ # Check if Unity needs a focus nudge to make progress
220
+ # This handles OS-level throttling (e.g., macOS App Nap) that can
221
+ # stall PlayMode tests when Unity is in the background.
222
+ progress = data.get("progress", {})
223
+ editor_is_focused = progress.get("editor_is_focused", True)
224
+ last_update_unix_ms = data.get("last_update_unix_ms")
225
+ current_time_ms = int(time.time() * 1000)
226
+
227
+ if should_nudge(
228
+ status=status,
229
+ editor_is_focused=editor_is_focused,
230
+ last_update_unix_ms=last_update_unix_ms,
231
+ current_time_ms=current_time_ms,
232
+ stall_threshold_ms=10_000, # 10 seconds without progress
233
+ ):
234
+ logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
235
+ nudged = await nudge_unity_focus(focus_duration_s=0.5)
236
+ if nudged:
237
+ logger.info(f"Test job {job_id} nudge completed")
238
+
239
+ # Check timeout
240
+ remaining = deadline - asyncio.get_event_loop().time()
241
+ if remaining <= 0:
242
+ # Timeout reached, return current status
243
+ return GetTestJobResponse(**response)
244
+
245
+ # Wait before next poll (but don't exceed remaining time)
246
+ await asyncio.sleep(min(poll_interval, remaining))
247
+
248
+ # No wait_timeout - return immediately (original behavior)
249
+ response = await _fetch_status()
250
+ if isinstance(response, dict):
251
+ if not response.get("success", True):
252
+ return MCPResponse(**response)
253
+ return GetTestJobResponse(**response)
254
+ return MCPResponse(success=False, error=str(response))