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.
Files changed (53) hide show
  1. main.py +4 -3
  2. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/METADATA +2 -2
  3. mcpforunityserver-9.0.1.dist-info/RECORD +72 -0
  4. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/top_level.txt +0 -1
  5. services/custom_tool_service.py +13 -8
  6. services/resources/active_tool.py +1 -1
  7. services/resources/custom_tools.py +2 -2
  8. services/resources/editor_state.py +283 -30
  9. services/resources/gameobject.py +243 -0
  10. services/resources/layers.py +1 -1
  11. services/resources/prefab_stage.py +1 -1
  12. services/resources/project_info.py +1 -1
  13. services/resources/selection.py +1 -1
  14. services/resources/tags.py +1 -1
  15. services/resources/unity_instances.py +1 -1
  16. services/resources/windows.py +1 -1
  17. services/state/external_changes_scanner.py +3 -4
  18. services/tools/batch_execute.py +24 -9
  19. services/tools/debug_request_context.py +8 -2
  20. services/tools/execute_custom_tool.py +6 -1
  21. services/tools/execute_menu_item.py +6 -3
  22. services/tools/find_gameobjects.py +89 -0
  23. services/tools/find_in_file.py +26 -19
  24. services/tools/manage_asset.py +13 -44
  25. services/tools/manage_components.py +131 -0
  26. services/tools/manage_editor.py +9 -8
  27. services/tools/manage_gameobject.py +115 -79
  28. services/tools/manage_material.py +80 -31
  29. services/tools/manage_prefabs.py +7 -1
  30. services/tools/manage_scene.py +30 -13
  31. services/tools/manage_script.py +62 -19
  32. services/tools/manage_scriptable_object.py +22 -10
  33. services/tools/manage_shader.py +8 -1
  34. services/tools/manage_vfx.py +738 -0
  35. services/tools/preflight.py +15 -12
  36. services/tools/read_console.py +11 -4
  37. services/tools/refresh_unity.py +24 -14
  38. services/tools/run_tests.py +162 -53
  39. services/tools/script_apply_edits.py +15 -7
  40. services/tools/set_active_instance.py +12 -7
  41. services/tools/utils.py +60 -6
  42. transport/legacy/port_discovery.py +2 -2
  43. transport/legacy/unity_connection.py +1 -1
  44. transport/plugin_hub.py +24 -16
  45. transport/unity_instance_middleware.py +4 -3
  46. transport/unity_transport.py +2 -1
  47. mcpforunityserver-8.7.1.dist-info/RECORD +0 -71
  48. routes/__init__.py +0 -0
  49. services/resources/editor_state_v2.py +0 -270
  50. services/tools/test_jobs.py +0 -94
  51. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/WHEEL +0 -0
  52. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/entry_points.txt +0 -0
  53. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.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,6 +4,8 @@ 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
11
  from services.tools.utils import coerce_int, coerce_bool
@@ -19,7 +21,10 @@ def _strip_stacktrace_from_list(items: list) -> None:
19
21
 
20
22
 
21
23
  @mcp_for_unity_tool(
22
- 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')."
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
+ ),
23
28
  )
24
29
  async def read_console(
25
30
  ctx: Context,
@@ -32,8 +37,10 @@ async def read_console(
32
37
  filter_text: Annotated[str, "Text filter for messages"] | None = None,
33
38
  since_timestamp: Annotated[str,
34
39
  "Get messages after this timestamp (ISO 8601)"] | None = None,
35
- page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
36
- cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
40
+ page_size: Annotated[int | str,
41
+ "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
42
+ cursor: Annotated[int | str,
43
+ "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
37
44
  format: Annotated[Literal['plain', 'detailed',
38
45
  'json'], "Output format"] | None = None,
39
46
  include_stacktrace: Annotated[bool | str,
@@ -44,7 +51,7 @@ async def read_console(
44
51
  unity_instance = get_unity_instance_from_context(ctx)
45
52
  # Set defaults if values are None
46
53
  action = action if action is not None else 'get'
47
- types = types if types is not None else ['error', 'warning']
54
+ types = types if types is not None else ['error', 'warning', 'log']
48
55
  format = format if format is not None else 'plain'
49
56
  # Coerce booleans defensively (strings like 'true'/'false')
50
57
 
@@ -5,6 +5,7 @@ import time
5
5
  from typing import Annotated, Any, Literal
6
6
 
7
7
  from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
8
9
 
9
10
  from models import MCPResponse
10
11
  from services.registry import mcp_for_unity_tool
@@ -12,17 +13,25 @@ from services.tools import get_unity_instance_from_context
12
13
  import transport.unity_transport as unity_transport
13
14
  from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
14
15
  from services.state.external_changes_scanner import external_changes_scanner
16
+ import services.resources.editor_state as editor_state
15
17
 
16
18
 
17
19
  @mcp_for_unity_tool(
18
- description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
20
+ description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
21
+ annotations=ToolAnnotations(
22
+ title="Refresh Unity",
23
+ destructiveHint=True,
24
+ ),
19
25
  )
20
26
  async def refresh_unity(
21
27
  ctx: Context,
22
28
  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,
29
+ scope: Annotated[Literal["assets", "scripts", "all"],
30
+ "Refresh scope"] = "all",
31
+ compile: Annotated[Literal["none", "request"],
32
+ "Whether to request compilation"] = "none",
33
+ wait_for_ready: Annotated[bool,
34
+ "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
26
35
  ) -> MCPResponse | dict[str, Any]:
27
36
  unity_instance = get_unity_instance_from_context(ctx)
28
37
 
@@ -48,33 +57,34 @@ async def refresh_unity(
48
57
  hint = response.get("hint")
49
58
  err = (response.get("error") or response.get("message") or "")
50
59
  reason = _extract_response_reason(response)
51
- is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
60
+ is_retryable = (hint == "retry") or (
61
+ "disconnected" in str(err).lower())
52
62
  if (not wait_for_ready) or (not is_retryable):
53
63
  return MCPResponse(**response)
54
64
  if reason not in {"reloading", "no_unity_session"}:
55
65
  recovered_from_disconnect = True
56
66
 
57
67
  # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
58
- # poll the canonical editor_state v2 resource until ready or timeout.
68
+ # poll the canonical editor_state resource until ready or timeout.
59
69
  if wait_for_ready:
60
70
  timeout_s = 60.0
61
71
  start = time.monotonic()
62
- from services.resources.editor_state_v2 import get_editor_state_v2
63
72
 
64
73
  while time.monotonic() - start < timeout_s:
65
- state_resp = await get_editor_state_v2(ctx)
66
- state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
67
- data = (state or {}).get("data") if isinstance(state, dict) else None
68
- advice = (data or {}).get("advice") if isinstance(data, dict) else None
74
+ state_resp = await editor_state.get_editor_state(ctx)
75
+ state = state_resp.model_dump() if hasattr(
76
+ state_resp, "model_dump") else state_resp
77
+ data = (state or {}).get("data") if isinstance(
78
+ state, dict) else None
79
+ advice = (data or {}).get(
80
+ "advice") if isinstance(data, dict) else None
69
81
  if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
70
82
  break
71
83
  await asyncio.sleep(0.25)
72
84
 
73
85
  # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
74
86
  try:
75
- from services.resources.editor_state_v2 import _infer_single_instance_id
76
-
77
- inst = unity_instance or await _infer_single_instance_id(ctx)
87
+ inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
78
88
  if inst:
79
89
  external_changes_scanner.clear_dirty(inst)
80
90
  except Exception:
@@ -1,16 +1,19 @@
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
+ from typing import Annotated, Any, Literal
3
6
 
4
7
  from fastmcp import Context
5
- from pydantic import BaseModel, Field
8
+ from mcp.types import ToolAnnotations
9
+ from pydantic import BaseModel
6
10
 
7
11
  from models import MCPResponse
8
12
  from services.registry import mcp_for_unity_tool
9
13
  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
14
  from services.tools.preflight import preflight
15
+ import transport.unity_transport as unity_transport
16
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
17
 
15
18
 
16
19
  class RunTestsSummary(BaseModel):
@@ -38,31 +41,83 @@ class RunTestsResult(BaseModel):
38
41
  results: list[RunTestsTestResult] | None = None
39
42
 
40
43
 
41
- class RunTestsResponse(MCPResponse):
42
- data: RunTestsResult | None = None
44
+ class RunTestsStartData(BaseModel):
45
+ job_id: str
46
+ status: str
47
+ mode: str | None = None
48
+ include_details: bool | None = None
49
+ include_failed_tests: bool | None = None
50
+
51
+
52
+ class RunTestsStartResponse(MCPResponse):
53
+ data: RunTestsStartData | None = None
54
+
55
+
56
+ class TestJobFailure(BaseModel):
57
+ full_name: str | None = None
58
+ message: str | None = None
59
+
60
+
61
+ class TestJobProgress(BaseModel):
62
+ completed: int | None = None
63
+ total: int | None = None
64
+ current_test_full_name: str | None = None
65
+ current_test_started_unix_ms: int | None = None
66
+ last_finished_test_full_name: str | None = None
67
+ last_finished_unix_ms: int | None = None
68
+ stuck_suspected: bool | None = None
69
+ editor_is_focused: bool | None = None
70
+ blocked_reason: str | None = None
71
+ failures_so_far: list[TestJobFailure] | None = None
72
+ failures_capped: bool | None = None
73
+
74
+
75
+ class GetTestJobData(BaseModel):
76
+ job_id: str
77
+ status: str
78
+ mode: str | None = None
79
+ started_unix_ms: int | None = None
80
+ finished_unix_ms: int | None = None
81
+ last_update_unix_ms: int | None = None
82
+ progress: TestJobProgress | None = None
83
+ error: str | None = None
84
+ result: RunTestsResult | None = None
85
+
86
+
87
+ class GetTestJobResponse(MCPResponse):
88
+ data: GetTestJobData | None = None
43
89
 
44
90
 
45
91
  @mcp_for_unity_tool(
46
- description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
92
+ description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
93
+ annotations=ToolAnnotations(
94
+ title="Run Tests",
95
+ destructiveHint=True,
96
+ ),
47
97
  )
48
98
  async def run_tests(
49
99
  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:
100
+ mode: Annotated[Literal["EditMode", "PlayMode"],
101
+ "Unity test mode to run"] = "EditMode",
102
+ test_names: Annotated[list[str] | str,
103
+ "Full names of specific tests to run"] | None = None,
104
+ group_names: Annotated[list[str] | str,
105
+ "Same as test_names, except it allows for Regex"] | None = None,
106
+ category_names: Annotated[list[str] | str,
107
+ "NUnit category names to filter by"] | None = None,
108
+ assembly_names: Annotated[list[str] | str,
109
+ "Assembly names to filter tests by"] | None = None,
110
+ include_failed_tests: Annotated[bool,
111
+ "Include details for failed/skipped tests only (default: false)"] = False,
112
+ include_details: Annotated[bool,
113
+ "Include details for all tests (default: false)"] = False,
114
+ ) -> RunTestsStartResponse | MCPResponse:
59
115
  unity_instance = get_unity_instance_from_context(ctx)
60
116
 
61
117
  gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
62
118
  if isinstance(gate, MCPResponse):
63
119
  return gate
64
120
 
65
- # Coerce string or list to list of strings
66
121
  def _coerce_string_list(value) -> list[str] | None:
67
122
  if value is None:
68
123
  return None
@@ -74,47 +129,101 @@ async def run_tests(
74
129
  return None
75
130
 
76
131
  params: dict[str, Any] = {"mode": mode}
77
- ts = coerce_int(timeout_seconds)
78
- if ts is not None:
79
- params["timeoutSeconds"] = ts
132
+ if (t := _coerce_string_list(test_names)):
133
+ params["testNames"] = t
134
+ if (g := _coerce_string_list(group_names)):
135
+ params["groupNames"] = g
136
+ if (c := _coerce_string_list(category_names)):
137
+ params["categoryNames"] = c
138
+ if (a := _coerce_string_list(assembly_names)):
139
+ params["assemblyNames"] = a
140
+ if include_failed_tests:
141
+ params["includeFailedTests"] = True
142
+ if include_details:
143
+ params["includeDetails"] = True
80
144
 
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
145
+ response = await unity_transport.send_with_unity_instance(
146
+ async_send_command_with_retry,
147
+ unity_instance,
148
+ "run_tests",
149
+ params,
150
+ )
85
151
 
86
- group_names_list = _coerce_string_list(group_names)
87
- if group_names_list:
88
- params["groupNames"] = group_names_list
152
+ if isinstance(response, dict):
153
+ if not response.get("success", True):
154
+ return MCPResponse(**response)
155
+ return RunTestsStartResponse(**response)
156
+ return MCPResponse(success=False, error=str(response))
89
157
 
90
- category_names_list = _coerce_string_list(category_names)
91
- if category_names_list:
92
- params["categoryNames"] = category_names_list
93
158
 
94
- assembly_names_list = _coerce_string_list(assembly_names)
95
- if assembly_names_list:
96
- params["assemblyNames"] = assembly_names_list
159
+ @mcp_for_unity_tool(
160
+ description="Polls an async Unity test job by job_id.",
161
+ annotations=ToolAnnotations(
162
+ title="Get Test Job",
163
+ readOnlyHint=True,
164
+ ),
165
+ )
166
+ async def get_test_job(
167
+ ctx: Context,
168
+ job_id: Annotated[str, "Job id returned by run_tests"],
169
+ include_failed_tests: Annotated[bool,
170
+ "Include details for failed/skipped tests only (default: false)"] = False,
171
+ include_details: Annotated[bool,
172
+ "Include details for all tests (default: false)"] = False,
173
+ wait_timeout: Annotated[int | None,
174
+ "If set, wait up to this many seconds for tests to complete before returning. "
175
+ "Reduces polling frequency and avoids client-side loop detection. "
176
+ "Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
177
+ ) -> GetTestJobResponse | MCPResponse:
178
+ unity_instance = get_unity_instance_from_context(ctx)
97
179
 
98
- # Add verbosity parameters
180
+ params: dict[str, Any] = {"job_id": job_id}
99
181
  if include_failed_tests:
100
182
  params["includeFailedTests"] = True
101
183
  if include_details:
102
184
  params["includeDetails"] = True
103
185
 
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
186
+ async def _fetch_status() -> dict[str, Any]:
187
+ return await unity_transport.send_with_unity_instance(
188
+ async_send_command_with_retry,
189
+ unity_instance,
190
+ "get_test_job",
191
+ params,
192
+ )
193
+
194
+ # If wait_timeout is specified, poll server-side until complete or timeout
195
+ if wait_timeout and wait_timeout > 0:
196
+ deadline = asyncio.get_event_loop().time() + wait_timeout
197
+ poll_interval = 2.0 # Poll Unity every 2 seconds
198
+
199
+ while True:
200
+ response = await _fetch_status()
201
+
202
+ if not isinstance(response, dict):
203
+ return MCPResponse(success=False, error=str(response))
204
+
205
+ if not response.get("success", True):
206
+ return MCPResponse(**response)
207
+
208
+ # Check if tests are done
209
+ data = response.get("data", {})
210
+ status = data.get("status", "")
211
+ if status in ("succeeded", "failed", "cancelled"):
212
+ return GetTestJobResponse(**response)
213
+
214
+ # Check timeout
215
+ remaining = deadline - asyncio.get_event_loop().time()
216
+ if remaining <= 0:
217
+ # Timeout reached, return current status
218
+ return GetTestJobResponse(**response)
219
+
220
+ # Wait before next poll (but don't exceed remaining time)
221
+ await asyncio.sleep(min(poll_interval, remaining))
222
+
223
+ # No wait_timeout - return immediately (original behavior)
224
+ response = await _fetch_status()
225
+ if isinstance(response, dict):
226
+ if not response.get("success", True):
227
+ return MCPResponse(**response)
228
+ return GetTestJobResponse(**response)
229
+ return MCPResponse(success=False, error=str(response))
@@ -4,6 +4,7 @@ import re
4
4
  from typing import Annotated, Any, Union
5
5
 
6
6
  from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
7
8
 
8
9
  from services.registry import mcp_for_unity_tool
9
10
  from services.tools import get_unity_instance_from_context
@@ -228,7 +229,7 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
228
229
  - name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
229
230
  - name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
230
231
  - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
231
- - name or path using uri prefixes: unity://path/..., file://...
232
+ - name or path using uri prefixes: mcpforunity://path/..., file://...
232
233
  - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
233
234
 
234
235
  Returns (name_without_extension, directory_path_under_Assets).
@@ -237,8 +238,8 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
237
238
  p = (path or "").strip()
238
239
 
239
240
  def strip_prefix(s: str) -> str:
240
- if s.startswith("unity://path/"):
241
- return s[len("unity://path/"):]
241
+ if s.startswith("mcpforunity://path/"):
242
+ return s[len("mcpforunity://path/"):]
242
243
  if s.startswith("file://"):
243
244
  return s[len("file://"):]
244
245
  return s
@@ -309,8 +310,10 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
309
310
  # Natural-language parsing removed; clients should send structured edits.
310
311
 
311
312
 
312
- @mcp_for_unity_tool(name="script_apply_edits", description=(
313
- """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
313
+ @mcp_for_unity_tool(
314
+ name="script_apply_edits",
315
+ description=(
316
+ """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
314
317
  Best practices:
315
318
  - Prefer anchor_* ops for pattern-based insert/replace near stable markers
316
319
  - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
@@ -356,7 +359,12 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
356
359
  ],
357
360
  }
358
361
  ]"""
359
- ))
362
+ ),
363
+ annotations=ToolAnnotations(
364
+ title="Script Apply Edits",
365
+ destructiveHint=True,
366
+ ),
367
+ )
360
368
  async def script_apply_edits(
361
369
  ctx: Context,
362
370
  name: Annotated[str, "Name of the script to edit"],
@@ -372,7 +380,7 @@ async def script_apply_edits(
372
380
  unity_instance = get_unity_instance_from_context(ctx)
373
381
  await ctx.info(
374
382
  f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
375
-
383
+
376
384
  # Parse edits if they came as a stringified JSON
377
385
  edits = parse_json_payload(edits)
378
386
  if not isinstance(edits, list):
@@ -2,6 +2,8 @@ from typing import Annotated, Any
2
2
  from types import SimpleNamespace
3
3
 
4
4
  from fastmcp import Context
5
+ from mcp.types import ToolAnnotations
6
+
5
7
  from services.registry import mcp_for_unity_tool
6
8
  from transport.legacy.unity_connection import get_unity_connection_pool
7
9
  from transport.unity_instance_middleware import get_unity_instance_middleware
@@ -10,7 +12,10 @@ from transport.unity_transport import _current_transport
10
12
 
11
13
 
12
14
  @mcp_for_unity_tool(
13
- description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
15
+ description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
16
+ annotations=ToolAnnotations(
17
+ title="Set Active Instance",
18
+ ),
14
19
  )
15
20
  async def set_active_instance(
16
21
  ctx: Context,
@@ -51,7 +56,7 @@ async def set_active_instance(
51
56
  return {
52
57
  "success": False,
53
58
  "error": "Instance identifier is required. "
54
- "Use unity://instances to copy a Name@hash or provide a hash prefix."
59
+ "Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
55
60
  }
56
61
  resolved = None
57
62
  if "@" in value:
@@ -60,7 +65,7 @@ async def set_active_instance(
60
65
  return {
61
66
  "success": False,
62
67
  "error": f"Instance '{value}' not found. "
63
- "Use unity://instances to copy an exact Name@hash."
68
+ "Use mcpforunity://instances to copy an exact Name@hash."
64
69
  }
65
70
  else:
66
71
  lookup = value.lower()
@@ -75,7 +80,7 @@ async def set_active_instance(
75
80
  return {
76
81
  "success": False,
77
82
  "error": f"Instance hash '{value}' does not match any running Unity editors. "
78
- "Use unity://instances to confirm the available hashes."
83
+ "Use mcpforunity://instances to confirm the available hashes."
79
84
  }
80
85
  if len(matches) > 1:
81
86
  matching_ids = ", ".join(
@@ -84,10 +89,10 @@ async def set_active_instance(
84
89
  return {
85
90
  "success": False,
86
91
  "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
87
- "Provide the full Name@hash from unity://instances."
92
+ "Provide the full Name@hash from mcpforunity://instances."
88
93
  }
89
94
  resolved = matches[0]
90
-
95
+
91
96
  if resolved is None:
92
97
  # Should be unreachable due to logic above, but satisfies static analysis
93
98
  return {
@@ -101,7 +106,7 @@ async def set_active_instance(
101
106
  # The session key is an internal detail but useful for debugging response.
102
107
  middleware.set_active_instance(ctx, resolved.id)
103
108
  session_key = middleware.get_session_key(ctx)
104
-
109
+
105
110
  return {
106
111
  "success": True,
107
112
  "message": f"Active instance set to {resolved.id}",