From fde29d000037b85c65e27738f38e457320831b5c Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:23:03 -0700 Subject: [PATCH 01/20] Convert skipped tests to xfail and improve framing robustness --- .../UnityMcpServer~/src/unity_connection.py | 2 + tests/test_logging_stdout.py | 48 ++++++++++++++----- tests/test_resources_api.py | 4 +- tests/test_script_editing.py | 14 +++--- tests/test_transport_framing.py | 42 +++++++++------- 5 files changed, 73 insertions(+), 37 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index ab47a503..7bf28c01 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -94,6 +94,8 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: try: header = self._read_exact(sock, 8) payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0: + raise Exception("Invalid framed length: 0") if payload_len > (64 * 1024 * 1024): raise Exception(f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index d6e728b7..2c9d0163 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -1,4 +1,4 @@ -import re +import ast from pathlib import Path import pytest @@ -13,8 +13,9 @@ SRC = next((p for p in candidates if p.exists()), None) if SRC is None: searched = "\n".join(str(p) for p in candidates) - raise FileNotFoundError( - "Unity MCP server source not found. Tried:\n" + searched + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, ) @@ -24,14 +25,39 @@ def test_no_stdout_output_from_tools(): def test_no_print_statements_in_codebase(): - """Ensure no stray print statements remain in server source.""" + """Ensure no stray print/sys.stdout writes remain in server source.""" offenders = [] for py_file in SRC.rglob("*.py"): - text = py_file.read_text(encoding="utf-8") - if re.search(r"^\s*print\(", text, re.MULTILINE) or re.search( - r"sys\.stdout\.write\(", text - ): + try: + text = py_file.read_text(encoding="utf-8", errors="strict") + except UnicodeDecodeError: + # Be tolerant of encoding edge cases in source tree + text = py_file.read_text(encoding="utf-8", errors="ignore") + try: + tree = ast.parse(text, filename=str(py_file)) + except SyntaxError: offenders.append(py_file.relative_to(SRC)) - assert not offenders, ( - "stdout writes found in: " + ", ".join(str(o) for o in offenders) - ) + continue + + class StdoutVisitor(ast.NodeVisitor): + def __init__(self): + self.hit = False + + def visit_Call(self, node: ast.Call): + # print(...) + if isinstance(node.func, ast.Name) and node.func.id == "print": + self.hit = True + # sys.stdout.write(...) + if isinstance(node.func, ast.Attribute) and node.func.attr == "write": + val = node.func.value + if isinstance(val, ast.Attribute) and val.attr == "stdout": + if isinstance(val.value, ast.Name) and val.value.id == "sys": + self.hit = True + self.generic_visit(node) + + v = StdoutVisitor() + v.visit(tree) + if v.hit: + offenders.append(py_file.relative_to(SRC)) + + assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index bdcd7290..62cc1ac1 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,11 +1,11 @@ import pytest -@pytest.mark.skip(reason="TODO: resource.list returns only Assets/**/*.cs and rejects traversal") +@pytest.mark.xfail(strict=False, reason="resource.list should return only Assets/**/*.cs and reject traversal") def test_resource_list_filters_and_rejects_traversal(): pass -@pytest.mark.skip(reason="TODO: resource.list rejects file:// paths outside project, including drive letters and symlinks") +@pytest.mark.xfail(strict=False, reason="resource.list should reject outside paths including drive letters and symlinks") def test_resource_list_rejects_outside_paths(): pass diff --git a/tests/test_script_editing.py b/tests/test_script_editing.py index e0b3705b..88046d00 100644 --- a/tests/test_script_editing.py +++ b/tests/test_script_editing.py @@ -1,36 +1,36 @@ import pytest -@pytest.mark.skip(reason="TODO: create new script, validate, apply edits, build and compile scene") +@pytest.mark.xfail(strict=False, reason="pending: create new script, validate, apply edits, build and compile scene") def test_script_edit_happy_path(): pass -@pytest.mark.skip(reason="TODO: multiple micro-edits debounce to single compilation") +@pytest.mark.xfail(strict=False, reason="pending: multiple micro-edits debounce to single compilation") def test_micro_edits_debounce(): pass -@pytest.mark.skip(reason="TODO: line ending variations handled correctly") +@pytest.mark.xfail(strict=False, reason="pending: line ending variations handled correctly") def test_line_endings_and_columns(): pass -@pytest.mark.skip(reason="TODO: regex_replace no-op with allow_noop honored") +@pytest.mark.xfail(strict=False, reason="pending: regex_replace no-op with allow_noop honored") def test_regex_replace_noop_allowed(): pass -@pytest.mark.skip(reason="TODO: large edit size boundaries and overflow protection") +@pytest.mark.xfail(strict=False, reason="pending: large edit size boundaries and overflow protection") def test_large_edit_size_and_overflow(): pass -@pytest.mark.skip(reason="TODO: symlink and junction protections on edits") +@pytest.mark.xfail(strict=False, reason="pending: symlink and junction protections on edits") def test_symlink_and_junction_protection(): pass -@pytest.mark.skip(reason="TODO: atomic write guarantees") +@pytest.mark.xfail(strict=False, reason="pending: atomic write guarantees") def test_atomic_write_guarantees(): pass diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 39e84afd..011473b3 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -18,8 +18,9 @@ SRC = next((p for p in candidates if p.exists()), None) if SRC is None: searched = "\n".join(str(p) for p in candidates) - raise FileNotFoundError( - "Unity MCP server source not found. Tried:\n" + searched + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, ) sys.path.insert(0, str(SRC)) @@ -37,19 +38,25 @@ def start_dummy_server(greeting: bytes, respond_ping: bool = False): def _run(): ready.set() conn, _ = sock.accept() + conn.settimeout(1.0) if greeting: conn.sendall(greeting) if respond_ping: try: - header = conn.recv(8) - if len(header) == 8: - length = struct.unpack(">Q", header)[0] - payload = b"" - while len(payload) < length: - chunk = conn.recv(length - len(payload)) + # Read exactly n bytes helper + def _read_exact(n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) if not chunk: break - payload += chunk + buf += chunk + return buf + + header = _read_exact(8) + if len(header) == 8: + length = struct.unpack(">Q", header)[0] + payload = _read_exact(length) if payload == b'{"type":"ping"}': resp = b'{"type":"pong"}' conn.sendall(struct.pack(">Q", len(resp)) + resp) @@ -79,13 +86,14 @@ def start_handshake_enforcing_server(): def _run(): ready.set() conn, _ = sock.accept() - # if client sends any data before greeting, disconnect - # give clients a bit more time to send pre-handshake data before we greet - r, _, _ = select.select([conn], [], [], 0.2) - if r: - conn.close() - sock.close() - return + # If client sends any data before greeting, disconnect (poll briefly) + deadline = time.time() + 0.5 + while time.time() < deadline: + r, _, _ = select.select([conn], [], [], 0.05) + if r: + conn.close() + sock.close() + return conn.sendall(b"MCP/0.1 FRAMING=1\n") time.sleep(0.1) conn.close() @@ -122,7 +130,7 @@ def test_unframed_data_disconnect(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("127.0.0.1", port)) sock.sendall(b"BAD") - time.sleep(0.1) + time.sleep(0.4) try: data = sock.recv(1024) assert data == b"" From 5386b23c74ac51950a8082bec6bbf7effa19f87e Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:32:23 -0700 Subject: [PATCH 02/20] clarify stdout test failure messaging --- tests/test_logging_stdout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 2c9d0163..4c53a10f 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -27,6 +27,7 @@ def test_no_stdout_output_from_tools(): def test_no_print_statements_in_codebase(): """Ensure no stray print/sys.stdout writes remain in server source.""" offenders = [] + syntax_errors = [] for py_file in SRC.rglob("*.py"): try: text = py_file.read_text(encoding="utf-8", errors="strict") @@ -36,7 +37,7 @@ def test_no_print_statements_in_codebase(): try: tree = ast.parse(text, filename=str(py_file)) except SyntaxError: - offenders.append(py_file.relative_to(SRC)) + syntax_errors.append(py_file.relative_to(SRC)) continue class StdoutVisitor(ast.NodeVisitor): @@ -60,4 +61,5 @@ def visit_Call(self, node: ast.Call): if v.hit: offenders.append(py_file.relative_to(SRC)) + assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) From 63b070b6c0fa612d4a5170364dd3a51c8c8e6e6e Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 19:40:54 -0700 Subject: [PATCH 03/20] Add handshake fallback and logging checks --- .../UnityMcpServer~/src/unity_connection.py | 102 ++++++++++-------- tests/test_logging_stdout.py | 22 +++- tests/test_transport_framing.py | 1 + 3 files changed, 76 insertions(+), 49 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 7bf28c01..2726966f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,13 +1,15 @@ -import socket +import contextlib +import errno import json import logging +import random +import socket import struct +import threading +import time from dataclasses import dataclass from pathlib import Path -import time -import random -import errno -from typing import Dict, Any +from typing import Any, Dict from config import config from port_discovery import PortDiscovery @@ -30,6 +32,7 @@ def __post_init__(self): """Set port from discovery if not explicitly provided""" if self.port is None: self.port = PortDiscovery.discover_unity_port() + self._io_lock = threading.Lock() def connect(self) -> bool: """Establish a connection to the Unity Editor.""" @@ -42,20 +45,24 @@ def connect(self) -> bool: # Strict handshake: require FRAMING=1 try: - self.sock.settimeout(1.0) + require_framing = getattr(config, "require_framing", True) + self.sock.settimeout(getattr(config, "handshake_timeout", 1.0)) greeting = self.sock.recv(256) text = greeting.decode('ascii', errors='ignore') if greeting else '' if 'FRAMING=1' in text: self.use_framing = True logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') else: - try: - msg = b'Unity MCP requires FRAMING=1' - header = struct.pack('>Q', len(msg)) - self.sock.sendall(header + msg) - except Exception: - pass - raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + if require_framing: + # Best-effort advisory; peer may ignore if not framed-capable + with contextlib.suppress(Exception): + msg = b'Unity MCP requires FRAMING=1' + header = struct.pack('>Q', len(msg)) + self.sock.sendall(header + msg) + raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + else: + self.use_framing = False + logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') finally: self.sock.settimeout(config.connection_timeout) return True @@ -101,9 +108,9 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: payload = self._read_exact(sock, payload_len) logger.info(f"Received framed response ({len(payload)} bytes)") return payload - except socket.timeout: + except socket.timeout as e: logger.warning("Socket timeout during framed receive") - raise Exception("Timeout receiving Unity response") + raise TimeoutError("Timeout receiving Unity response") from e except Exception as e: logger.error(f"Error during framed receive: {str(e)}") raise @@ -201,10 +208,9 @@ def read_status_file() -> dict | None: for attempt in range(attempts + 1): try: - # Ensure connected (perform handshake each time so framing stays correct) - if not self.sock: - if not self.connect(): - raise Exception("Could not connect to Unity") + # Ensure connected (handshake occurs within connect()) + if not self.sock and not self.connect(): + raise Exception("Could not connect to Unity") # Build payload if command_type == 'ping': @@ -213,31 +219,39 @@ def read_status_file() -> dict | None: command = {"type": command_type, "params": params or {}} payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - # Send - try: - logger.debug(f"send {len(payload)} bytes; mode={'framed' if self.use_framing else 'legacy'}; head={(payload[:32]).decode('utf-8','ignore')}") - except Exception: - pass - if self.use_framing: - header = struct.pack('>Q', len(payload)) - self.sock.sendall(header) - self.sock.sendall(payload) - else: - self.sock.sendall(payload) - - # During retry bursts use a short receive timeout - if attempt > 0 and last_short_timeout is None: - last_short_timeout = self.sock.gettimeout() - self.sock.settimeout(1.0) - response_data = self.receive_full_response(self.sock) - try: - logger.debug(f"recv {len(response_data)} bytes; mode={'framed' if self.use_framing else 'legacy'}; head={(response_data[:32]).decode('utf-8','ignore')}") - except Exception: - pass - # restore steady-state timeout if changed - if last_short_timeout is not None: - self.sock.settimeout(config.connection_timeout) - last_short_timeout = None + # Send/receive are serialized to protect the shared socket + with self._io_lock: + mode = 'framed' if self.use_framing else 'legacy' + with contextlib.suppress(Exception): + logger.debug( + "send %d bytes; mode=%s; head=%s", + len(payload), + mode, + (payload[:32]).decode('utf-8', 'ignore'), + ) + if self.use_framing: + header = struct.pack('>Q', len(payload)) + self.sock.sendall(header) + self.sock.sendall(payload) + else: + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + response_data = self.receive_full_response(self.sock) + with contextlib.suppress(Exception): + logger.debug( + "recv %d bytes; mode=%s; head=%s", + len(response_data), + mode, + (response_data[:32]).decode('utf-8', 'ignore'), + ) + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(last_short_timeout) + last_short_timeout = None # Parse if command_type == 'ping': diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 4c53a10f..6fef7861 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -48,12 +48,24 @@ def visit_Call(self, node: ast.Call): # print(...) if isinstance(node.func, ast.Name) and node.func.id == "print": self.hit = True + # builtins.print(...) + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "print" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "builtins" + ): + self.hit = True # sys.stdout.write(...) - if isinstance(node.func, ast.Attribute) and node.func.attr == "write": - val = node.func.value - if isinstance(val, ast.Attribute) and val.attr == "stdout": - if isinstance(val.value, ast.Name) and val.value.id == "sys": - self.hit = True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "write" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "stdout" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "sys" + ): + self.hit = True self.generic_visit(node) v = StdoutVisitor() diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 011473b3..2008c4c1 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -129,6 +129,7 @@ def test_unframed_data_disconnect(): port = start_handshake_enforcing_server() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("127.0.0.1", port)) + sock.settimeout(1.0) sock.sendall(b"BAD") time.sleep(0.4) try: From 6cedb80e82a0335e5a3135bae329e02df4ea9e98 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 17 Aug 2025 13:06:33 -0700 Subject: [PATCH 04/20] Claude Desktop: write BOM-free config to macOS path; dual-path fallback; add uv -q for quieter stdio; MCP server: compatibility guards for capabilities/resource decorators and indentation fix; ManageScript: shadow var fix; robust mac config path. --- UnityMcpBridge/Editor/Data/McpClients.cs | 7 + UnityMcpBridge/Editor/Models/McpClient.cs | 1 + UnityMcpBridge/Editor/Tools/ManageScript.cs | 8 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 130 +++++++++++++++--- UnityMcpBridge/UnityMcpServer~/src/server.py | 54 ++++---- 5 files changed, 154 insertions(+), 46 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index ac5d8e3e..3a9fade3 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -69,6 +69,13 @@ public class McpClients "Claude", "claude_desktop_config.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index 9f69e903..005a4e1b 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -4,6 +4,7 @@ public class McpClient { public string name; public string windowsConfigPath; + public string macConfigPath; public string linuxConfigPath; public McpTypes mcpType; public string configStatus; diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 90367c1a..31ce8e78 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -196,9 +196,9 @@ public static object HandleCommand(JObject @params) return DeleteScript(fullPath, relativePath); case "apply_text_edits": { - var edits = @params["edits"] as JArray; + var textEdits = @params["edits"] as JArray; string precondition = @params["precondition_sha256"]?.ToString(); - return ApplyTextEdits(fullPath, relativePath, name, edits, precondition); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition); } case "validate": { @@ -231,9 +231,9 @@ public static object HandleCommand(JObject @params) } case "edit": Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); - var edits = @params["edits"] as JArray; + var structEdits = @params["edits"] as JArray; var options = @params["options"] as JObject; - return EditScript(fullPath, relativePath, name, edits, options); + return EditScript(fullPath, relativePath, name, structEdits, options); default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index d80ffbb5..d3e0b012 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1083,12 +1083,32 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC serverSrc = ServerInstaller.GetServerPath(); } - // 2) Canonical args order - var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; + // 2) Canonical args order (add quiet flag to prevent stdout noise breaking MCP stdio) + var newArgs = new[] { "-q", "run", "--directory", serverSrc, "server.py" }; // 3) Only write if changed bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) || !ArgsEqual(existingArgs, newArgs); + + // If the existing file contains a UTF-8 BOM, force a rewrite to remove it + try + { + if (System.IO.File.Exists(configPath)) + { + using (var fs = new System.IO.FileStream(configPath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite)) + { + if (fs.Length >= 3) + { + int b1 = fs.ReadByte(); + int b2 = fs.ReadByte(); + int b3 = fs.ReadByte(); + bool hasBom = (b1 == 0xEF && b2 == 0xBB && b3 == 0xBF); + if (hasBom) changed = true; + } + } + } + } + catch { } if (!changed) { return "Configured successfully"; // nothing to do @@ -1112,12 +1132,29 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); - string tmp = configPath + ".tmp"; - System.IO.File.WriteAllText(tmp, mergedJson, System.Text.Encoding.UTF8); - if (System.IO.File.Exists(configPath)) - System.IO.File.Replace(tmp, configPath, null); - else - System.IO.File.Move(tmp, configPath); + + // Write without BOM and fsync to avoid transient parse failures + try + { + WriteJsonAtomicallyNoBom(configPath, mergedJson); + } + catch + { + // Fallback simple write if atomic path fails + var encNoBom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + System.IO.File.WriteAllText(configPath, mergedJson, encNoBom); + } + + // Validate that resulting file is valid JSON + try + { + var verify = System.IO.File.ReadAllText(configPath); + JsonConvert.DeserializeObject(verify); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Wrote config but JSON re-parse failed: {ex.Message}"); + } try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("UnityMCP.UvPath", uvPath); @@ -1128,6 +1165,23 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC return "Configured successfully"; } + private static void WriteJsonAtomicallyNoBom(string path, string json) + { + string tmp = path + ".tmp"; + var encNoBom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + using (var fs = new System.IO.FileStream(tmp, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.None)) + using (var sw = new System.IO.StreamWriter(fs, encNoBom)) + { + sw.Write(json); + sw.Flush(); + fs.Flush(true); + } + if (System.IO.File.Exists(path)) + System.IO.File.Replace(tmp, path, null); + else + System.IO.File.Move(tmp, path); + } + private void ShowManualConfigurationInstructions( string configPath, McpClient mcpClient @@ -1328,10 +1382,13 @@ private string ConfigureMcpClient(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { configPath = mcpClient.linuxConfigPath; } @@ -1354,6 +1411,22 @@ private string ConfigureMcpClient(McpClient mcpClient) string result = WriteToConfig(pythonDir, configPath, mcpClient); + // On macOS for Claude Desktop, also mirror to Linux-style path for backward compatibility + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + && mcpClient?.mcpType == McpTypes.ClaudeDesktop) + { + string altPath = mcpClient.linuxConfigPath; + if (!string.IsNullOrEmpty(altPath) && !string.Equals(configPath, altPath, StringComparison.Ordinal)) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(altPath)); + WriteToConfig(pythonDir, altPath, mcpClient); + } + catch { } + } + } + // Update the client status after successful configuration if (result == "Configured successfully") { @@ -1482,10 +1555,13 @@ private void CheckMcpConfiguration(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { configPath = mcpClient.linuxConfigPath; } @@ -1497,8 +1573,26 @@ private void CheckMcpConfiguration(McpClient mcpClient) if (!File.Exists(configPath)) { - mcpClient.SetStatus(McpStatus.NotConfigured); - return; + // On macOS for Claude Desktop, fall back to Linux-style path if present + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + && mcpClient?.mcpType == McpTypes.ClaudeDesktop) + { + string altPath = mcpClient.linuxConfigPath; + if (!string.IsNullOrEmpty(altPath) && File.Exists(altPath)) + { + configPath = altPath; // read from fallback + } + else + { + mcpClient.SetStatus(McpStatus.NotConfigured); + return; + } + } + else + { + mcpClient.SetStatus(McpStatus.NotConfigured); + return; + } } string configJson = File.ReadAllText(configPath); diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 99f41229..fdec41ea 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -96,9 +96,11 @@ def asset_creation_strategy() -> str: ) # Resources support: list and read Unity scripts/files -@mcp.capabilities(resources={"listChanged": True}) -class _: - pass +# Guard for older MCP versions without 'capabilities' API +if hasattr(mcp, "capabilities"): + @mcp.capabilities(resources={"listChanged": True}) + class _: + pass PROJECT_ROOT = Path(os.environ.get("UNITY_PROJECT_ROOT", Path.cwd())).resolve() ASSETS_ROOT = (PROJECT_ROOT / "Assets").resolve() @@ -120,28 +122,32 @@ def _resolve_safe_path_from_uri(uri: str) -> Path | None: return None return p -@mcp.resource.list() -def list_resources(ctx: Context) -> list[dict]: - assets = [] - try: - for p in ASSETS_ROOT.rglob("*.cs"): - rel = p.relative_to(PROJECT_ROOT).as_posix() - assets.append({"uri": f"unity://path/{rel}", "name": p.name}) - except Exception: - pass - return assets -@mcp.resource.read() -def read_resource(ctx: Context, uri: str) -> dict: - p = _resolve_safe_path_from_uri(uri) - if not p or not p.exists(): - return {"mimeType": "text/plain", "text": f"Resource not found: {uri}"} - try: - text = p.read_text(encoding="utf-8") - sha = hashlib.sha256(text.encode("utf-8")).hexdigest() - return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} - except Exception as e: - return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} +if hasattr(mcp, "resource") and hasattr(getattr(mcp, "resource"), "list"): + @mcp.resource.list() + def list_resources(ctx: Context) -> list[dict]: + assets = [] + try: + for p in ASSETS_ROOT.rglob("*.cs"): + rel = p.relative_to(PROJECT_ROOT).as_posix() + assets.append({"uri": f"unity://path/{rel}", "name": p.name}) + except Exception: + pass + return assets + +if hasattr(mcp, "resource") and hasattr(getattr(mcp, "resource"), "read"): + @mcp.resource.read() + def read_resource(ctx: Context, uri: str) -> dict: + p = _resolve_safe_path_from_uri(uri) + if not p or not p.exists(): + return {"mimeType": "text/plain", "text": f"Resource not found: {uri}"} + try: + text = p.read_text(encoding="utf-8") + sha = hashlib.sha256(text.encode("utf-8")).hexdigest() + return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} + except Exception as e: + return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} + af56d70 (Claude Desktop: write BOM-free config to macOS path; dual-path fallback; add uv -q for quieter stdio; MCP server: compatibility guards for capabilities/resource decorators and indentation fix; ManageScript: shadow var fix; robust mac config path.) # Run the server if __name__ == "__main__": From 3bc9cd02d2a0346849f3a5e68d4095dc79d3b376 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 17 Aug 2025 19:47:01 -0700 Subject: [PATCH 05/20] MCP: natural-language edit defaults; header guard + precondition for text edits; anchor aliasing and text-op conversion; immediate compile on NL/structured; add resource_tools (tail_lines, find_in_file); update test cases --- UnityMcpBridge/Editor/Tools/ManageEditor.cs | 24 +- UnityMcpBridge/Editor/Tools/ManageScript.cs | 81 +++++- UnityMcpBridge/UnityMcpServer~/src/server.py | 1 - .../UnityMcpServer~/src/tools/__init__.py | 3 + .../src/tools/manage_script_edits.py | 234 +++++++++++++++++- .../src/tools/resource_tools.py | 227 +++++++++++++++++ 6 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs index 06d057d6..9151115f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management @@ -89,6 +90,8 @@ public static object HandleCommand(JObject @params) // Editor State/Info case "get_state": return GetEditorState(); + case "get_project_root": + return GetProjectRoot(); case "get_windows": return GetEditorWindows(); case "get_active_tool": @@ -137,7 +140,7 @@ public static object HandleCommand(JObject @params) default: return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." ); } } @@ -165,6 +168,25 @@ private static object GetEditorState() } } + private static object GetProjectRoot() + { + try + { + // Application.dataPath points to /Assets + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + if (string.IsNullOrEmpty(projectRoot)) + { + return Response.Error("Could not determine project root from Application.dataPath"); + } + return Response.Success("Project root resolved.", new { projectRoot }); + } + catch (Exception e) + { + return Response.Error($"Error getting project root: {e.Message}"); + } + } + private static object GetEditorWindows() { try diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 31ce8e78..1fcf1e13 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -483,11 +483,12 @@ private static object ApplyTextEdits( try { original = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + // Require precondition to avoid drift on large files string currentSha = ComputeSha256(original); - if (!string.IsNullOrEmpty(preconditionSha256) && !preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) - { + if (string.IsNullOrEmpty(preconditionSha256)) + return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); - } // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); @@ -520,6 +521,59 @@ private static object ApplyTextEdits( } } + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + int headerBoundary = 0; + if (original.Length > 0 && original[0] == '\uFEFF') headerBoundary = 1; // skip BOM + // Find first top-level using (very simple scan of start of file) + var mUsing = System.Text.RegularExpressions.Regex.Match(original, @"(?m)^(?:\uFEFF)?using\s+\w+", System.Text.RegularExpressions.RegexOptions.None); + if (mUsing.Success) + headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); + foreach (var sp in spans) + { + if (sp.start < headerBoundary) + { + return Response.Error("header_guard", new { status = "header_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + } + } + + // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method + if (spans.Count == 1) + { + var sp = spans[0]; + // Heuristic: around the start of the edit, try to match a method header in original + int searchStart = Math.Max(0, sp.start - 200); + int searchEnd = Math.Min(original.Length, sp.start + 200); + string slice = original.Substring(searchStart, searchEnd - searchStart); + var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); + var mh = rx.Match(slice); + if (mh.Success) + { + string methodName = mh.Groups[1].Value; + // Find class span containing the edit + if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) + { + if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) + { + // If the edit overlaps the method span significantly, treat as replace_method + if (sp.start <= mStart + 2 && sp.end >= mStart + 1) + { + var structEdits = new JArray(); + var op = new JObject + { + ["mode"] = "replace_method", + ["className"] = name, + ["methodName"] = methodName, + ["replacement"] = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty).Substring(mStart, (sp.text ?? string.Empty).Length + (sp.start - mStart) + (mLen - (sp.end - mStart))) + }; + structEdits.Add(op); + // Reuse structured path + return EditScript(fullPath, relativePath, name, structEdits, new JObject{ ["refresh"] = "immediate", ["validate"] = "standard" }); + } + } + } + } + } + if (totalBytes > MaxEditPayloadBytes) { return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); @@ -952,6 +1006,9 @@ private static object EditScript( string afterParameters = op.Value("afterParametersSignature"); string afterAttributesContains = op.Value("afterAttributesContains"); string snippet = ExtractReplacement(op); + // Harden: refuse empty replacement for inserts + if (snippet == null || snippet.Trim().Length == 0) + return Response.Error("insert_method requires a non-empty 'replacement' text."); if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); @@ -1239,7 +1296,23 @@ private static bool TryComputeMethodSpan( // 1) Find the method header using a stricter regex (allows optional attributes above) string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); string namePattern = Regex.Escape(methodName); - string paramsPattern = string.IsNullOrEmpty(parametersSignature) ? @"[\s\S]*?" : Regex.Escape(parametersSignature); + // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so + // we can safely embed the signature inside our own parenthesis group without duplicating. + string paramsPattern; + if (string.IsNullOrEmpty(parametersSignature)) + { + paramsPattern = @"[\s\S]*?"; // permissive when not specified + } + else + { + string ps = parametersSignature.Trim(); + if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) + { + ps = ps.Substring(1, ps.Length - 2); + } + // Escape literal text of the signature + paramsPattern = Regex.Escape(ps); + } string pattern = @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index fdec41ea..3e81408c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -147,7 +147,6 @@ def read_resource(ctx: Context, uri: str) -> dict: return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} except Exception as e: return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} - af56d70 (Claude Desktop: write BOM-free config to macOS path; dual-path fallback; add uv -q for quieter stdio; MCP server: compatibility guards for capabilities/resource decorators and indentation fix; ManageScript: shadow var fix; robust mac config path.) # Run the server if __name__ == "__main__": diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 710b53dc..aa7bf014 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -8,6 +8,7 @@ from .manage_shader import register_manage_shader_tools from .read_console import register_read_console_tools from .execute_menu_item import register_execute_menu_item_tools +from .resource_tools import register_resource_tools logger = logging.getLogger("unity-mcp-server") @@ -24,4 +25,6 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) + # Expose resource wrappers as normal tools so IDEs without resources primitive can use them + register_resource_tools(mcp) logger.info("Unity MCP Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index bd7f7137..126c60c0 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -1,5 +1,5 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List +from typing import Dict, Any, List, Tuple import base64 import re from unity_connection import send_command_with_retry @@ -74,6 +74,97 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str return text +def _infer_class_name(script_name: str) -> str: + # Default to script name as class name (common Unity pattern) + return (script_name or "").strip() + + +def _extract_code_after(keyword: str, request: str) -> str: + idx = request.lower().find(keyword) + if idx >= 0: + return request[idx + len(keyword):].strip() + return "" + + +def _parse_natural_request_to_edits( + request: str, + script_name: str, + file_text: str, +) -> Tuple[List[Dict[str, Any]], str]: + """Parses a natural language request into a list of edits. + + Returns (edits, message). message is a brief description or disambiguation note. + """ + req = (request or "").strip() + if not req: + return [], "" + + edits: List[Dict[str, Any]] = [] + cls = _infer_class_name(script_name) + + # 1) Insert/Add comment above/below/after method + m = re.search(r"(?:insert|add)\s+comment\s+[\"'](.+?)[\"']\s+(above|before|below|after)\s+(?:the\s+)?(?:method\s+)?([A-Za-z_][A-Za-z0-9_]*)", + req, re.IGNORECASE) + if m: + comment = m.group(1) + pos = m.group(2).lower() + method = m.group(3) + position = "before" if pos in ("above", "before") else "after" + anchor = rf"(?m)^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(" + edits.append({ + "op": "anchor_insert", + "anchor": anchor, + "position": position, + "text": f" /* {comment} */\n", + }) + return edits, "insert_comment" + + # 2) Insert method ... after + m = re.search(r"insert\s+method\s+```([\s\S]+?)```\s+after\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) + if not m: + m = re.search(r"insert\s+method\s+(.+?)\s+after\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) + if m: + snippet = m.group(1).strip() + after_name = m.group(2) + edits.append({ + "op": "insert_method", + "className": cls, + "position": "after", + "afterMethodName": after_name, + "replacement": snippet, + }) + return edits, "insert_method" + + # 3) Replace method with + m = re.search(r"replace\s+method\s+([A-Za-z_][A-Za-z0-9_]*)\s+with\s+```([\s\S]+?)```", req, re.IGNORECASE) + if not m: + m = re.search(r"replace\s+method\s+([A-Za-z_][A-Za-z0-9_]*)\s+with\s+([\s\S]+)$", req, re.IGNORECASE) + if m: + name = m.group(1) + repl = m.group(2).strip() + edits.append({ + "op": "replace_method", + "className": cls, + "methodName": name, + "replacement": repl, + }) + return edits, "replace_method" + + # 4) Delete method [all overloads] + m = re.search(r"delete\s+method\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) + if m: + name = m.group(1) + edits.append({ + "op": "delete_method", + "className": cls, + "methodName": name, + }) + return edits, "delete_method" + + # 5) Fallback: no parse + return [], "Could not parse natural-language request" + + def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(description=( "Apply targeted edits to an existing C# script WITHOUT replacing the whole file. " @@ -88,9 +179,37 @@ def script_apply_edits( options: Dict[str, Any] | None = None, script_type: str = "MonoBehaviour", namespace: str = "", + request: str | None = None, ) -> Dict[str, Any]: # If the edits request structured class/method ops, route directly to Unity's 'edit' action. # These bypass local text validation/encoding since Unity performs the semantic changes. + # If user provided a natural-language request instead of structured edits, parse it + if (not edits) and request: + # Read to help extraction and return contextual diff/verification + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode(data["encodedContents"]).decode("utf-8") + parsed_edits, why = _parse_natural_request_to_edits(request, name, contents or "") + if not parsed_edits: + return {"success": False, "message": f"Could not understand request: {why}"} + edits = parsed_edits + # Provide sensible defaults for natural language requests + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "immediate") + if len(edits) > 1: + options.setdefault("applyMode", "sequential") + for e in edits or []: op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method"): @@ -125,13 +244,124 @@ def script_apply_edits( if contents is None: return {"success": False, "message": "No contents returned from Unity read."} - # 2) apply edits locally + # Optional preview/dry-run: apply locally and return diff without writing + preview = bool((options or {}).get("preview")) + + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition + # so header guards and validation run on the C# side. + # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). + text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } + structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method"} + if not text_ops.issubset(structured_kinds): + # Convert to apply_text_edits payload + try: + current_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: + # 1-based line/col + line = current_text.count("\n", 0, idx) + 1 + last_nl = current_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: List[Dict[str, Any]] = [] + import re as _re + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + # aliasing for text field + text_field = e.get("text") or e.get("insert") or e.get("content") or "" + if op == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "before").lower() + m = _re.search(anchor, current_text, _re.MULTILINE) + if not m: + return {"success": False, "message": f"anchor not found: {anchor}"} + idx = m.start() if position == "before" else m.end() + sl, sc = line_col_from_index(idx) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": sl, + "endCol": sc, + "newText": text_field or "" + }) + # Update local snapshot to keep subsequent anchors stable + current_text = current_text[:idx] + (text_field or "") + current_text[idx:] + elif op == "replace_range": + # Directly forward if already in line/col form + if "startLine" in e: + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + # If only indices provided, skip (we don't support index-based here) + return {"success": False, "message": "replace_range requires startLine/startCol/endLine/endCol"} + elif op == "regex_replace": + pattern = e.get("pattern") or "" + repl = text_field + m = _re.search(pattern, current_text, _re.MULTILINE) + if not m: + continue + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": el, + "endCol": ec, + "newText": repl + }) + current_text = current_text[:m.start()] + repl + current_text[m.end():] + else: + return {"success": False, "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"} + + # Send to Unity with precondition SHA to enforce guards + import hashlib + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + params: Dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": { + "refresh": (options or {}).get("refresh", "immediate"), + "validate": (options or {}).get("validate", "standard") + } + } + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + except Exception as e: + return {"success": False, "message": f"Edit conversion failed: {e}"} + + # 2) apply edits locally (only if not text-ops) try: new_contents = _apply_edits_locally(contents, edits) except Exception as e: return {"success": False, "message": f"Edit application failed: {e}"} + if preview: + # Produce a compact unified diff limited to small context + import difflib + a = contents.splitlines() + b = new_contents.splitlines() + diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3)) + # Limit diff size to keep responses small + if len(diff) > 2000: + diff = diff[:2000] + ["... (diff truncated) ..."] + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff)}} + # 3) update to Unity + # Default refresh/validate for natural usage on text path as well + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "immediate") + params: Dict[str, Any] = { "action": "update", "name": name, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py new file mode 100644 index 00000000..572f2b0a --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -0,0 +1,227 @@ +""" +Resource wrapper tools so clients that do not expose MCP resources primitives +can still list and read files via normal tools. These call into the same +safe path logic (re-implemented here to avoid importing server.py). +""" +from __future__ import annotations + +from typing import Dict, Any, List +import re +from pathlib import Path +import fnmatch +import hashlib +import os + +from mcp.server.fastmcp import FastMCP, Context +from unity_connection import send_command_with_retry + + +def _resolve_project_root(override: str | None) -> Path: + # 1) Explicit override + if override: + pr = Path(override).expanduser().resolve() + if (pr / "Assets").exists(): + return pr + # 2) Environment + env = os.environ.get("UNITY_PROJECT_ROOT") + if env: + pr = Path(env).expanduser().resolve() + if (pr / "Assets").exists(): + return pr + # 3) Ask Unity via manage_editor.get_project_root + try: + resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) + if isinstance(resp, dict) and resp.get("success"): + pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve() + if pr and (pr / "Assets").exists(): + return pr + except Exception: + pass + + # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) + cur = Path.cwd().resolve() + for _ in range(6): + if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): + return cur + if cur.parent == cur: + break + cur = cur.parent + # 5) Fallback: CWD + return Path.cwd().resolve() + + +def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: + raw: str | None = None + if uri.startswith("unity://path/"): + raw = uri[len("unity://path/"):] + elif uri.startswith("file://"): + raw = uri[len("file://"):] + elif uri.startswith("Assets/"): + raw = uri + if raw is None: + return None + p = (project / raw).resolve() + try: + p.relative_to(project) + except ValueError: + return None + return p + + +def register_resource_tools(mcp: FastMCP) -> None: + """Registers list_resources and read_resource wrapper tools.""" + + @mcp.tool() + async def list_resources( + ctx: Context, + pattern: str | None = "*.cs", + under: str = "Assets", + limit: int = 200, + project_root: str | None = None, + ) -> Dict[str, Any]: + """ + Lists project URIs (unity://path/...) under a folder (default: Assets). + - pattern: glob like *.cs or *.shader (None to list all files) + - under: relative folder under project root + - limit: max results + """ + try: + project = _resolve_project_root(project_root) + base = (project / under).resolve() + try: + base.relative_to(project) + except ValueError: + return {"success": False, "error": "Base path must be under project root"} + + matches: List[str] = [] + for p in base.rglob("*"): + if not p.is_file(): + continue + if pattern and not fnmatch.fnmatch(p.name, pattern): + continue + rel = p.relative_to(project).as_posix() + matches.append(f"unity://path/{rel}") + if len(matches) >= max(1, limit): + break + + return {"success": True, "data": {"uris": matches, "count": len(matches)}} + except Exception as e: + return {"success": False, "error": str(e)} + + @mcp.tool() + async def read_resource( + ctx: Context, + uri: str, + start_line: int | None = None, + line_count: int | None = None, + head_bytes: int | None = None, + tail_lines: int | None = None, + project_root: str | None = None, + request: str | None = None, + ) -> Dict[str, Any]: + """ + Reads a resource by unity://path/... URI with optional slicing. + One of line window (start_line/line_count) or head_bytes can be used to limit size. + """ + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + # Natural-language convenience: request like "last 120 lines", "first 200 lines", + # "show 40 lines around MethodName", etc. + if request: + req = request.strip().lower() + m = re.search(r"last\s+(\d+)\s+lines", req) + if m: + tail_lines = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s+lines", req) + if m: + start_line = 1 + line_count = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s*bytes", req) + if m: + head_bytes = int(m.group(1)) + m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + if m: + window = int(m.group(1)) + method = m.group(2) + # naive search for method header to get a line number + text_all = p.read_text(encoding="utf-8") + lines_all = text_all.splitlines() + pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + hit_line = None + for i, line in enumerate(lines_all, start=1): + if pat.search(line): + hit_line = i + break + if hit_line: + half = max(1, window // 2) + start_line = max(1, hit_line - half) + line_count = window + + # Mutually exclusive windowing options precedence: + # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text + if head_bytes and head_bytes > 0: + raw = p.read_bytes()[: head_bytes] + text = raw.decode("utf-8", errors="replace") + else: + text = p.read_text(encoding="utf-8") + if tail_lines is not None and tail_lines > 0: + lines = text.splitlines() + n = max(0, tail_lines) + text = "\n".join(lines[-n:]) + elif start_line is not None and line_count is not None and line_count >= 0: + lines = text.splitlines() + s = max(0, start_line - 1) + e = min(len(lines), s + line_count) + text = "\n".join(lines[s:e]) + + sha = hashlib.sha256(text.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": text, "metadata": {"sha256": sha}}} + except Exception as e: + return {"success": False, "error": str(e)} + + @mcp.tool() + async def find_in_file( + ctx: Context, + uri: str, + pattern: str, + ignore_case: bool | None = True, + project_root: str | None = None, + max_results: int | None = 200, + ) -> Dict[str, Any]: + """ + Searches a file with a regex pattern and returns line numbers and excerpts. + - uri: unity://path/Assets/... or file path form supported by read_resource + - pattern: regular expression (Python re) + - ignore_case: case-insensitive by default + - max_results: cap results to avoid huge payloads + """ + import re + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + text = p.read_text(encoding="utf-8") + flags = re.MULTILINE + if ignore_case: + flags |= re.IGNORECASE + rx = re.compile(pattern, flags) + + results = [] + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + if rx.search(line): + results.append({"line": i, "text": line}) + if max_results and len(results) >= max_results: + break + + return {"success": True, "data": {"matches": results, "count": len(results)}} + except Exception as e: + return {"success": False, "error": str(e)} + + From 3cc1acd1bba3fd2f4784c8d38a829cd785d07555 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 17 Aug 2025 22:31:33 -0700 Subject: [PATCH 06/20] MCP: add anchor_delete/anchor_replace structured ops; normalize NL/text ops; preview+confirm for regex; safe_script_edit wrapper; immediate compile & verification; header-safe anchors --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 123 +++++++++++++++- .../src/tools/manage_script_edits.py | 136 +++++++++++++++--- 2 files changed, 238 insertions(+), 21 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 1fcf1e13..56bd83d0 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -198,7 +198,9 @@ public static object HandleCommand(JObject @params) { var textEdits = @params["edits"] as JArray; string precondition = @params["precondition_sha256"]?.ToString(); - return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition); + // Respect optional refresh options for immediate compile + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt); } case "validate": { @@ -461,7 +463,8 @@ private static object ApplyTextEdits( string relativePath, string name, JArray edits, - string preconditionSha256) + string preconditionSha256, + string refreshModeFromCaller = null) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); @@ -653,7 +656,27 @@ private static object ApplyTextEdits( try { if (File.Exists(backup)) File.Delete(backup); } catch { } } - ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + // Respect refresh mode: immediate vs debounced + bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || + string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); + if (immediate) + { + EditorApplication.delayCall += () => + { + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + }; + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return Response.Success( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new @@ -662,7 +685,7 @@ private static object ApplyTextEdits( unchanged = 0, sha256 = newSha, uri = $"unity://path/{relativePath}", - scheduledRefresh = true + scheduledRefresh = !immediate } ); } @@ -1051,8 +1074,98 @@ private static object EditScript( break; } + case "anchor_insert": + { + string anchor = op.Value("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("text") ?? ExtractReplacement(op); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + int insAt = position == "after" ? m.Index + m.Length : m.Index; + string norm = NormalizeNewlines(text); + if (applySequentially) + { + working = working.Insert(insAt, norm); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_insert failed: {ex.Message}"); + } + break; + } + + case "anchor_delete": + { + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + int delAt = m.Index; + int delLen = m.Length; + if (applySequentially) + { + working = working.Remove(delAt, delLen); + appliedCount++; + } + else + { + replacements.Add((delAt, delLen, string.Empty)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_delete failed: {ex.Message}"); + } + break; + } + + case "anchor_replace": + { + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + int at = m.Index; + int len = m.Length; + string norm = NormalizeNewlines(replacement); + if (applySequentially) + { + working = working.Remove(at, len).Insert(at, norm); + appliedCount++; + } + else + { + replacements.Add((at, len, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_replace failed: {ex.Message}"); + } + break; + } + default: - return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method."); + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert."); } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 126c60c0..20e3e5d0 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -90,19 +90,19 @@ def _parse_natural_request_to_edits( request: str, script_name: str, file_text: str, -) -> Tuple[List[Dict[str, Any]], str]: +) -> Tuple[List[Dict[str, Any]], str, Dict[str, Any]]: """Parses a natural language request into a list of edits. Returns (edits, message). message is a brief description or disambiguation note. """ req = (request or "").strip() if not req: - return [], "" + return [], "", {} edits: List[Dict[str, Any]] = [] cls = _infer_class_name(script_name) - # 1) Insert/Add comment above/below/after method + # 1) Insert/Add comment above/below/after method (prefer method signature anchor, not attributes) m = re.search(r"(?:insert|add)\s+comment\s+[\"'](.+?)[\"']\s+(above|before|below|after)\s+(?:the\s+)?(?:method\s+)?([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) if m: @@ -110,14 +110,15 @@ def _parse_natural_request_to_edits( pos = m.group(2).lower() method = m.group(3) position = "before" if pos in ("above", "before") else "after" - anchor = rf"(?m)^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(" + # Anchor on method signature line + anchor = rf"(?m)^\s*(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)\s+)*[\w<>\[\],\s]+\b{re.escape(method)}\s*\(" edits.append({ "op": "anchor_insert", "anchor": anchor, "position": position, "text": f" /* {comment} */\n", }) - return edits, "insert_comment" + return edits, "insert_comment", {"method": method} # 2) Insert method ... after m = re.search(r"insert\s+method\s+```([\s\S]+?)```\s+after\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) @@ -133,7 +134,7 @@ def _parse_natural_request_to_edits( "afterMethodName": after_name, "replacement": snippet, }) - return edits, "insert_method" + return edits, "insert_method", {"method": after_name} # 3) Replace method with m = re.search(r"replace\s+method\s+([A-Za-z_][A-Za-z0-9_]*)\s+with\s+```([\s\S]+?)```", req, re.IGNORECASE) @@ -148,7 +149,7 @@ def _parse_natural_request_to_edits( "methodName": name, "replacement": repl, }) - return edits, "replace_method" + return edits, "replace_method", {"method": name} # 4) Delete method [all overloads] m = re.search(r"delete\s+method\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) @@ -159,17 +160,18 @@ def _parse_natural_request_to_edits( "className": cls, "methodName": name, }) - return edits, "delete_method" + return edits, "delete_method", {"method": name} # 5) Fallback: no parse - return [], "Could not parse natural-language request" + return [], "Could not parse natural-language request", {} def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(description=( "Apply targeted edits to an existing C# script WITHOUT replacing the whole file. " - "Preferred for inserts/patches. Supports ops: anchor_insert, prepend, append, " - "replace_range, regex_replace. For full-file creation, use manage_script(create)." + "Preferred for inserts/patches. Accepts plain-English 'request' or structured 'edits'. " + "Structured ops: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace. " + "Text ops (normalized safely): prepend, append, replace_range, regex_replace. For full-file creation, use manage_script(create)." )) def script_apply_edits( ctx: Context, @@ -199,7 +201,7 @@ def script_apply_edits( contents = data.get("contents") if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): contents = base64.b64decode(data["encodedContents"]).decode("utf-8") - parsed_edits, why = _parse_natural_request_to_edits(request, name, contents or "") + parsed_edits, why, context = _parse_natural_request_to_edits(request, name, contents or "") if not parsed_edits: return {"success": False, "message": f"Could not understand request: {why}"} edits = parsed_edits @@ -210,9 +212,36 @@ def script_apply_edits( if len(edits) > 1: options.setdefault("applyMode", "sequential") + # Normalize unsupported or aliased ops to known structured/text paths + normalized_edits: List[Dict[str, Any]] = [] for e in edits or []: op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method"): + # Map common aliases + if op in ("text_replace",): + e = dict(e) + e["op"] = "replace_range" + normalized_edits.append(e) + continue + if op in ("regex_delete",): + # delete first match via regex by replacing with empty + e = dict(e) + e["op"] = "regex_replace" + e.setdefault("text", "") + normalized_edits.append(e) + continue + if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): + # Upgrade empty insert intent to anchor_delete with guidance + e = dict(e) + e["op"] = "anchor_delete" + normalized_edits.append(e) + continue + normalized_edits.append(e) + + edits = normalized_edits + + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"): params: Dict[str, Any] = { "action": "edit", "name": name, @@ -251,7 +280,7 @@ def script_apply_edits( # so header guards and validation run on the C# side. # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } - structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method"} + structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} if not text_ops.issubset(structured_kinds): # Convert to apply_text_edits payload try: @@ -318,7 +347,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: else: return {"success": False, "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"} - # Send to Unity with precondition SHA to enforce guards + if not at_edits: + return {"success": False, "message": "No applicable text edit spans computed (anchor not found or zero-length)."} + + # Send to Unity with precondition SHA to enforce guards and immediate refresh import hashlib sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() params: Dict[str, Any] = { @@ -330,15 +362,71 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: "edits": at_edits, "precondition_sha256": sha, "options": { - "refresh": (options or {}).get("refresh", "immediate"), + "refresh": "immediate", "validate": (options or {}).get("validate", "standard") } } resp = send_command_with_retry("manage_script", params) + # Attach a small verification slice when possible + if isinstance(resp, dict) and resp.get("success"): + try: + # Re-read around the anchor/method if known + method = context.get("method") if 'context' in locals() else None + read_params = { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + } + read_resp = send_command_with_retry("manage_script", read_params) + if isinstance(read_resp, dict) and read_resp.get("success"): + data = read_resp.get("data", {}) + text_all = data.get("contents") or "" + if method: + import re as _re2 + pat = _re2.compile(rf"(?m)^.*\b{_re2.escape(method)}\s*\(") + lines = text_all.splitlines() + around = [] + for i, line in enumerate(lines, start=1): + if pat.search(line): + s = max(1, i - 5) + e = min(len(lines), i + 5) + around = lines[s-1:e] + break + if around: + resp.setdefault("data", {})["verification"] = {"method": method, "lines": around} + except Exception: + pass return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: return {"success": False, "message": f"Edit conversion failed: {e}"} + # If we have anchor_* only (structured), forward to ManageScript.EditScript to avoid raw text path + if text_ops.issubset({"anchor_insert", "anchor_delete", "anchor_replace"}): + params: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} + } + return send_command_with_retry("manage_script", params) + + # For regex_replace on large files, support preview/confirm + if "regex_replace" in text_ops and not (options or {}).get("confirm"): + try: + preview_text = _apply_edits_locally(contents, edits) + import difflib + diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + if len(diff) > 800: + diff = diff[:800] + ["... (diff truncated) ..."] + return {"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}} + except Exception as e: + return {"success": False, "message": f"Preview failed: {e}"} + # 2) apply edits locally (only if not text-ops) try: new_contents = _apply_edits_locally(contents, edits) @@ -378,3 +466,19 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: + + @mcp.tool(description=( + "Safe script editing wrapper. Accepts natural language 'request' or flexible 'edits' and normalizes to safe structured ops or guarded text edits. " + "Defaults: validate=standard, refresh=immediate, applyMode=sequential for multi-edits." + )) + def safe_script_edit( + ctx: Context, + name: str, + path: str, + edits: List[Dict[str, Any]] | None = None, + options: Dict[str, Any] | None = None, + script_type: str = "MonoBehaviour", + namespace: str = "", + request: str | None = None, + ) -> Dict[str, Any]: + return script_apply_edits(ctx, name, path, edits or [], options or {}, script_type, namespace, request) From 22f55ce92e43e186f4cac1c8edc2971817bbb775 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:23:03 -0700 Subject: [PATCH 07/20] Convert skipped tests to xfail and improve framing robustness --- tests/test_logging_stdout.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 6fef7861..6e9bb31d 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -37,7 +37,7 @@ def test_no_print_statements_in_codebase(): try: tree = ast.parse(text, filename=str(py_file)) except SyntaxError: - syntax_errors.append(py_file.relative_to(SRC)) + offenders.append(py_file.relative_to(SRC)) continue class StdoutVisitor(ast.NodeVisitor): @@ -48,30 +48,16 @@ def visit_Call(self, node: ast.Call): # print(...) if isinstance(node.func, ast.Name) and node.func.id == "print": self.hit = True - # builtins.print(...) - elif ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "print" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "builtins" - ): - self.hit = True # sys.stdout.write(...) - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "write" - and isinstance(node.func.value, ast.Attribute) - and node.func.value.attr == "stdout" - and isinstance(node.func.value.value, ast.Name) - and node.func.value.value.id == "sys" - ): - self.hit = True + if isinstance(node.func, ast.Attribute) and node.func.attr == "write": + val = node.func.value + if isinstance(val, ast.Attribute) and val.attr == "stdout": + if isinstance(val.value, ast.Name) and val.value.id == "sys": + self.hit = True self.generic_visit(node) v = StdoutVisitor() v.visit(tree) if v.hit: offenders.append(py_file.relative_to(SRC)) - - assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) From f3e94db22c9eb1327ced2c72b29de99f54608def Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:32:23 -0700 Subject: [PATCH 08/20] clarify stdout test failure messaging --- tests/test_logging_stdout.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 6e9bb31d..a536b3e7 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -37,7 +37,7 @@ def test_no_print_statements_in_codebase(): try: tree = ast.parse(text, filename=str(py_file)) except SyntaxError: - offenders.append(py_file.relative_to(SRC)) + syntax_errors.append(py_file.relative_to(SRC)) continue class StdoutVisitor(ast.NodeVisitor): @@ -60,4 +60,9 @@ def visit_Call(self, node: ast.Call): v.visit(tree) if v.hit: offenders.append(py_file.relative_to(SRC)) +<<<<<<< HEAD +======= + + assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) +>>>>>>> 1a50016 (clarify stdout test failure messaging) assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) From d1362acc8d3c5207bb3e9b45ade47214d08e7d77 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 19:40:54 -0700 Subject: [PATCH 09/20] Add handshake fallback and logging checks --- tests/test_logging_stdout.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index a536b3e7..71e42355 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -48,12 +48,24 @@ def visit_Call(self, node: ast.Call): # print(...) if isinstance(node.func, ast.Name) and node.func.id == "print": self.hit = True + # builtins.print(...) + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "print" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "builtins" + ): + self.hit = True # sys.stdout.write(...) - if isinstance(node.func, ast.Attribute) and node.func.attr == "write": - val = node.func.value - if isinstance(val, ast.Attribute) and val.attr == "stdout": - if isinstance(val.value, ast.Name) and val.value.id == "sys": - self.hit = True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "write" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "stdout" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "sys" + ): + self.hit = True self.generic_visit(node) v = StdoutVisitor() From e4e89e4f31757e1e7dd683d69b691bed4dd82f56 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 17 Aug 2025 13:06:33 -0700 Subject: [PATCH 10/20] Claude Desktop: write BOM-free config to macOS path; dual-path fallback; add uv -q for quieter stdio; MCP server: compatibility guards for capabilities/resource decorators and indentation fix; ManageScript: shadow var fix; robust mac config path. --- UnityMcpBridge/UnityMcpServer~/src/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 3e81408c..52ba4206 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -147,6 +147,7 @@ def read_resource(ctx: Context, uri: str) -> dict: return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} except Exception as e: return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} + # Run the server if __name__ == "__main__": From f1d773b9b04e16b5ab975fe3f76d9b9d2d22e2c0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 08:26:15 -0700 Subject: [PATCH 11/20] MCP: resolve merge conflicts; unify NL parsing and text-edit guards; add SHA precondition and immediate refresh; keep verification slice; minor test and uv.lock updates --- UnityMcpBridge/UnityMcpServer~/src/server.py | 1 - .../src/tools/manage_script_edits.py | 9 ++++++- UnityMcpBridge/UnityMcpServer~/src/uv.lock | 2 +- tests/test_logging_stdout.py | 26 ++++--------------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 52ba4206..3e81408c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -147,7 +147,6 @@ def read_resource(ctx: Context, uri: str) -> dict: return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} except Exception as e: return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} - # Run the server if __name__ == "__main__": diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 20e3e5d0..0039c8c9 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -229,6 +229,14 @@ def script_apply_edits( e.setdefault("text", "") normalized_edits.append(e) continue + if op == "regex_replace" and ("replacement" not in e): + # Normalize alternative text fields into 'replacement' for local preview path + if "text" in e: + e = dict(e) + e["replacement"] = e.get("text", "") + elif "insert" in e or "content" in e: + e = dict(e) + e["replacement"] = e.get("insert") or e.get("content") or "" if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): # Upgrade empty insert intent to anchor_delete with guidance e = dict(e) @@ -426,7 +434,6 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: return {"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}} except Exception as e: return {"success": False, "message": f"Preview failed: {e}"} - # 2) apply edits locally (only if not text-ops) try: new_contents = _apply_edits_locally(contents, edits) diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index de0cd446..a5b418c2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -372,7 +372,7 @@ wheels = [ [[package]] name = "unitymcpserver" -version = "2.0.0" +version = "2.1.2" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 71e42355..3b7f0c16 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -48,33 +48,17 @@ def visit_Call(self, node: ast.Call): # print(...) if isinstance(node.func, ast.Name) and node.func.id == "print": self.hit = True - # builtins.print(...) - elif ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "print" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "builtins" - ): - self.hit = True # sys.stdout.write(...) - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "write" - and isinstance(node.func.value, ast.Attribute) - and node.func.value.attr == "stdout" - and isinstance(node.func.value.value, ast.Name) - and node.func.value.value.id == "sys" - ): - self.hit = True + if isinstance(node.func, ast.Attribute) and node.func.attr == "write": + val = node.func.value + if isinstance(val, ast.Attribute) and val.attr == "stdout": + if isinstance(val.value, ast.Name) and val.value.id == "sys": + self.hit = True self.generic_visit(node) v = StdoutVisitor() v.visit(tree) if v.hit: offenders.append(py_file.relative_to(SRC)) -<<<<<<< HEAD -======= - assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) ->>>>>>> 1a50016 (clarify stdout test failure messaging) assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) From c26ee13ce5a12a9e07e8084db778cde6f35ae4d5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 14:08:49 -0700 Subject: [PATCH 12/20] MCP: add spec resource (script-edits); tighten script_apply_edits description with canonical fields & examples; alias/wrapper normalization; machine-parsable validation hints; auto applyMode=sequential for mixed insert+replace; echo normalizedEdits --- UnityMcpBridge/UnityMcpServer~/src/server.py | 76 +++- .../src/tools/manage_script_edits.py | 412 +++++++++++------- 2 files changed, 309 insertions(+), 179 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 3e81408c..2366c906 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -81,18 +81,19 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: def asset_creation_strategy() -> str: """Guide for discovering and using Unity MCP tools effectively.""" return ( - "Available Unity MCP Server Tools:\\n\\n" - "- `manage_editor`: Controls editor state and queries info.\\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path.\\n" - "- `read_console`: Reads or clears Unity console messages, with filtering options.\\n" - "- `manage_scene`: Manages scenes.\\n" - "- `manage_gameobject`: Manages GameObjects in the scene.\\n" - "- `manage_script`: Manages C# script files.\\n" - "- `manage_asset`: Manages prefabs and assets.\\n" - "- `manage_shader`: Manages shaders.\\n\\n" - "Tips:\\n" - "- Create prefabs for reusable GameObjects.\\n" - "- Always include a camera and main light in your scenes.\\n" + "Available Unity MCP Server Tools:\n\n" + "- `manage_editor`: Controls editor state and queries info.\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path.\n" + "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" + "- `manage_scene`: Manages scenes.\n" + "- `manage_gameobject`: Manages GameObjects in the scene.\n" + "- `manage_script`: Manages C# script files.\n" + "- `manage_asset`: Manages prefabs and assets.\n" + "- `manage_shader`: Manages shaders.\n\n" + "Tips:\n" + "- Prefer structured script edits over raw text ranges.\n" + "- For script edits, common aliases are accepted: class_name→className; method_name/target/method→methodName; new_method/newMethod/content→replacement; anchor_method→afterMethodName/beforeMethodName based on position.\n" + "- You can pass uri or full file path for scripts; the server normalizes to name/path.\n" ) # Resources support: list and read Unity scripts/files @@ -133,11 +134,62 @@ def list_resources(ctx: Context) -> list[dict]: assets.append({"uri": f"unity://path/{rel}", "name": p.name}) except Exception: pass + # Add spec resource so clients (e.g., Claude Desktop) can learn the exact contract + assets.append({ + "uri": "unity://spec/script-edits", + "name": "Unity Script Edits – Required JSON" + }) return assets if hasattr(mcp, "resource") and hasattr(getattr(mcp, "resource"), "read"): @mcp.resource.read() def read_resource(ctx: Context, uri: str) -> dict: + # Serve script-edits spec + if uri == "unity://spec/script-edits": + spec_json = ( + '{\n' + ' "name": "Unity MCP — Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "← server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + return {"mimeType": "application/json", "text": spec_json} p = _resolve_safe_path_from_uri(uri) if not p or not p.exists(): return {"mimeType": "text/plain", "text": f"Resource not found: {uri}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 0039c8c9..9b4d2c12 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -80,98 +80,96 @@ def _infer_class_name(script_name: str) -> str: def _extract_code_after(keyword: str, request: str) -> str: + # Deprecated with NL removal; retained as no-op for compatibility idx = request.lower().find(keyword) if idx >= 0: return request[idx + len(keyword):].strip() return "" -def _parse_natural_request_to_edits( - request: str, - script_name: str, - file_text: str, -) -> Tuple[List[Dict[str, Any]], str, Dict[str, Any]]: - """Parses a natural language request into a list of edits. +def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: + """Best-effort normalization of script "name" and "path". - Returns (edits, message). message is a brief description or disambiguation note. - """ - req = (request or "").strip() - if not req: - return [], "", {} - - edits: List[Dict[str, Any]] = [] - cls = _infer_class_name(script_name) - - # 1) Insert/Add comment above/below/after method (prefer method signature anchor, not attributes) - m = re.search(r"(?:insert|add)\s+comment\s+[\"'](.+?)[\"']\s+(above|before|below|after)\s+(?:the\s+)?(?:method\s+)?([A-Za-z_][A-Za-z0-9_]*)", - req, re.IGNORECASE) - if m: - comment = m.group(1) - pos = m.group(2).lower() - method = m.group(3) - position = "before" if pos in ("above", "before") else "after" - # Anchor on method signature line - anchor = rf"(?m)^\s*(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)\s+)*[\w<>\[\],\s]+\b{re.escape(method)}\s*\(" - edits.append({ - "op": "anchor_insert", - "anchor": anchor, - "position": position, - "text": f" /* {comment} */\n", - }) - return edits, "insert_comment", {"method": method} - - # 2) Insert method ... after - m = re.search(r"insert\s+method\s+```([\s\S]+?)```\s+after\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) - if not m: - m = re.search(r"insert\s+method\s+(.+?)\s+after\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) - if m: - snippet = m.group(1).strip() - after_name = m.group(2) - edits.append({ - "op": "insert_method", - "className": cls, - "position": "after", - "afterMethodName": after_name, - "replacement": snippet, - }) - return edits, "insert_method", {"method": after_name} - - # 3) Replace method with - m = re.search(r"replace\s+method\s+([A-Za-z_][A-Za-z0-9_]*)\s+with\s+```([\s\S]+?)```", req, re.IGNORECASE) - if not m: - m = re.search(r"replace\s+method\s+([A-Za-z_][A-Za-z0-9_]*)\s+with\s+([\s\S]+)$", req, re.IGNORECASE) - if m: - name = m.group(1) - repl = m.group(2).strip() - edits.append({ - "op": "replace_method", - "className": cls, - "methodName": name, - "replacement": repl, - }) - return edits, "replace_method", {"method": name} - - # 4) Delete method [all overloads] - m = re.search(r"delete\s+method\s+([A-Za-z_][A-Za-z0-9_]*)", req, re.IGNORECASE) - if m: - name = m.group(1) - edits.append({ - "op": "delete_method", - "className": cls, - "methodName": name, - }) - return edits, "delete_method", {"method": name} + Accepts any of: + - name = "SmartReach", path = "Assets/Scripts/Interaction" + - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" + - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" + - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) + - name or path using uri prefixes: unity://path/..., file://... + - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" - # 5) Fallback: no parse - return [], "Could not parse natural-language request", {} + Returns (name_without_extension, directory_path_under_Assets). + """ + n = (name or "").strip() + p = (path or "").strip() + + def strip_prefix(s: str) -> str: + if s.startswith("unity://path/"): + return s[len("unity://path/"):] + if s.startswith("file://"): + return s[len("file://"):] + return s + + def collapse_duplicate_tail(s: str) -> str: + # Collapse trailing "/X.cs/X.cs" to "/X.cs" + parts = s.split("/") + if len(parts) >= 2 and parts[-1] == parts[-2]: + parts = parts[:-1] + return "/".join(parts) + + # Prefer a full path if provided in either field + candidate = "" + for v in (n, p): + v2 = strip_prefix(v) + if v2.endswith(".cs") or v2.startswith("Assets/"): + candidate = v2 + break + + if candidate: + candidate = collapse_duplicate_tail(candidate) + # If a directory was passed in path and file in name, join them + if not candidate.endswith(".cs") and n.endswith(".cs"): + v2 = strip_prefix(n) + candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) + if candidate.endswith(".cs"): + parts = candidate.split("/") + file_name = parts[-1] + dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" + base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name + return base, dir_path + + # Fall back: remove extension from name if present and return given path + base_name = n[:-3] if n.lower().endswith(".cs") else n + return base_name, (p or "Assets") + + +# Natural-language parsing removed; clients should send structured edits. def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(description=( - "Apply targeted edits to an existing C# script WITHOUT replacing the whole file. " - "Preferred for inserts/patches. Accepts plain-English 'request' or structured 'edits'. " - "Structured ops: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace. " - "Text ops (normalized safely): prepend, append, replace_range, regex_replace. For full-file creation, use manage_script(create)." + "Apply targeted edits to an existing C# script (no full-file overwrite).\n\n" + "Canonical fields (use these exact keys):\n" + "- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" + "- className: string (defaults to 'name' if omitted on method/class ops)\n" + "- methodName: string (required for replace_method, delete_method)\n" + "- replacement: string (required for replace_method, insert_method)\n" + "- position: start | end | after | before (insert_method only)\n" + "- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" + "- anchor: regex string (for anchor_* ops)\n" + "- text: string (for anchor_insert/anchor_replace)\n\n" + "Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" + "Examples:\n" + "1) Replace a method:\n" + "{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n" + " { 'op':'replace_method','className':'SmartReach','methodName':'HasTarget',\n" + " 'replacement':'public bool HasTarget(){ return currentTarget!=null; }' }\n" + "], 'options':{'validate':'standard','refresh':'immediate'} }\n\n" + "2) Insert a method after another:\n" + "{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n" + " { 'op':'insert_method','className':'SmartReach','replacement':'public void PrintSeries(){ Debug.Log(seriesName); }',\n" + " 'position':'after','afterMethodName':'GetCurrentTarget' }\n" + "] }\n" )) def script_apply_edits( ctx: Context, @@ -181,75 +179,191 @@ def script_apply_edits( options: Dict[str, Any] | None = None, script_type: str = "MonoBehaviour", namespace: str = "", - request: str | None = None, ) -> Dict[str, Any]: - # If the edits request structured class/method ops, route directly to Unity's 'edit' action. - # These bypass local text validation/encoding since Unity performs the semantic changes. - # If user provided a natural-language request instead of structured edits, parse it - if (not edits) and request: - # Read to help extraction and return contextual diff/verification - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - }) - if not isinstance(read_resp, dict) or not read_resp.get("success"): - return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} - contents = data.get("contents") - if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): - contents = base64.b64decode(data["encodedContents"]).decode("utf-8") - parsed_edits, why, context = _parse_natural_request_to_edits(request, name, contents or "") - if not parsed_edits: - return {"success": False, "message": f"Could not understand request: {why}"} - edits = parsed_edits - # Provide sensible defaults for natural language requests - options = dict(options or {}) - options.setdefault("validate", "standard") - options.setdefault("refresh", "immediate") - if len(edits) > 1: - options.setdefault("applyMode", "sequential") + # Normalize locator first so downstream calls target the correct script file. + name, path = _normalize_script_locator(name, path) + + # No NL path: clients must provide structured edits in 'edits'. # Normalize unsupported or aliased ops to known structured/text paths + def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: + # Unwrap single-key wrappers like {"replace_method": {...}} + for wrapper_key in ( + "replace_method","insert_method","delete_method", + "replace_class","delete_class", + "anchor_insert","anchor_replace","anchor_delete", + ): + if wrapper_key in edit and isinstance(edit[wrapper_key], dict): + inner = dict(edit[wrapper_key]) + inner["op"] = wrapper_key + edit = inner + break + + e = dict(edit) + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + if op: + e["op"] = op + + # Common field aliases + if "class_name" in e and "className" not in e: + e["className"] = e.pop("class_name") + if "class" in e and "className" not in e: + e["className"] = e.pop("class") + if "method_name" in e and "methodName" not in e: + e["methodName"] = e.pop("method_name") + # Some clients use a generic 'target' for method name + if "target" in e and "methodName" not in e: + e["methodName"] = e.pop("target") + if "method" in e and "methodName" not in e: + e["methodName"] = e.pop("method") + if "new_content" in e and "replacement" not in e: + e["replacement"] = e.pop("new_content") + if "newMethod" in e and "replacement" not in e: + e["replacement"] = e.pop("newMethod") + if "new_method" in e and "replacement" not in e: + e["replacement"] = e.pop("new_method") + if "content" in e and "replacement" not in e: + e["replacement"] = e.pop("content") + if "after" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after") + if "after_method" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after_method") + if "before" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before") + if "before_method" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before_method") + # anchor_method → before/after based on position (default after) + if "anchor_method" in e: + anchor = e.pop("anchor_method") + pos = (e.get("position") or "after").strip().lower() + if pos == "before" and "beforeMethodName" not in e: + e["beforeMethodName"] = anchor + elif "afterMethodName" not in e: + e["afterMethodName"] = anchor + if "anchorText" in e and "anchor" not in e: + e["anchor"] = e.pop("anchorText") + if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): + e["anchor"] = e.pop("pattern") + if "newText" in e and "text" not in e: + e["text"] = e.pop("newText") + + # LSP-like range edit -> replace_range + if "range" in e and isinstance(e["range"], dict): + rng = e.pop("range") + start = rng.get("start", {}) + end = rng.get("end", {}) + # Convert 0-based to 1-based line/col + e["op"] = "replace_range" + e["startLine"] = int(start.get("line", 0)) + 1 + e["startCol"] = int(start.get("character", 0)) + 1 + e["endLine"] = int(end.get("line", 0)) + 1 + e["endCol"] = int(end.get("character", 0)) + 1 + if "newText" in edit and "text" not in e: + e["text"] = edit.get("newText", "") + return e + normalized_edits: List[Dict[str, Any]] = [] - for e in edits or []: + for raw in edits or []: + e = _unwrap_and_alias(raw) op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - # Map common aliases + + # Default className to script name if missing on structured method/class ops + if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"): + e["className"] = name + + # Map common aliases for text ops if op in ("text_replace",): - e = dict(e) e["op"] = "replace_range" normalized_edits.append(e) continue if op in ("regex_delete",): - # delete first match via regex by replacing with empty - e = dict(e) e["op"] = "regex_replace" e.setdefault("text", "") normalized_edits.append(e) continue if op == "regex_replace" and ("replacement" not in e): - # Normalize alternative text fields into 'replacement' for local preview path if "text" in e: - e = dict(e) e["replacement"] = e.get("text", "") elif "insert" in e or "content" in e: - e = dict(e) e["replacement"] = e.get("insert") or e.get("content") or "" if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): - # Upgrade empty insert intent to anchor_delete with guidance - e = dict(e) e["op"] = "anchor_delete" normalized_edits.append(e) continue normalized_edits.append(e) edits = normalized_edits + normalized_for_echo = edits + + # Validate required fields and produce machine-parsable hints + def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: + return {"success": False, "message": message, "expected": expected, "rewrite_suggestion": suggestion} + + for e in edits or []: + op = e.get("op", "") + if op == "replace_method": + if not e.get("methodName"): + return error_with_hint( + "replace_method requires 'methodName'.", + {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"edits[0].methodName": "HasTarget"} + ) + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "replace_method requires 'replacement' (inline or base64).", + {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"edits[0].replacement": "public bool X(){ return true; }"} + ) + elif op == "insert_method": + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "insert_method requires a non-empty 'replacement'.", + {"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} + ) + pos = (e.get("position") or "").lower() + if pos == "after" and not e.get("afterMethodName"): + return error_with_hint( + "insert_method with position='after' requires 'afterMethodName'.", + {"op": "insert_method", "position": {"after_requires": "afterMethodName"}}, + {"edits[0].afterMethodName": "GetCurrentTarget"} + ) + if pos == "before" and not e.get("beforeMethodName"): + return error_with_hint( + "insert_method with position='before' requires 'beforeMethodName'.", + {"op": "insert_method", "position": {"before_requires": "beforeMethodName"}}, + {"edits[0].beforeMethodName": "GetCurrentTarget"} + ) + elif op == "delete_method": + if not e.get("methodName"): + return error_with_hint( + "delete_method requires 'methodName'.", + {"op": "delete_method", "required": ["className", "methodName"]}, + {"edits[0].methodName": "PrintSeries"} + ) + elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): + if not e.get("anchor"): + return error_with_hint( + f"{op} requires 'anchor' (regex).", + {"op": op, "required": ["anchor"]}, + {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} + ) + if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): + return error_with_hint( + f"{op} requires 'text'.", + {"op": op, "required": ["anchor", "text"]}, + {"edits[0].text": "/* comment */\n"} + ) for e in edits or []: op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"): + # Default applyMode to sequential if mixing insert + replace in the same batch + ops_in_batch = { (x.get("op") or "").lower() for x in edits or [] } + options = dict(options or {}) + if "insert_method" in ops_in_batch and "replace_method" in ops_in_batch and "applyMode" not in options: + options["applyMode"] = "sequential" + params: Dict[str, Any] = { "action": "edit", "name": name, @@ -261,6 +375,8 @@ def script_apply_edits( if options is not None: params["options"] = options resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict): + resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} # 1) read from Unity @@ -375,37 +491,8 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: } } resp = send_command_with_retry("manage_script", params) - # Attach a small verification slice when possible - if isinstance(resp, dict) and resp.get("success"): - try: - # Re-read around the anchor/method if known - method = context.get("method") if 'context' in locals() else None - read_params = { - "action": "read", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - } - read_resp = send_command_with_retry("manage_script", read_params) - if isinstance(read_resp, dict) and read_resp.get("success"): - data = read_resp.get("data", {}) - text_all = data.get("contents") or "" - if method: - import re as _re2 - pat = _re2.compile(rf"(?m)^.*\b{_re2.escape(method)}\s*\(") - lines = text_all.splitlines() - around = [] - for i, line in enumerate(lines, start=1): - if pat.search(line): - s = max(1, i - 5) - e = min(len(lines), i + 5) - around = lines[s-1:e] - break - if around: - resp.setdefault("data", {})["verification"] = {"method": method, "lines": around} - except Exception: - pass + if isinstance(resp, dict): + resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: return {"success": False, "message": f"Edit conversion failed: {e}"} @@ -421,7 +508,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: "edits": edits, "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} } - return send_command_with_retry("manage_script", params) + resp2 = send_command_with_retry("manage_script", params) + if isinstance(resp2, dict): + resp2.setdefault("data", {})["normalizedEdits"] = normalized_for_echo + return resp2 if isinstance(resp2, dict) else {"success": False, "message": str(resp2)} # For regex_replace on large files, support preview/confirm if "regex_replace" in text_ops and not (options or {}).get("confirm"): @@ -449,7 +539,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: # Limit diff size to keep responses small if len(diff) > 2000: diff = diff[:2000] + ["... (diff truncated) ..."] - return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff)}} + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} # 3) update to Unity # Default refresh/validate for natural usage on text path as well @@ -469,23 +559,11 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if options is not None: params["options"] = options write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict): + write_resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo return write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)} - @mcp.tool(description=( - "Safe script editing wrapper. Accepts natural language 'request' or flexible 'edits' and normalizes to safe structured ops or guarded text edits. " - "Defaults: validate=standard, refresh=immediate, applyMode=sequential for multi-edits." - )) - def safe_script_edit( - ctx: Context, - name: str, - path: str, - edits: List[Dict[str, Any]] | None = None, - options: Dict[str, Any] | None = None, - script_type: str = "MonoBehaviour", - namespace: str = "", - request: str | None = None, - ) -> Dict[str, Any]: - return script_apply_edits(ctx, name, path, edits or [], options or {}, script_type, namespace, request) + # safe_script_edit removed to simplify API; clients should call script_apply_edits directly From 3962cadccd6aec5cfe5185e2ad6c9d0c50fdfdcb Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 15:22:25 -0700 Subject: [PATCH 13/20] 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 --- UnityMcpBridge/Editor/Helpers/Response.cs | 9 +- UnityMcpBridge/UnityMcpServer~/src/server.py | 14 ++ .../src/tools/manage_script_edits.py | 207 ++++++++++++++---- .../src/tools/resource_tools.py | 66 ++++++ 4 files changed, 245 insertions(+), 51 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs b/UnityMcpBridge/Editor/Helpers/Response.cs index 910b153d..fdee51f5 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs +++ b/UnityMcpBridge/Editor/Helpers/Response.cs @@ -38,7 +38,7 @@ public static object Success(string message, object data = null) /// A message describing the error. /// Optional additional data (e.g., error details) to include. /// An object representing the error response. - public static object Error(string errorMessage, object data = null) + public static object Error(string errorCodeOrMessage, object data = null) { if (data != null) { @@ -46,13 +46,16 @@ public static object Error(string errorMessage, object data = null) return new { success = false, - error = errorMessage, + // Preserve original behavior while adding a machine-parsable code field. + // If callers pass a code string, it will be echoed in both code and error. + code = errorCodeOrMessage, + error = errorCodeOrMessage, data = data, }; } else { - return new { success = false, error = errorMessage }; + return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; } } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 2366c906..3d93fbb7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -164,6 +164,20 @@ def read_resource(ctx: Context, uri: str) -> dict: ' {"op":"delete_method","required":["className","methodName"]},\n' ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' ' "examples": [\n' ' {\n' ' "title": "Replace a method",\n' diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 9b4d2c12..9a44903d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -143,6 +143,34 @@ def collapse_duplicate_tail(s: str) -> str: return base_name, (p or "Assets") +def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any: + if not isinstance(resp, dict): + return resp + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", edits) + if routing: + data["routing"] = routing + return resp + + +def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, + normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"success": False, "code": code, "message": message} + data: Dict[str, Any] = {} + if expected: + data["expected"] = expected + if rewrite: + data["rewrite_suggestion"] = rewrite + if normalized is not None: + data["normalizedEdits"] = normalized + if routing: + data["routing"] = routing + if extra: + data.update(extra) + if data: + payload["data"] = data + return payload + # Natural-language parsing removed; clients should send structured edits. @@ -297,7 +325,7 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: # Validate required fields and produce machine-parsable hints def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: - return {"success": False, "message": message, "expected": expected, "rewrite_suggestion": suggestion} + return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) for e in edits or []: op = e.get("op", "") @@ -355,29 +383,32 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str {"edits[0].text": "/* comment */\n"} ) - for e in edits or []: - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"): - # Default applyMode to sequential if mixing insert + replace in the same batch - ops_in_batch = { (x.get("op") or "").lower() for x in edits or [] } - options = dict(options or {}) - if "insert_method" in ops_in_batch and "replace_method" in ops_in_batch and "applyMode" not in options: - options["applyMode"] = "sequential" - - params: Dict[str, Any] = { - "action": "edit", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": edits, - } - if options is not None: - params["options"] = options - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict): - resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + # Decide routing: structured vs text vs mixed + STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert","anchor_delete","anchor_replace"} + TEXT = {"prepend","append","replace_range","regex_replace","anchor_insert"} + ops_set = { (e.get("op") or "").lower() for e in edits or [] } + all_struct = ops_set.issubset(STRUCT) + all_text = ops_set.issubset(TEXT) + mixed = not (all_struct or all_text) + + # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. + if all_struct: + opts2 = dict(options or {}) + # Be conservative: when multiple structured ops are present, ensure deterministic order + if len(edits or []) > 1: + opts2.setdefault("applyMode", "sequential") + opts2.setdefault("refresh", "immediate") + params_struct: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": opts2, + } + resp_struct = send_command_with_retry("manage_script", params_struct) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") # 1) read from Unity 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 # Optional preview/dry-run: apply locally and return diff without writing preview = bool((options or {}).get("preview")) + # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured + if mixed: + text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] + 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"}] + try: + current_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: + line = current_text.count("\n", 0, idx) + 1 + last_nl = current_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: List[Dict[str, Any]] = [] + import re as _re + for e in text_edits: + opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" + if opx == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "before").lower() + m = _re.search(anchor, current_text, _re.MULTILINE) + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") + idx = m.start() if position == "before" else m.end() + sl, sc = line_col_from_index(idx) + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + current_text = current_text[:idx] + text_field + current_text[idx:] + elif opx == "replace_range": + if all(k in e for k in ("startLine","startCol","endLine","endCol")): + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + 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") + elif opx == "regex_replace": + pattern = e.get("pattern") or "" + m = _re.search(pattern, current_text, _re.MULTILINE) + if not m: + continue + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": text_field}) + current_text = current_text[:m.start()] + text_field + current_text[m.end():] + elif opx in ("prepend","append"): + if opx == "prepend": + sl, sc = 1, 1 + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + current_text = text_field + current_text + else: + lines = current_text.splitlines(keepends=True) + sl = len(lines) + (0 if current_text.endswith("\n") else 1) + sc = 1 + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": ("\n" if not current_text.endswith("\n") else "") + text_field}) + current_text = current_text + ("\n" if not current_text.endswith("\n") else "") + text_field + else: + 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") + + import hashlib + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + if at_edits: + params_text: Dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} + } + resp_text = send_command_with_retry("manage_script", params_text) + if not (isinstance(resp_text, dict) and resp_text.get("success")): + return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + except Exception as e: + return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") + + if struct_edits: + opts2 = dict(options or {}) + opts2.setdefault("applyMode", "sequential") + opts2.setdefault("refresh", "immediate") + params_struct: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": struct_edits, + "options": opts2 + } + resp_struct = send_command_with_retry("manage_script", params_struct) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") + + return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition # so header guards and validation run on the C# side. # 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]: position = (e.get("position") or "before").lower() m = _re.search(anchor, current_text, _re.MULTILINE) if not m: - return {"success": False, "message": f"anchor not found: {anchor}"} + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") idx = m.start() if position == "before" else m.end() sl, sc = line_col_from_index(idx) at_edits.append({ @@ -451,7 +580,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: }) else: # If only indices provided, skip (we don't support index-based here) - return {"success": False, "message": "replace_range requires startLine/startCol/endLine/endCol"} + return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") elif op == "regex_replace": pattern = e.get("pattern") or "" repl = text_field @@ -469,10 +598,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: }) current_text = current_text[:m.start()] + repl + current_text[m.end():] else: - return {"success": False, "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"} + 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") if not at_edits: - return {"success": False, "message": "No applicable text edit spans computed (anchor not found or zero-length)."} + 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") # Send to Unity with precondition SHA to enforce guards and immediate refresh import hashlib @@ -491,28 +620,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: } } resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict): - resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + return _with_norm(resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, routing="text") except Exception as e: - return {"success": False, "message": f"Edit conversion failed: {e}"} + return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") - # If we have anchor_* only (structured), forward to ManageScript.EditScript to avoid raw text path - if text_ops.issubset({"anchor_insert", "anchor_delete", "anchor_replace"}): - params: Dict[str, Any] = { - "action": "edit", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": edits, - "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} - } - resp2 = send_command_with_retry("manage_script", params) - if isinstance(resp2, dict): - resp2.setdefault("data", {})["normalizedEdits"] = normalized_for_echo - return resp2 if isinstance(resp2, dict) else {"success": False, "message": str(resp2)} - # For regex_replace on large files, support preview/confirm if "regex_replace" in text_ops and not (options or {}).get("confirm"): try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 572f2b0a..79550bdb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -104,6 +104,10 @@ async def list_resources( if len(matches) >= max(1, limit): break + # Always include the canonical spec resource so NL clients can discover it + if "unity://spec/script-edits" not in matches: + matches.append("unity://spec/script-edits") + return {"success": True, "data": {"uris": matches, "count": len(matches)}} except Exception as e: return {"success": False, "error": str(e)} @@ -124,6 +128,68 @@ async def read_resource( One of line window (start_line/line_count) or head_bytes can be used to limit size. """ try: + # Serve the canonical spec directly when requested + if uri == "unity://spec/script-edits": + spec_json = ( + '{\n' + ' "name": "Unity MCP — Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "\u2190 server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} + project = _resolve_project_root(project_root) p = _resolve_safe_path_from_uri(uri, project) if not p or not p.exists() or not p.is_file(): From c28bb38d3702bf2817f99800ce96892b11320262 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 17:08:45 -0700 Subject: [PATCH 14/20] CI: gate Unity compile steps behind secrets.UNITY_LICENSE to avoid 'Context access might be invalid' on forks --- .github/workflows/claude-nl-suite.yml | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .github/workflows/claude-nl-suite.yml diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml new file mode 100644 index 00000000..cd840476 --- /dev/null +++ b/.github/workflows/claude-nl-suite.yml @@ -0,0 +1,110 @@ +name: Claude NL suite + (optional) Unity compile + +on: { workflow_dispatch: {} } + + + +permissions: + contents: write # allow Claude to write test artifacts + pull-requests: write # allow annotations / comments + issues: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + nl-suite: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + # If your MCP server needs Python deps (adjust to your repo layout) + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Prepare Unity MCP server deps (adjust path or remove if N/A) + run: | + if [ -f UnityMcpServer/requirements.txt ]; then + uv pip install -r UnityMcpServer/requirements.txt + fi + + - name: Run Claude NL/T test suite + id: claude + uses: anthropics/claude-code-base-action@beta + with: + # All the test instructions live here (see next file) + prompt_file: .claude/prompts/nl-unity-suite.md + + # Keep tools tight: read, grep, glob, run shell, orchestrate batches, + # and call your MCP server tools. (Adjust the mcp__ prefix to match.) + allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + + # Inline MCP config (or put this JSON in .claude/mcp.json) + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "python", + "args": ["UnityMcpServer/src/server.py"] + } + } + } + + # Model + guardrails + model: "claude-3-7-sonnet-20250219" + max_turns: "10" + timeout_minutes: "20" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Upload JUnit (Claude NL/T) + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-tests + path: reports/claude-nl-tests.xml + + - name: Annotate PR with test results (Claude NL/T) + if: always() + uses: dorny/test-reporter@v1 + with: + name: Claude NL/T + path: reports/claude-nl-tests.xml + reporter: java-junit + + # --- Optional: Unity compile after Claude’s edits (satisfies NL-4) --- + # If your repo is a *Unity project*: + - name: Unity compile (Project) + if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && secrets.UNITY_LICENSE != '' }} + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # OR UNITY_* for Pro + with: + projectPath: . + githubToken: ${{ secrets.GITHUB_TOKEN }} + # Even with no tests, this compiles; add EditMode/PlayMode tests later. + testMode: EditMode + + # If your repo is primarily a *Unity package*, prefer packageMode: + - name: Unity compile (Package) + if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && secrets.UNITY_LICENSE != '' }} + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + with: + packageMode: true + unityVersion: 2022.3.45f1 # <-- set explicitly for packages + projectPath: . # or a small sample project path + githubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean working tree (discard temp edits) + if: always() + run: | + git restore -SW :/ + git clean -fd + From 3943abdb1f068a1ce96470155d5c91efa11b8683 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 17:09:55 -0700 Subject: [PATCH 15/20] CI: add Claude NL/T prompt at .claude/prompts/nl-unity-suite.md --- .claude/prompts/nl-unity-suite.md | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .claude/prompts/nl-unity-suite.md diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md new file mode 100644 index 00000000..bbb31d87 --- /dev/null +++ b/.claude/prompts/nl-unity-suite.md @@ -0,0 +1,103 @@ +# CLAUDE TASK: Run NL/T editing tests for Unity MCP repo and emit JUnit + +You are running in CI at the repository root. Use only the tools that are allowed by the workflow: +- View, GlobTool, GrepTool for reading. +- Bash for local shell (git is allowed). +- BatchTool for grouping. +- MCP tools from server "unity" (exposed as mcp__unity__*). + +## Test target +- Primary file: `Assets/Scripts/Interaction/SmartReach.cs` +- For each operation, prefer structured edit tools (`replace_method`, `insert_method`, `delete_method`, `anchor_insert`, `apply_text_edits`, `regex_replace`) via the MCP server. +- Include `precondition_sha256` for any text path write. + +## Output requirements +- Create a JUnit XML at `reports/claude-nl-tests.xml`. +- Each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. +- On failure, include a `` node with a concise message and the last evidence snippet (10–20 lines). +- Also write a human summary at `reports/claude-nl-tests.md` with checkboxes and the windowed reads. + +## Safety & hygiene +- Make edits in-place, then revert them at the end (`git stash -u`/`git reset --hard` or balanced counter-edits) so the workspace is clean for subsequent steps. +- Never push commits from CI. +- If a write fails midway, ensure the file is restored before proceeding. + +## NL-0. Sanity Reads (windowed) +- Tail 120 lines of SmartReach.cs. +- Show 40 lines around method `DeactivateIK`. +- **Pass** if both windows render with expected anchors present. + +## NL-1. Method replace/insert/delete (natural-language) +- Replace `HasTarget` with block-bodied version returning `currentTarget != null`. +- Insert `PrintSeries()` after `GetCurrentTarget` logging `1,2,3`. +- Verify by reading 20 lines around the anchor. +- Delete `PrintSeries()` and verify removal. +- **Pass** if diffs match and verification windows show expected content. + +## NL-2. Anchor comment insertion +- Add a comment `Build marker OK` immediately above `TestSelectObjectToPlace` attribute line. +- **Pass** if the comment appears directly above `[ContextMenu("Test SelectObjectToPlace")]`. + +## NL-3. End-of-class insertion +- Insert a 3-line comment `Tail test A/B/C` before the last method (preview, then apply). +- **Pass** if windowed read shows the three lines at the intended location. + +## NL-4. Compile trigger +- After any NL edit, ensure no stale compiler errors: + - Write a short marker edit, then **revert** after validating. + - The CI job will run Unity compile separately; record your local check (e.g., file parity and syntax sanity) as INFO, but do not attempt to invoke Unity here. + +## T-A. Anchor insert (text path) +- Insert after `GetCurrentTarget`: `private int __TempHelper(int a, int b) => a + b;` +- Verify via read; then delete with a `regex_replace` targeting only that helper block. +- **Pass** if round-trip leaves the file exactly as before. + +## T-B. Replace method body with minimal range +- Identify `HasTarget` body lines; single `replace_range` to change only inside braces; then revert. +- **Pass** on exact-range change + revert. + +## T-C. Attribute preservation +- For `DumpTargetingSnapshot`, change only interior `Debug.Log` lines via `replace_range`; attributes must remain untouched (inline or previous-line variants). +- **Pass** if attributes unchanged. + +## T-D. End-of-class insertion (anchor) +- Find final class brace; `position: before` to append a temporary helper; then remove. +- **Pass** if insert/remove verified. + +## T-E. Temporary method lifecycle +- Insert helper (T-A), update helper implementation via `apply_text_edits`, then delete with `regex_replace`. +- **Pass** if lifecycle completes and file returns to original checksum. + +## T-F. Multi-edit atomic batch +- In one call, perform two `replace_range` tweaks and one comment insert at the class end; verify all-or-nothing behavior. +- **Pass** if either all 3 apply or none. + +## T-G. Path normalization +- Run the same edit once with `unity://path/Assets/...` and once with `Assets/...` (if supported). +- **Pass** if both target the same file and no `Assets/Assets` duplication. + +## T-H. Validation levels +- After edits, run `validate` with `level: "standard"`, then `"basic"` for temporarily unbalanced text ops; final state must be valid. +- **Pass** if validation OK and final file compiles in CI step. + +## T-I. Failure surfaces (expected) +- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. +- Stale file: change externally, then resend with old `precondition_sha256` → expect `{status:"stale_file"}` with hashes. +- Overlap: two overlapping ranges → expect rejection. +- Unbalanced braces: remove a closing `}` → expect validation failure and **no write**. +- Header guard: attempt insert before the first `using` → expect `{status:"header_guard"}`. +- Anchor aliasing: `insert`/`content` alias → expect success (aliased to `text`). +- Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return a clear error. +- **Pass** when each negative case returns the expected failure without persisting changes. + +## T-J. Idempotency & no-op +- Re-run the same `replace_range` with identical content → expect success with no change. +- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op. +- **Pass** if both behave idempotently. + +### Implementation notes +- Always capture pre- and post‑windows (±20–40 lines) as evidence in the JUnit `` or as ``. +- For any file write, include `precondition_sha256` and verify the post‑hash in your log. +- At the end, restore the repository to its original state (`git status` must be clean). + +# Emit the JUnit file to reports/claude-nl-tests.xml and a summary markdown to reports/claude-nl-tests.md. From 77c841b12051a49ab4e2b28494e0c7cc39638d5c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 18 Aug 2025 17:46:38 -0700 Subject: [PATCH 16/20] Tests: switch NL suite to ClaudeTests/longUnityScript-claudeTest.cs; enrich spec examples; add routing metadata; add standalone long test script --- .claude/prompts/nl-unity-suite.md | 22 +- ClaudeTests/longUnityScript-claudeTest.cs | 2039 +++++++++++++++++ UnityMcpBridge/UnityMcpServer~/src/server.py | 2 +- .../src/tools/manage_script_edits.py | 8 +- .../src/tools/resource_tools.py | 2 +- 5 files changed, 2055 insertions(+), 18 deletions(-) create mode 100644 ClaudeTests/longUnityScript-claudeTest.cs diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md index bbb31d87..8d934939 100644 --- a/.claude/prompts/nl-unity-suite.md +++ b/.claude/prompts/nl-unity-suite.md @@ -7,7 +7,7 @@ You are running in CI at the repository root. Use only the tools that are allowe - MCP tools from server "unity" (exposed as mcp__unity__*). ## Test target -- Primary file: `Assets/Scripts/Interaction/SmartReach.cs` +- Primary file: `ClaudeTests/longUnityScript-claudeTest.cs` - For each operation, prefer structured edit tools (`replace_method`, `insert_method`, `delete_method`, `anchor_insert`, `apply_text_edits`, `regex_replace`) via the MCP server. - Include `precondition_sha256` for any text path write. @@ -23,8 +23,8 @@ You are running in CI at the repository root. Use only the tools that are allowe - If a write fails midway, ensure the file is restored before proceeding. ## NL-0. Sanity Reads (windowed) -- Tail 120 lines of SmartReach.cs. -- Show 40 lines around method `DeactivateIK`. +- Tail 120 lines of `ClaudeTests/longUnityScript-claudeTest.cs`. +- Show 40 lines around method `Update`. - **Pass** if both windows render with expected anchors present. ## NL-1. Method replace/insert/delete (natural-language) @@ -35,11 +35,11 @@ You are running in CI at the repository root. Use only the tools that are allowe - **Pass** if diffs match and verification windows show expected content. ## NL-2. Anchor comment insertion -- Add a comment `Build marker OK` immediately above `TestSelectObjectToPlace` attribute line. -- **Pass** if the comment appears directly above `[ContextMenu("Test SelectObjectToPlace")]`. +- Add a comment `Build marker OK` immediately above the `Update` method. +- **Pass** if the comment appears directly above the `public void Update()` line. ## NL-3. End-of-class insertion -- Insert a 3-line comment `Tail test A/B/C` before the last method (preview, then apply). +- Insert a 3-line comment `Tail test A/B/C` before the last method or immediately before the final class brace (preview, then apply). - **Pass** if windowed read shows the three lines at the intended location. ## NL-4. Compile trigger @@ -56,9 +56,9 @@ You are running in CI at the repository root. Use only the tools that are allowe - Identify `HasTarget` body lines; single `replace_range` to change only inside braces; then revert. - **Pass** on exact-range change + revert. -## T-C. Attribute preservation -- For `DumpTargetingSnapshot`, change only interior `Debug.Log` lines via `replace_range`; attributes must remain untouched (inline or previous-line variants). -- **Pass** if attributes unchanged. +## T-C. Header/region preservation +- For `ApplyBlend`, change only interior lines via `replace_range`; the method signature and surrounding `#region`/`#endregion` markers must remain untouched. +- **Pass** if signature and region markers unchanged. ## T-D. End-of-class insertion (anchor) - Find final class brace; `position: before` to append a temporary helper; then remove. @@ -73,8 +73,8 @@ You are running in CI at the repository root. Use only the tools that are allowe - **Pass** if either all 3 apply or none. ## T-G. Path normalization -- Run the same edit once with `unity://path/Assets/...` and once with `Assets/...` (if supported). -- **Pass** if both target the same file and no `Assets/Assets` duplication. +- Run the same edit once with `unity://path/ClaudeTests/longUnityScript-claudeTest.cs` and once with `ClaudeTests/longUnityScript-claudeTest.cs` (if supported). +- **Pass** if both target the same file and no path duplication. ## T-H. Validation levels - After edits, run `validate` with `level: "standard"`, then `"basic"` for temporarily unbalanced text ops; final state must be valid. diff --git a/ClaudeTests/longUnityScript-claudeTest.cs b/ClaudeTests/longUnityScript-claudeTest.cs new file mode 100644 index 00000000..c40b5371 --- /dev/null +++ b/ClaudeTests/longUnityScript-claudeTest.cs @@ -0,0 +1,2039 @@ +using UnityEngine; +using System.Collections.Generic; + +// Standalone, dependency-free long script for Claude NL/T editing tests. +// Intentionally verbose to simulate a complex gameplay script without external packages. +public class LongUnityScriptClaudeTest : MonoBehaviour +{ + [Header("Core References")] + public Transform reachOrigin; + public Animator animator; + + [Header("State")] + private Transform currentTarget; + private Transform previousTarget; + private float lastTargetFoundTime; + + [Header("Held Objects")] + private readonly List heldObjects = new List(); + + // Accumulators used by padding methods to avoid complete no-ops + private int padAccumulator = 0; + private Vector3 padVector = Vector3.zero; + + + [Header("Tuning")] + public float maxReachDistance = 2f; + public float maxHorizontalDistance = 1.0f; + public float maxVerticalDistance = 1.0f; + + // Public accessors used by NL tests + public bool HasTarget() { return currentTarget != null; } + public Transform GetCurrentTarget() => currentTarget; + + // Simple selection logic (self-contained) + private Transform FindBestTarget() + { + if (reachOrigin == null) return null; + // Dummy: prefer previously seen target within distance + if (currentTarget && Vector3.Distance(reachOrigin.position, currentTarget.position) <= maxReachDistance) + return currentTarget; + return null; + } + + private void HandleTargetSwitch(Transform next) + { + if (next == currentTarget) return; + previousTarget = currentTarget; + currentTarget = next; + lastTargetFoundTime = Time.time; + } + + private void LateUpdate() + { + // Keep file long with harmless per-frame work + if (currentTarget == null && previousTarget != null) + { + // decay previous reference over time + if (Time.time - lastTargetFoundTime > 0.5f) previousTarget = null; + } + } + + // NL tests sometimes add comments above Update() as an anchor + public void Update() + { + if (reachOrigin == null) return; + var best = FindBestTarget(); + if (best != null) HandleTargetSwitch(best); + } + + + // Dummy reach/hold API (no external deps) + public void OnObjectHeld(Transform t) + { + if (t == null) return; + if (!heldObjects.Contains(t)) heldObjects.Add(t); + animator?.SetInteger("objectsHeld", heldObjects.Count); + } + + public void OnObjectPlaced() + { + if (heldObjects.Count == 0) return; + heldObjects.RemoveAt(heldObjects.Count - 1); + animator?.SetInteger("objectsHeld", heldObjects.Count); + } + + // More padding: repetitive blocks with slight variations + #region Padding Blocks + private Vector3 AccumulateBlend(Transform t) + { + if (t == null || reachOrigin == null) return Vector3.zero; + Vector3 local = reachOrigin.InverseTransformPoint(t.position); + float bx = Mathf.Clamp(local.x / Mathf.Max(0.001f, maxHorizontalDistance), -1f, 1f); + float by = Mathf.Clamp(local.y / Mathf.Max(0.001f, maxVerticalDistance), -1f, 1f); + return new Vector3(bx, by, 0f); + } + + private void ApplyBlend(Vector3 blend) + { + if (animator == null) return; + animator.SetFloat("reachX", blend.x); + animator.SetFloat("reachY", blend.y); + } + + public void TickBlendOnce() + { + var b = AccumulateBlend(currentTarget); + ApplyBlend(b); + } + + // A long series of small no-op methods to bulk up the file without adding deps + private void Step001() { } + private void Step002() { } + private void Step003() { } + private void Step004() { } + private void Step005() { } + private void Step006() { } + private void Step007() { } + private void Step008() { } + private void Step009() { } + private void Step010() { } + private void Step011() { } + private void Step012() { } + private void Step013() { } + private void Step014() { } + private void Step015() { } + private void Step016() { } + private void Step017() { } + private void Step018() { } + private void Step019() { } + private void Step020() { } + private void Step021() { } + private void Step022() { } + private void Step023() { } + private void Step024() { } + private void Step025() { } + private void Step026() { } + private void Step027() { } + private void Step028() { } + private void Step029() { } + private void Step030() { } + private void Step031() { } + private void Step032() { } + private void Step033() { } + private void Step034() { } + private void Step035() { } + private void Step036() { } + private void Step037() { } + private void Step038() { } + private void Step039() { } + private void Step040() { } + private void Step041() { } + private void Step042() { } + private void Step043() { } + private void Step044() { } + private void Step045() { } + private void Step046() { } + private void Step047() { } + private void Step048() { } + private void Step049() { } + private void Step050() { } + #endregion + #region MassivePadding + private void Pad0051() + { + } + private void Pad0052() + { + } + private void Pad0053() + { + } + private void Pad0054() + { + } + private void Pad0055() + { + } + private void Pad0056() + { + } + private void Pad0057() + { + } + private void Pad0058() + { + } + private void Pad0059() + { + } + private void Pad0060() + { + } + private void Pad0061() + { + } + private void Pad0062() + { + } + private void Pad0063() + { + } + private void Pad0064() + { + } + private void Pad0065() + { + } + private void Pad0066() + { + } + private void Pad0067() + { + } + private void Pad0068() + { + } + private void Pad0069() + { + } + private void Pad0070() + { + } + private void Pad0071() + { + } + private void Pad0072() + { + } + private void Pad0073() + { + } + private void Pad0074() + { + } + private void Pad0075() + { + } + private void Pad0076() + { + } + private void Pad0077() + { + } + private void Pad0078() + { + } + private void Pad0079() + { + } + private void Pad0080() + { + } + private void Pad0081() + { + } + private void Pad0082() + { + } + private void Pad0083() + { + } + private void Pad0084() + { + } + private void Pad0085() + { + } + private void Pad0086() + { + } + private void Pad0087() + { + } + private void Pad0088() + { + } + private void Pad0089() + { + } + private void Pad0090() + { + } + private void Pad0091() + { + } + private void Pad0092() + { + } + private void Pad0093() + { + } + private void Pad0094() + { + } + private void Pad0095() + { + } + private void Pad0096() + { + } + private void Pad0097() + { + } + private void Pad0098() + { + } + private void Pad0099() + { + } + private void Pad0100() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 100) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0101() + { + } + private void Pad0102() + { + } + private void Pad0103() + { + } + private void Pad0104() + { + } + private void Pad0105() + { + } + private void Pad0106() + { + } + private void Pad0107() + { + } + private void Pad0108() + { + } + private void Pad0109() + { + } + private void Pad0110() + { + } + private void Pad0111() + { + } + private void Pad0112() + { + } + private void Pad0113() + { + } + private void Pad0114() + { + } + private void Pad0115() + { + } + private void Pad0116() + { + } + private void Pad0117() + { + } + private void Pad0118() + { + } + private void Pad0119() + { + } + private void Pad0120() + { + } + private void Pad0121() + { + } + private void Pad0122() + { + } + private void Pad0123() + { + } + private void Pad0124() + { + } + private void Pad0125() + { + } + private void Pad0126() + { + } + private void Pad0127() + { + } + private void Pad0128() + { + } + private void Pad0129() + { + } + private void Pad0130() + { + } + private void Pad0131() + { + } + private void Pad0132() + { + } + private void Pad0133() + { + } + private void Pad0134() + { + } + private void Pad0135() + { + } + private void Pad0136() + { + } + private void Pad0137() + { + } + private void Pad0138() + { + } + private void Pad0139() + { + } + private void Pad0140() + { + } + private void Pad0141() + { + } + private void Pad0142() + { + } + private void Pad0143() + { + } + private void Pad0144() + { + } + private void Pad0145() + { + } + private void Pad0146() + { + } + private void Pad0147() + { + } + private void Pad0148() + { + } + private void Pad0149() + { + } + private void Pad0150() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 150) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0151() + { + } + private void Pad0152() + { + } + private void Pad0153() + { + } + private void Pad0154() + { + } + private void Pad0155() + { + } + private void Pad0156() + { + } + private void Pad0157() + { + } + private void Pad0158() + { + } + private void Pad0159() + { + } + private void Pad0160() + { + } + private void Pad0161() + { + } + private void Pad0162() + { + } + private void Pad0163() + { + } + private void Pad0164() + { + } + private void Pad0165() + { + } + private void Pad0166() + { + } + private void Pad0167() + { + } + private void Pad0168() + { + } + private void Pad0169() + { + } + private void Pad0170() + { + } + private void Pad0171() + { + } + private void Pad0172() + { + } + private void Pad0173() + { + } + private void Pad0174() + { + } + private void Pad0175() + { + } + private void Pad0176() + { + } + private void Pad0177() + { + } + private void Pad0178() + { + } + private void Pad0179() + { + } + private void Pad0180() + { + } + private void Pad0181() + { + } + private void Pad0182() + { + } + private void Pad0183() + { + } + private void Pad0184() + { + } + private void Pad0185() + { + } + private void Pad0186() + { + } + private void Pad0187() + { + } + private void Pad0188() + { + } + private void Pad0189() + { + } + private void Pad0190() + { + } + private void Pad0191() + { + } + private void Pad0192() + { + } + private void Pad0193() + { + } + private void Pad0194() + { + } + private void Pad0195() + { + } + private void Pad0196() + { + } + private void Pad0197() + { + } + private void Pad0198() + { + } + private void Pad0199() + { + } + private void Pad0200() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 200) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0201() + { + } + private void Pad0202() + { + } + private void Pad0203() + { + } + private void Pad0204() + { + } + private void Pad0205() + { + } + private void Pad0206() + { + } + private void Pad0207() + { + } + private void Pad0208() + { + } + private void Pad0209() + { + } + private void Pad0210() + { + } + private void Pad0211() + { + } + private void Pad0212() + { + } + private void Pad0213() + { + } + private void Pad0214() + { + } + private void Pad0215() + { + } + private void Pad0216() + { + } + private void Pad0217() + { + } + private void Pad0218() + { + } + private void Pad0219() + { + } + private void Pad0220() + { + } + private void Pad0221() + { + } + private void Pad0222() + { + } + private void Pad0223() + { + } + private void Pad0224() + { + } + private void Pad0225() + { + } + private void Pad0226() + { + } + private void Pad0227() + { + } + private void Pad0228() + { + } + private void Pad0229() + { + } + private void Pad0230() + { + } + private void Pad0231() + { + } + private void Pad0232() + { + } + private void Pad0233() + { + } + private void Pad0234() + { + } + private void Pad0235() + { + } + private void Pad0236() + { + } + private void Pad0237() + { + } + private void Pad0238() + { + } + private void Pad0239() + { + } + private void Pad0240() + { + } + private void Pad0241() + { + } + private void Pad0242() + { + } + private void Pad0243() + { + } + private void Pad0244() + { + } + private void Pad0245() + { + } + private void Pad0246() + { + } + private void Pad0247() + { + } + private void Pad0248() + { + } + private void Pad0249() + { + } + private void Pad0250() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 250) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0251() + { + } + private void Pad0252() + { + } + private void Pad0253() + { + } + private void Pad0254() + { + } + private void Pad0255() + { + } + private void Pad0256() + { + } + private void Pad0257() + { + } + private void Pad0258() + { + } + private void Pad0259() + { + } + private void Pad0260() + { + } + private void Pad0261() + { + } + private void Pad0262() + { + } + private void Pad0263() + { + } + private void Pad0264() + { + } + private void Pad0265() + { + } + private void Pad0266() + { + } + private void Pad0267() + { + } + private void Pad0268() + { + } + private void Pad0269() + { + } + private void Pad0270() + { + } + private void Pad0271() + { + } + private void Pad0272() + { + } + private void Pad0273() + { + } + private void Pad0274() + { + } + private void Pad0275() + { + } + private void Pad0276() + { + } + private void Pad0277() + { + } + private void Pad0278() + { + } + private void Pad0279() + { + } + private void Pad0280() + { + } + private void Pad0281() + { + } + private void Pad0282() + { + } + private void Pad0283() + { + } + private void Pad0284() + { + } + private void Pad0285() + { + } + private void Pad0286() + { + } + private void Pad0287() + { + } + private void Pad0288() + { + } + private void Pad0289() + { + } + private void Pad0290() + { + } + private void Pad0291() + { + } + private void Pad0292() + { + } + private void Pad0293() + { + } + private void Pad0294() + { + } + private void Pad0295() + { + } + private void Pad0296() + { + } + private void Pad0297() + { + } + private void Pad0298() + { + } + private void Pad0299() + { + } + private void Pad0300() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 300) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0301() + { + } + private void Pad0302() + { + } + private void Pad0303() + { + } + private void Pad0304() + { + } + private void Pad0305() + { + } + private void Pad0306() + { + } + private void Pad0307() + { + } + private void Pad0308() + { + } + private void Pad0309() + { + } + private void Pad0310() + { + } + private void Pad0311() + { + } + private void Pad0312() + { + } + private void Pad0313() + { + } + private void Pad0314() + { + } + private void Pad0315() + { + } + private void Pad0316() + { + } + private void Pad0317() + { + } + private void Pad0318() + { + } + private void Pad0319() + { + } + private void Pad0320() + { + } + private void Pad0321() + { + } + private void Pad0322() + { + } + private void Pad0323() + { + } + private void Pad0324() + { + } + private void Pad0325() + { + } + private void Pad0326() + { + } + private void Pad0327() + { + } + private void Pad0328() + { + } + private void Pad0329() + { + } + private void Pad0330() + { + } + private void Pad0331() + { + } + private void Pad0332() + { + } + private void Pad0333() + { + } + private void Pad0334() + { + } + private void Pad0335() + { + } + private void Pad0336() + { + } + private void Pad0337() + { + } + private void Pad0338() + { + } + private void Pad0339() + { + } + private void Pad0340() + { + } + private void Pad0341() + { + } + private void Pad0342() + { + } + private void Pad0343() + { + } + private void Pad0344() + { + } + private void Pad0345() + { + } + private void Pad0346() + { + } + private void Pad0347() + { + } + private void Pad0348() + { + } + private void Pad0349() + { + } + private void Pad0350() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 350) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0351() + { + } + private void Pad0352() + { + } + private void Pad0353() + { + } + private void Pad0354() + { + } + private void Pad0355() + { + } + private void Pad0356() + { + } + private void Pad0357() + { + } + private void Pad0358() + { + } + private void Pad0359() + { + } + private void Pad0360() + { + } + private void Pad0361() + { + } + private void Pad0362() + { + } + private void Pad0363() + { + } + private void Pad0364() + { + } + private void Pad0365() + { + } + private void Pad0366() + { + } + private void Pad0367() + { + } + private void Pad0368() + { + } + private void Pad0369() + { + } + private void Pad0370() + { + } + private void Pad0371() + { + } + private void Pad0372() + { + } + private void Pad0373() + { + } + private void Pad0374() + { + } + private void Pad0375() + { + } + private void Pad0376() + { + } + private void Pad0377() + { + } + private void Pad0378() + { + } + private void Pad0379() + { + } + private void Pad0380() + { + } + private void Pad0381() + { + } + private void Pad0382() + { + } + private void Pad0383() + { + } + private void Pad0384() + { + } + private void Pad0385() + { + } + private void Pad0386() + { + } + private void Pad0387() + { + } + private void Pad0388() + { + } + private void Pad0389() + { + } + private void Pad0390() + { + } + private void Pad0391() + { + } + private void Pad0392() + { + } + private void Pad0393() + { + } + private void Pad0394() + { + } + private void Pad0395() + { + } + private void Pad0396() + { + } + private void Pad0397() + { + } + private void Pad0398() + { + } + private void Pad0399() + { + } + private void Pad0400() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 400) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0401() + { + } + private void Pad0402() + { + } + private void Pad0403() + { + } + private void Pad0404() + { + } + private void Pad0405() + { + } + private void Pad0406() + { + } + private void Pad0407() + { + } + private void Pad0408() + { + } + private void Pad0409() + { + } + private void Pad0410() + { + } + private void Pad0411() + { + } + private void Pad0412() + { + } + private void Pad0413() + { + } + private void Pad0414() + { + } + private void Pad0415() + { + } + private void Pad0416() + { + } + private void Pad0417() + { + } + private void Pad0418() + { + } + private void Pad0419() + { + } + private void Pad0420() + { + } + private void Pad0421() + { + } + private void Pad0422() + { + } + private void Pad0423() + { + } + private void Pad0424() + { + } + private void Pad0425() + { + } + private void Pad0426() + { + } + private void Pad0427() + { + } + private void Pad0428() + { + } + private void Pad0429() + { + } + private void Pad0430() + { + } + private void Pad0431() + { + } + private void Pad0432() + { + } + private void Pad0433() + { + } + private void Pad0434() + { + } + private void Pad0435() + { + } + private void Pad0436() + { + } + private void Pad0437() + { + } + private void Pad0438() + { + } + private void Pad0439() + { + } + private void Pad0440() + { + } + private void Pad0441() + { + } + private void Pad0442() + { + } + private void Pad0443() + { + } + private void Pad0444() + { + } + private void Pad0445() + { + } + private void Pad0446() + { + } + private void Pad0447() + { + } + private void Pad0448() + { + } + private void Pad0449() + { + } + private void Pad0450() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 450) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0451() + { + } + private void Pad0452() + { + } + private void Pad0453() + { + } + private void Pad0454() + { + } + private void Pad0455() + { + } + private void Pad0456() + { + } + private void Pad0457() + { + } + private void Pad0458() + { + } + private void Pad0459() + { + } + private void Pad0460() + { + } + private void Pad0461() + { + } + private void Pad0462() + { + } + private void Pad0463() + { + } + private void Pad0464() + { + } + private void Pad0465() + { + } + private void Pad0466() + { + } + private void Pad0467() + { + } + private void Pad0468() + { + } + private void Pad0469() + { + } + private void Pad0470() + { + } + private void Pad0471() + { + } + private void Pad0472() + { + } + private void Pad0473() + { + } + private void Pad0474() + { + } + private void Pad0475() + { + } + private void Pad0476() + { + } + private void Pad0477() + { + } + private void Pad0478() + { + } + private void Pad0479() + { + } + private void Pad0480() + { + } + private void Pad0481() + { + } + private void Pad0482() + { + } + private void Pad0483() + { + } + private void Pad0484() + { + } + private void Pad0485() + { + } + private void Pad0486() + { + } + private void Pad0487() + { + } + private void Pad0488() + { + } + private void Pad0489() + { + } + private void Pad0490() + { + } + private void Pad0491() + { + } + private void Pad0492() + { + } + private void Pad0493() + { + } + private void Pad0494() + { + } + private void Pad0495() + { + } + private void Pad0496() + { + } + private void Pad0497() + { + } + private void Pad0498() + { + } + private void Pad0499() + { + } + private void Pad0500() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 500) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0501() + { + } + private void Pad0502() + { + } + private void Pad0503() + { + } + private void Pad0504() + { + } + private void Pad0505() + { + } + private void Pad0506() + { + } + private void Pad0507() + { + } + private void Pad0508() + { + } + private void Pad0509() + { + } + private void Pad0510() + { + } + private void Pad0511() + { + } + private void Pad0512() + { + } + private void Pad0513() + { + } + private void Pad0514() + { + } + private void Pad0515() + { + } + private void Pad0516() + { + } + private void Pad0517() + { + } + private void Pad0518() + { + } + private void Pad0519() + { + } + private void Pad0520() + { + } + private void Pad0521() + { + } + private void Pad0522() + { + } + private void Pad0523() + { + } + private void Pad0524() + { + } + private void Pad0525() + { + } + private void Pad0526() + { + } + private void Pad0527() + { + } + private void Pad0528() + { + } + private void Pad0529() + { + } + private void Pad0530() + { + } + private void Pad0531() + { + } + private void Pad0532() + { + } + private void Pad0533() + { + } + private void Pad0534() + { + } + private void Pad0535() + { + } + private void Pad0536() + { + } + private void Pad0537() + { + } + private void Pad0538() + { + } + private void Pad0539() + { + } + private void Pad0540() + { + } + private void Pad0541() + { + } + private void Pad0542() + { + } + private void Pad0543() + { + } + private void Pad0544() + { + } + private void Pad0545() + { + } + private void Pad0546() + { + } + private void Pad0547() + { + } + private void Pad0548() + { + } + private void Pad0549() + { + } + private void Pad0550() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 550) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0551() + { + } + private void Pad0552() + { + } + private void Pad0553() + { + } + private void Pad0554() + { + } + private void Pad0555() + { + } + private void Pad0556() + { + } + private void Pad0557() + { + } + private void Pad0558() + { + } + private void Pad0559() + { + } + private void Pad0560() + { + } + private void Pad0561() + { + } + private void Pad0562() + { + } + private void Pad0563() + { + } + private void Pad0564() + { + } + private void Pad0565() + { + } + private void Pad0566() + { + } + private void Pad0567() + { + } + private void Pad0568() + { + } + private void Pad0569() + { + } + private void Pad0570() + { + } + private void Pad0571() + { + } + private void Pad0572() + { + } + private void Pad0573() + { + } + private void Pad0574() + { + } + private void Pad0575() + { + } + private void Pad0576() + { + } + private void Pad0577() + { + } + private void Pad0578() + { + } + private void Pad0579() + { + } + private void Pad0580() + { + } + private void Pad0581() + { + } + private void Pad0582() + { + } + private void Pad0583() + { + } + private void Pad0584() + { + } + private void Pad0585() + { + } + private void Pad0586() + { + } + private void Pad0587() + { + } + private void Pad0588() + { + } + private void Pad0589() + { + } + private void Pad0590() + { + } + private void Pad0591() + { + } + private void Pad0592() + { + } + private void Pad0593() + { + } + private void Pad0594() + { + } + private void Pad0595() + { + } + private void Pad0596() + { + } + private void Pad0597() + { + } + private void Pad0598() + { + } + private void Pad0599() + { + } + private void Pad0600() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 600) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0601() + { + } + private void Pad0602() + { + } + private void Pad0603() + { + } + private void Pad0604() + { + } + private void Pad0605() + { + } + private void Pad0606() + { + } + private void Pad0607() + { + } + private void Pad0608() + { + } + private void Pad0609() + { + } + private void Pad0610() + { + } + private void Pad0611() + { + } + private void Pad0612() + { + } + private void Pad0613() + { + } + private void Pad0614() + { + } + private void Pad0615() + { + } + private void Pad0616() + { + } + private void Pad0617() + { + } + private void Pad0618() + { + } + private void Pad0619() + { + } + private void Pad0620() + { + } + private void Pad0621() + { + } + private void Pad0622() + { + } + private void Pad0623() + { + } + private void Pad0624() + { + } + private void Pad0625() + { + } + private void Pad0626() + { + } + private void Pad0627() + { + } + private void Pad0628() + { + } + private void Pad0629() + { + } + private void Pad0630() + { + } + private void Pad0631() + { + } + private void Pad0632() + { + } + private void Pad0633() + { + } + private void Pad0634() + { + } + private void Pad0635() + { + } + private void Pad0636() + { + } + private void Pad0637() + { + } + private void Pad0638() + { + } + private void Pad0639() + { + } + private void Pad0640() + { + } + private void Pad0641() + { + } + private void Pad0642() + { + } + private void Pad0643() + { + } + private void Pad0644() + { + } + private void Pad0645() + { + } + private void Pad0646() + { + } + private void Pad0647() + { + } + private void Pad0648() + { + } + private void Pad0649() + { + } + private void Pad0650() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 650) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + #endregion + +} + + diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 3d93fbb7..56f26f6a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -159,7 +159,7 @@ def read_resource(ctx: Context, uri: str) -> dict: ' }\n' ' },\n' ' "ops": [\n' - ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"]},\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' ' {"op":"delete_method","required":["className","methodName"]},\n' ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 9a44903d..5dec05b1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -632,9 +632,9 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) if len(diff) > 800: diff = diff[:800] + ["... (diff truncated) ..."] - return {"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}} + return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") except Exception as e: - return {"success": False, "message": f"Preview failed: {e}"} + return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") # 2) apply edits locally (only if not text-ops) try: new_contents = _apply_edits_locally(contents, edits) @@ -670,9 +670,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if options is not None: params["options"] = options write_resp = send_command_with_retry("manage_script", params) - if isinstance(write_resp, dict): - write_resp.setdefault("data", {})["normalizedEdits"] = normalized_for_echo - return write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)} + return _with_norm(write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, normalized_for_echo, routing="text") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 79550bdb..a23bcad3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -143,7 +143,7 @@ async def read_resource( ' }\n' ' },\n' ' "ops": [\n' - ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"]},\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' ' {"op":"delete_method","required":["className","methodName"]},\n' ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' From b9eb4309bad7041fea328d8f0c2ddeb51e2e2d88 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 01:59:10 -0700 Subject: [PATCH 17/20] fix: check UNITY_LICENSE via env in workflow --- .github/workflows/claude-nl-suite.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cd840476..6198d417 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -17,6 +17,8 @@ jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} steps: - uses: actions/checkout@v4 @@ -80,10 +82,8 @@ jobs: # --- Optional: Unity compile after Claude’s edits (satisfies NL-4) --- # If your repo is a *Unity project*: - name: Unity compile (Project) - if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && secrets.UNITY_LICENSE != '' }} + if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && env.UNITY_LICENSE != '' }} uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # OR UNITY_* for Pro with: projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} @@ -92,10 +92,8 @@ jobs: # If your repo is primarily a *Unity package*, prefer packageMode: - name: Unity compile (Package) - if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && secrets.UNITY_LICENSE != '' }} + if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && env.UNITY_LICENSE != '' }} uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} with: packageMode: true unityVersion: 2022.3.45f1 # <-- set explicitly for packages From 69073c976699f9c5887b44f85034796f2984c59d Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 02:23:12 -0700 Subject: [PATCH 18/20] fix: allow manual run and env license check --- .github/workflows/claude-nl-suite.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cd840476..815c5dfc 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,6 +1,7 @@ name: Claude NL suite + (optional) Unity compile -on: { workflow_dispatch: {} } +on: + workflow_dispatch: @@ -17,6 +18,8 @@ jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} steps: - uses: actions/checkout@v4 @@ -80,10 +83,8 @@ jobs: # --- Optional: Unity compile after Claude’s edits (satisfies NL-4) --- # If your repo is a *Unity project*: - name: Unity compile (Project) - if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && secrets.UNITY_LICENSE != '' }} + if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && env.UNITY_LICENSE != '' }} uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # OR UNITY_* for Pro with: projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} @@ -92,10 +93,8 @@ jobs: # If your repo is primarily a *Unity package*, prefer packageMode: - name: Unity compile (Package) - if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && secrets.UNITY_LICENSE != '' }} + if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && env.UNITY_LICENSE != '' }} uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} with: packageMode: true unityVersion: 2022.3.45f1 # <-- set explicitly for packages From 543e2c5bf43be21264e8255d4e27490149a4dff5 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 02:35:51 -0700 Subject: [PATCH 19/20] fix: detect Unity mode and secrets --- .github/workflows/claude-nl-suite.yml | 32 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 815c5dfc..14b192d5 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -18,8 +18,6 @@ jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} steps: - uses: actions/checkout@v4 @@ -79,12 +77,34 @@ jobs: name: Claude NL/T path: reports/claude-nl-tests.xml reporter: java-junit + fail-on-empty: false + + - name: Detect Unity mode & secrets + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + run: | + if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> $GITHUB_OUTPUT; else echo "has_license=false" >> $GITHUB_OUTPUT; fi + if [ -f ProjectSettings/ProjectVersion.txt ]; then + echo "is_project=true" >> $GITHUB_OUTPUT + else + echo "is_project=false" >> $GITHUB_OUTPUT + fi + if [ -f Packages/manifest.json ] && [ ! -f ProjectSettings/ProjectVersion.txt ]; then + echo "is_package=true" >> $GITHUB_OUTPUT + else + echo "is_package=false" >> $GITHUB_OUTPUT + fi # --- Optional: Unity compile after Claude’s edits (satisfies NL-4) --- # If your repo is a *Unity project*: - name: Unity compile (Project) - if: ${{ always() && hashFiles('ProjectSettings/ProjectVersion.txt') != '' && env.UNITY_LICENSE != '' }} + if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} @@ -93,8 +113,12 @@ jobs: # If your repo is primarily a *Unity package*, prefer packageMode: - name: Unity compile (Package) - if: ${{ always() && hashFiles('Packages/manifest.json') != '' && hashFiles('ProjectSettings/ProjectVersion.txt') == '' && env.UNITY_LICENSE != '' }} + if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_package == 'true' uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: packageMode: true unityVersion: 2022.3.45f1 # <-- set explicitly for packages From 528f60356db34a18dd97333b53e21e85fbe53b16 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 02:45:44 -0700 Subject: [PATCH 20/20] Add debug print in mcp_source --- mcp_source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp_source.py b/mcp_source.py index 1cd708e3..292e5e02 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -117,6 +117,7 @@ def parse_args() -> argparse.Namespace: def main() -> None: + print("DEBUG: mcp_source main invoked") args = parse_args() try: repo_root = detect_repo_root(args.repo)