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