Skip to content

Commit 3962cad

Browse files
committed
MCP: add script-edits spec resource; route all-structured edits via 'edit'; add routing='text' for pure text; echo normalizedEdits; C#: include 'code' in error payloads
1 parent c26ee13 commit 3962cad

File tree

4 files changed

+245
-51
lines changed

4 files changed

+245
-51
lines changed

UnityMcpBridge/Editor/Helpers/Response.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,24 @@ public static object Success(string message, object data = null)
3838
/// <param name="errorMessage">A message describing the error.</param>
3939
/// <param name="data">Optional additional data (e.g., error details) to include.</param>
4040
/// <returns>An object representing the error response.</returns>
41-
public static object Error(string errorMessage, object data = null)
41+
public static object Error(string errorCodeOrMessage, object data = null)
4242
{
4343
if (data != null)
4444
{
4545
// Note: The key is "error" for error messages, not "message"
4646
return new
4747
{
4848
success = false,
49-
error = errorMessage,
49+
// Preserve original behavior while adding a machine-parsable code field.
50+
// If callers pass a code string, it will be echoed in both code and error.
51+
code = errorCodeOrMessage,
52+
error = errorCodeOrMessage,
5053
data = data,
5154
};
5255
}
5356
else
5457
{
55-
return new { success = false, error = errorMessage };
58+
return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage };
5659
}
5760
}
5861
}

UnityMcpBridge/UnityMcpServer~/src/server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ def read_resource(ctx: Context, uri: str) -> dict:
164164
' {"op":"delete_method","required":["className","methodName"]},\n'
165165
' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n'
166166
' ],\n'
167+
' "apply_text_edits_recipe": {\n'
168+
' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n'
169+
' "step2_apply": {\n'
170+
' "tool": "manage_script",\n'
171+
' "args": {\n'
172+
' "action": "apply_text_edits",\n'
173+
' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n'
174+
' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n'
175+
' "precondition_sha256": "<sha-from-step1>",\n'
176+
' "options": {"refresh": "immediate", "validate": "standard"}\n'
177+
' }\n'
178+
' },\n'
179+
' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n'
180+
' },\n'
167181
' "examples": [\n'
168182
' {\n'
169183
' "title": "Replace a method",\n'

UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py

Lines changed: 159 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,34 @@ def collapse_duplicate_tail(s: str) -> str:
143143
return base_name, (p or "Assets")
144144

145145

146+
def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any:
147+
if not isinstance(resp, dict):
148+
return resp
149+
data = resp.setdefault("data", {})
150+
data.setdefault("normalizedEdits", edits)
151+
if routing:
152+
data["routing"] = routing
153+
return resp
154+
155+
156+
def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None,
157+
normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]:
158+
payload: Dict[str, Any] = {"success": False, "code": code, "message": message}
159+
data: Dict[str, Any] = {}
160+
if expected:
161+
data["expected"] = expected
162+
if rewrite:
163+
data["rewrite_suggestion"] = rewrite
164+
if normalized is not None:
165+
data["normalizedEdits"] = normalized
166+
if routing:
167+
data["routing"] = routing
168+
if extra:
169+
data.update(extra)
170+
if data:
171+
payload["data"] = data
172+
return payload
173+
146174
# Natural-language parsing removed; clients should send structured edits.
147175

148176

@@ -297,7 +325,7 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]:
297325

298326
# Validate required fields and produce machine-parsable hints
299327
def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]:
300-
return {"success": False, "message": message, "expected": expected, "rewrite_suggestion": suggestion}
328+
return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
301329

302330
for e in edits or []:
303331
op = e.get("op", "")
@@ -355,29 +383,32 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str
355383
{"edits[0].text": "/* comment */\n"}
356384
)
357385

358-
for e in edits or []:
359-
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
360-
if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"):
361-
# Default applyMode to sequential if mixing insert + replace in the same batch
362-
ops_in_batch = { (x.get("op") or "").lower() for x in edits or [] }
363-
options = dict(options or {})
364-
if "insert_method" in ops_in_batch and "replace_method" in ops_in_batch and "applyMode" not in options:
365-
options["applyMode"] = "sequential"
366-
367-
params: Dict[str, Any] = {
368-
"action": "edit",
369-
"name": name,
370-
"path": path,
371-
"namespace": namespace,
372-
"scriptType": script_type,
373-
"edits": edits,
374-
}
375-
if options is not None:
376-
params["options"] = options
377-
resp = send_command_with_retry("manage_script", params)
378-
if isinstance(resp, dict):
379-
resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo
380-
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
386+
# Decide routing: structured vs text vs mixed
387+
STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert","anchor_delete","anchor_replace"}
388+
TEXT = {"prepend","append","replace_range","regex_replace","anchor_insert"}
389+
ops_set = { (e.get("op") or "").lower() for e in edits or [] }
390+
all_struct = ops_set.issubset(STRUCT)
391+
all_text = ops_set.issubset(TEXT)
392+
mixed = not (all_struct or all_text)
393+
394+
# If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
395+
if all_struct:
396+
opts2 = dict(options or {})
397+
# Be conservative: when multiple structured ops are present, ensure deterministic order
398+
if len(edits or []) > 1:
399+
opts2.setdefault("applyMode", "sequential")
400+
opts2.setdefault("refresh", "immediate")
401+
params_struct: Dict[str, Any] = {
402+
"action": "edit",
403+
"name": name,
404+
"path": path,
405+
"namespace": namespace,
406+
"scriptType": script_type,
407+
"edits": edits,
408+
"options": opts2,
409+
}
410+
resp_struct = send_command_with_retry("manage_script", params_struct)
411+
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
381412

382413
# 1) read from Unity
383414
read_resp = send_command_with_retry("manage_script", {
@@ -400,6 +431,104 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str
400431
# Optional preview/dry-run: apply locally and return diff without writing
401432
preview = bool((options or {}).get("preview"))
402433

434+
# If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
435+
if mixed:
436+
text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT]
437+
struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT and (e.get("op") or "").lower() not in {"anchor_insert"}]
438+
try:
439+
current_text = contents
440+
def line_col_from_index(idx: int) -> Tuple[int, int]:
441+
line = current_text.count("\n", 0, idx) + 1
442+
last_nl = current_text.rfind("\n", 0, idx)
443+
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
444+
return line, col
445+
446+
at_edits: List[Dict[str, Any]] = []
447+
import re as _re
448+
for e in text_edits:
449+
opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
450+
text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or ""
451+
if opx == "anchor_insert":
452+
anchor = e.get("anchor") or ""
453+
position = (e.get("position") or "before").lower()
454+
m = _re.search(anchor, current_text, _re.MULTILINE)
455+
if not m:
456+
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
457+
idx = m.start() if position == "before" else m.end()
458+
sl, sc = line_col_from_index(idx)
459+
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
460+
current_text = current_text[:idx] + text_field + current_text[idx:]
461+
elif opx == "replace_range":
462+
if all(k in e for k in ("startLine","startCol","endLine","endCol")):
463+
at_edits.append({
464+
"startLine": int(e.get("startLine", 1)),
465+
"startCol": int(e.get("startCol", 1)),
466+
"endLine": int(e.get("endLine", 1)),
467+
"endCol": int(e.get("endCol", 1)),
468+
"newText": text_field
469+
})
470+
else:
471+
return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
472+
elif opx == "regex_replace":
473+
pattern = e.get("pattern") or ""
474+
m = _re.search(pattern, current_text, _re.MULTILINE)
475+
if not m:
476+
continue
477+
sl, sc = line_col_from_index(m.start())
478+
el, ec = line_col_from_index(m.end())
479+
at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": text_field})
480+
current_text = current_text[:m.start()] + text_field + current_text[m.end():]
481+
elif opx in ("prepend","append"):
482+
if opx == "prepend":
483+
sl, sc = 1, 1
484+
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
485+
current_text = text_field + current_text
486+
else:
487+
lines = current_text.splitlines(keepends=True)
488+
sl = len(lines) + (0 if current_text.endswith("\n") else 1)
489+
sc = 1
490+
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": ("\n" if not current_text.endswith("\n") else "") + text_field})
491+
current_text = current_text + ("\n" if not current_text.endswith("\n") else "") + text_field
492+
else:
493+
return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
494+
495+
import hashlib
496+
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
497+
if at_edits:
498+
params_text: Dict[str, Any] = {
499+
"action": "apply_text_edits",
500+
"name": name,
501+
"path": path,
502+
"namespace": namespace,
503+
"scriptType": script_type,
504+
"edits": at_edits,
505+
"precondition_sha256": sha,
506+
"options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")}
507+
}
508+
resp_text = send_command_with_retry("manage_script", params_text)
509+
if not (isinstance(resp_text, dict) and resp_text.get("success")):
510+
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
511+
except Exception as e:
512+
return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
513+
514+
if struct_edits:
515+
opts2 = dict(options or {})
516+
opts2.setdefault("applyMode", "sequential")
517+
opts2.setdefault("refresh", "immediate")
518+
params_struct: Dict[str, Any] = {
519+
"action": "edit",
520+
"name": name,
521+
"path": path,
522+
"namespace": namespace,
523+
"scriptType": script_type,
524+
"edits": struct_edits,
525+
"options": opts2
526+
}
527+
resp_struct = send_command_with_retry("manage_script", params_struct)
528+
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
529+
530+
return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
531+
403532
# If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
404533
# so header guards and validation run on the C# side.
405534
# Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
@@ -427,7 +556,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
427556
position = (e.get("position") or "before").lower()
428557
m = _re.search(anchor, current_text, _re.MULTILINE)
429558
if not m:
430-
return {"success": False, "message": f"anchor not found: {anchor}"}
559+
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
431560
idx = m.start() if position == "before" else m.end()
432561
sl, sc = line_col_from_index(idx)
433562
at_edits.append({
@@ -451,7 +580,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
451580
})
452581
else:
453582
# If only indices provided, skip (we don't support index-based here)
454-
return {"success": False, "message": "replace_range requires startLine/startCol/endLine/endCol"}
583+
return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
455584
elif op == "regex_replace":
456585
pattern = e.get("pattern") or ""
457586
repl = text_field
@@ -469,10 +598,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
469598
})
470599
current_text = current_text[:m.start()] + repl + current_text[m.end():]
471600
else:
472-
return {"success": False, "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}
601+
return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
473602

474603
if not at_edits:
475-
return {"success": False, "message": "No applicable text edit spans computed (anchor not found or zero-length)."}
604+
return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
476605

477606
# Send to Unity with precondition SHA to enforce guards and immediate refresh
478607
import hashlib
@@ -491,28 +620,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
491620
}
492621
}
493622
resp = send_command_with_retry("manage_script", params)
494-
if isinstance(resp, dict):
495-
resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo
496-
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
623+
return _with_norm(resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, routing="text")
497624
except Exception as e:
498-
return {"success": False, "message": f"Edit conversion failed: {e}"}
625+
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
499626

500-
# If we have anchor_* only (structured), forward to ManageScript.EditScript to avoid raw text path
501-
if text_ops.issubset({"anchor_insert", "anchor_delete", "anchor_replace"}):
502-
params: Dict[str, Any] = {
503-
"action": "edit",
504-
"name": name,
505-
"path": path,
506-
"namespace": namespace,
507-
"scriptType": script_type,
508-
"edits": edits,
509-
"options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")}
510-
}
511-
resp2 = send_command_with_retry("manage_script", params)
512-
if isinstance(resp2, dict):
513-
resp2.setdefault("data", {})["normalizedEdits"] = normalized_for_echo
514-
return resp2 if isinstance(resp2, dict) else {"success": False, "message": str(resp2)}
515-
516627
# For regex_replace on large files, support preview/confirm
517628
if "regex_replace" in text_ops and not (options or {}).get("confirm"):
518629
try:

0 commit comments

Comments
 (0)