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.
@@ -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,
@@ -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="Performs asset operations (import, create, modify, delete, etc.) in Unity."
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."] = False,
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')."] | None = None,
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
- # Coerce numeric inputs defensively
87
- def _coerce_int(value, default=None):
88
- if value is None:
89
- return default
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
- if isinstance(value, bool):
92
- return default
93
- if isinstance(value, int):
94
- return int(value)
95
- s = str(value).strip()
96
- if s.lower() in ("", "none", "null"):
97
- return default
98
- return int(float(s))
99
- except Exception:
100
- return default
101
-
102
- page_size = _coerce_int(page_size)
103
- page_number = _coerce_int(page_number)
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,
@@ -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
- # Coerce numeric inputs defensively
36
- def _coerce_int(value, default=None):
37
- if value is None:
38
- return default
39
- try:
40
- if isinstance(value, bool):
41
- return default
42
- if isinstance(value, int):
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
+