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,645 @@
1
+ import base64
2
+ import os
3
+ from typing import Annotated, Any, Literal
4
+ from urllib.parse import urlparse, unquote
5
+
6
+ from fastmcp import FastMCP, 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
+ import transport.legacy.unity_connection
13
+
14
+
15
+ def _split_uri(uri: str) -> tuple[str, str]:
16
+ """Split an incoming URI or path into (name, directory) suitable for Unity.
17
+
18
+ Rules:
19
+ - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
+ - file://... → percent-decode, normalize, strip host and leading slashes,
21
+ then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
22
+ Otherwise, fall back to original name/dir behavior.
23
+ - plain paths → decode/normalize separators; if they contain an 'Assets' segment,
24
+ return relative to 'Assets'.
25
+ """
26
+ raw_path: str
27
+ if uri.startswith("mcpforunity://path/"):
28
+ raw_path = uri[len("mcpforunity://path/"):]
29
+ elif uri.startswith("file://"):
30
+ parsed = urlparse(uri)
31
+ host = (parsed.netloc or "").strip()
32
+ p = parsed.path or ""
33
+ # UNC: file://server/share/... -> //server/share/...
34
+ if host and host.lower() != "localhost":
35
+ p = f"//{host}{p}"
36
+ # Use percent-decoded path, preserving leading slashes
37
+ raw_path = unquote(p)
38
+ else:
39
+ raw_path = uri
40
+
41
+ # Percent-decode any residual encodings and normalize separators
42
+ raw_path = unquote(raw_path).replace("\\", "/")
43
+ # Strip leading slash only for Windows drive-letter forms like "/C:/..."
44
+ if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
45
+ raw_path = raw_path[1:]
46
+
47
+ # Normalize path (collapse ../, ./)
48
+ norm = os.path.normpath(raw_path).replace("\\", "/")
49
+
50
+ # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
51
+ parts = [p for p in norm.split("/") if p not in ("", ".")]
52
+ idx = next((i for i, seg in enumerate(parts)
53
+ if seg.lower() == "assets"), None)
54
+ assets_rel = "/".join(parts[idx:]) if idx is not None else None
55
+
56
+ effective_path = assets_rel if assets_rel else norm
57
+ # For POSIX absolute paths outside Assets, drop the leading '/'
58
+ # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
59
+ if effective_path.startswith("/"):
60
+ effective_path = effective_path[1:]
61
+
62
+ name = os.path.splitext(os.path.basename(effective_path))[0]
63
+ directory = os.path.dirname(effective_path)
64
+ return name, directory
65
+
66
+
67
+ @mcp_for_unity_tool(
68
+ description=(
69
+ """Apply small text edits to a C# script identified by URI.
70
+ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!
71
+ RECOMMENDED WORKFLOW:
72
+ 1. First call resources/read with start_line/line_count to verify exact content
73
+ 2. Count columns carefully (or use find_in_file to locate patterns)
74
+ 3. Apply your edit with precise coordinates
75
+ 4. Consider script_apply_edits with anchors for safer pattern-based replacements
76
+ Notes:
77
+ - For method/class operations, use script_apply_edits (safer, structured edits)
78
+ - For pattern-based replacements, consider anchor operations in script_apply_edits
79
+ - Lines, columns are 1-indexed
80
+ - Tabs count as 1 column"""
81
+ ),
82
+ annotations=ToolAnnotations(
83
+ title="Apply Text Edits",
84
+ destructiveHint=True,
85
+ ),
86
+ )
87
+ async def apply_text_edits(
88
+ ctx: Context,
89
+ uri: Annotated[str, "URI of the script to edit under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."],
90
+ edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"],
91
+ precondition_sha256: Annotated[str,
92
+ "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None,
93
+ strict: Annotated[bool,
94
+ "Optional strict flag, used to enforce strict mode"] | None = None,
95
+ options: Annotated[dict[str, Any],
96
+ "Optional options, used to pass additional options to the script editor"] | None = None,
97
+ ) -> dict[str, Any]:
98
+ unity_instance = get_unity_instance_from_context(ctx)
99
+ await ctx.info(
100
+ f"Processing apply_text_edits: {uri} (unity_instance={unity_instance or 'default'})")
101
+ name, directory = _split_uri(uri)
102
+
103
+ # Normalize common aliases/misuses for resilience:
104
+ # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
105
+ # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text}
106
+ # If normalization is required, read current contents to map indices -> 1-based line/col.
107
+ def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
108
+ for e in arr or []:
109
+ if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e):
110
+ return True
111
+ return False
112
+
113
+ normalized_edits: list[dict[str, Any]] = []
114
+ warnings: list[str] = []
115
+ if _needs_normalization(edits):
116
+ # Read file to support index->line/col conversion when needed
117
+ read_resp = await send_with_unity_instance(
118
+ transport.legacy.unity_connection.async_send_command_with_retry,
119
+ unity_instance,
120
+ "manage_script",
121
+ {
122
+ "action": "read",
123
+ "name": name,
124
+ "path": directory,
125
+ },
126
+ )
127
+ if not (isinstance(read_resp, dict) and read_resp.get("success")):
128
+ return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
129
+ data = read_resp.get("data", {})
130
+ contents = data.get("contents")
131
+ if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
132
+ try:
133
+ contents = base64.b64decode(data.get("encodedContents", "").encode(
134
+ "utf-8")).decode("utf-8", "replace")
135
+ except Exception:
136
+ contents = contents or ""
137
+
138
+ # Helper to map 0-based character index to 1-based line/col
139
+ def line_col_from_index(idx: int) -> tuple[int, int]:
140
+ if idx <= 0:
141
+ return 1, 1
142
+ # Count lines up to idx and position within line
143
+ nl_count = contents.count("\n", 0, idx)
144
+ line = nl_count + 1
145
+ last_nl = contents.rfind("\n", 0, idx)
146
+ col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
147
+ return line, col
148
+
149
+ for e in edits or []:
150
+ e2 = dict(e)
151
+ # Map text->newText if needed
152
+ if "newText" not in e2 and "text" in e2:
153
+ e2["newText"] = e2.pop("text")
154
+
155
+ if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2:
156
+ # Guard: explicit fields must be 1-based.
157
+ zero_based = False
158
+ for k in ("startLine", "startCol", "endLine", "endCol"):
159
+ try:
160
+ if int(e2.get(k, 1)) < 1:
161
+ zero_based = True
162
+ except Exception:
163
+ pass
164
+ if zero_based:
165
+ if strict:
166
+ return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}}
167
+ # Normalize by clamping to 1 and warn
168
+ for k in ("startLine", "startCol", "endLine", "endCol"):
169
+ try:
170
+ if int(e2.get(k, 1)) < 1:
171
+ e2[k] = 1
172
+ except Exception:
173
+ pass
174
+ warnings.append(
175
+ "zero_based_explicit_fields_normalized")
176
+ normalized_edits.append(e2)
177
+ continue
178
+
179
+ rng = e2.get("range")
180
+ if isinstance(rng, dict):
181
+ # LSP style: 0-based
182
+ s = rng.get("start", {})
183
+ t = rng.get("end", {})
184
+ e2["startLine"] = int(s.get("line", 0)) + 1
185
+ e2["startCol"] = int(s.get("character", 0)) + 1
186
+ e2["endLine"] = int(t.get("line", 0)) + 1
187
+ e2["endCol"] = int(t.get("character", 0)) + 1
188
+ e2.pop("range", None)
189
+ normalized_edits.append(e2)
190
+ continue
191
+ if isinstance(rng, (list, tuple)) and len(rng) == 2:
192
+ try:
193
+ a = int(rng[0])
194
+ b = int(rng[1])
195
+ if b < a:
196
+ a, b = b, a
197
+ sl, sc = line_col_from_index(a)
198
+ el, ec = line_col_from_index(b)
199
+ e2["startLine"] = sl
200
+ e2["startCol"] = sc
201
+ e2["endLine"] = el
202
+ e2["endCol"] = ec
203
+ e2.pop("range", None)
204
+ normalized_edits.append(e2)
205
+ continue
206
+ except Exception:
207
+ pass
208
+ # Could not normalize this edit
209
+ return {
210
+ "success": False,
211
+ "code": "missing_field",
212
+ "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'",
213
+ "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e}
214
+ }
215
+ else:
216
+ # Even when edits appear already in explicit form, validate 1-based coordinates.
217
+ normalized_edits = []
218
+ for e in edits or []:
219
+ e2 = dict(e)
220
+ has_all = all(k in e2 for k in (
221
+ "startLine", "startCol", "endLine", "endCol"))
222
+ if has_all:
223
+ zero_based = False
224
+ for k in ("startLine", "startCol", "endLine", "endCol"):
225
+ try:
226
+ if int(e2.get(k, 1)) < 1:
227
+ zero_based = True
228
+ except Exception:
229
+ pass
230
+ if zero_based:
231
+ if strict:
232
+ return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}}
233
+ for k in ("startLine", "startCol", "endLine", "endCol"):
234
+ try:
235
+ if int(e2.get(k, 1)) < 1:
236
+ e2[k] = 1
237
+ except Exception:
238
+ pass
239
+ if "zero_based_explicit_fields_normalized" not in warnings:
240
+ warnings.append(
241
+ "zero_based_explicit_fields_normalized")
242
+ normalized_edits.append(e2)
243
+
244
+ # Preflight: detect overlapping ranges among normalized line/col spans
245
+ def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]:
246
+ return (
247
+ int(e.get("startLine", 1)) if key_start else int(
248
+ e.get("endLine", 1)),
249
+ int(e.get("startCol", 1)) if key_start else int(
250
+ e.get("endCol", 1)),
251
+ )
252
+
253
+ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
254
+ return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1])
255
+
256
+ # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap.
257
+ spans = []
258
+ for e in normalized_edits or []:
259
+ try:
260
+ s = _pos_tuple(e, True)
261
+ t = _pos_tuple(e, False)
262
+ if s != t:
263
+ spans.append((s, t))
264
+ except Exception:
265
+ # If coordinates missing or invalid, let the server validate later
266
+ pass
267
+
268
+ if spans:
269
+ spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1]))
270
+ for i in range(1, len(spans_sorted)):
271
+ prev_end = spans_sorted[i-1][1]
272
+ curr_start = spans_sorted[i][0]
273
+ # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start
274
+ if not _le(prev_end, curr_start):
275
+ conflicts = [{
276
+ "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]},
277
+ "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]},
278
+ "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]},
279
+ "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]},
280
+ }]
281
+ return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}}
282
+
283
+ # Note: Do not auto-compute precondition if missing; callers should supply it
284
+ # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and
285
+ # preserves existing call-count expectations in clients/tests.
286
+
287
+ # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance
288
+ opts: dict[str, Any] = dict(options or {})
289
+ try:
290
+ if len(normalized_edits) > 1 and "applyMode" not in opts:
291
+ opts["applyMode"] = "atomic"
292
+ except Exception:
293
+ pass
294
+ # Support optional debug preview for span-by-span simulation without write
295
+ if opts.get("debug_preview"):
296
+ try:
297
+ import difflib
298
+ # Apply locally to preview final result
299
+ lines = []
300
+ # Build an indexable original from a read if we normalized from read; otherwise skip
301
+ prev = ""
302
+ # We cannot guarantee file contents here without a read; return normalized spans only
303
+ return {
304
+ "success": True,
305
+ "message": "Preview only (no write)",
306
+ "data": {
307
+ "normalizedEdits": normalized_edits,
308
+ "preview": True
309
+ }
310
+ }
311
+ except Exception as e:
312
+ return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}}
313
+
314
+ params = {
315
+ "action": "apply_text_edits",
316
+ "name": name,
317
+ "path": directory,
318
+ "edits": normalized_edits,
319
+ "precondition_sha256": precondition_sha256,
320
+ "options": opts,
321
+ }
322
+ params = {k: v for k, v in params.items() if v is not None}
323
+ resp = await send_with_unity_instance(
324
+ transport.legacy.unity_connection.async_send_command_with_retry,
325
+ unity_instance,
326
+ "manage_script",
327
+ params,
328
+ )
329
+ if isinstance(resp, dict):
330
+ data = resp.setdefault("data", {})
331
+ data.setdefault("normalizedEdits", normalized_edits)
332
+ if warnings:
333
+ data.setdefault("warnings", warnings)
334
+ if resp.get("success") and (options or {}).get("force_sentinel_reload"):
335
+ # Optional: flip sentinel via menu if explicitly requested
336
+ try:
337
+ import threading
338
+ import time
339
+ import json
340
+ import glob
341
+ import os
342
+
343
+ def _latest_status() -> dict | None:
344
+ try:
345
+ files = sorted(glob.glob(os.path.expanduser(
346
+ "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
347
+ if not files:
348
+ return None
349
+ with open(files[0], "r") as f:
350
+ return json.loads(f.read())
351
+ except Exception:
352
+ return None
353
+
354
+ async def _flip_async():
355
+ try:
356
+ time.sleep(0.1)
357
+ st = _latest_status()
358
+ if st and st.get("reloading"):
359
+ return
360
+ await transport.legacy.unity_connection.async_send_command_with_retry(
361
+ "execute_menu_item",
362
+ {"menuPath": "MCP/Flip Reload Sentinel"},
363
+ max_retries=0,
364
+ retry_ms=0,
365
+ instance_id=unity_instance,
366
+ )
367
+ except Exception:
368
+ pass
369
+ threading.Thread(target=_flip_async, daemon=True).start()
370
+ except Exception:
371
+ pass
372
+ return resp
373
+ return resp
374
+ return {"success": False, "message": str(resp)}
375
+
376
+
377
+ @mcp_for_unity_tool(
378
+ description="Create a new C# script at the given project path.",
379
+ annotations=ToolAnnotations(
380
+ title="Create Script",
381
+ destructiveHint=True,
382
+ ),
383
+ )
384
+ async def create_script(
385
+ ctx: Context,
386
+ path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
387
+ contents: Annotated[str, "Contents of the script to create (plain text C# code). The server handles Base64 encoding."],
388
+ script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
389
+ namespace: Annotated[str, "Namespace for the script"] | None = None,
390
+ ) -> dict[str, Any]:
391
+ unity_instance = get_unity_instance_from_context(ctx)
392
+ await ctx.info(
393
+ f"Processing create_script: {path} (unity_instance={unity_instance or 'default'})")
394
+ name = os.path.splitext(os.path.basename(path))[0]
395
+ directory = os.path.dirname(path)
396
+ # Local validation to avoid round-trips on obviously bad input
397
+ norm_path = os.path.normpath(
398
+ (path or "").replace("\\", "/")).replace("\\", "/")
399
+ if not directory or directory.split("/")[0].lower() != "assets":
400
+ return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."}
401
+ if ".." in norm_path.split("/") or norm_path.startswith("/"):
402
+ return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."}
403
+ if not name:
404
+ return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
405
+ if not norm_path.lower().endswith(".cs"):
406
+ return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
407
+ params: dict[str, Any] = {
408
+ "action": "create",
409
+ "name": name,
410
+ "path": directory,
411
+ "namespace": namespace,
412
+ "scriptType": script_type,
413
+ }
414
+ if contents:
415
+ params["encodedContents"] = base64.b64encode(
416
+ contents.encode("utf-8")).decode("utf-8")
417
+ params["contentsEncoded"] = True
418
+ params = {k: v for k, v in params.items() if v is not None}
419
+ resp = await send_with_unity_instance(
420
+ transport.legacy.unity_connection.async_send_command_with_retry,
421
+ unity_instance,
422
+ "manage_script",
423
+ params,
424
+ )
425
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
426
+
427
+
428
+ @mcp_for_unity_tool(
429
+ description="Delete a C# script by URI or Assets-relative path.",
430
+ annotations=ToolAnnotations(
431
+ title="Delete Script",
432
+ destructiveHint=True,
433
+ ),
434
+ )
435
+ async def delete_script(
436
+ ctx: Context,
437
+ uri: Annotated[str, "URI of the script to delete under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."],
438
+ ) -> dict[str, Any]:
439
+ """Delete a C# script by URI."""
440
+ unity_instance = get_unity_instance_from_context(ctx)
441
+ await ctx.info(
442
+ f"Processing delete_script: {uri} (unity_instance={unity_instance or 'default'})")
443
+ name, directory = _split_uri(uri)
444
+ if not directory or directory.split("/")[0].lower() != "assets":
445
+ return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
446
+ params = {"action": "delete", "name": name, "path": directory}
447
+ resp = await send_with_unity_instance(
448
+ transport.legacy.unity_connection.async_send_command_with_retry,
449
+ unity_instance,
450
+ "manage_script",
451
+ params,
452
+ )
453
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
454
+
455
+
456
+ @mcp_for_unity_tool(
457
+ description="Validate a C# script and return diagnostics.",
458
+ annotations=ToolAnnotations(
459
+ title="Validate Script",
460
+ readOnlyHint=True,
461
+ ),
462
+ )
463
+ async def validate_script(
464
+ ctx: Context,
465
+ uri: Annotated[str, "URI of the script to validate under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."],
466
+ level: Annotated[Literal['basic', 'standard'],
467
+ "Validation level"] = "basic",
468
+ include_diagnostics: Annotated[bool,
469
+ "Include full diagnostics and summary"] = False,
470
+ ) -> dict[str, Any]:
471
+ unity_instance = get_unity_instance_from_context(ctx)
472
+ await ctx.info(
473
+ f"Processing validate_script: {uri} (unity_instance={unity_instance or 'default'})")
474
+ name, directory = _split_uri(uri)
475
+ if not directory or directory.split("/")[0].lower() != "assets":
476
+ return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
477
+ if level not in ("basic", "standard"):
478
+ return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."}
479
+ params = {
480
+ "action": "validate",
481
+ "name": name,
482
+ "path": directory,
483
+ "level": level,
484
+ }
485
+ resp = await send_with_unity_instance(
486
+ transport.legacy.unity_connection.async_send_command_with_retry,
487
+ unity_instance,
488
+ "manage_script",
489
+ params,
490
+ )
491
+ if isinstance(resp, dict) and resp.get("success"):
492
+ diags = resp.get("data", {}).get("diagnostics", []) or []
493
+ warnings = sum(1 for d in diags if str(
494
+ d.get("severity", "")).lower() == "warning")
495
+ errors = sum(1 for d in diags if str(
496
+ d.get("severity", "")).lower() in ("error", "fatal"))
497
+ if include_diagnostics:
498
+ return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
499
+ return {"success": True, "data": {"warnings": warnings, "errors": errors}}
500
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
501
+
502
+
503
+ @mcp_for_unity_tool(
504
+ description="Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits. Read-only action: read. Modifying actions: create, delete.",
505
+ annotations=ToolAnnotations(
506
+ title="Manage Script",
507
+ destructiveHint=True,
508
+ ),
509
+ )
510
+ async def manage_script(
511
+ ctx: Context,
512
+ action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."],
513
+ name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
514
+ path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
515
+ contents: Annotated[str, "Contents of the script to create",
516
+ "C# code for 'create' action"] | None = None,
517
+ script_type: Annotated[str, "Script type (e.g., 'C#')",
518
+ "Type hint (e.g., 'MonoBehaviour')"] | None = None,
519
+ namespace: Annotated[str, "Namespace for the script"] | None = None,
520
+ ) -> dict[str, Any]:
521
+ unity_instance = get_unity_instance_from_context(ctx)
522
+ await ctx.info(
523
+ f"Processing manage_script: {action} (unity_instance={unity_instance or 'default'})")
524
+ try:
525
+ # Prepare parameters for Unity
526
+ params = {
527
+ "action": action,
528
+ "name": name,
529
+ "path": path,
530
+ "namespace": namespace,
531
+ "scriptType": script_type,
532
+ }
533
+
534
+ # Base64 encode the contents if they exist to avoid JSON escaping issues
535
+ if contents:
536
+ if action == 'create':
537
+ params["encodedContents"] = base64.b64encode(
538
+ contents.encode('utf-8')).decode('utf-8')
539
+ params["contentsEncoded"] = True
540
+ else:
541
+ params["contents"] = contents
542
+
543
+ params = {k: v for k, v in params.items() if v is not None}
544
+
545
+ response = await send_with_unity_instance(
546
+ transport.legacy.unity_connection.async_send_command_with_retry,
547
+ unity_instance,
548
+ "manage_script",
549
+ params,
550
+ )
551
+
552
+ if isinstance(response, dict):
553
+ if response.get("success"):
554
+ if response.get("data", {}).get("contentsEncoded"):
555
+ decoded_contents = base64.b64decode(
556
+ response["data"]["encodedContents"]).decode('utf-8')
557
+ response["data"]["contents"] = decoded_contents
558
+ del response["data"]["encodedContents"]
559
+ del response["data"]["contentsEncoded"]
560
+
561
+ return {
562
+ "success": True,
563
+ "message": response.get("message", "Operation successful."),
564
+ "data": response.get("data"),
565
+ }
566
+ return response
567
+
568
+ return {"success": False, "message": str(response)}
569
+
570
+ except Exception as e:
571
+ return {
572
+ "success": False,
573
+ "message": f"Python error managing script: {str(e)}",
574
+ }
575
+
576
+
577
+ @mcp_for_unity_tool(
578
+ description=(
579
+ """Get manage_script capabilities (supported ops, limits, and guards).
580
+ Returns:
581
+ - ops: list of supported structured ops
582
+ - text_ops: list of supported text ops
583
+ - max_edit_payload_bytes: server edit payload cap
584
+ - guards: header/using guard enabled flag"""
585
+ ),
586
+ annotations=ToolAnnotations(
587
+ title="Manage Script Capabilities",
588
+ readOnlyHint=True,
589
+ ),
590
+ )
591
+ async def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
592
+ await ctx.info("Processing manage_script_capabilities")
593
+ try:
594
+ # Keep in sync with server/Editor ManageScript implementation
595
+ ops = [
596
+ "replace_class", "delete_class", "replace_method", "delete_method",
597
+ "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"
598
+ ]
599
+ text_ops = ["replace_range", "regex_replace", "prepend", "append"]
600
+ # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback
601
+ max_edit_payload_bytes = 256 * 1024
602
+ guards = {"using_guard": True}
603
+ extras = {"get_sha": True}
604
+ return {"success": True, "data": {
605
+ "ops": ops,
606
+ "text_ops": text_ops,
607
+ "max_edit_payload_bytes": max_edit_payload_bytes,
608
+ "guards": guards,
609
+ "extras": extras,
610
+ }}
611
+ except Exception as e:
612
+ return {"success": False, "error": f"capabilities error: {e}"}
613
+
614
+
615
+ @mcp_for_unity_tool(
616
+ description="Get SHA256 and basic metadata for a Unity C# script without returning file contents. Requires uri (script path under Assets/ or mcpforunity://path/Assets/... or file://...).",
617
+ annotations=ToolAnnotations(
618
+ title="Get SHA",
619
+ readOnlyHint=True,
620
+ ),
621
+ )
622
+ async def get_sha(
623
+ ctx: Context,
624
+ uri: Annotated[str, "URI of the script to edit under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."],
625
+ ) -> dict[str, Any]:
626
+ unity_instance = get_unity_instance_from_context(ctx)
627
+ await ctx.info(
628
+ f"Processing get_sha: {uri} (unity_instance={unity_instance or 'default'})")
629
+ try:
630
+ name, directory = _split_uri(uri)
631
+ params = {"action": "get_sha", "name": name, "path": directory}
632
+ resp = await send_with_unity_instance(
633
+ transport.legacy.unity_connection.async_send_command_with_retry,
634
+ unity_instance,
635
+ "manage_script",
636
+ params,
637
+ )
638
+ if isinstance(resp, dict) and resp.get("success"):
639
+ data = resp.get("data", {})
640
+ minimal = {"sha256": data.get(
641
+ "sha256"), "lengthBytes": data.get("lengthBytes")}
642
+ return {"success": True, "data": minimal}
643
+ return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
644
+ except Exception as e:
645
+ return {"success": False, "message": f"get_sha error: {e}"}