mcpforunityserver 8.5.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 (79) 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 +207 -62
  26. {mcpforunityserver-8.5.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.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.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 -21
  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 +245 -0
  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 +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -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 +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  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 +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,243 @@
1
+ """
2
+ MCP Resources for reading GameObject data from Unity scenes.
3
+
4
+ These resources provide read-only access to:
5
+ - Single GameObject data (mcpforunity://scene/gameobject/{id})
6
+ - All components on a GameObject (mcpforunity://scene/gameobject/{id}/components)
7
+ - Single component on a GameObject (mcpforunity://scene/gameobject/{id}/component/{name})
8
+ """
9
+ from typing import Any
10
+ from pydantic import BaseModel
11
+ from fastmcp import Context
12
+
13
+ from models import MCPResponse
14
+ from services.registry import mcp_for_unity_resource
15
+ from services.tools import get_unity_instance_from_context
16
+ from transport.unity_transport import send_with_unity_instance
17
+ from transport.legacy.unity_connection import async_send_command_with_retry
18
+
19
+
20
+ def _normalize_response(response: dict | Any) -> MCPResponse:
21
+ """Normalize Unity transport response to MCPResponse."""
22
+ if isinstance(response, dict):
23
+ return MCPResponse(**response)
24
+ return response
25
+
26
+
27
+ def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | None]:
28
+ """
29
+ Validate and convert instance_id string to int.
30
+ Returns (id_int, None) on success or (None, error_response) on failure.
31
+ """
32
+ try:
33
+ return int(instance_id), None
34
+ except ValueError:
35
+ return None, MCPResponse(success=False, error=f"Invalid instance ID: {instance_id}")
36
+
37
+
38
+ # =============================================================================
39
+ # Static Helper Resource (shows in UI)
40
+ # =============================================================================
41
+
42
+ @mcp_for_unity_resource(
43
+ uri="mcpforunity://scene/gameobject-api",
44
+ name="gameobject_api",
45
+ description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below."
46
+ )
47
+ async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse:
48
+ """
49
+ Returns documentation for the GameObject resource API.
50
+
51
+ This is a helper resource that explains how to use the parameterized
52
+ GameObject resources which require an instance ID.
53
+ """
54
+ docs = {
55
+ "overview": "GameObject resources provide read-only access to Unity scene objects.",
56
+ "workflow": [
57
+ "1. Use find_gameobjects tool to search for GameObjects and get instance IDs",
58
+ "2. Use the instance ID to access detailed data via resources below"
59
+ ],
60
+ "best_practices": [
61
+ "⚡ Use batch_execute for multiple operations: Combine create/modify/component calls into one batch_execute call for 10-100x better performance",
62
+ "Example: Creating 5 cubes → 1 batch_execute with 5 manage_gameobject commands instead of 5 separate calls",
63
+ "Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands"
64
+ ],
65
+ "resources": {
66
+ "mcpforunity://scene/gameobject/{instance_id}": {
67
+ "description": "Get basic GameObject data (name, tag, layer, transform, component type list)",
68
+ "example": "mcpforunity://scene/gameobject/-81840",
69
+ "returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"]
70
+ },
71
+ "mcpforunity://scene/gameobject/{instance_id}/components": {
72
+ "description": "Get all components with full property serialization (paginated)",
73
+ "example": "mcpforunity://scene/gameobject/-81840/components",
74
+ "parameters": {
75
+ "page_size": "Number of components per page (default: 25)",
76
+ "cursor": "Pagination offset (default: 0)",
77
+ "include_properties": "Include full property data (default: true)"
78
+ }
79
+ },
80
+ "mcpforunity://scene/gameobject/{instance_id}/component/{component_name}": {
81
+ "description": "Get a single component by type name with full properties",
82
+ "example": "mcpforunity://scene/gameobject/-81840/component/Camera",
83
+ "note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')"
84
+ }
85
+ },
86
+ "related_tools": {
87
+ "find_gameobjects": "Search for GameObjects by name, tag, layer, component, or path",
88
+ "manage_components": "Add, remove, or modify components on GameObjects",
89
+ "manage_gameobject": "Create, modify, or delete GameObjects"
90
+ }
91
+ }
92
+ return MCPResponse(success=True, data=docs)
93
+
94
+
95
+ class TransformData(BaseModel):
96
+ """Transform component data."""
97
+ position: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
98
+ localPosition: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
99
+ rotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
100
+ localRotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
101
+ scale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
102
+ lossyScale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
103
+
104
+
105
+ class GameObjectData(BaseModel):
106
+ """Data for a single GameObject (without full component serialization)."""
107
+ instanceID: int
108
+ name: str
109
+ tag: str = "Untagged"
110
+ layer: int = 0
111
+ layerName: str = "Default"
112
+ active: bool = True
113
+ activeInHierarchy: bool = True
114
+ isStatic: bool = False
115
+ transform: TransformData = TransformData()
116
+ parent: int | None = None
117
+ children: list[int] = []
118
+ componentTypes: list[str] = []
119
+ path: str = ""
120
+
121
+
122
+ # TODO: Use these typed response classes for better type safety once
123
+ # we update the endpoints to validate response structure more strictly.
124
+ class GameObjectResponse(MCPResponse):
125
+ """Response containing GameObject data."""
126
+ data: GameObjectData | None = None
127
+
128
+
129
+ @mcp_for_unity_resource(
130
+ uri="mcpforunity://scene/gameobject/{instance_id}",
131
+ name="gameobject",
132
+ description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties)."
133
+ )
134
+ async def get_gameobject(ctx: Context, instance_id: str) -> MCPResponse:
135
+ """Get GameObject data by instance ID."""
136
+ unity_instance = get_unity_instance_from_context(ctx)
137
+
138
+ id_int, error = _validate_instance_id(instance_id)
139
+ if error:
140
+ return error
141
+
142
+ response = await send_with_unity_instance(
143
+ async_send_command_with_retry,
144
+ unity_instance,
145
+ "get_gameobject",
146
+ {"instanceID": id_int}
147
+ )
148
+
149
+ return _normalize_response(response)
150
+
151
+
152
+ class ComponentsData(BaseModel):
153
+ """Data for components on a GameObject."""
154
+ gameObjectID: int
155
+ gameObjectName: str
156
+ components: list[Any] = []
157
+ cursor: int = 0
158
+ pageSize: int = 25
159
+ nextCursor: int | None = None
160
+ totalCount: int = 0
161
+ hasMore: bool = False
162
+ includeProperties: bool = True
163
+
164
+
165
+ class ComponentsResponse(MCPResponse):
166
+ """Response containing components data."""
167
+ data: ComponentsData | None = None
168
+
169
+
170
+ @mcp_for_unity_resource(
171
+ uri="mcpforunity://scene/gameobject/{instance_id}/components",
172
+ name="gameobject_components",
173
+ description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters."
174
+ )
175
+ async def get_gameobject_components(
176
+ ctx: Context,
177
+ instance_id: str,
178
+ page_size: int = 25,
179
+ cursor: int = 0,
180
+ include_properties: bool = True
181
+ ) -> MCPResponse:
182
+ """Get all components on a GameObject."""
183
+ unity_instance = get_unity_instance_from_context(ctx)
184
+
185
+ id_int, error = _validate_instance_id(instance_id)
186
+ if error:
187
+ return error
188
+
189
+ response = await send_with_unity_instance(
190
+ async_send_command_with_retry,
191
+ unity_instance,
192
+ "get_gameobject_components",
193
+ {
194
+ "instanceID": id_int,
195
+ "pageSize": page_size,
196
+ "cursor": cursor,
197
+ "includeProperties": include_properties
198
+ }
199
+ )
200
+
201
+ return _normalize_response(response)
202
+
203
+
204
+ class SingleComponentData(BaseModel):
205
+ """Data for a single component."""
206
+ gameObjectID: int
207
+ gameObjectName: str
208
+ component: Any = None
209
+
210
+
211
+ class SingleComponentResponse(MCPResponse):
212
+ """Response containing single component data."""
213
+ data: SingleComponentData | None = None
214
+
215
+
216
+ @mcp_for_unity_resource(
217
+ uri="mcpforunity://scene/gameobject/{instance_id}/component/{component_name}",
218
+ name="gameobject_component",
219
+ description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties."
220
+ )
221
+ async def get_gameobject_component(
222
+ ctx: Context,
223
+ instance_id: str,
224
+ component_name: str
225
+ ) -> MCPResponse:
226
+ """Get a specific component on a GameObject."""
227
+ unity_instance = get_unity_instance_from_context(ctx)
228
+
229
+ id_int, error = _validate_instance_id(instance_id)
230
+ if error:
231
+ return error
232
+
233
+ response = await send_with_unity_instance(
234
+ async_send_command_with_retry,
235
+ unity_instance,
236
+ "get_gameobject_component",
237
+ {
238
+ "instanceID": id_int,
239
+ "componentName": component_name
240
+ }
241
+ )
242
+
243
+ return _normalize_response(response)
@@ -13,7 +13,7 @@ class LayersResponse(MCPResponse):
13
13
 
14
14
 
15
15
  @mcp_for_unity_resource(
16
- uri="unity://project/layers",
16
+ uri="mcpforunity://project/layers",
17
17
  name="project_layers",
18
18
  description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools."
19
19
  )
@@ -23,7 +23,7 @@ class PrefabStageResponse(MCPResponse):
23
23
 
24
24
 
25
25
  @mcp_for_unity_resource(
26
- uri="unity://editor/prefab-stage",
26
+ uri="mcpforunity://editor/prefab-stage",
27
27
  name="editor_prefab_stage",
28
28
  description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited."
29
29
  )
@@ -23,7 +23,7 @@ class ProjectInfoResponse(MCPResponse):
23
23
 
24
24
 
25
25
  @mcp_for_unity_resource(
26
- uri="unity://project/info",
26
+ uri="mcpforunity://project/info",
27
27
  name="project_info",
28
28
  description="Static project information including root path, Unity version, and platform. This data rarely changes."
29
29
  )
@@ -39,7 +39,7 @@ class SelectionResponse(MCPResponse):
39
39
 
40
40
 
41
41
  @mcp_for_unity_resource(
42
- uri="unity://editor/selection",
42
+ uri="mcpforunity://editor/selection",
43
43
  name="editor_selection",
44
44
  description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties."
45
45
  )
@@ -14,7 +14,7 @@ class TagsResponse(MCPResponse):
14
14
 
15
15
 
16
16
  @mcp_for_unity_resource(
17
- uri="unity://project/tags",
17
+ uri="mcpforunity://project/tags",
18
18
  name="project_tags",
19
19
  description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools."
20
20
  )
@@ -11,7 +11,7 @@ from transport.unity_transport import _current_transport
11
11
 
12
12
 
13
13
  @mcp_for_unity_resource(
14
- uri="unity://instances",
14
+ uri="mcpforunity://instances",
15
15
  name="unity_instances",
16
16
  description="Lists all running Unity Editor instances with their details."
17
17
  )
@@ -31,7 +31,7 @@ class WindowsResponse(MCPResponse):
31
31
 
32
32
 
33
33
  @mcp_for_unity_resource(
34
- uri="unity://editor/windows",
34
+ uri="mcpforunity://editor/windows",
35
35
  name="editor_windows",
36
36
  description="All currently open editor windows with their titles, types, positions, and focus state."
37
37
  )
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+
11
+ def _now_unix_ms() -> int:
12
+ return int(time.time() * 1000)
13
+
14
+
15
+ def _in_pytest() -> bool:
16
+ # Keep scanner inert during the Python integration suite unless explicitly invoked.
17
+ return bool(os.environ.get("PYTEST_CURRENT_TEST"))
18
+
19
+
20
+ @dataclass
21
+ class ExternalChangesState:
22
+ project_root: str | None = None
23
+ last_scan_unix_ms: int | None = None
24
+ last_seen_mtime_ns: int | None = None
25
+ dirty: bool = False
26
+ dirty_since_unix_ms: int | None = None
27
+ external_changes_last_seen_unix_ms: int | None = None
28
+ last_cleared_unix_ms: int | None = None
29
+ # Cached package roots referenced by Packages/manifest.json "file:" dependencies
30
+ extra_roots: list[str] | None = None
31
+ manifest_last_mtime_ns: int | None = None
32
+
33
+
34
+ class ExternalChangesScanner:
35
+ """
36
+ Lightweight external-changes detector using recursive max-mtime scan.
37
+
38
+ This is intentionally conservative:
39
+ - It only marks dirty when it sees a strictly newer mtime than the baseline.
40
+ - It scans at most once per scan_interval_ms per instance to keep overhead bounded.
41
+ """
42
+
43
+ def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
44
+ self._states: dict[str, ExternalChangesState] = {}
45
+ self._scan_interval_ms = int(scan_interval_ms)
46
+ self._max_entries = int(max_entries)
47
+
48
+ def _get_state(self, instance_id: str) -> ExternalChangesState:
49
+ return self._states.setdefault(instance_id, ExternalChangesState())
50
+
51
+ def set_project_root(self, instance_id: str, project_root: str | None) -> None:
52
+ st = self._get_state(instance_id)
53
+ if project_root:
54
+ st.project_root = project_root
55
+
56
+ def clear_dirty(self, instance_id: str) -> None:
57
+ st = self._get_state(instance_id)
58
+ st.dirty = False
59
+ st.dirty_since_unix_ms = None
60
+ st.last_cleared_unix_ms = _now_unix_ms()
61
+ # Reset baseline to “now” on next scan.
62
+ st.last_seen_mtime_ns = None
63
+
64
+ def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
65
+ newest: int | None = None
66
+ entries = 0
67
+
68
+ for root in roots:
69
+ if not root.exists():
70
+ continue
71
+
72
+ # Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
73
+ for dirpath, dirnames, filenames in os.walk(str(root)):
74
+ entries += 1
75
+ if entries > self._max_entries:
76
+ return newest
77
+
78
+ dp = Path(dirpath)
79
+ name = dp.name.lower()
80
+ if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
81
+ dirnames[:] = []
82
+ continue
83
+
84
+ # Allow skipping hidden directories quickly
85
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
86
+
87
+ for fn in filenames:
88
+ if fn.startswith("."):
89
+ continue
90
+ entries += 1
91
+ if entries > self._max_entries:
92
+ return newest
93
+ p = dp / fn
94
+ try:
95
+ stat = p.stat()
96
+ except OSError:
97
+ continue
98
+ m = getattr(stat, "st_mtime_ns", None)
99
+ if m is None:
100
+ # Fallback when st_mtime_ns is unavailable
101
+ m = int(stat.st_mtime * 1_000_000_000)
102
+ newest = m if newest is None else max(newest, int(m))
103
+
104
+ return newest
105
+
106
+ def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
107
+ """
108
+ Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
109
+ Returns a list of Paths that exist and are directories.
110
+ """
111
+ manifest_path = project_root / "Packages" / "manifest.json"
112
+ try:
113
+ stat = manifest_path.stat()
114
+ except OSError:
115
+ st.extra_roots = []
116
+ st.manifest_last_mtime_ns = None
117
+ return []
118
+
119
+ mtime_ns = getattr(stat, "st_mtime_ns", int(
120
+ stat.st_mtime * 1_000_000_000))
121
+ if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
122
+ return [Path(p) for p in st.extra_roots if p]
123
+
124
+ try:
125
+ raw = manifest_path.read_text(encoding="utf-8")
126
+ doc = json.loads(raw)
127
+ except Exception:
128
+ st.extra_roots = []
129
+ st.manifest_last_mtime_ns = mtime_ns
130
+ return []
131
+
132
+ deps = doc.get("dependencies") if isinstance(doc, dict) else None
133
+ if not isinstance(deps, dict):
134
+ st.extra_roots = []
135
+ st.manifest_last_mtime_ns = mtime_ns
136
+ return []
137
+
138
+ roots: list[str] = []
139
+ base_dir = manifest_path.parent
140
+
141
+ for _, ver in deps.items():
142
+ if not isinstance(ver, str):
143
+ continue
144
+ v = ver.strip()
145
+ if not v.startswith("file:"):
146
+ continue
147
+ suffix = v[len("file:"):].strip()
148
+ # Handle file:///abs/path or file:/abs/path
149
+ if suffix.startswith("///"):
150
+ candidate = Path("/" + suffix.lstrip("/"))
151
+ elif suffix.startswith("/"):
152
+ candidate = Path(suffix)
153
+ else:
154
+ candidate = (base_dir / suffix).resolve()
155
+ try:
156
+ if candidate.exists() and candidate.is_dir():
157
+ roots.append(str(candidate))
158
+ except OSError:
159
+ continue
160
+
161
+ # De-dupe, preserve order
162
+ deduped: list[str] = []
163
+ seen = set()
164
+ for r in roots:
165
+ if r not in seen:
166
+ seen.add(r)
167
+ deduped.append(r)
168
+
169
+ st.extra_roots = deduped
170
+ st.manifest_last_mtime_ns = mtime_ns
171
+ return [Path(p) for p in deduped if p]
172
+
173
+ def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
174
+ """
175
+ Returns a small dict suitable for embedding in editor_state_v2.assets:
176
+ - external_changes_dirty
177
+ - external_changes_last_seen_unix_ms
178
+ - dirty_since_unix_ms
179
+ - last_cleared_unix_ms
180
+ """
181
+ st = self._get_state(instance_id)
182
+
183
+ if _in_pytest():
184
+ return {
185
+ "external_changes_dirty": st.dirty,
186
+ "external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
187
+ "dirty_since_unix_ms": st.dirty_since_unix_ms,
188
+ "last_cleared_unix_ms": st.last_cleared_unix_ms,
189
+ }
190
+
191
+ now = _now_unix_ms()
192
+ if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
193
+ return {
194
+ "external_changes_dirty": st.dirty,
195
+ "external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
196
+ "dirty_since_unix_ms": st.dirty_since_unix_ms,
197
+ "last_cleared_unix_ms": st.last_cleared_unix_ms,
198
+ }
199
+
200
+ st.last_scan_unix_ms = now
201
+
202
+ project_root = st.project_root
203
+ if not project_root:
204
+ return {
205
+ "external_changes_dirty": st.dirty,
206
+ "external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
207
+ "dirty_since_unix_ms": st.dirty_since_unix_ms,
208
+ "last_cleared_unix_ms": st.last_cleared_unix_ms,
209
+ }
210
+
211
+ root = Path(project_root)
212
+ paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
213
+ # Include any local package roots referenced by file: deps in Packages/manifest.json
214
+ try:
215
+ paths.extend(self._resolve_manifest_extra_roots(root, st))
216
+ except Exception:
217
+ pass
218
+ newest = self._scan_paths_max_mtime_ns(paths)
219
+ if newest is None:
220
+ return {
221
+ "external_changes_dirty": st.dirty,
222
+ "external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
223
+ "dirty_since_unix_ms": st.dirty_since_unix_ms,
224
+ "last_cleared_unix_ms": st.last_cleared_unix_ms,
225
+ }
226
+
227
+ if st.last_seen_mtime_ns is None:
228
+ st.last_seen_mtime_ns = newest
229
+ elif newest > st.last_seen_mtime_ns:
230
+ st.last_seen_mtime_ns = newest
231
+ st.external_changes_last_seen_unix_ms = now
232
+ if not st.dirty:
233
+ st.dirty = True
234
+ st.dirty_since_unix_ms = now
235
+
236
+ return {
237
+ "external_changes_dirty": st.dirty,
238
+ "external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
239
+ "dirty_since_unix_ms": st.dirty_since_unix_ms,
240
+ "last_cleared_unix_ms": st.last_cleared_unix_ms,
241
+ }
242
+
243
+
244
+ # Global singleton (simple, process-local)
245
+ external_changes_scanner = ExternalChangesScanner()
@@ -20,7 +20,7 @@ __all__ = [
20
20
  ]
21
21
 
22
22
 
23
- def register_all_tools(mcp: FastMCP):
23
+ def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
24
24
  """
25
25
  Auto-discover and register all tools in the tools/ directory.
26
26
 
@@ -46,6 +46,11 @@ def register_all_tools(mcp: FastMCP):
46
46
  description = tool_info['description']
47
47
  kwargs = tool_info['kwargs']
48
48
 
49
+ if not project_scoped_tools and tool_name == "execute_custom_tool":
50
+ logger.info(
51
+ "Skipping execute_custom_tool registration (project-scoped tools disabled)")
52
+ continue
53
+
49
54
  # Apply the @mcp.tool decorator, telemetry, and logging
50
55
  wrapped = log_execution(tool_name, "Tool")(func)
51
56
  wrapped = telemetry_tool(tool_name)(wrapped)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from typing import Annotated, Any
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
@@ -16,22 +17,33 @@ MAX_COMMANDS_PER_BATCH = 25
16
17
  @mcp_for_unity_tool(
17
18
  name="batch_execute",
18
19
  description=(
19
- "Runs a list of MCP tool calls as one batch. Use it to send a full sequence of commands, "
20
- "inspect the results, then submit the next batch for the following step."
20
+ "Executes multiple MCP commands in a single batch for dramatically better performance. "
21
+ "STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
22
+ "or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
23
+ "sequential tool calls. Supports up to 25 commands per batch. "
24
+ "Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
25
+ ),
26
+ annotations=ToolAnnotations(
27
+ title="Batch Execute",
28
+ destructiveHint=True,
21
29
  ),
22
30
  )
23
31
  async def batch_execute(
24
32
  ctx: Context,
25
33
  commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."],
26
- parallel: Annotated[bool | None, "Attempt to run read-only commands in parallel"] = None,
27
- fail_fast: Annotated[bool | None, "Stop processing after the first failure"] = None,
28
- max_parallelism: Annotated[int | None, "Hint for the maximum number of parallel workers"] = None,
34
+ parallel: Annotated[bool | None,
35
+ "Attempt to run read-only commands in parallel"] = None,
36
+ fail_fast: Annotated[bool | None,
37
+ "Stop processing after the first failure"] = None,
38
+ max_parallelism: Annotated[int | None,
39
+ "Hint for the maximum number of parallel workers"] = None,
29
40
  ) -> dict[str, Any]:
30
41
  """Proxy the batch_execute tool to the Unity Editor transporter."""
31
42
  unity_instance = get_unity_instance_from_context(ctx)
32
43
 
33
44
  if not isinstance(commands, list) or not commands:
34
- raise ValueError("'commands' must be a non-empty list of command specifications")
45
+ raise ValueError(
46
+ "'commands' must be a non-empty list of command specifications")
35
47
 
36
48
  if len(commands) > MAX_COMMANDS_PER_BATCH:
37
49
  raise ValueError(
@@ -41,18 +53,21 @@ async def batch_execute(
41
53
  normalized_commands: list[dict[str, Any]] = []
42
54
  for index, command in enumerate(commands):
43
55
  if not isinstance(command, dict):
44
- raise ValueError(f"Command at index {index} must be an object with 'tool' and 'params' keys")
56
+ raise ValueError(
57
+ f"Command at index {index} must be an object with 'tool' and 'params' keys")
45
58
 
46
59
  tool_name = command.get("tool")
47
60
  params = command.get("params", {})
48
61
 
49
62
  if not tool_name or not isinstance(tool_name, str):
50
- raise ValueError(f"Command at index {index} is missing a valid 'tool' name")
63
+ raise ValueError(
64
+ f"Command at index {index} is missing a valid 'tool' name")
51
65
 
52
66
  if params is None:
53
67
  params = {}
54
68
  if not isinstance(params, dict):
55
- raise ValueError(f"Command '{tool_name}' must specify parameters as an object/dict")
69
+ raise ValueError(
70
+ f"Command '{tool_name}' must specify parameters as an object/dict")
56
71
 
57
72
  normalized_commands.append({
58
73
  "tool": tool_name,