mcpforunityserver 9.4.0b20260203025228__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 (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
@@ -0,0 +1,88 @@
1
+ from typing import Annotated, Literal, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ from fastmcp import Context
5
+
6
+ from models import MCPResponse
7
+ from models.unity_response import parse_resource_response
8
+ from services.registry import mcp_for_unity_resource
9
+ from services.tools import get_unity_instance_from_context
10
+ from transport.unity_transport import send_with_unity_instance
11
+ from transport.legacy.unity_connection import async_send_command_with_retry
12
+
13
+
14
+ class TestItem(BaseModel):
15
+ name: Annotated[str, Field(description="The name of the test.")]
16
+ full_name: Annotated[str, Field(description="The full name of the test.")]
17
+ mode: Annotated[Literal["EditMode", "PlayMode"],
18
+ Field(description="The mode the test is for.")]
19
+
20
+
21
+ class PaginatedTestsData(BaseModel):
22
+ """Paginated test results."""
23
+ items: list[TestItem] = Field(description="Tests on current page")
24
+ cursor: int = Field(description="Current page cursor (0-based)")
25
+ nextCursor: Optional[int] = Field(None, description="Next page cursor, null if last page")
26
+ totalCount: int = Field(description="Total number of tests across all pages")
27
+ pageSize: int = Field(description="Number of items per page")
28
+ hasMore: bool = Field(description="Whether there are more items after this page")
29
+
30
+
31
+ class GetTestsResponse(MCPResponse):
32
+ """Response containing paginated test data."""
33
+ data: PaginatedTestsData = Field(description="Paginated test data")
34
+
35
+
36
+ @mcp_for_unity_resource(
37
+ uri="mcpforunity://tests",
38
+ name="get_tests",
39
+ description="Provides the first page of Unity tests (default 50 items). "
40
+ "For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests"
41
+ )
42
+ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
43
+ """Provides a paginated list of all Unity tests.
44
+
45
+ Returns the first page of tests using Unity's default pagination (50 items).
46
+ For advanced filtering or pagination control, use the run_tests tool which
47
+ accepts mode, filter, page_size, and cursor parameters.
48
+ """
49
+ unity_instance = get_unity_instance_from_context(ctx)
50
+
51
+ response = await send_with_unity_instance(
52
+ async_send_command_with_retry,
53
+ unity_instance,
54
+ "get_tests",
55
+ {},
56
+ )
57
+ return parse_resource_response(response, GetTestsResponse)
58
+
59
+
60
+ @mcp_for_unity_resource(
61
+ uri="mcpforunity://tests/{mode}",
62
+ name="get_tests_for_mode",
63
+ description="Provides the first page of tests for a specific mode (EditMode or PlayMode). "
64
+ "For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests/{mode}"
65
+ )
66
+ async def get_tests_for_mode(
67
+ ctx: Context,
68
+ mode: Annotated[Literal["EditMode", "PlayMode"], Field(
69
+ description="The mode to filter tests by (EditMode or PlayMode)."
70
+ )],
71
+ ) -> GetTestsResponse | MCPResponse:
72
+ """Provides the first page of tests for a specific mode.
73
+
74
+ Args:
75
+ mode: The test mode to filter by (EditMode or PlayMode)
76
+
77
+ Returns the first page of tests using Unity's default pagination (50 items).
78
+ For advanced filtering or pagination control, use the run_tests tool.
79
+ """
80
+ unity_instance = get_unity_instance_from_context(ctx)
81
+
82
+ response = await send_with_unity_instance(
83
+ async_send_command_with_retry,
84
+ unity_instance,
85
+ "get_tests_for_mode",
86
+ {"mode": mode},
87
+ )
88
+ return parse_resource_response(response, GetTestsResponse)
@@ -0,0 +1,125 @@
1
+ """
2
+ Resource to list all available Unity Editor instances.
3
+ """
4
+ from typing import Any
5
+
6
+ from fastmcp import Context
7
+ from services.registry import mcp_for_unity_resource
8
+ from transport.legacy.unity_connection import get_unity_connection_pool
9
+ from transport.plugin_hub import PluginHub
10
+ from core.config import config
11
+
12
+
13
+ @mcp_for_unity_resource(
14
+ uri="mcpforunity://instances",
15
+ name="unity_instances",
16
+ description="Lists all running Unity Editor instances with their details.\n\nURI: mcpforunity://instances"
17
+ )
18
+ async def unity_instances(ctx: Context) -> dict[str, Any]:
19
+ """
20
+ List all available Unity Editor instances.
21
+
22
+ Returns information about each instance including:
23
+ - id: Unique identifier (ProjectName@hash)
24
+ - name: Project name
25
+ - path: Full project path (stdio only)
26
+ - hash: 8-character hash of project path
27
+ - port: TCP port number (stdio only)
28
+ - status: Current status (running, reloading, etc.) (stdio only)
29
+ - last_heartbeat: Last heartbeat timestamp (stdio only)
30
+ - unity_version: Unity version (if available)
31
+ - connected_at: Connection timestamp (HTTP only)
32
+
33
+ Returns:
34
+ Dictionary containing list of instances and metadata
35
+ """
36
+ await ctx.info("Listing Unity instances")
37
+
38
+ try:
39
+ transport = (config.transport_mode or "stdio").lower()
40
+ if transport == "http":
41
+ # HTTP/WebSocket transport: query PluginHub
42
+ # In remote-hosted mode, filter sessions by user_id
43
+ user_id = ctx.get_state(
44
+ "user_id") if config.http_remote_hosted else None
45
+ sessions_data = await PluginHub.get_sessions(user_id=user_id)
46
+ sessions = sessions_data.sessions
47
+
48
+ instances = []
49
+ for session_id, session_info in sessions.items():
50
+ project = session_info.project
51
+ project_hash = session_info.hash
52
+
53
+ if not project or not project_hash:
54
+ raise ValueError(
55
+ "PluginHub session missing required 'project' or 'hash' fields."
56
+ )
57
+
58
+ instances.append({
59
+ "id": f"{project}@{project_hash}",
60
+ "name": project,
61
+ "hash": project_hash,
62
+ "unity_version": session_info.unity_version,
63
+ "connected_at": session_info.connected_at,
64
+ "session_id": session_id,
65
+ })
66
+
67
+ # Check for duplicate project names
68
+ name_counts = {}
69
+ for inst in instances:
70
+ name_counts[inst["name"]] = name_counts.get(
71
+ inst["name"], 0) + 1
72
+
73
+ duplicates = [name for name,
74
+ count in name_counts.items() if count > 1]
75
+
76
+ result = {
77
+ "success": True,
78
+ "transport": transport,
79
+ "instance_count": len(instances),
80
+ "instances": instances,
81
+ }
82
+
83
+ if duplicates:
84
+ result["warning"] = (
85
+ f"Multiple instances found with duplicate project names: {duplicates}. "
86
+ f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
87
+ )
88
+
89
+ return result
90
+ else:
91
+ # Stdio/TCP transport: query connection pool
92
+ pool = get_unity_connection_pool()
93
+ instances = pool.discover_all_instances(force_refresh=False)
94
+
95
+ # Check for duplicate project names
96
+ name_counts = {}
97
+ for inst in instances:
98
+ name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
99
+
100
+ duplicates = [name for name,
101
+ count in name_counts.items() if count > 1]
102
+
103
+ result = {
104
+ "success": True,
105
+ "transport": transport,
106
+ "instance_count": len(instances),
107
+ "instances": [inst.to_dict() for inst in instances],
108
+ }
109
+
110
+ if duplicates:
111
+ result["warning"] = (
112
+ f"Multiple instances found with duplicate project names: {duplicates}. "
113
+ f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
114
+ )
115
+
116
+ return result
117
+
118
+ except Exception as e:
119
+ await ctx.error(f"Error listing Unity instances: {e}")
120
+ return {
121
+ "success": False,
122
+ "error": f"Failed to list Unity instances: {str(e)}",
123
+ "instance_count": 0,
124
+ "instances": []
125
+ }
@@ -0,0 +1,48 @@
1
+ from pydantic import BaseModel
2
+ from fastmcp import Context
3
+
4
+ from models import MCPResponse
5
+ from models.unity_response import parse_resource_response
6
+ from services.registry import mcp_for_unity_resource
7
+ from services.tools import get_unity_instance_from_context
8
+ from transport.unity_transport import send_with_unity_instance
9
+ from transport.legacy.unity_connection import async_send_command_with_retry
10
+
11
+
12
+ class WindowPosition(BaseModel):
13
+ """Window position and size."""
14
+ x: float = 0.0
15
+ y: float = 0.0
16
+ width: float = 0.0
17
+ height: float = 0.0
18
+
19
+
20
+ class WindowInfo(BaseModel):
21
+ """Information about an editor window."""
22
+ title: str = ""
23
+ typeName: str = ""
24
+ isFocused: bool = False
25
+ position: WindowPosition = WindowPosition()
26
+ instanceID: int = 0
27
+
28
+
29
+ class WindowsResponse(MCPResponse):
30
+ """List of all open editor windows."""
31
+ data: list[WindowInfo] = []
32
+
33
+
34
+ @mcp_for_unity_resource(
35
+ uri="mcpforunity://editor/windows",
36
+ name="editor_windows",
37
+ description="All currently open editor windows with their titles, types, positions, and focus state.\n\nURI: mcpforunity://editor/windows"
38
+ )
39
+ async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
40
+ """Get all open editor windows."""
41
+ unity_instance = get_unity_instance_from_context(ctx)
42
+ response = await send_with_unity_instance(
43
+ async_send_command_with_retry,
44
+ unity_instance,
45
+ "get_windows",
46
+ {}
47
+ )
48
+ return parse_resource_response(response, WindowsResponse)
@@ -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()
@@ -0,0 +1,83 @@
1
+ """MCP tools package - auto-discovery and Unity routing helpers."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import TypeVar
7
+
8
+ from fastmcp import Context, FastMCP
9
+ from core.telemetry_decorator import telemetry_tool
10
+ from core.logging_decorator import log_execution
11
+ from utils.module_discovery import discover_modules
12
+ from services.registry import get_registered_tools
13
+
14
+ logger = logging.getLogger("mcp-for-unity-server")
15
+
16
+ # Export decorator and helpers for easy imports within tools
17
+ __all__ = [
18
+ "register_all_tools",
19
+ "get_unity_instance_from_context",
20
+ ]
21
+
22
+
23
+ def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
24
+ """
25
+ Auto-discover and register all tools in the tools/ directory.
26
+
27
+ Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
28
+ functions will be automatically registered.
29
+ """
30
+ logger.info("Auto-discovering MCP for Unity Server tools...")
31
+ # Dynamic import of all modules in this directory
32
+ tools_dir = Path(__file__).parent
33
+
34
+ # Discover and import all modules
35
+ list(discover_modules(tools_dir, __package__))
36
+
37
+ tools = get_registered_tools()
38
+
39
+ if not tools:
40
+ logger.warning("No MCP tools registered!")
41
+ return
42
+
43
+ for tool_info in tools:
44
+ func = tool_info['func']
45
+ tool_name = tool_info['name']
46
+ description = tool_info['description']
47
+ kwargs = tool_info['kwargs']
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
+
54
+ # Apply decorators: logging -> telemetry -> mcp.tool
55
+ # Note: Parameter normalization (camelCase -> snake_case) is handled by
56
+ # ParamNormalizerMiddleware before FastMCP validation
57
+ wrapped = log_execution(tool_name, "Tool")(func)
58
+ wrapped = telemetry_tool(tool_name)(wrapped)
59
+ wrapped = mcp.tool(
60
+ name=tool_name, description=description, **kwargs)(wrapped)
61
+ tool_info['func'] = wrapped
62
+ logger.debug(f"Registered tool: {tool_name} - {description}")
63
+
64
+ logger.info(f"Registered {len(tools)} MCP tools")
65
+
66
+
67
+ def get_unity_instance_from_context(
68
+ ctx: Context,
69
+ key: str = "unity_instance",
70
+ ) -> str | None:
71
+ """Extract the unity_instance value from middleware state.
72
+
73
+ The instance is set via the set_active_instance tool and injected into
74
+ request state by UnityInstanceMiddleware.
75
+ """
76
+ get_state_fn = getattr(ctx, "get_state", None)
77
+ if callable(get_state_fn):
78
+ try:
79
+ return get_state_fn(key)
80
+ except Exception: # pragma: no cover - defensive
81
+ pass
82
+
83
+ return None
@@ -0,0 +1,93 @@
1
+ """Defines the batch_execute tool for orchestrating multiple Unity MCP commands."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Annotated, Any
5
+
6
+ from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
8
+
9
+ from services.registry import mcp_for_unity_tool
10
+ from services.tools import get_unity_instance_from_context
11
+ from transport.unity_transport import send_with_unity_instance
12
+ from transport.legacy.unity_connection import async_send_command_with_retry
13
+
14
+ MAX_COMMANDS_PER_BATCH = 25
15
+
16
+
17
+ @mcp_for_unity_tool(
18
+ name="batch_execute",
19
+ description=(
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,
29
+ ),
30
+ )
31
+ async def batch_execute(
32
+ ctx: Context,
33
+ commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."],
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,
40
+ ) -> dict[str, Any]:
41
+ """Proxy the batch_execute tool to the Unity Editor transporter."""
42
+ unity_instance = get_unity_instance_from_context(ctx)
43
+
44
+ if not isinstance(commands, list) or not commands:
45
+ raise ValueError(
46
+ "'commands' must be a non-empty list of command specifications")
47
+
48
+ if len(commands) > MAX_COMMANDS_PER_BATCH:
49
+ raise ValueError(
50
+ f"batch_execute currently supports up to {MAX_COMMANDS_PER_BATCH} commands; received {len(commands)}"
51
+ )
52
+
53
+ normalized_commands: list[dict[str, Any]] = []
54
+ for index, command in enumerate(commands):
55
+ if not isinstance(command, dict):
56
+ raise ValueError(
57
+ f"Command at index {index} must be an object with 'tool' and 'params' keys")
58
+
59
+ tool_name = command.get("tool")
60
+ params = command.get("params", {})
61
+
62
+ if not tool_name or not isinstance(tool_name, str):
63
+ raise ValueError(
64
+ f"Command at index {index} is missing a valid 'tool' name")
65
+
66
+ if params is None:
67
+ params = {}
68
+ if not isinstance(params, dict):
69
+ raise ValueError(
70
+ f"Command '{tool_name}' must specify parameters as an object/dict")
71
+
72
+ normalized_commands.append({
73
+ "tool": tool_name,
74
+ "params": params,
75
+ })
76
+
77
+ payload: dict[str, Any] = {
78
+ "commands": normalized_commands,
79
+ }
80
+
81
+ if parallel is not None:
82
+ payload["parallel"] = bool(parallel)
83
+ if fail_fast is not None:
84
+ payload["failFast"] = bool(fail_fast)
85
+ if max_parallelism is not None:
86
+ payload["maxParallelism"] = int(max_parallelism)
87
+
88
+ return await send_with_unity_instance(
89
+ async_send_command_with_retry,
90
+ unity_instance,
91
+ "batch_execute",
92
+ payload,
93
+ )