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
@@ -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
-
@@ -1,94 +0,0 @@
1
- """Async Unity Test Runner jobs: start + poll."""
2
- from __future__ import annotations
3
-
4
- from typing import Annotated, Any, Literal
5
-
6
- from fastmcp import Context
7
-
8
- from models import MCPResponse
9
- from services.registry import mcp_for_unity_tool
10
- from services.tools import get_unity_instance_from_context
11
- from services.tools.preflight import preflight
12
- import transport.unity_transport as unity_transport
13
- from transport.legacy.unity_connection import async_send_command_with_retry
14
-
15
-
16
- @mcp_for_unity_tool(description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.")
17
- async def run_tests_async(
18
- ctx: Context,
19
- mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
20
- test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | None = None,
21
- group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
22
- category_names: Annotated[list[str] | str, "NUnit category names to filter by"] | None = None,
23
- assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
24
- include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
25
- include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
26
- ) -> dict[str, Any] | MCPResponse:
27
- unity_instance = get_unity_instance_from_context(ctx)
28
-
29
- gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
30
- if isinstance(gate, MCPResponse):
31
- return gate
32
-
33
- def _coerce_string_list(value) -> list[str] | None:
34
- if value is None:
35
- return None
36
- if isinstance(value, str):
37
- return [value] if value.strip() else None
38
- if isinstance(value, list):
39
- result = [str(v).strip() for v in value if v and str(v).strip()]
40
- return result if result else None
41
- return None
42
-
43
- params: dict[str, Any] = {"mode": mode}
44
- if (t := _coerce_string_list(test_names)):
45
- params["testNames"] = t
46
- if (g := _coerce_string_list(group_names)):
47
- params["groupNames"] = g
48
- if (c := _coerce_string_list(category_names)):
49
- params["categoryNames"] = c
50
- if (a := _coerce_string_list(assembly_names)):
51
- params["assemblyNames"] = a
52
- if include_failed_tests:
53
- params["includeFailedTests"] = True
54
- if include_details:
55
- params["includeDetails"] = True
56
-
57
- response = await unity_transport.send_with_unity_instance(
58
- async_send_command_with_retry,
59
- unity_instance,
60
- "run_tests_async",
61
- params,
62
- )
63
-
64
- if isinstance(response, dict) and not response.get("success", True):
65
- return MCPResponse(**response)
66
- return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
67
-
68
-
69
- @mcp_for_unity_tool(description="Polls an async Unity test job by job_id.")
70
- async def get_test_job(
71
- ctx: Context,
72
- job_id: Annotated[str, "Job id returned by run_tests_async"],
73
- include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
74
- include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
75
- ) -> dict[str, Any] | MCPResponse:
76
- unity_instance = get_unity_instance_from_context(ctx)
77
-
78
- params: dict[str, Any] = {"job_id": job_id}
79
- if include_failed_tests:
80
- params["includeFailedTests"] = True
81
- if include_details:
82
- params["includeDetails"] = True
83
-
84
- response = await unity_transport.send_with_unity_instance(
85
- async_send_command_with_retry,
86
- unity_instance,
87
- "get_test_job",
88
- params,
89
- )
90
- if isinstance(response, dict) and not response.get("success", True):
91
- return MCPResponse(**response)
92
- return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
93
-
94
-