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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- 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}"}
|