mcpforunityserver 8.2.3__py3-none-any.whl → 8.7.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.
- main.py +68 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/METADATA +38 -66
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/RECORD +24 -18
- services/resources/editor_state.py +10 -1
- services/resources/editor_state_v2.py +270 -0
- services/state/external_changes_scanner.py +246 -0
- services/tools/debug_request_context.py +9 -0
- services/tools/manage_asset.py +46 -25
- services/tools/manage_gameobject.py +20 -1
- services/tools/manage_scene.py +37 -18
- services/tools/manage_scriptable_object.py +75 -0
- services/tools/preflight.py +107 -0
- services/tools/read_console.py +13 -30
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +32 -20
- services/tools/test_jobs.py +94 -0
- services/tools/utils.py +17 -0
- transport/plugin_hub.py +118 -7
- transport/unity_instance_middleware.py +90 -0
- transport/unity_transport.py +16 -6
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
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(stat.st_mtime * 1_000_000_000))
|
|
120
|
+
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
|
|
121
|
+
return [Path(p) for p in st.extra_roots if p]
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
raw = manifest_path.read_text(encoding="utf-8")
|
|
125
|
+
doc = json.loads(raw)
|
|
126
|
+
except Exception:
|
|
127
|
+
st.extra_roots = []
|
|
128
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
deps = doc.get("dependencies") if isinstance(doc, dict) else None
|
|
132
|
+
if not isinstance(deps, dict):
|
|
133
|
+
st.extra_roots = []
|
|
134
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
roots: list[str] = []
|
|
138
|
+
base_dir = manifest_path.parent
|
|
139
|
+
|
|
140
|
+
for _, ver in deps.items():
|
|
141
|
+
if not isinstance(ver, str):
|
|
142
|
+
continue
|
|
143
|
+
v = ver.strip()
|
|
144
|
+
if not v.startswith("file:"):
|
|
145
|
+
continue
|
|
146
|
+
suffix = v[len("file:") :].strip()
|
|
147
|
+
# Handle file:///abs/path or file:/abs/path
|
|
148
|
+
if suffix.startswith("///"):
|
|
149
|
+
candidate = Path("/" + suffix.lstrip("/"))
|
|
150
|
+
elif suffix.startswith("/"):
|
|
151
|
+
candidate = Path(suffix)
|
|
152
|
+
else:
|
|
153
|
+
candidate = (base_dir / suffix).resolve()
|
|
154
|
+
try:
|
|
155
|
+
if candidate.exists() and candidate.is_dir():
|
|
156
|
+
roots.append(str(candidate))
|
|
157
|
+
except OSError:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# De-dupe, preserve order
|
|
161
|
+
deduped: list[str] = []
|
|
162
|
+
seen = set()
|
|
163
|
+
for r in roots:
|
|
164
|
+
if r not in seen:
|
|
165
|
+
seen.add(r)
|
|
166
|
+
deduped.append(r)
|
|
167
|
+
|
|
168
|
+
st.extra_roots = deduped
|
|
169
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
170
|
+
return [Path(p) for p in deduped if p]
|
|
171
|
+
|
|
172
|
+
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
|
|
173
|
+
"""
|
|
174
|
+
Returns a small dict suitable for embedding in editor_state_v2.assets:
|
|
175
|
+
- external_changes_dirty
|
|
176
|
+
- external_changes_last_seen_unix_ms
|
|
177
|
+
- dirty_since_unix_ms
|
|
178
|
+
- last_cleared_unix_ms
|
|
179
|
+
"""
|
|
180
|
+
st = self._get_state(instance_id)
|
|
181
|
+
|
|
182
|
+
if _in_pytest():
|
|
183
|
+
return {
|
|
184
|
+
"external_changes_dirty": st.dirty,
|
|
185
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
186
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
187
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
now = _now_unix_ms()
|
|
191
|
+
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
|
|
192
|
+
return {
|
|
193
|
+
"external_changes_dirty": st.dirty,
|
|
194
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
195
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
196
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
st.last_scan_unix_ms = now
|
|
200
|
+
|
|
201
|
+
project_root = st.project_root
|
|
202
|
+
if not project_root:
|
|
203
|
+
return {
|
|
204
|
+
"external_changes_dirty": st.dirty,
|
|
205
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
206
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
207
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
root = Path(project_root)
|
|
211
|
+
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
|
|
212
|
+
# Include any local package roots referenced by file: deps in Packages/manifest.json
|
|
213
|
+
try:
|
|
214
|
+
paths.extend(self._resolve_manifest_extra_roots(root, st))
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
newest = self._scan_paths_max_mtime_ns(paths)
|
|
218
|
+
if newest is None:
|
|
219
|
+
return {
|
|
220
|
+
"external_changes_dirty": st.dirty,
|
|
221
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
222
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
223
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if st.last_seen_mtime_ns is None:
|
|
227
|
+
st.last_seen_mtime_ns = newest
|
|
228
|
+
elif newest > st.last_seen_mtime_ns:
|
|
229
|
+
st.last_seen_mtime_ns = newest
|
|
230
|
+
st.external_changes_last_seen_unix_ms = now
|
|
231
|
+
if not st.dirty:
|
|
232
|
+
st.dirty = True
|
|
233
|
+
st.dirty_since_unix_ms = now
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"external_changes_dirty": st.dirty,
|
|
237
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
238
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
239
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Global singleton (simple, process-local)
|
|
244
|
+
external_changes_scanner = ExternalChangesScanner()
|
|
245
|
+
|
|
246
|
+
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
from typing import Any
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from core.telemetry import get_package_version
|
|
2
6
|
|
|
3
7
|
from fastmcp import Context
|
|
4
8
|
from services.registry import mcp_for_unity_tool
|
|
@@ -50,6 +54,11 @@ def debug_request_context(ctx: Context) -> dict[str, Any]:
|
|
|
50
54
|
return {
|
|
51
55
|
"success": True,
|
|
52
56
|
"data": {
|
|
57
|
+
"server": {
|
|
58
|
+
"version": get_package_version(),
|
|
59
|
+
"cwd": os.getcwd(),
|
|
60
|
+
"argv": list(sys.argv),
|
|
61
|
+
},
|
|
53
62
|
"request_context": {
|
|
54
63
|
"client_id": rc_client_id,
|
|
55
64
|
"session_id": rc_session_id,
|
services/tools/manage_asset.py
CHANGED
|
@@ -9,38 +9,51 @@ from typing import Annotated, Any, Literal
|
|
|
9
9
|
from fastmcp import Context
|
|
10
10
|
from services.registry import mcp_for_unity_tool
|
|
11
11
|
from services.tools import get_unity_instance_from_context
|
|
12
|
-
from services.tools.utils import parse_json_payload
|
|
12
|
+
from services.tools.utils import parse_json_payload, coerce_int
|
|
13
13
|
from transport.unity_transport import send_with_unity_instance
|
|
14
14
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
from services.tools.preflight import preflight
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@mcp_for_unity_tool(
|
|
18
|
-
description=
|
|
19
|
+
description=(
|
|
20
|
+
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
|
21
|
+
"Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
|
|
22
|
+
"`generate_preview=false` (previews can add large base64 blobs)."
|
|
23
|
+
)
|
|
19
24
|
)
|
|
20
25
|
async def manage_asset(
|
|
21
26
|
ctx: Context,
|
|
22
27
|
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
|
|
23
|
-
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
|
|
28
|
+
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
|
|
24
29
|
asset_type: Annotated[str,
|
|
25
|
-
"Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
|
|
30
|
+
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
|
|
26
31
|
properties: Annotated[dict[str, Any] | str,
|
|
27
32
|
"Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
|
|
28
33
|
destination: Annotated[str,
|
|
29
34
|
"Target path for 'duplicate'/'move'."] | None = None,
|
|
30
35
|
generate_preview: Annotated[bool,
|
|
31
|
-
"Generate a preview/thumbnail for the asset when supported."
|
|
36
|
+
"Generate a preview/thumbnail for the asset when supported. "
|
|
37
|
+
"Warning: previews may include large base64 payloads; keep false unless needed."] = False,
|
|
32
38
|
search_pattern: Annotated[str,
|
|
33
|
-
"Search pattern (e.g., '*.prefab'
|
|
39
|
+
"Search pattern (e.g., '*.prefab' or AssetDatabase filters like 't:MonoScript'). "
|
|
40
|
+
"Recommended: put queries like 't:MonoScript' here and set path='Assets'."] | None = None,
|
|
34
41
|
filter_type: Annotated[str, "Filter type for search"] | None = None,
|
|
35
42
|
filter_date_after: Annotated[str,
|
|
36
43
|
"Date after which to filter"] | None = None,
|
|
37
44
|
page_size: Annotated[int | float | str,
|
|
38
|
-
"Page size for pagination"] | None = None,
|
|
45
|
+
"Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses)."] | None = None,
|
|
39
46
|
page_number: Annotated[int | float | str,
|
|
40
|
-
"Page number for pagination"] | None = None,
|
|
47
|
+
"Page number for pagination (1-based)."] | None = None,
|
|
41
48
|
) -> dict[str, Any]:
|
|
42
49
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
43
50
|
|
|
51
|
+
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
|
|
52
|
+
# wait/refresh to avoid stale reads and flaky timeouts.
|
|
53
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
54
|
+
if gate is not None:
|
|
55
|
+
return gate.model_dump()
|
|
56
|
+
|
|
44
57
|
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
45
58
|
try:
|
|
46
59
|
parsed = json.loads(raw)
|
|
@@ -83,24 +96,32 @@ async def manage_asset(
|
|
|
83
96
|
await ctx.error(parse_error)
|
|
84
97
|
return {"success": False, "message": parse_error}
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
page_size = coerce_int(page_size)
|
|
100
|
+
page_number = coerce_int(page_number)
|
|
101
|
+
|
|
102
|
+
# --- Payload-safe normalization for common LLM mistakes (search) ---
|
|
103
|
+
# Unity's C# handler treats `path` as a folder scope. If a model mistakenly puts a query like
|
|
104
|
+
# "t:MonoScript" into `path`, Unity will consider it an invalid folder and fall back to searching
|
|
105
|
+
# the entire project, which is token-heavy. Normalize such cases into search_pattern + Assets scope.
|
|
106
|
+
action_l = (action or "").lower()
|
|
107
|
+
if action_l == "search":
|
|
90
108
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
raw_path = (path or "").strip()
|
|
110
|
+
except (AttributeError, TypeError):
|
|
111
|
+
# Handle case where path is not a string despite type annotation
|
|
112
|
+
raw_path = ""
|
|
113
|
+
|
|
114
|
+
# If the caller put an AssetDatabase query into `path`, treat it as `search_pattern`.
|
|
115
|
+
if (not search_pattern) and raw_path.startswith("t:"):
|
|
116
|
+
search_pattern = raw_path
|
|
117
|
+
path = "Assets"
|
|
118
|
+
await ctx.info("manage_asset(search): normalized query from `path` into `search_pattern` and set path='Assets'")
|
|
119
|
+
|
|
120
|
+
# If the caller used `asset_type` to mean a search filter, map it to filter_type.
|
|
121
|
+
# (In Unity, filterType becomes `t:<filterType>`.)
|
|
122
|
+
if (not filter_type) and asset_type and isinstance(asset_type, str):
|
|
123
|
+
filter_type = asset_type
|
|
124
|
+
await ctx.info("manage_asset(search): mapped `asset_type` into `filter_type` for safer server-side filtering")
|
|
104
125
|
|
|
105
126
|
# Prepare parameters for the C# handler
|
|
106
127
|
params_dict = {
|
|
@@ -7,7 +7,8 @@ from services.registry import mcp_for_unity_tool
|
|
|
7
7
|
from services.tools import get_unity_instance_from_context
|
|
8
8
|
from transport.unity_transport import send_with_unity_instance
|
|
9
9
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
|
-
from services.tools.utils import coerce_bool, parse_json_payload
|
|
10
|
+
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
|
11
|
+
from services.tools.preflight import preflight
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@mcp_for_unity_tool(
|
|
@@ -68,6 +69,11 @@ async def manage_gameobject(
|
|
|
68
69
|
# Controls whether serialization of private [SerializeField] fields is included
|
|
69
70
|
includeNonPublicSerialized: Annotated[bool | str,
|
|
70
71
|
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
|
|
72
|
+
# --- Paging/safety for get_components ---
|
|
73
|
+
page_size: Annotated[int | str, "Page size for get_components paging."] | None = None,
|
|
74
|
+
cursor: Annotated[int | str, "Opaque cursor for get_components paging (offset)."] | None = None,
|
|
75
|
+
max_components: Annotated[int | str, "Hard cap on returned components per request (safety)."] | None = None,
|
|
76
|
+
include_properties: Annotated[bool | str, "If true, include serialized component properties (bounded)."] | None = None,
|
|
71
77
|
# --- Parameters for 'duplicate' ---
|
|
72
78
|
new_name: Annotated[str,
|
|
73
79
|
"New name for the duplicated object (default: SourceName_Copy)"] | None = None,
|
|
@@ -87,6 +93,10 @@ async def manage_gameobject(
|
|
|
87
93
|
# Removed session_state import
|
|
88
94
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
89
95
|
|
|
96
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
97
|
+
if gate is not None:
|
|
98
|
+
return gate.model_dump()
|
|
99
|
+
|
|
90
100
|
if action is None:
|
|
91
101
|
return {
|
|
92
102
|
"success": False,
|
|
@@ -134,7 +144,12 @@ async def manage_gameobject(
|
|
|
134
144
|
search_in_children = coerce_bool(search_in_children)
|
|
135
145
|
search_inactive = coerce_bool(search_inactive)
|
|
136
146
|
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
|
|
147
|
+
include_properties = coerce_bool(include_properties)
|
|
137
148
|
world_space = coerce_bool(world_space, default=True)
|
|
149
|
+
# If coercion fails, omit these fields (None) rather than preserving invalid input.
|
|
150
|
+
page_size = coerce_int(page_size, default=None)
|
|
151
|
+
cursor = coerce_int(cursor, default=None)
|
|
152
|
+
max_components = coerce_int(max_components, default=None)
|
|
138
153
|
|
|
139
154
|
# Coerce 'component_properties' from JSON string to dict for client compatibility
|
|
140
155
|
component_properties = parse_json_payload(component_properties)
|
|
@@ -194,6 +209,10 @@ async def manage_gameobject(
|
|
|
194
209
|
"searchInactive": search_inactive,
|
|
195
210
|
"componentName": component_name,
|
|
196
211
|
"includeNonPublicSerialized": includeNonPublicSerialized,
|
|
212
|
+
"pageSize": page_size,
|
|
213
|
+
"cursor": cursor,
|
|
214
|
+
"maxComponents": max_components,
|
|
215
|
+
"includeProperties": include_properties,
|
|
197
216
|
# Parameters for 'duplicate'
|
|
198
217
|
"new_name": new_name,
|
|
199
218
|
"offset": offset,
|
services/tools/manage_scene.py
CHANGED
|
@@ -3,8 +3,10 @@ from typing import Annotated, Literal, Any
|
|
|
3
3
|
from fastmcp import Context
|
|
4
4
|
from services.registry import mcp_for_unity_tool
|
|
5
5
|
from services.tools import get_unity_instance_from_context
|
|
6
|
+
from services.tools.utils import coerce_int, coerce_bool
|
|
6
7
|
from transport.unity_transport import send_with_unity_instance
|
|
7
8
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
from services.tools.preflight import preflight
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
@mcp_for_unity_tool(
|
|
@@ -27,29 +29,30 @@ async def manage_scene(
|
|
|
27
29
|
"Unity build index (quote as string, e.g., '0')."] | None = None,
|
|
28
30
|
screenshot_file_name: Annotated[str, "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None,
|
|
29
31
|
screenshot_super_size: Annotated[int | str, "Screenshot supersize multiplier (integer ≥1). Optional." ] | None = None,
|
|
32
|
+
# --- get_hierarchy paging/safety ---
|
|
33
|
+
parent: Annotated[str | int, "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None,
|
|
34
|
+
page_size: Annotated[int | str, "Page size for get_hierarchy paging."] | None = None,
|
|
35
|
+
cursor: Annotated[int | str, "Opaque cursor for paging (offset)."] | None = None,
|
|
36
|
+
max_nodes: Annotated[int | str, "Hard cap on returned nodes per request (safety)."] | None = None,
|
|
37
|
+
max_depth: Annotated[int | str, "Accepted for forward-compatibility; current paging returns a single level."] | None = None,
|
|
38
|
+
max_children_per_node: Annotated[int | str, "Child paging hint (safety)."] | None = None,
|
|
39
|
+
include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None,
|
|
30
40
|
) -> dict[str, Any]:
|
|
31
41
|
# Get active instance from session state
|
|
32
42
|
# Removed session_state import
|
|
33
43
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
44
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
45
|
+
if gate is not None:
|
|
46
|
+
return gate.model_dump()
|
|
34
47
|
try:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return int(value)
|
|
44
|
-
s = str(value).strip()
|
|
45
|
-
if s.lower() in ("", "none", "null"):
|
|
46
|
-
return default
|
|
47
|
-
return int(float(s))
|
|
48
|
-
except Exception:
|
|
49
|
-
return default
|
|
50
|
-
|
|
51
|
-
coerced_build_index = _coerce_int(build_index, default=None)
|
|
52
|
-
coerced_super_size = _coerce_int(screenshot_super_size, default=None)
|
|
48
|
+
coerced_build_index = coerce_int(build_index, default=None)
|
|
49
|
+
coerced_super_size = coerce_int(screenshot_super_size, default=None)
|
|
50
|
+
coerced_page_size = coerce_int(page_size, default=None)
|
|
51
|
+
coerced_cursor = coerce_int(cursor, default=None)
|
|
52
|
+
coerced_max_nodes = coerce_int(max_nodes, default=None)
|
|
53
|
+
coerced_max_depth = coerce_int(max_depth, default=None)
|
|
54
|
+
coerced_max_children_per_node = coerce_int(max_children_per_node, default=None)
|
|
55
|
+
coerced_include_transform = coerce_bool(include_transform, default=None)
|
|
53
56
|
|
|
54
57
|
params: dict[str, Any] = {"action": action}
|
|
55
58
|
if name:
|
|
@@ -62,6 +65,22 @@ async def manage_scene(
|
|
|
62
65
|
params["fileName"] = screenshot_file_name
|
|
63
66
|
if coerced_super_size is not None:
|
|
64
67
|
params["superSize"] = coerced_super_size
|
|
68
|
+
|
|
69
|
+
# get_hierarchy paging/safety params (optional)
|
|
70
|
+
if parent is not None:
|
|
71
|
+
params["parent"] = parent
|
|
72
|
+
if coerced_page_size is not None:
|
|
73
|
+
params["pageSize"] = coerced_page_size
|
|
74
|
+
if coerced_cursor is not None:
|
|
75
|
+
params["cursor"] = coerced_cursor
|
|
76
|
+
if coerced_max_nodes is not None:
|
|
77
|
+
params["maxNodes"] = coerced_max_nodes
|
|
78
|
+
if coerced_max_depth is not None:
|
|
79
|
+
params["maxDepth"] = coerced_max_depth
|
|
80
|
+
if coerced_max_children_per_node is not None:
|
|
81
|
+
params["maxChildrenPerNode"] = coerced_max_children_per_node
|
|
82
|
+
if coerced_include_transform is not None:
|
|
83
|
+
params["includeTransform"] = coerced_include_transform
|
|
65
84
|
|
|
66
85
|
# Use centralized retry helper with instance routing
|
|
67
86
|
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_scene", params)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool wrapper for managing ScriptableObject assets via Unity MCP.
|
|
3
|
+
|
|
4
|
+
Unity-side handler: MCPForUnity.Editor.Tools.ManageScriptableObject
|
|
5
|
+
Command name: "manage_scriptable_object"
|
|
6
|
+
Actions:
|
|
7
|
+
- create: create an SO asset (optionally with patches)
|
|
8
|
+
- modify: apply serialized property patches to an existing SO asset
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Annotated, Any, Literal
|
|
14
|
+
|
|
15
|
+
from fastmcp import Context
|
|
16
|
+
|
|
17
|
+
from services.registry import mcp_for_unity_tool
|
|
18
|
+
from services.tools import get_unity_instance_from_context
|
|
19
|
+
from services.tools.utils import coerce_bool, parse_json_payload
|
|
20
|
+
from transport.unity_transport import send_with_unity_instance
|
|
21
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp_for_unity_tool(
|
|
25
|
+
description="Creates and modifies ScriptableObject assets using Unity SerializedObject property paths."
|
|
26
|
+
)
|
|
27
|
+
async def manage_scriptable_object(
|
|
28
|
+
ctx: Context,
|
|
29
|
+
action: Annotated[Literal["create", "modify"], "Action to perform: create or modify."],
|
|
30
|
+
# --- create params ---
|
|
31
|
+
type_name: Annotated[str | None, "Namespace-qualified ScriptableObject type name (for create)."] = None,
|
|
32
|
+
folder_path: Annotated[str | None, "Target folder under Assets/... (for create)."] = None,
|
|
33
|
+
asset_name: Annotated[str | None, "Asset file name without extension (for create)."] = None,
|
|
34
|
+
overwrite: Annotated[bool | str | None, "If true, overwrite existing asset at same path (for create)."] = None,
|
|
35
|
+
# --- modify params ---
|
|
36
|
+
target: Annotated[dict[str, Any] | str | None, "Target asset reference {guid|path} (for modify)."] = None,
|
|
37
|
+
# --- shared ---
|
|
38
|
+
patches: Annotated[list[dict[str, Any]] | str | None, "Patch list (or JSON string) to apply."] = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
41
|
+
|
|
42
|
+
# Tolerate JSON-string payloads (LLMs sometimes stringify complex objects)
|
|
43
|
+
parsed_target = parse_json_payload(target)
|
|
44
|
+
parsed_patches = parse_json_payload(patches)
|
|
45
|
+
|
|
46
|
+
if parsed_target is not None and not isinstance(parsed_target, dict):
|
|
47
|
+
return {"success": False, "message": "manage_scriptable_object: 'target' must be an object {guid|path} (or JSON string of such)."}
|
|
48
|
+
|
|
49
|
+
if parsed_patches is not None and not isinstance(parsed_patches, list):
|
|
50
|
+
return {"success": False, "message": "manage_scriptable_object: 'patches' must be a list (or JSON string of a list)."}
|
|
51
|
+
|
|
52
|
+
params: dict[str, Any] = {
|
|
53
|
+
"action": action,
|
|
54
|
+
"typeName": type_name,
|
|
55
|
+
"folderPath": folder_path,
|
|
56
|
+
"assetName": asset_name,
|
|
57
|
+
"overwrite": coerce_bool(overwrite, default=None),
|
|
58
|
+
"target": parsed_target,
|
|
59
|
+
"patches": parsed_patches,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Remove None values to keep Unity handler simpler
|
|
63
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
64
|
+
|
|
65
|
+
response = await send_with_unity_instance(
|
|
66
|
+
async_send_command_with_retry,
|
|
67
|
+
unity_instance,
|
|
68
|
+
"manage_scriptable_object",
|
|
69
|
+
params,
|
|
70
|
+
)
|
|
71
|
+
await ctx.info(f"Response {response}")
|
|
72
|
+
return response if isinstance(response, dict) else {"success": False, "message": "Unexpected response from Unity."}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|