From 401e27654ae06f5cdcfee720821f9192b2c8558c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 14 Aug 2025 22:37:34 -0700 Subject: [PATCH 001/311] Unity MCP: stable framing handshake + non-blocking script writes; remove blob stream tools; simplify tool registration - Python server: always consume handshake and negotiate framing on reconnects (prevents invalid framed length).\n- C#: strict FRAMING=1 handshake and NoDelay; debounce AssetDatabase/compilation.\n- Tools: keep manage_script + script edits; remove manage_script_stream and test tools from default registration.\n- Editor window: guard against auto retargeting IDE config. --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 85 +++++++++++-- UnityMcpBridge/Editor/UnityMcpBridge.cs | 119 ++++++++++++++++-- .../Editor/Windows/UnityMcpEditorWindow.cs | 36 +++--- UnityMcpBridge/UnityMcpServer~/src/server.py | 16 +++ .../UnityMcpServer~/src/unity_connection.py | 94 +++++++++----- 5 files changed, 285 insertions(+), 65 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index d79e17a6..8fa018b1 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -6,6 +6,7 @@ using UnityEditor; using UnityEngine; using UnityMcpBridge.Editor.Helpers; +using System.Threading; #if USE_ROSLYN using Microsoft.CodeAnalysis; @@ -217,13 +218,20 @@ string namespaceName try { - File.WriteAllText(fullPath, contents); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); // Ensure Unity recognizes the new script - return Response.Success( + // Atomic-ish create + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + File.Move(tmp, fullPath); + + var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { path = relativePath } + new { path = relativePath, scheduledRefresh = true } ); + + // Schedule heavy work AFTER replying + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + return ok; } catch (Exception e) { @@ -298,13 +306,33 @@ string contents try { - File.WriteAllText(fullPath, contents); - AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes - AssetDatabase.Refresh(); - return Response.Success( + // Safe write with atomic replace when available + var encoding = System.Text.Encoding.UTF8; + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + } + catch (PlatformNotSupportedException) + { + // Fallback for platforms without File.Replace + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", - new { path = relativePath } + new { path = relativePath, scheduledRefresh = true } ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; } catch (Exception e) { @@ -1028,3 +1056,40 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge } } +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + private static DateTime _last; + + public static void Schedule(string relPath, TimeSpan window) + { + Interlocked.Exchange(ref _pending, 1); + var now = DateTime.UtcNow; + if ((now - _last) < window) return; + _last = now; + + EditorApplication.delayCall += () => + { + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + // Prefer targeted import and script compile over full refresh + AssetDatabase.ImportAsset(relPath, ImportAssetOptions.ForceUpdate); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + }; + } +} + +static class ManageScriptRefreshHelpers +{ + public static void ScheduleScriptRefresh(string relPath) + { + RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); + } +} + diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index b7e8ef0e..38030e28 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -395,22 +395,68 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { + const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap + bool framingEnabledForConnection = false; + try + { + var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; + Debug.Log($"UNITY-MCP: Client connected {ep}"); + } + catch { } + // Strict framing: always require FRAMING=1 and frame all I/O + try + { + client.NoDelay = true; + } + catch { } + try + { + string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; + byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length); + } + catch { /* ignore */ } + framingEnabledForConnection = true; + Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + byte[] buffer = new byte[8192]; while (isRunning) { try { - int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) + // Strict framed mode + string commandText = null; + bool usedFraming = true; + + if (true) { - break; // Client disconnected + // Enforced framed mode for this connection + byte[] header = new byte[8]; + int headerFilled = 0; + while (headerFilled < 8) + { + int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); + if (r == 0) + { + return; // disconnected + } + headerFilled += r; + } + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen == 0UL || payloadLen > (ulong)MaxMessageBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + byte[] payload = await ReadExactAsync(stream, (int)payloadLen); + commandText = System.Text.Encoding.UTF8.GetString(payload); } - string commandText = System.Text.Encoding.UTF8.GetString( - buffer, - 0, - bytesRead - ); + try + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + Debug.Log($"UNITY-MCP: recv {(usedFraming ? "framed" : "legacy")}: {preview}"); + } + catch { } string commandId = Guid.NewGuid().ToString(); TaskCompletionSource tcs = new(); @@ -422,6 +468,12 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); + if (framingEnabledForConnection) + { + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); continue; } @@ -433,6 +485,12 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + if (true) + { + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(responseBytes, 0, responseBytes.Length); } catch (Exception ex) @@ -444,6 +502,51 @@ private static async Task HandleClientAsync(TcpClient client) } } + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] data = new byte[count]; + int offset = 0; + while (offset < count) + { + int r = await stream.ReadAsync(data, offset, count - offset); + if (r == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += r; + } + return data; + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new System.ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + private static void ProcessCommands() { List processedIds = new(); diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 9e42d7ff..19446406 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1550,29 +1550,33 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else { - // Attempt auto-rewrite once if the package path changed - try + // Attempt auto-rewrite once if the package path changed, but only when explicitly enabled + bool autoManage = UnityEditor.EditorPrefs.GetBool("UnityMCP.AutoManageIDEConfig", false); + if (autoManage) { - string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); - if (rewriteResult == "Configured successfully") + try { - if (debugLogsEnabled) + string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") { - UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + } + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + mcpClient.SetStatus(McpStatus.IncorrectPath); } - mcpClient.SetStatus(McpStatus.Configured); } - else + catch (Exception ex) { mcpClient.SetStatus(McpStatus.IncorrectPath); - } - } - catch (Exception ex) - { - mcpClient.SetStatus(McpStatus.IncorrectPath); - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + } } } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 55360b57..52633ef4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,11 +1,13 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +from logging.handlers import RotatingFileHandler from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection +from pathlib import Path # Configure logging using settings from config logging.basicConfig( @@ -14,6 +16,20 @@ ) logger = logging.getLogger("unity-mcp-server") +# File logging to avoid stdout interference with MCP stdio +try: + log_dir = Path.home() / ".unity-mcp" + log_dir.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler(str(log_dir / "server.log"), maxBytes=5*1024*1024, backupCount=3) + file_handler.setFormatter(logging.Formatter(config.log_format)) + file_handler.setLevel(getattr(logging, config.log_level)) + logger.addHandler(file_handler) + # Prevent duplicate propagation to root handlers + logger.propagate = False +except Exception: + # If file logging setup fails, continue with stderr logging only + pass + # Global connection state _unity_connection: UnityConnection = None diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 9bad736d..bc602040 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,6 +1,7 @@ import socket import json import logging +import struct from dataclasses import dataclass from pathlib import Path import time @@ -23,6 +24,7 @@ class UnityConnection: host: str = config.unity_host port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication + use_framing: bool = False # Negotiated per-connection def __post_init__(self): """Set port from discovery if not explicitly provided""" @@ -37,6 +39,19 @@ def connect(self) -> bool: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + self.sock.settimeout(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.info('Unity MCP handshake received: FRAMING=1 (strict)') + else: + raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + finally: + self.sock.settimeout(config.connection_timeout) return True except Exception as e: logger.error(f"Failed to connect to Unity: {str(e)}") @@ -53,8 +68,33 @@ def disconnect(self): finally: self.sock = None + def _read_exact(self, sock: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise Exception("Connection closed before reading expected bytes") + data.extend(chunk) + return bytes(data) + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: """Receive a complete response from Unity, handling chunked data.""" + if self.use_framing: + try: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0 or payload_len > (64 * 1024 * 1024): + raise Exception(f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.info(f"Received framed response ({len(payload)} bytes)") + return payload + except socket.timeout: + logger.warning("Socket timeout during framed receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during framed receive: {str(e)}") + raise + chunks = [] sock.settimeout(config.connection_timeout) # Use timeout from config try: @@ -166,13 +206,26 @@ def read_status_file() -> dict | None: payload = json.dumps(command, ensure_ascii=False).encode('utf-8') # Send - self.sock.sendall(payload) + 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) @@ -241,43 +294,22 @@ def read_status_file() -> dict | None: _unity_connection = None def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" + """Retrieve or establish a persistent Unity connection. + + Note: Do NOT ping on every retrieval to avoid connection storms. Rely on + send_command() exceptions to detect broken sockets and reconnect there. + """ global _unity_connection if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection + return _unity_connection + logger.info("Creating new Unity connection") _unity_connection = UnityConnection() if not _unity_connection.connect(): _unity_connection = None raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") + logger.info("Connected to Unity on startup") + return _unity_connection # ----------------------------- From 7eeac659f50212bbc7bb4fbd22805d8d61e3555f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 10:59:35 -0700 Subject: [PATCH 002/311] Bridge framing hardening: 64MiB cap, zero-length reject, timeout ReadExact, safe write framing; remove unused vars --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 56 +++++++++++++++++-------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 38030e28..fa707483 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -35,6 +35,8 @@ private static Dictionary< > commandQueue = new(); private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads + private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients // Debug helpers private static bool IsDebugEnabled() @@ -395,8 +397,7 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { - const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap - bool framingEnabledForConnection = false; + // Framed I/O only; legacy mode removed try { var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; @@ -416,7 +417,6 @@ private static async Task HandleClientAsync(TcpClient client) await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length); } catch { /* ignore */ } - framingEnabledForConnection = true; Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); byte[] buffer = new byte[8192]; @@ -431,23 +431,14 @@ private static async Task HandleClientAsync(TcpClient client) if (true) { // Enforced framed mode for this connection - byte[] header = new byte[8]; - int headerFilled = 0; - while (headerFilled < 8) - { - int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); - if (r == 0) - { - return; // disconnected - } - headerFilled += r; - } + byte[] header = await ReadExactAsync(stream, 8, FrameIOTimeoutMs); ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen == 0UL || payloadLen > (ulong)MaxMessageBytes) + if (payloadLen == 0UL || payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } - byte[] payload = await ReadExactAsync(stream, (int)payloadLen); + int payloadLenInt = checked((int)payloadLen); + byte[] payload = await ReadExactAsync(stream, payloadLenInt, FrameIOTimeoutMs); commandText = System.Text.Encoding.UTF8.GetString(payload); } @@ -468,7 +459,10 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); - if (framingEnabledForConnection) + if ((ulong)pingResponseBytes.Length > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {pingResponseBytes.Length}"); + } { byte[] outHeader = new byte[8]; WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); @@ -485,7 +479,10 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - if (true) + if ((ulong)responseBytes.Length > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {responseBytes.Length}"); + } { byte[] outHeader = new byte[8]; WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); @@ -518,6 +515,29 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS return data; } + // Timeout-aware exact read helper; avoids indefinite stalls + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs) + { + byte[] data = new byte[count]; + int offset = 0; + while (offset < count) + { + var readTask = stream.ReadAsync(data, offset, count - offset); + var completed = await System.Threading.Tasks.Task.WhenAny(readTask, System.Threading.Tasks.Task.Delay(timeoutMs)); + if (completed != readTask) + { + throw new System.IO.IOException("Read timed out"); + } + int r = readTask.Result; + if (r == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += r; + } + return data; + } + private static ulong ReadUInt64BigEndian(byte[] buffer) { if (buffer == null || buffer.Length < 8) return 0UL; From eafe3095c7284bed1a953883fd74223b93ee3b63 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 14:15:31 -0700 Subject: [PATCH 003/311] ManageScript: improve method span parsing and validation behavior for MCP edit ops; mitigate false 'no opening brace' errors and allow relaxed validation for text edits --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 652 +++++++++++++++++++- 1 file changed, 632 insertions(+), 20 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 8fa018b1..7c9861a5 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; @@ -48,6 +49,47 @@ namespace UnityMcpBridge.Editor.Tools /// public static class ManageScript { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = Application.dataPath.Replace('\\', '/'); + string targetDir = Path.Combine(assets, (relDir ?? "Scripts")).Replace('\\', '/'); + string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if directory is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + if (di.Exists) + { + var attrs = di.Attributes; + if ((attrs & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } /// /// Main handler for script management actions. /// @@ -97,29 +139,16 @@ public static object HandleCommand(JObject @params) ); } - // Ensure path is relative to Assets/, removing any leading "Assets/" - // Set default directory to "Scripts" if path is not provided - string relativeDir = path ?? "Scripts"; // Default to "Scripts" if path is null - if (!string.IsNullOrEmpty(relativeDir)) - { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } - } - // Handle empty string case explicitly after processing - if (string.IsNullOrEmpty(relativeDir)) + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { - relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } - // Construct paths + // Construct file paths string scriptFileName = $"{name}.cs"; - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Application.dataPath ends in "Assets" string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine("Assets", relativeDir, scriptFileName) - .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); // Ensure the target directory exists for create/update if (action == "create" || action == "update") @@ -154,6 +183,12 @@ public static object HandleCommand(JObject @params) return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); + case "edit": + { + var edits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, edits, options); + } default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." @@ -222,7 +257,17 @@ string namespaceName var enc = System.Text.Encoding.UTF8; var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, contents, enc); - File.Move(tmp, fullPath); + try + { + // Prefer atomic move within same volume + File.Move(tmp, fullPath); + } + catch (IOException) + { + // Cross-volume or other IO constraint: fallback to copy + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", @@ -318,7 +363,12 @@ string contents } catch (PlatformNotSupportedException) { - // Fallback for platforms without File.Replace + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + } + catch (IOException) + { + // Cross-volume moves can throw IOException; fallback to copy File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } } @@ -372,6 +422,568 @@ private static object DeleteScript(string fullPath, string relativePath) } } + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("replace_class requires 'className'."); + if (replacement == null) + return Response.Error("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return Response.Error($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return Response.Error($"Replacement snippet invalid: {vErr}"); + + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return Response.Error($"delete_class failed: {why}"); + + replacements.Add((s, l, string.Empty)); + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); + if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + return Response.Error($"replace_method failed: {whyMethod}"); + + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + return Response.Error($"delete_method failed: {whyMethod}"); + + replacements.Add((mStart, mLen, string.Empty)); + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + + 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."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + replacements.Add((insAt, 0, text)); + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + replacements.Add((insAt, 0, text)); + } + break; + } + + default: + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method."); + } + } + + if (HasOverlaps(replacements)) + return Response.Error("Edits overlap; split into separate calls or adjust targets."); + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + else if (errors != null && errors.Length > 0) + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try { File.Replace(tmp, fullPath, backup); } + catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + var ok = Response.Success( + $"Applied {replacements.Count} structured edit(s) to '{relativePath}'.", + new { path = relativePath, editsApplied = replacements.Count, scheduledRefresh = !immediate } + ); + + if (immediate) + { + // Force an immediate import/compile on the main thread + AssetDatabase.ImportAsset(relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return Response.Error($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 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); + string pattern = + @"(?m)^[\t ]*(?:\[[^\n\]]+\][\t ]*\n)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + int i = headerIndex; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + /// /// Generates basic C# script content based on name and type. /// From 73d212fc9c6d980d0f2f8b3b77dcbafd11327f05 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 22:45:35 -0700 Subject: [PATCH 004/311] Unity MCP: prefer micro-edits & resources; add script_apply_edits priority and server apply_text_edits/validate; add resources list/read; deprecate manage_script read/update/edit; remove stdout prints; tweak connection handshake logging --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 308 ++++++++++++++++-- .../UnityMcpServer~/src/pyrightconfig.json | 4 + UnityMcpBridge/UnityMcpServer~/src/server.py | 75 ++++- .../UnityMcpServer~/src/tools/__init__.py | 7 +- .../src/tools/manage_script.py | 10 +- .../src/tools/manage_script_edits.py | 148 +++++++++ .../UnityMcpServer~/src/unity_connection.py | 15 +- test_unity_socket_framing.py | 88 +++++ 8 files changed, 613 insertions(+), 42 deletions(-) create mode 100644 UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py create mode 100644 test_unity_socket_framing.py diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 7c9861a5..d2df4584 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -56,7 +56,14 @@ public static class ManageScript private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) { string assets = Application.dataPath.Replace('\\', '/'); - string targetDir = Path.Combine(assets, (relDir ?? "Scripts")).Replace('\\', '/'); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); + rel = rel.TrimStart('/'); + + string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); string full = Path.GetFullPath(targetDir).Replace('\\', '/'); bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) @@ -178,17 +185,40 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": - return ReadScript(fullPath, relativePath); + return Response.Error("Deprecated: reads are resources now. Use resources/read with a unity://path or unity://script URI."); case "update": - return UpdateScript(fullPath, relativePath, name, contents); + return Response.Error("Deprecated: use apply_text_edits (small, line/col edits) rather than whole-file replace."); case "delete": return DeleteScript(fullPath, relativePath); - case "edit": + case "apply_text_edits": { var edits = @params["edits"] as JArray; - var options = @params["options"] as JObject; - return EditScript(fullPath, relativePath, name, edits, options); + string precondition = @params["precondition_sha256"]?.ToString(); // optional, currently ignored here + return ApplyTextEdits(fullPath, relativePath, name, edits); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "strict" => ValidationLevel.Strict, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diags); + var result = new + { + isValid = ok, + diagnostics = diags ?? Array.Empty() + }; + return ok ? Response.Success("Validation completed.", result) : Response.Error("Validation failed.", result); } + case "edit": + return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." @@ -390,6 +420,108 @@ string contents } } + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + return Response.Error("Edits overlap; split into separate calls or adjust ranges."); + } + + string working = original; + foreach (var sp in spans) + { + working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + } + + // Validate result + var level = GetValidationLevelFromGUI(); + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + + // Atomic write and schedule refresh + try + { + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try { File.Replace(tmp, fullPath, backup); } + catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + return Response.Success($"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { path = relativePath, editsApplied = spans.Count, scheduledRefresh = true }); + } + catch (Exception ex) + { + return Response.Error($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\n') { line++; col = 1; } + else { col++; } + } + index = -1; return false; + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) @@ -448,6 +580,12 @@ private static object EditScript( try { var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; foreach (var e in edits) { @@ -473,7 +611,15 @@ private static object EditScript( if (!ValidateClassSnippet(replacement, className, out var vErr)) return Response.Error($"Replacement snippet invalid: {vErr}"); - replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } break; } @@ -487,7 +633,15 @@ private static object EditScript( if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) return Response.Error($"delete_class failed: {why}"); - replacements.Add((s, l, string.Empty)); + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } break; } @@ -509,9 +663,24 @@ private static object EditScript( return Response.Error($"replace_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) - return Response.Error($"replace_method failed: {whyMethod}"); + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + } - replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } break; } @@ -531,9 +700,24 @@ private static object EditScript( return Response.Error($"delete_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) - return Response.Error($"delete_method failed: {whyMethod}"); + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + } - replacements.Add((mStart, mLen, string.Empty)); + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } break; } @@ -561,14 +745,30 @@ private static object EditScript( return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); int insAt = aStart + aLen; string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); - replacements.Add((insAt, 0, text)); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } } else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) return Response.Error($"insert_method failed: {whyIns}"); else { string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); - replacements.Add((insAt, 0, text)); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } } break; } @@ -578,11 +778,15 @@ private static object EditScript( } } - if (HasOverlaps(replacements)) - return Response.Error("Edits overlap; split into separate calls or adjust targets."); + if (!applySequentially) + { + if (HasOverlaps(replacements)) + return Response.Error("Edits overlap; split into separate calls or adjust targets."); - foreach (var r in replacements.OrderByDescending(r => r.start)) - working = working.Remove(r.start, r.length).Insert(r.start, r.text); + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } // Validate result using override from options if provided; otherwise GUI strictness var level = GetValidationLevelFromGUI(); @@ -621,8 +825,8 @@ private static object EditScript( bool immediate = refreshMode == "immediate" || refreshMode == "sync"; var ok = Response.Success( - $"Applied {replacements.Count} structured edit(s) to '{relativePath}'.", - new { path = relativePath, editsApplied = replacements.Count, scheduledRefresh = !immediate } + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new { path = relativePath, editsApplied = appliedCount, scheduledRefresh = !immediate } ); if (immediate) @@ -796,9 +1000,9 @@ private static bool TryComputeMethodSpan( string namePattern = Regex.Escape(methodName); string paramsPattern = string.IsNullOrEmpty(parametersSignature) ? @"[\s\S]*?" : Regex.Escape(parametersSignature); string pattern = - @"(?m)^[\t ]*(?:\[[^\n\]]+\][\t ]*\n)*[\t ]*" + + @"(?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+)*" + - rtPattern + @"[\t ]+" + namePattern + @"\s*\(" + paramsPattern + @"\)"; + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; string slice = source.Substring(searchStart, searchEnd - searchStart); var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); @@ -843,7 +1047,13 @@ private static bool TryComputeMethodSpan( } // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end - int i = headerIndex; + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; for (; i < searchEnd; i++) { @@ -875,6 +1085,58 @@ private static bool TryComputeMethodSpan( break; } + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (;;) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') { // expression-bodied method: seek to terminating semicolon diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json new file mode 100644 index 00000000..cfa4ff8c --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": "none" +} diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 52633ef4..88add06d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,5 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +import sys from logging.handlers import RotatingFileHandler from dataclasses import dataclass from contextlib import asynccontextmanager @@ -9,12 +10,20 @@ from unity_connection import get_unity_connection, UnityConnection from pathlib import Path -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) +# Configure logging: strictly stderr/file only (never stdout) +stderr_handler = logging.StreamHandler(stream=sys.stderr) +stderr_handler.setFormatter(logging.Formatter(config.log_format)) + +handlers = [stderr_handler] logger = logging.getLogger("unity-mcp-server") +logger.setLevel(getattr(logging, config.log_level)) +for h in list(logger.handlers): + logger.removeHandler(h) +for h in list(logging.getLogger().handlers): + logging.getLogger().removeHandler(h) +logger.addHandler(stderr_handler) +logging.getLogger().addHandler(stderr_handler) +logging.getLogger().setLevel(getattr(logging, config.log_level)) # File logging to avoid stdout interference with MCP stdio try: @@ -84,6 +93,62 @@ def asset_creation_strategy() -> str: "- Always include a camera and main light in your scenes.\\n" ) +# Resources support: list and read Unity scripts/files +@mcp.capabilities(resources={"listChanged": True}) +class _: + pass + +import os +import hashlib + +def _unity_assets_root() -> str: + # Heuristic: from the Unity project root (one level up from Library/ProjectSettings), 'Assets' + # Here, assume server runs from repo; let clients pass absolute paths under project too. + return None + +def _safe_path(uri: str) -> str | None: + # URIs: unity://path/Assets/... or file:///absolute + if uri.startswith("unity://path/"): + p = uri[len("unity://path/"):] + return p + if uri.startswith("file://"): + return uri[len("file://"):] + # Minimal tolerance for plain Assets/... paths + if uri.startswith("Assets/"): + return uri + return None + +@mcp.resource.list() +def list_resources(ctx: Context) -> list[dict]: + # Lightweight: expose only C# under Assets by default + assets = [] + try: + root = os.getcwd() + for base, _, files in os.walk(os.path.join(root, "Assets")): + for f in files: + if f.endswith(".cs"): + rel = os.path.relpath(os.path.join(base, f), root).replace("\\", "/") + assets.append({ + "uri": f"unity://path/{rel}", + "name": os.path.basename(rel) + }) + except Exception: + pass + return assets + +@mcp.resource.read() +def read_resource(ctx: Context, uri: str) -> dict: + path = _safe_path(uri) + if not path or not os.path.exists(path): + return {"mimeType": "text/plain", "text": f"Resource not found: {uri}"} + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + 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}"} + # Run the server if __name__ == "__main__": mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 4d8d63cf..91ee9495 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,3 +1,4 @@ +from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools from .manage_editor import register_manage_editor_tools @@ -9,7 +10,9 @@ def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" - print("Registering Unity MCP Server refactored tools...") + # Note: Do not print to stdout; Claude treats stdout as MCP JSON. Use logging. + # Prefer the surgical edits tool so LLMs discover it first + register_manage_script_edits_tools(mcp) register_manage_script_tools(mcp) register_manage_scene_tools(mcp) register_manage_editor_tools(mcp) @@ -18,4 +21,4 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) - print("Unity MCP Server tool registration complete.") + # Do not print to stdout here either. diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index a41fb85c..af44a446 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -19,8 +19,10 @@ def manage_script( script_type: str, namespace: str ) -> Dict[str, Any]: - """Manages C# scripts in Unity (create, read, update, delete). - Make reference variables public for easier access in the Unity Editor. + """Manage C# scripts in Unity. + + IMPORTANT: + - This router is minimized. Use resources/read for file content and 'script_apply_edits' for changes. Args: action: Operation ('create', 'read', 'update', 'delete'). @@ -34,6 +36,10 @@ def manage_script( Dictionary with results ('success', 'message', 'data'). """ try: + # Deprecate full-file update path entirely + if action == 'update': + return {"success": False, "message": "Deprecated: use script_apply_edits (line/col edits) or resources/read + small edits."} + # Prepare parameters for Unity params = { "action": action, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py new file mode 100644 index 00000000..9cb746df --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -0,0 +1,148 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any, List +import base64 +import re +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE + m = re.search(anchor, text, flags) + if not m: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = m.start() if position == "before" else m.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + end_line = int(edit.get("endLine", start_line)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + if start_line < 1 or end_line < start_line or end_line > len(lines): + raise RuntimeError("replace_range out of bounds") + a = start_line - 1 + b = end_line + rep = replacement + if rep and not rep.endswith("\n"): + rep += "\n" + text = "".join(lines[:a]) + rep + "".join(lines[b:]) + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +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)." + )) + def script_apply_edits( + ctx: Context, + name: str, + path: str, + edits: List[Dict[str, Any]], + options: Dict[str, Any] | None = None, + script_type: str = "MonoBehaviour", + namespace: str = "", + ) -> Dict[str, Any]: + # If the edits request structured class/method ops, route directly to Unity's 'edit' action + 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"): + 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) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + # 1) read from Unity + 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") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # 2) apply edits locally + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + # 3) update to Unity + params: Dict[str, Any] = { + "action": "update", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "encodedContents": base64.b64encode(new_contents.encode("utf-8")).decode("ascii"), + "contentsEncoded": True, + } + if options is not None: + params["options"] = options + write_resp = send_command_with_retry("manage_script", params) + return write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)} + + + diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index bc602040..f04fb430 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -38,7 +38,7 @@ def connect(self) -> bool: try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + logger.debug(f"Connected to Unity at {self.host}:{self.port}") # Strict handshake: require FRAMING=1 try: @@ -47,7 +47,7 @@ def connect(self) -> bool: text = greeting.decode('ascii', errors='ignore') if greeting else '' if 'FRAMING=1' in text: self.use_framing = True - logger.info('Unity MCP handshake received: FRAMING=1 (strict)') + logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') else: raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') finally: @@ -188,15 +188,10 @@ def read_status_file() -> dict | None: for attempt in range(attempts + 1): try: - # Ensure connected + # Ensure connected (perform handshake each time so framing stays correct) if not self.sock: - # During retries use short connect timeout - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(1.0) - self.sock.connect((self.host, self.port)) - # restore steady-state timeout for receive - self.sock.settimeout(config.connection_timeout) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + if not self.connect(): + raise Exception("Could not connect to Unity") # Build payload if command_type == 'ping': diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py new file mode 100644 index 00000000..b0e179c9 --- /dev/null +++ b/test_unity_socket_framing.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import socket, struct, json, sys + +HOST = "127.0.0.1" +PORT = 6400 +SIZE_MB = int(sys.argv[1]) if len(sys.argv) > 1 else 5 # e.g., 5 or 10 +FILL = "R" + +def recv_exact(sock, n): + buf = bytearray(n) + view = memoryview(buf) + off = 0 + while off < n: + r = sock.recv_into(view[off:]) + if r == 0: + raise RuntimeError("socket closed") + off += r + return bytes(buf) + +def is_valid_json(b): + try: + json.loads(b.decode("utf-8")) + return True + except Exception: + return False + +def recv_legacy_json(sock, timeout=60): + sock.settimeout(timeout) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + data = b"".join(chunks) + if not data: + raise RuntimeError("no data, socket closed") + return data + chunks.append(chunk) + data = b"".join(chunks) + if data.strip() == b"ping": + return data + if is_valid_json(data): + return data + +def main(): + body = { + "type": "read_console", + "params": { + "action": "get", + "types": ["all"], + "count": 1000, + "format": "detailed", + "includeStacktrace": True, + "filterText": FILL * (SIZE_MB * 1024 * 1024) + } + } + body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + + with socket.create_connection((HOST, PORT), timeout=5) as s: + s.settimeout(2) + # Read optional greeting + try: + greeting = s.recv(256) + except Exception: + greeting = b"" + greeting_text = greeting.decode("ascii", errors="ignore").strip() + print(f"Greeting: {greeting_text or '(none)'}") + + framing = "FRAMING=1" in greeting_text + print(f"Using framing? {framing}") + + s.settimeout(120) + if framing: + header = struct.pack(">Q", len(body_bytes)) + s.sendall(header + body_bytes) + resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] + print(f"Response framed length: {resp_len}") + resp = recv_exact(s, resp_len) + else: + s.sendall(body_bytes) + resp = recv_legacy_json(s) + + print(f"Response bytes: {len(resp)}") + print(f"Response head: {resp[:120].decode('utf-8','ignore')}") + +if __name__ == "__main__": + main() + + From a12dcab7b0dcffa184bb9964831a95947f08447a Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 16 Aug 2025 03:49:52 -0700 Subject: [PATCH 005/311] test: add initial script and asset edit tests --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 178 ++++++++++++++++-- .../UnityMcpServer~/src/tools/manage_asset.py | 2 +- .../src/tools/manage_script.py | 124 ++++++++++-- test_unity_socket_framing.py | 5 +- tests/test_script_tools.py | 123 ++++++++++++ 5 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 tests/test_script_tools.py diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index d2df4584..0d2fae60 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -8,10 +8,12 @@ using UnityEngine; using UnityMcpBridge.Editor.Helpers; using System.Threading; +using System.Security.Cryptography; #if USE_ROSLYN using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; #endif #if UNITY_EDITOR @@ -193,12 +195,12 @@ public static object HandleCommand(JObject @params) case "apply_text_edits": { var edits = @params["edits"] as JArray; - string precondition = @params["precondition_sha256"]?.ToString(); // optional, currently ignored here - return ApplyTextEdits(fullPath, relativePath, name, edits); + string precondition = @params["precondition_sha256"]?.ToString(); + return ApplyTextEdits(fullPath, relativePath, name, edits, precondition); } case "validate": { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "basic"; var chosen = level switch { "basic" => ValidationLevel.Basic, @@ -209,13 +211,19 @@ public static object HandleCommand(JObject @params) try { fileText = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } - bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diags); - var result = new + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => { - isValid = ok, - diagnostics = diags ?? Array.Empty() - }; - return ok ? Response.Success("Validation completed.", result) : Response.Error("Validation failed.", result); + var m = Regex.Match(s, @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$"); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); } case "edit": return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); @@ -299,9 +307,10 @@ string namespaceName try { File.Delete(tmp); } catch { } } + var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { path = relativePath, scheduledRefresh = true } + new { uri, scheduledRefresh = true } ); // Schedule heavy work AFTER replying @@ -423,11 +432,14 @@ string contents /// /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. /// + private const int MaxEditPayloadBytes = 15 * 1024; + private static object ApplyTextEdits( string fullPath, string relativePath, string name, - JArray edits) + JArray edits, + string preconditionSha256) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); @@ -438,8 +450,15 @@ private static object ApplyTextEdits( try { original = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + string currentSha = ComputeSha256(original); + if (!string.IsNullOrEmpty(preconditionSha256) && !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)>(); + int totalBytes = 0; foreach (var e in edits) { try @@ -457,6 +476,7 @@ private static object ApplyTextEdits( if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); } catch (Exception ex) { @@ -464,6 +484,11 @@ private static object ApplyTextEdits( } } + if (totalBytes > MaxEditPayloadBytes) + { + return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + // Ensure non-overlap and apply from back to front spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) @@ -478,10 +503,40 @@ private static object ApplyTextEdits( working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); } - // Validate result - var level = GetValidationLevelFromGUI(); - if (!ValidateScriptSyntax(working, level, out var errors)) - return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString() }); + } + +#if USE_ROSLYN + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + return Response.Error("syntax_error", new { status = "syntax_error", diagnostics }); + } + + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } +#endif + + string newSha = ComputeSha256(working); // Atomic write and schedule refresh try @@ -495,7 +550,17 @@ private static object ApplyTextEdits( catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - return Response.Success($"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { path = relativePath, editsApplied = spans.Count, scheduledRefresh = true }); + return Response.Success( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + applied = spans.Count, + unchanged = 0, + sha256 = newSha, + uri = $"unity://path/{relativePath}", + scheduledRefresh = true + } + ); } catch (Exception ex) { @@ -522,6 +587,84 @@ private static bool TryIndexFromLineCol(string text, int line1, int col1, out in index = -1; return false; } + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) @@ -537,7 +680,8 @@ private static object DeleteScript(string fullPath, string relativePath) { AssetDatabase.Refresh(); return Response.Success( - $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully." + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } ); } else diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 19ac0c2e..ccafb047 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -76,4 +76,4 @@ async def manage_asset( # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity - return result if isinstance(result, dict) else {"success": False, "message": str(result)} \ No newline at end of file + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index af44a446..f7836da3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,14 +1,95 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any +from typing import Dict, Any, List from unity_connection import get_unity_connection, send_command_with_retry from config import config import time import os import base64 + def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" + def _split_uri(uri: str) -> tuple[str, str]: + if uri.startswith("unity://path/"): + path = uri[len("unity://path/") :] + elif uri.startswith("file://"): + path = uri[len("file://") :] + else: + path = uri + path = path.replace("\\", "/") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + return name, directory + + @mcp.tool() + def apply_text_edits( + ctx: Context, + uri: str, + edits: List[Dict[str, Any]], + precondition_sha256: str | None = None, + ) -> Dict[str, Any]: + """Apply small text edits to a C# script identified by URI.""" + name, directory = _split_uri(uri) + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": edits, + "precondition_sha256": precondition_sha256, + } + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def create_script( + ctx: Context, + path: str, + contents: str = "", + script_type: str | None = None, + namespace: str | None = None, + ) -> Dict[str, Any]: + """Create a new C# script at the given path.""" + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + params: Dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents is not None: + params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: + """Delete a C# script by URI.""" + name, directory = _split_uri(uri) + params = {"action": "delete", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def validate_script( + ctx: Context, uri: str, level: str = "basic" + ) -> Dict[str, Any]: + """Validate a C# script and return diagnostics.""" + name, directory = _split_uri(uri) + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + @mcp.tool() def manage_script( ctx: Context, @@ -17,12 +98,13 @@ def manage_script( path: str, contents: str, script_type: str, - namespace: str + namespace: str, ) -> Dict[str, Any]: - """Manage C# scripts in Unity. + """Compatibility router for legacy script operations. IMPORTANT: - - This router is minimized. Use resources/read for file content and 'script_apply_edits' for changes. + - Direct file reads should use resources/read. + - Edits should use apply_text_edits. Args: action: Operation ('create', 'read', 'update', 'delete'). @@ -38,7 +120,7 @@ def manage_script( try: # Deprecate full-file update path entirely if action == 'update': - return {"success": False, "message": "Deprecated: use script_apply_edits (line/col edits) or resources/read + small edits."} + return {"success": False, "message": "Deprecated: use apply_text_edits or resources/read + small edits."} # Prepare parameters for Unity params = { @@ -46,36 +128,40 @@ def manage_script( "name": name, "path": path, "namespace": namespace, - "scriptType": script_type + "scriptType": script_type, } - + # Base64 encode the contents if they exist to avoid JSON escaping issues if contents is not None: if action in ['create', 'update']: - # Encode content for safer transmission params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents - - # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} - # Send command via centralized retry helper response = send_command_with_retry("manage_script", params) - - # Process response from Unity + if isinstance(response, dict) and response.get("success"): - # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else { + "success": False, + "message": str(response), + } except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index b0e179c9..c24064a1 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -3,7 +3,10 @@ HOST = "127.0.0.1" PORT = 6400 -SIZE_MB = int(sys.argv[1]) if len(sys.argv) > 1 else 5 # e.g., 5 or 10 +try: + SIZE_MB = int(sys.argv[1]) +except (IndexError, ValueError): + SIZE_MB = 5 # e.g., 5 or 10 FILL = "R" def recv_exact(sock, n): diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py new file mode 100644 index 00000000..9b953a1a --- /dev/null +++ b/tests/test_script_tools.py @@ -0,0 +1,123 @@ +import sys +import pathlib +import importlib.util +import types +import pytest + +# add server src to path and load modules without triggering package imports +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp to satisfy imports without full dependency +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") + +class _Dummy: + pass + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + +def load_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +manage_script_module = load_module(SRC / "tools" / "manage_script.py", "manage_script_module") +manage_asset_module = load_module(SRC / "tools" / "manage_asset.py", "manage_asset_module") + + +class DummyMCP: + def __init__(self): + self.tools = {} + + def tool(self): + def decorator(func): + self.tools[func.__name__] = func + return func + return decorator + +def setup_manage_script(): + mcp = DummyMCP() + manage_script_module.register_manage_script_tools(mcp) + return mcp.tools + +def setup_manage_asset(): + mcp = DummyMCP() + manage_asset_module.register_manage_asset_tools(mcp) + return mcp.tools + +def test_apply_text_edits_long_file(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + captured = {} + + def fake_send(cmd, params): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} + resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) + assert captured["cmd"] == "manage_script" + assert captured["params"]["action"] == "apply_text_edits" + assert captured["params"]["edits"][0]["startLine"] == 1005 + assert resp["success"] is True + +def test_sequential_edits_use_precondition(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + calls = [] + + def fake_send(cmd, params): + calls.append(params) + return {"success": True, "sha256": f"hash{len(calls)}"} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} + resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) + edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} + resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) + + assert calls[1]["precondition_sha256"] == resp1["sha256"] + assert resp2["sha256"] == "hash2" + +def test_manage_asset_prefab_modify_request(monkeypatch): + tools = setup_manage_asset() + manage_asset = tools["manage_asset"] + captured = {} + + async def fake_async(cmd, params, loop=None): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async) + monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object()) + + async def run(): + resp = await manage_asset( + None, + action="modify", + path="Assets/Prefabs/Player.prefab", + properties={"hp": 100}, + ) + assert captured["cmd"] == "manage_asset" + assert captured["params"]["action"] == "modify" + assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" + assert captured["params"]["properties"] == {"hp": 100} + assert resp["success"] is True + + import asyncio + asyncio.run(run()) From de4a6bc36137fb97510c90d46bc71242919b77ec Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 16 Aug 2025 06:18:37 -0700 Subject: [PATCH 006/311] Maintain manage_script compatibility and add safety checks --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 35 +++++++++++++++---- .../Editor/Windows/UnityMcpEditorWindow.cs | 9 +++++ .../UnityMcpServer~/src/pyrightconfig.json | 9 ++++- test_unity_socket_framing.py | 3 ++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0d2fae60..19c9f24a 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -187,9 +187,11 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": - return Response.Error("Deprecated: reads are resources now. Use resources/read with a unity://path or unity://script URI."); + Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); case "update": - return Response.Error("Deprecated: use apply_text_edits (small, line/col edits) rather than whole-file replace."); + Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); case "apply_text_edits": @@ -226,10 +228,13 @@ public static object HandleCommand(JObject @params) : Response.Error("Validation failed.", result); } case "edit": - return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var edits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, edits, options); default: return Response.Error( - $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } } @@ -581,10 +586,26 @@ private static bool TryIndexFromLineCol(string text, int line1, int col1, out in } if (i == text.Length) break; char c = text[i]; - if (c == '\n') { line++; col = 1; } - else { col++; } + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } } - index = -1; return false; + index = -1; + return false; } private static string ComputeSha256(string contents) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 19446406..d80ffbb5 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1579,6 +1579,15 @@ private void CheckMcpConfiguration(McpClient mcpClient) } } } + else + { + // Surface mismatch even if auto-manage is disabled + mcpClient.SetStatus(McpStatus.IncorrectPath); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: IDE config mismatch for '{mcpClient.name}' and auto-manage disabled"); + } + } } } else diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json index cfa4ff8c..4fdeb465 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json +++ b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json @@ -1,4 +1,11 @@ { "typeCheckingMode": "basic", - "reportMissingImports": "none" + "reportMissingImports": "none", + "pythonVersion": "3.11", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.11" + } + ] } diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index c24064a1..7495ccb3 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -77,6 +77,9 @@ def main(): s.sendall(header + body_bytes) resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] print(f"Response framed length: {resp_len}") + MAX_RESP = 128 * 1024 * 1024 + if resp_len <= 0 or resp_len > MAX_RESP: + raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") resp = recv_exact(s, resp_len) else: s.sendall(body_bytes) From c13a2dae6f27f07ddcf9b7269f18b35d63d58097 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 16 Aug 2025 09:06:27 -0700 Subject: [PATCH 007/311] Support explicit validation levels --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 37 +++++++++++++++---- .../Editor/Windows/UnityMcpEditorWindow.cs | 9 +++++ .../UnityMcpServer~/src/pyrightconfig.json | 9 ++++- test_unity_socket_framing.py | 3 ++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0d2fae60..29339604 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -187,9 +187,11 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": - return Response.Error("Deprecated: reads are resources now. Use resources/read with a unity://path or unity://script URI."); + Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); case "update": - return Response.Error("Deprecated: use apply_text_edits (small, line/col edits) rather than whole-file replace."); + Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); case "apply_text_edits": @@ -204,7 +206,9 @@ public static object HandleCommand(JObject @params) var chosen = level switch { "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, _ => ValidationLevel.Standard }; string fileText; @@ -226,10 +230,13 @@ public static object HandleCommand(JObject @params) : Response.Error("Validation failed.", result); } case "edit": - return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var edits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, edits, options); default: return Response.Error( - $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } } @@ -581,10 +588,26 @@ private static bool TryIndexFromLineCol(string text, int line1, int col1, out in } if (i == text.Length) break; char c = text[i]; - if (c == '\n') { line++; col = 1; } - else { col++; } + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } } - index = -1; return false; + index = -1; + return false; } private static string ComputeSha256(string contents) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 19446406..d80ffbb5 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1579,6 +1579,15 @@ private void CheckMcpConfiguration(McpClient mcpClient) } } } + else + { + // Surface mismatch even if auto-manage is disabled + mcpClient.SetStatus(McpStatus.IncorrectPath); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: IDE config mismatch for '{mcpClient.name}' and auto-manage disabled"); + } + } } } else diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json index cfa4ff8c..4fdeb465 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json +++ b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json @@ -1,4 +1,11 @@ { "typeCheckingMode": "basic", - "reportMissingImports": "none" + "reportMissingImports": "none", + "pythonVersion": "3.11", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.11" + } + ] } diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index c24064a1..7495ccb3 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -77,6 +77,9 @@ def main(): s.sendall(header + body_bytes) resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] print(f"Response framed length: {resp_len}") + MAX_RESP = 128 * 1024 * 1024 + if resp_len <= 0 or resp_len > MAX_RESP: + raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") resp = recv_exact(s, resp_len) else: s.sendall(body_bytes) From 49a3355c7f0451122f9dc8774761f8171383f2c9 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 06:58:28 -0700 Subject: [PATCH 008/311] Fix script tool returns and handshake edge cases --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 77 ++++++++----------- UnityMcpBridge/UnityMcpServer~/src/server.py | 56 ++++++-------- .../UnityMcpServer~/src/tools/__init__.py | 7 +- .../src/tools/manage_script.py | 40 +++++----- .../src/tools/manage_script_edits.py | 8 +- .../UnityMcpServer~/src/unity_connection.py | 13 +++- 6 files changed, 99 insertions(+), 102 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index fa707483..feb631ba 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -431,15 +431,7 @@ private static async Task HandleClientAsync(TcpClient client) if (true) { // Enforced framed mode for this connection - byte[] header = await ReadExactAsync(stream, 8, FrameIOTimeoutMs); - ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen == 0UL || payloadLen > MaxFrameBytes) - { - throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); - } - int payloadLenInt = checked((int)payloadLen); - byte[] payload = await ReadExactAsync(stream, payloadLenInt, FrameIOTimeoutMs); - commandText = System.Text.Encoding.UTF8.GetString(payload); + commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); } try @@ -459,16 +451,7 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); - if ((ulong)pingResponseBytes.Length > MaxFrameBytes) - { - throw new System.IO.IOException($"Frame too large: {pingResponseBytes.Length}"); - } - { - byte[] outHeader = new byte[8]; - WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); - await stream.WriteAsync(outHeader, 0, outHeader.Length); - } - await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); + await WriteFrameAsync(stream, pingResponseBytes); continue; } @@ -479,16 +462,7 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - if ((ulong)responseBytes.Length > MaxFrameBytes) - { - throw new System.IO.IOException($"Frame too large: {responseBytes.Length}"); - } - { - byte[] outHeader = new byte[8]; - WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); - await stream.WriteAsync(outHeader, 0, outHeader.Length); - } - await stream.WriteAsync(responseBytes, 0, responseBytes.Length); + await WriteFrameAsync(stream, responseBytes); } catch (Exception ex) { @@ -499,22 +473,6 @@ private static async Task HandleClientAsync(TcpClient client) } } - private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count) - { - byte[] data = new byte[count]; - int offset = 0; - while (offset < count) - { - int r = await stream.ReadAsync(data, offset, count - offset); - if (r == 0) - { - throw new System.IO.IOException("Connection closed before reading expected bytes"); - } - offset += r; - } - return data; - } - // Timeout-aware exact read helper; avoids indefinite stalls private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs) { @@ -538,6 +496,35 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS return data; } + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) + { + if ((ulong)payload.LongLength > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + } + byte[] header = new byte[8]; + WriteUInt64BigEndian(header, (ulong)payload.LongLength); + await stream.WriteAsync(header, 0, header.Length); + await stream.WriteAsync(payload, 0, payload.Length); + } + + private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs) + { + byte[] header = await ReadExactAsync(stream, 8, timeoutMs); + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen == 0UL || payloadLen > MaxFrameBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + if (payloadLen > int.MaxValue) + { + throw new System.IO.IOException("Frame too large for buffer"); + } + int count = (int)payloadLen; + byte[] payload = await ReadExactAsync(stream, count, timeoutMs); + return System.Text.Encoding.UTF8.GetString(payload); + } + private static ulong ReadUInt64BigEndian(byte[] buffer) { if (buffer == null || buffer.Length < 8) return 0UL; diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 88add06d..99f41229 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -9,6 +9,8 @@ from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection from pathlib import Path +import os +import hashlib # Configure logging: strictly stderr/file only (never stdout) stderr_handler = logging.StreamHandler(stream=sys.stderr) @@ -98,52 +100,44 @@ def asset_creation_strategy() -> str: class _: pass -import os -import hashlib - -def _unity_assets_root() -> str: - # Heuristic: from the Unity project root (one level up from Library/ProjectSettings), 'Assets' - # Here, assume server runs from repo; let clients pass absolute paths under project too. - return None +PROJECT_ROOT = Path(os.environ.get("UNITY_PROJECT_ROOT", Path.cwd())).resolve() +ASSETS_ROOT = (PROJECT_ROOT / "Assets").resolve() -def _safe_path(uri: str) -> str | None: - # URIs: unity://path/Assets/... or file:///absolute +def _resolve_safe_path_from_uri(uri: str) -> Path | None: + raw: str | None = None if uri.startswith("unity://path/"): - p = uri[len("unity://path/"):] - return p - if uri.startswith("file://"): - return uri[len("file://"):] - # Minimal tolerance for plain Assets/... paths - if uri.startswith("Assets/"): - return uri - return None + 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_ROOT / raw).resolve() + try: + p.relative_to(PROJECT_ROOT) + except ValueError: + return None + return p @mcp.resource.list() def list_resources(ctx: Context) -> list[dict]: - # Lightweight: expose only C# under Assets by default assets = [] try: - root = os.getcwd() - for base, _, files in os.walk(os.path.join(root, "Assets")): - for f in files: - if f.endswith(".cs"): - rel = os.path.relpath(os.path.join(base, f), root).replace("\\", "/") - assets.append({ - "uri": f"unity://path/{rel}", - "name": os.path.basename(rel) - }) + 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: - path = _safe_path(uri) - if not path or not os.path.exists(path): + 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: - with open(path, "r", encoding="utf-8") as f: - text = f.read() + 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: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 91ee9495..710b53dc 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,3 +1,4 @@ +import logging from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools @@ -8,9 +9,11 @@ from .read_console import register_read_console_tools from .execute_menu_item import register_execute_menu_item_tools +logger = logging.getLogger("unity-mcp-server") + def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" - # Note: Do not print to stdout; Claude treats stdout as MCP JSON. Use logging. + logger.info("Registering Unity MCP Server refactored tools...") # Prefer the surgical edits tool so LLMs discover it first register_manage_script_edits_tools(mcp) register_manage_script_tools(mcp) @@ -21,4 +24,4 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) - # Do not print to stdout here either. + logger.info("Unity MCP Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index f7836da3..b44dd743 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -60,7 +60,7 @@ def create_script( "namespace": namespace, "scriptType": script_type, } - if contents is not None: + if contents: params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} @@ -107,7 +107,7 @@ def manage_script( - Edits should use apply_text_edits. Args: - action: Operation ('create', 'read', 'update', 'delete'). + action: Operation ('create', 'read', 'delete'). name: Script name (no .cs extension). path: Asset path (default: "Assets/"). contents: C# code for 'create'/'update'. @@ -132,8 +132,8 @@ def manage_script( } # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: + if contents: + if action == 'create': params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: @@ -143,22 +143,22 @@ def manage_script( response = send_command_with_retry("manage_script", params) - if isinstance(response, dict) and response.get("success"): - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return { - "success": True, - "message": response.get("message", "Operation successful."), - "data": response.get("data"), - } - return response if isinstance(response, dict) else { - "success": False, - "message": str(response), - } + if isinstance(response, dict): + if response.get("success"): + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response + + return {"success": False, "message": str(response)} except Exception as e: return { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 9cb746df..bd7f7137 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -51,10 +51,11 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str end_line = int(edit.get("endLine", start_line)) replacement = edit.get("text", "") lines = text.splitlines(keepends=True) - if start_line < 1 or end_line < start_line or end_line > len(lines): + max_end = len(lines) + 1 + if start_line < 1 or end_line < start_line or end_line > max_end: raise RuntimeError("replace_range out of bounds") a = start_line - 1 - b = end_line + b = min(end_line, len(lines)) rep = replacement if rep and not rep.endswith("\n"): rep += "\n" @@ -88,7 +89,8 @@ def script_apply_edits( script_type: str = "MonoBehaviour", namespace: str = "", ) -> Dict[str, Any]: - # If the edits request structured class/method ops, route directly to Unity's 'edit' action + # 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. 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"): diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index f04fb430..ab47a503 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -49,12 +49,23 @@ def connect(self) -> bool: 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}') finally: self.sock.settimeout(config.connection_timeout) return True except Exception as e: logger.error(f"Failed to connect to Unity: {str(e)}") + try: + if self.sock: + self.sock.close() + except Exception: + pass self.sock = None return False @@ -83,7 +94,7 @@ 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 or payload_len > (64 * 1024 * 1024): + if payload_len > (64 * 1024 * 1024): raise Exception(f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) logger.info(f"Received framed response ({len(payload)} bytes)") From c735117910e8d6bff6c9ce357011b1a9b6e55d32 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 07:35:51 -0700 Subject: [PATCH 009/311] Prevent overflow when counting edit bytes --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 29339604..1324d9b2 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -465,7 +465,7 @@ private static object ApplyTextEdits( // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); - int totalBytes = 0; + long totalBytes = 0; foreach (var e in edits) { try @@ -483,7 +483,10 @@ private static object ApplyTextEdits( if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); - totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } } catch (Exception ex) { From 79ba5ecdb7f245aff30707e88bf42047d4775dc7 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 08:03:30 -0700 Subject: [PATCH 010/311] Fix refresh debounce scheduling --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 32 +++++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 1324d9b2..87025b07 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -202,7 +202,7 @@ public static object HandleCommand(JObject @params) } case "validate": { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "basic"; + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; var chosen = level switch { "basic" => ValidationLevel.Basic, @@ -343,8 +343,10 @@ private static object ReadScript(string fullPath, string relativePath) // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"unity://path/{relativePath}"; var responseData = new { + uri, path = relativePath, contents = contents, // For large files, also include base64-encoded version @@ -420,9 +422,10 @@ string contents } // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", - new { path = relativePath, scheduledRefresh = true } + new { uri, path = relativePath, scheduledRefresh = true } ); // Schedule a debounced import/compile on next editor tick to avoid stalling the reply @@ -1523,11 +1526,14 @@ private static bool ValidateScriptSyntax(string contents, ValidationLevel level, } #if USE_ROSLYN - // Advanced Roslyn-based validation - if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) { - errors = errorList.ToArray(); - return level != ValidationLevel.Standard; //TODO: Allow standard to run roslyn right now, might formalize it in the future + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } } #endif @@ -2105,20 +2111,28 @@ static class RefreshDebounce { private static int _pending; private static DateTime _last; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + private static bool _scheduled; public static void Schedule(string relPath, TimeSpan window) { Interlocked.Exchange(ref _pending, 1); + lock (_lock) { _paths.Add(relPath); } var now = DateTime.UtcNow; - if ((now - _last) < window) return; + if (_scheduled && (now - _last) < window) return; _last = now; + _scheduled = true; EditorApplication.delayCall += () => { + _scheduled = false; if (Interlocked.Exchange(ref _pending, 0) == 1) { - // Prefer targeted import and script compile over full refresh - AssetDatabase.ImportAsset(relPath, ImportAssetOptions.ForceUpdate); + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); #if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif From 01fc4f16da47d8760f7f8cd4c7e7d828f03cf2ad Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 08:22:23 -0700 Subject: [PATCH 011/311] Clean up temp backups and guard symlinked edits --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 118 +++++++++++++++++--- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 1324d9b2..91801142 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -202,7 +202,7 @@ public static object HandleCommand(JObject @params) } case "validate": { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "basic"; + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; var chosen = level switch { "basic" => ValidationLevel.Basic, @@ -343,8 +343,10 @@ private static object ReadScript(string fullPath, string relativePath) // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"unity://path/{relativePath}"; var responseData = new { + uri, path = relativePath, contents = contents, // For large files, also include base64-encoded version @@ -406,23 +408,36 @@ string contents try { File.Replace(tempPath, fullPath, backupPath); + // Clean up backup to avoid stray assets inside the project + try + { + if (File.Exists(backupPath)) + File.Delete(backupPath); + } + catch + { + // ignore failures deleting the backup + } } catch (PlatformNotSupportedException) { File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } catch (IOException) { // Cross-volume moves can throw IOException; fallback to copy File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", - new { path = relativePath, scheduledRefresh = true } + new { uri, path = relativePath, scheduledRefresh = true } ); // Schedule a debounced import/compile on next editor tick to avoid stalling the reply @@ -450,6 +465,17 @@ private static object ApplyTextEdits( { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // If checking attributes fails, proceed without the symlink guard + } if (edits == null || edits.Count == 0) return Response.Error("No edits provided."); @@ -555,9 +581,23 @@ private static object ApplyTextEdits( var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); string backup = fullPath + ".bak"; - try { File.Replace(tmp, fullPath, backup); } - catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } - catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); return Response.Success( @@ -738,6 +778,17 @@ private static object EditScript( { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // ignore failures checking attributes and proceed + } if (edits == null || edits.Count == 0) return Response.Error("No edits provided."); @@ -986,9 +1037,23 @@ private static object EditScript( var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); string backup = fullPath + ".bak"; - try { File.Replace(tmp, fullPath, backup); } - catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } - catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } // Decide refresh behavior string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); @@ -1001,11 +1066,17 @@ private static object EditScript( if (immediate) { - // Force an immediate import/compile on the main thread - AssetDatabase.ImportAsset(relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); + // Force on main thread + EditorApplication.delayCall += () => + { + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); #if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif + }; } else { @@ -1523,11 +1594,14 @@ private static bool ValidateScriptSyntax(string contents, ValidationLevel level, } #if USE_ROSLYN - // Advanced Roslyn-based validation - if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) { - errors = errorList.ToArray(); - return level != ValidationLevel.Standard; //TODO: Allow standard to run roslyn right now, might formalize it in the future + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } } #endif @@ -2105,20 +2179,28 @@ static class RefreshDebounce { private static int _pending; private static DateTime _last; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + private static bool _scheduled; public static void Schedule(string relPath, TimeSpan window) { Interlocked.Exchange(ref _pending, 1); + lock (_lock) { _paths.Add(relPath); } var now = DateTime.UtcNow; - if ((now - _last) < window) return; + if (_scheduled && (now - _last) < window) return; _last = now; + _scheduled = true; EditorApplication.delayCall += () => { + _scheduled = false; if (Interlocked.Exchange(ref _pending, 0) == 1) { - // Prefer targeted import and script compile over full refresh - AssetDatabase.ImportAsset(relPath, ImportAssetOptions.ForceUpdate); + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); #if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif From fd791272075bd1c1258e961c29539b8b1d6af4bd Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 08:32:55 -0700 Subject: [PATCH 012/311] Fix debouncing race condition --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 68 +++++++++++++++------ 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 91801142..90367c1a 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -2178,36 +2178,68 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge static class RefreshDebounce { private static int _pending; - private static DateTime _last; private static readonly object _lock = new object(); private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // The timestamp of the most recent schedule request. + private static DateTime _lastRequest; + + // Guard to ensure we only have a single ticking callback running. private static bool _scheduled; public static void Schedule(string relPath, TimeSpan window) { + // Record that work is pending and track the path in a threadsafe way. Interlocked.Exchange(ref _pending, 1); - lock (_lock) { _paths.Add(relPath); } - var now = DateTime.UtcNow; - if (_scheduled && (now - _last) < window) return; - _last = now; - _scheduled = true; + lock (_lock) + { + _paths.Add(relPath); + _lastRequest = DateTime.UtcNow; - EditorApplication.delayCall += () => + // If a debounce timer is already scheduled it will pick up the new request. + if (_scheduled) + return; + + _scheduled = true; + } + + // Kick off a ticking callback that waits until the window has elapsed + // from the last request before performing the refresh. + EditorApplication.delayCall += () => Tick(window); + } + + private static void Tick(TimeSpan window) + { + bool ready; + lock (_lock) { - _scheduled = false; - if (Interlocked.Exchange(ref _pending, 0) == 1) + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) { - string[] toImport; - lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } - foreach (var p in toImport) - AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); + _scheduled = false; + } + } + + if (!ready) + { + // Window has not yet elapsed; check again on the next editor tick. + EditorApplication.delayCall += () => Tick(window); + return; + } + + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); #if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif - // Fallback if needed: - // AssetDatabase.Refresh(); - } - }; + // Fallback if needed: + // AssetDatabase.Refresh(); + } } } From 200483e826d00e25337138abc1bdd58441b3bb7d Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 08:59:42 -0700 Subject: [PATCH 013/311] Add initial transport handshake tests with plan placeholders --- tests/test_logging_stdout.py | 11 ++++ tests/test_resources_api.py | 11 ++++ tests/test_script_editing.py | 36 +++++++++++ tests/test_transport_framing.py | 102 ++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 tests/test_logging_stdout.py create mode 100644 tests/test_resources_api.py create mode 100644 tests/test_script_editing.py create mode 100644 tests/test_transport_framing.py diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py new file mode 100644 index 00000000..98dc23f4 --- /dev/null +++ b/tests/test_logging_stdout.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file") +def test_no_stdout_output_from_tools(): + pass + + +@pytest.mark.skip(reason="TODO: sweep for accidental print statements in codebase") +def test_no_print_statements_in_codebase(): + pass diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py new file mode 100644 index 00000000..bdcd7290 --- /dev/null +++ b/tests/test_resources_api.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.mark.skip(reason="TODO: resource.list returns only Assets/**/*.cs and rejects 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") +def test_resource_list_rejects_outside_paths(): + pass diff --git a/tests/test_script_editing.py b/tests/test_script_editing.py new file mode 100644 index 00000000..e0b3705b --- /dev/null +++ b/tests/test_script_editing.py @@ -0,0 +1,36 @@ +import pytest + + +@pytest.mark.skip(reason="TODO: 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") +def test_micro_edits_debounce(): + pass + + +@pytest.mark.skip(reason="TODO: 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") +def test_regex_replace_noop_allowed(): + pass + + +@pytest.mark.skip(reason="TODO: 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") +def test_symlink_and_junction_protection(): + pass + + +@pytest.mark.skip(reason="TODO: atomic write guarantees") +def test_atomic_write_guarantees(): + pass diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py new file mode 100644 index 00000000..1c3d02fa --- /dev/null +++ b/tests/test_transport_framing.py @@ -0,0 +1,102 @@ +import sys +import json +import struct +import socket +import threading +import time +from pathlib import Path + +import pytest + +# add server src to path +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +from unity_connection import UnityConnection + + +def start_dummy_server(greeting: bytes, respond_ping: bool = False): + """Start a minimal TCP server for handshake tests.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + + def _run(): + conn, _ = sock.accept() + 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)) + if not chunk: + break + payload += chunk + if payload == b'{"type":"ping"}': + resp = b'{"type":"pong"}' + conn.sendall(struct.pack(">Q", len(resp)) + resp) + except Exception: + pass + time.sleep(0.1) + try: + conn.close() + finally: + sock.close() + + threading.Thread(target=_run, daemon=True).start() + return port + + +def test_handshake_requires_framing(): + port = start_dummy_server(b"MCP/0.1\n") + conn = UnityConnection(host="127.0.0.1", port=port) + assert conn.connect() is False + assert conn.sock is None + + +def test_small_frame_ping_pong(): + port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True) + conn = UnityConnection(host="127.0.0.1", port=port) + assert conn.connect() is True + assert conn.use_framing is True + payload = b'{"type":"ping"}' + conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) + resp = conn.receive_full_response(conn.sock) + assert json.loads(resp.decode("utf-8"))["type"] == "pong" + conn.disconnect() + + +@pytest.mark.skip(reason="TODO: unframed data before reading greeting should disconnect") +def test_unframed_data_disconnect(): + pass + + +@pytest.mark.skip(reason="TODO: zero-length payload should raise error") +def test_zero_length_payload_error(): + pass + + +@pytest.mark.skip(reason="TODO: oversized payload should disconnect") +def test_oversized_payload_rejected(): + pass + + +@pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect") +def test_partial_frame_timeout(): + pass + + +@pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations") +def test_parallel_invocations_no_interleaving(): + pass + + +@pytest.mark.skip(reason="TODO: reconnection after drop mid-command") +def test_reconnect_mid_command(): + pass From a3c81d657d5a2333a9e985643b0721cfe22dc6c5 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 16:29:04 -0700 Subject: [PATCH 014/311] Fix dummy server startup and cleanup in transport tests --- tests/test_transport_framing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 1c3d02fa..50483f48 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -11,6 +11,8 @@ # add server src to path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +if not SRC.exists(): + raise FileNotFoundError(f"Server source directory not found: {SRC}") sys.path.insert(0, str(SRC)) from unity_connection import UnityConnection @@ -22,8 +24,10 @@ def start_dummy_server(greeting: bytes, respond_ping: bool = False): sock.bind(("127.0.0.1", 0)) sock.listen(1) port = sock.getsockname()[1] + ready = threading.Event() def _run(): + ready.set() conn, _ = sock.accept() if greeting: conn.sendall(greeting) @@ -46,10 +50,13 @@ def _run(): time.sleep(0.1) try: conn.close() + except Exception: + pass finally: sock.close() threading.Thread(target=_run, daemon=True).start() + ready.wait() return port From b01978c59e3e3a2589378a738fb6b764b05196fd Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 16:38:28 -0700 Subject: [PATCH 015/311] test: enforce no prints and handshake preamble --- README.md | 12 ++++++++++ tests/test_logging_stdout.py | 14 +++++++++-- tests/test_transport_framing.py | 42 +++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d3c5c111..bb4dd965 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,18 @@ Unity MCP connects your tools using two components: --- +### Transport framing + +Unity MCP requires explicit framing negotiation. After connecting, the server +sends a `MCP/0.1` greeting. Clients must respond with `FRAMING=1`, and all +subsequent messages are sent as 8-byte big-endian length-prefixed JSON frames. + +### Resource URIs + +Assets are addressed using `unity://` URIs relative to the project root. For +example, `unity://path/Assets/Scripts/Foo.cs` refers to the file +`Assets/Scripts/Foo.cs` inside the Unity project. + ## Installation ⚙️ > **Note:** The setup is constantly improving as we update the package. Check back if you randomly start to run into issues. diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 98dc23f4..d4389818 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -1,3 +1,6 @@ +import re +from pathlib import Path + import pytest @@ -6,6 +9,13 @@ def test_no_stdout_output_from_tools(): pass -@pytest.mark.skip(reason="TODO: sweep for accidental print statements in codebase") def test_no_print_statements_in_codebase(): - pass + """Ensure no stray print statements remain in server source.""" + src = Path(__file__).resolve().parents[1] / "UnityMcpBridge" / "UnityMcpServer~" / "src" + assert src.exists(), f"Server source directory not found: {src}" + 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): + offenders.append(py_file.relative_to(src)) + assert not offenders, f"print statements found in: {offenders}" diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 50483f48..602cb312 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -4,6 +4,7 @@ import socket import threading import time +import select from pathlib import Path import pytest @@ -60,6 +61,33 @@ def _run(): return port +def start_handshake_enforcing_server(): + """Server that drops connection if client sends data before handshake.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + ready = threading.Event() + + def _run(): + ready.set() + conn, _ = sock.accept() + # if client sends any data before greeting, disconnect + r, _, _ = select.select([conn], [], [], 0.1) + if r: + conn.close() + sock.close() + return + conn.sendall(b"MCP/0.1 FRAMING=1\n") + time.sleep(0.1) + conn.close() + sock.close() + + threading.Thread(target=_run, daemon=True).start() + ready.wait() + return port + + def test_handshake_requires_framing(): port = start_dummy_server(b"MCP/0.1\n") conn = UnityConnection(host="127.0.0.1", port=port) @@ -79,9 +107,19 @@ def test_small_frame_ping_pong(): conn.disconnect() -@pytest.mark.skip(reason="TODO: unframed data before reading greeting should disconnect") def test_unframed_data_disconnect(): - pass + port = start_handshake_enforcing_server() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(("127.0.0.1", port)) + sock.sendall(b"BAD") + time.sleep(0.1) + try: + data = sock.recv(1024) + assert data == b"" + except ConnectionError: + pass + finally: + sock.close() @pytest.mark.skip(reason="TODO: zero-length payload should raise error") From 555d96510bd077e3a1dc8789a2426943e2e55ff4 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 16:39:02 -0700 Subject: [PATCH 016/311] feat: add defensive server path resolution in tests --- tests/test_transport_framing.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 602cb312..46601c3a 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -9,11 +9,18 @@ import pytest -# add server src to path +# locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" -if not SRC.exists(): - raise FileNotFoundError(f"Server source directory not found: {SRC}") +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +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 + ) sys.path.insert(0, str(SRC)) from unity_connection import UnityConnection From e4544f68c3e0d271e9d9f48e5575b092e920fc0a Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 16:49:52 -0700 Subject: [PATCH 017/311] Refine server source path lookup --- README.md | 12 ------------ tests/test_logging_stdout.py | 20 ++++++++++++++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bb4dd965..d3c5c111 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,6 @@ Unity MCP connects your tools using two components: --- -### Transport framing - -Unity MCP requires explicit framing negotiation. After connecting, the server -sends a `MCP/0.1` greeting. Clients must respond with `FRAMING=1`, and all -subsequent messages are sent as 8-byte big-endian length-prefixed JSON frames. - -### Resource URIs - -Assets are addressed using `unity://` URIs relative to the project root. For -example, `unity://path/Assets/Scripts/Foo.cs` refers to the file -`Assets/Scripts/Foo.cs` inside the Unity project. - ## Installation ⚙️ > **Note:** The setup is constantly improving as we update the package. Check back if you randomly start to run into issues. diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index d4389818..38e55d20 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -4,6 +4,20 @@ import pytest +# locate server src dynamically to avoid hardcoded layout assumptions +ROOT = Path(__file__).resolve().parents[1] +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +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.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file") def test_no_stdout_output_from_tools(): pass @@ -11,11 +25,9 @@ def test_no_stdout_output_from_tools(): def test_no_print_statements_in_codebase(): """Ensure no stray print statements remain in server source.""" - src = Path(__file__).resolve().parents[1] / "UnityMcpBridge" / "UnityMcpServer~" / "src" - assert src.exists(), f"Server source directory not found: {src}" offenders = [] - for py_file in src.rglob("*.py"): + for py_file in SRC.rglob("*.py"): text = py_file.read_text(encoding="utf-8") if re.search(r"^\s*print\(", text, re.MULTILINE): - offenders.append(py_file.relative_to(src)) + offenders.append(py_file.relative_to(SRC)) assert not offenders, f"print statements found in: {offenders}" From 9dbb4ffbcb5cb23a61f39d499b461d8fb82ad353 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:02:36 -0700 Subject: [PATCH 018/311] Refine handshake tests and stdout hygiene --- tests/test_logging_stdout.py | 8 ++++++-- tests/test_transport_framing.py | 23 ++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 38e55d20..d6e728b7 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -28,6 +28,10 @@ def test_no_print_statements_in_codebase(): 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): + if re.search(r"^\s*print\(", text, re.MULTILINE) or re.search( + r"sys\.stdout\.write\(", text + ): offenders.append(py_file.relative_to(SRC)) - assert not offenders, f"print statements found in: {offenders}" + assert not offenders, ( + "stdout writes found in: " + ", ".join(str(o) for o in offenders) + ) diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 46601c3a..39e84afd 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -80,7 +80,8 @@ def _run(): ready.set() conn, _ = sock.accept() # if client sends any data before greeting, disconnect - r, _, _ = select.select([conn], [], [], 0.1) + # 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() @@ -105,13 +106,15 @@ def test_handshake_requires_framing(): def test_small_frame_ping_pong(): port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True) conn = UnityConnection(host="127.0.0.1", port=port) - assert conn.connect() is True - assert conn.use_framing is True - payload = b'{"type":"ping"}' - conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) - resp = conn.receive_full_response(conn.sock) - assert json.loads(resp.decode("utf-8"))["type"] == "pong" - conn.disconnect() + try: + assert conn.connect() is True + assert conn.use_framing is True + payload = b'{"type":"ping"}' + conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) + resp = conn.receive_full_response(conn.sock) + assert json.loads(resp.decode("utf-8"))["type"] == "pong" + finally: + conn.disconnect() def test_unframed_data_disconnect(): @@ -123,7 +126,9 @@ def test_unframed_data_disconnect(): try: data = sock.recv(1024) assert data == b"" - except ConnectionError: + except (ConnectionResetError, ConnectionAbortedError): + # Some platforms raise instead of returning empty bytes when the + # server closes the connection after detecting pre-handshake data. pass finally: sock.close() From fde29d000037b85c65e27738f38e457320831b5c Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 17 Aug 2025 17:23:03 -0700 Subject: [PATCH 019/311] 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 020/311] 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 021/311] 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 022/311] 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 023/311] 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 024/311] 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 025/311] 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 026/311] 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 027/311] 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 028/311] 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 029/311] 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 030/311] 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 031/311] 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 032/311] 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 033/311] 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 034/311] 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 035/311] 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 036/311] 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 037/311] 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 038/311] 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) From 230cb486e1f60683a3a5c16a5e65a44c24444fcb Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 03:24:21 -0700 Subject: [PATCH 039/311] fix: update Unity MCP server path in workflow --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 14b192d5..1c449825 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -52,7 +52,7 @@ jobs: "mcpServers": { "unity": { "command": "python", - "args": ["UnityMcpServer/src/server.py"] + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"] } } } From 1351927b2b88cb035a01c80a23ae5d1224b0d6cb Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 03:31:56 -0700 Subject: [PATCH 040/311] fix: update server dependency path --- .github/workflows/claude-nl-suite.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 1c449825..439aecb9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -31,8 +31,10 @@ jobs: - 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 + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt fi - name: Run Claude NL/T test suite From 32f91f2edafd309e0ed94e7e7ca3242bc09c9eeb Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 06:22:40 -0700 Subject: [PATCH 041/311] Update claude-nl-suite.yml From cd2e6e0022fec3e31fb7cd4c941d4e1323ceba7c Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 06:27:40 -0700 Subject: [PATCH 042/311] Update claude-nl-suite.yml --- .github/workflows/claude-nl-suite.yml | 61 ++++++++++++--------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 439aecb9..8fd68f12 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,9 +1,7 @@ -name: Claude NL suite + (optional) Unity compile +name: Claude NL suite + (optional) Unity compiles on: - workflow_dispatch: - - + workflow_dispatch: {} permissions: contents: write # allow Claude to write test artifacts @@ -18,37 +16,42 @@ jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - # If your MCP server needs Python deps (adjust to your repo layout) + # Python + uv for the Unity MCP server - 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) + - name: Install UnityMcpServer deps run: | - if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then - uv pip install UnityMcpBridge/UnityMcpServer~/src - elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then - uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" + else + echo "No Python deps found for UnityMcpServer~ (skipping)" 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) + # Test instructions live here 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.) + # Tight tool allowlist allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" - # Inline MCP config (or put this JSON in .claude/mcp.json) + # MCP server path (matches your screenshots) mcp_config: | { "mcpServers": { @@ -59,7 +62,7 @@ jobs: } } - # Model + guardrails + # Guardrails model: "claude-3-7-sonnet-20250219" max_turns: "10" timeout_minutes: "20" @@ -71,6 +74,7 @@ jobs: with: name: claude-nl-tests path: reports/claude-nl-tests.xml + if-no-files-found: ignore - name: Annotate PR with test results (Claude NL/T) if: always() @@ -81,25 +85,17 @@ jobs: reporter: java-junit fail-on-empty: false + # Detect secrets + project/package mode WITHOUT using secrets in `if:` - 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 + 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() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' uses: game-ci/unity-test-runner@v4 @@ -110,10 +106,8 @@ jobs: 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() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_package == 'true' uses: game-ci/unity-test-runner@v4 @@ -123,8 +117,8 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: packageMode: true - unityVersion: 2022.3.45f1 # <-- set explicitly for packages - projectPath: . # or a small sample project path + unityVersion: 2022.3.45f1 # set your exact version + projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} - name: Clean working tree (discard temp edits) @@ -132,4 +126,3 @@ jobs: run: | git restore -SW :/ git clean -fd - From 84585cc45fcb92f8a430353cea70a56bb368ca56 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 06:44:55 -0700 Subject: [PATCH 043/311] Remove debug invocation print --- mcp_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mcp_source.py b/mcp_source.py index 292e5e02..35fa74dd 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -117,7 +117,6 @@ 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) @@ -166,4 +165,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() From 7cfb4012d0ba9293b0fc1c574c4649800cd9488f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 07:22:55 -0700 Subject: [PATCH 044/311] updated claude test file --- .github/workflows/claude-nl-suite.yml | 68 +++++++++++++++++---------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cd840476..1b25e641 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,8 +1,7 @@ name: Claude NL suite + (optional) Unity compile -on: { workflow_dispatch: {} } - - +on: + workflow_dispatch: {} permissions: contents: write # allow Claude to write test artifacts @@ -17,46 +16,53 @@ jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - # If your MCP server needs Python deps (adjust to your repo layout) + # Python + uv for the Unity MCP server - 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) + - name: Install UnityMcpServer deps run: | - if [ -f UnityMcpServer/requirements.txt ]; then - uv pip install -r UnityMcpServer/requirements.txt + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" + else + echo "No Python deps found for UnityMcpServer~ (skipping)" 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) + # Test instructions live here 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.) + # Tight tool allowlist allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" - # Inline MCP config (or put this JSON in .claude/mcp.json) + # MCP server path (matches your screenshots) mcp_config: | { "mcpServers": { "unity": { "command": "python", - "args": ["UnityMcpServer/src/server.py"] + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"] } } } - # Model + guardrails + # Guardrails model: "claude-3-7-sonnet-20250219" max_turns: "10" timeout_minutes: "20" @@ -68,6 +74,7 @@ jobs: with: name: claude-nl-tests path: reports/claude-nl-tests.xml + if-no-files-found: ignore - name: Annotate PR with test results (Claude NL/T) if: always() @@ -76,30 +83,42 @@ jobs: name: Claude NL/T path: reports/claude-nl-tests.xml reporter: java-junit + fail-on-empty: false + + # Detect secrets + project/package mode WITHOUT using secrets in `if:` + - 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') != '' && secrets.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 }} # OR UNITY_* for Pro + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} 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 != '' }} + 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_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 - projectPath: . # or a small sample project path + unityVersion: 2022.3.45f1 # set your exact version + projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} - name: Clean working tree (discard temp edits) @@ -107,4 +126,3 @@ jobs: run: | git restore -SW :/ git clean -fd - From 88a2de26e77c768179172b98140dcbd66dcabe87 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 14:00:02 -0700 Subject: [PATCH 045/311] CI: add Claude NL suite workflow, prompt, and long Unity test script on default branch for manual runs --- .claude/prompts/nl-unity-suite.md | 103 ++ .github/workflows/claude-nl-suite.yml | 128 ++ ClaudeTests/longUnityScript-claudeTest.cs | 2039 +++++++++++++++++++++ 3 files changed, 2270 insertions(+) create mode 100644 .claude/prompts/nl-unity-suite.md create mode 100644 .github/workflows/claude-nl-suite.yml create mode 100644 ClaudeTests/longUnityScript-claudeTest.cs diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md new file mode 100644 index 00000000..8d934939 --- /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: `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. + +## 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 `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) +- 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 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 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 +- 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. 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. +- **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/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. +- **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. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml new file mode 100644 index 00000000..1b25e641 --- /dev/null +++ b/.github/workflows/claude-nl-suite.yml @@ -0,0 +1,128 @@ +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 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Python + uv for the Unity MCP server + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install UnityMcpServer deps + run: | + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" + else + echo "No Python deps found for UnityMcpServer~ (skipping)" + fi + + - name: Run Claude NL/T test suite + id: claude + uses: anthropics/claude-code-base-action@beta + with: + # Test instructions live here + prompt_file: .claude/prompts/nl-unity-suite.md + + # Tight tool allowlist + allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + + # MCP server path (matches your screenshots) + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "python", + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"] + } + } + } + + # 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 + if-no-files-found: ignore + + - 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 + fail-on-empty: false + + # Detect secrets + project/package mode WITHOUT using secrets in `if:` + - 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) --- + - name: Unity compile (Project) + 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 }} + testMode: EditMode + + - name: Unity compile (Package) + 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 your exact version + projectPath: . + githubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean working tree (discard temp edits) + if: always() + run: | + git restore -SW :/ + git clean -fd 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 + +} + + From cb4432e76be39102dc3365168ee45b9f0f038c13 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 14:15:20 -0700 Subject: [PATCH 046/311] CI: bring workflow from chore/ci-claude-nl-workflow --- .github/workflows/claude-nl-suite.yml | 132 ++++++++++++++++++++++++++ 1 file changed, 132 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..a84242a3 --- /dev/null +++ b/.github/workflows/claude-nl-suite.yml @@ -0,0 +1,132 @@ +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 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Python + uv for the Unity MCP server + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install UnityMcpServer deps + run: | + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv venv + . .venv/bin/activate + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + uv venv + . .venv/bin/activate + uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" + else + echo "No Python deps found (skipping)" + fi + + - name: Run Claude NL/T test suite + id: claude + uses: anthropics/claude-code-base-action@beta + with: + # Test instructions live here + prompt_file: .claude/prompts/nl-unity-suite.md + + # Tight tool allowlist + allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + + # MCP server path (matches your screenshots) + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "python", + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"] + } + } + } + + # 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 + if-no-files-found: ignore + + - 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 + fail-on-empty: false + + # Detect secrets + project/package mode WITHOUT using secrets in `if:` + - 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) --- + - name: Unity compile (Project) + 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 }} + testMode: EditMode + + - name: Unity compile (Package) + 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 your exact version + projectPath: . + githubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean working tree (discard temp edits) + if: always() + run: | + git restore -SW :/ + git clean -fd From aaa8b4d8b1dc5378bd86f7b2829f2e3ab9f6fd0d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 17:44:06 -0700 Subject: [PATCH 047/311] CI: start MCP via uv stdio; preflight tool listing; register script_edit + resource tools --- .claude/prompts/nl-unity-suite.md | 5 ++++ .github/workflows/claude-nl-suite.yml | 43 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md index 8d934939..752e2180 100644 --- a/.claude/prompts/nl-unity-suite.md +++ b/.claude/prompts/nl-unity-suite.md @@ -11,6 +11,11 @@ You are running in CI at the repository root. Use only the tools that are allowe - 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. +### Preflight (must run before NL/T tests) +- List available MCP tools from server `unity`. +- Assert that at least these tools are present: `script_apply_edits` (structured), `manage_script` (with `apply_text_edits` action available via arguments), and resource wrappers. +- If missing, fail fast with a concise message so logs clearly show the server didn’t advertise edit endpoints. + ## Output requirements - Create a JUnit XML at `reports/claude-nl-tests.xml`. - Each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 1b25e641..4509de90 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -34,13 +34,33 @@ jobs: run: | set -eux if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" else - echo "No Python deps found for UnityMcpServer~ (skipping)" + echo "No Python deps found (skipping)" + fi + - name: Verify Python env + run: | + set -eux + which python + python -V + python -c "import mcp; print('mcp ok')" + + - name: Prepare reports dir + run: | + set -eux + mkdir -p reports + - name: Run Claude NL/T test suite id: claude uses: anthropics/claude-code-base-action@beta @@ -48,16 +68,24 @@ jobs: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Tight tool allowlist - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + # Tight tool allowlist (permit basic shell ops + git) + allowed_tools: "Bash(mkdir:*,ls:*,ps:*,cat:*,tee:*,git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" - # MCP server path (matches your screenshots) + # MCP server path mcp_config: | { "mcpServers": { "unity": { - "command": "python", - "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"] + "command": "uv", + "args": [ + "run", + "--directory", + "UnityMcpBridge/UnityMcpServer~/src", + "python", + "server.py" + ], + "type": "stdio", + "env": { "UNITY_MCP_LOG": "debug" } } } } @@ -92,9 +120,12 @@ jobs: 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) --- - name: Unity compile (Project) if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' From 7e276bb336cced84d688cc85006af5dd462aaf43 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 17:46:21 -0700 Subject: [PATCH 048/311] CI: fail-fast preflight for MCP modules; gate Claude step on success() --- .github/workflows/claude-nl-suite.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 4509de90..729818e7 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -56,12 +56,28 @@ jobs: python -V python -c "import mcp; print('mcp ok')" + - name: Preflight MCP modules (fail fast) + run: | + set -eux + uv run --directory UnityMcpBridge/UnityMcpServer~/src python - <<'PY' + import sys, pkgutil + import tools + mods = {name for _, name, _ in pkgutil.iter_modules(tools.__path__)} + required = {"manage_script","manage_script_edits","resource_tools"} + missing = required - mods + if missing: + print(f"Missing MCP tool modules: {sorted(missing)}") + sys.exit(1) + print("MCP tool modules present:", sorted(required)) + PY + - name: Prepare reports dir run: | set -eux mkdir -p reports - name: Run Claude NL/T test suite + if: success() id: claude uses: anthropics/claude-code-base-action@beta with: From 325ca413180337cd842d09e447e94874cb7089ce Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 17:48:41 -0700 Subject: [PATCH 049/311] CI: log MCP server path and contents for startup diagnostics --- .github/workflows/claude-nl-suite.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 729818e7..b13c4e80 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -76,6 +76,22 @@ jobs: set -eux mkdir -p reports + - name: Log MCP server location (diagnostic) + run: | + set -ux # do not -e; we want logs even if missing + SRV_DIR="UnityMcpBridge/UnityMcpServer~/src" + echo "MCP server dir := ${SRV_DIR}" + python - <<'PY' + import pathlib + p = pathlib.Path('UnityMcpBridge/UnityMcpServer~/src').resolve() + print('Resolved path:', p) + print('Exists:', p.exists()) + print('server.py present:', (p / 'server.py').exists()) + PY + ls -la "${SRV_DIR}" || true + uv --version || true + uv run --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + - name: Run Claude NL/T test suite if: success() id: claude From 87cf9c0a4f777934fa9b8da0ea128ee513c26dbf Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 17:55:52 -0700 Subject: [PATCH 050/311] CI: use uv --active with --directory for MCP server and diagnostics --- .github/workflows/claude-nl-suite.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b13c4e80..ff801d35 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -90,7 +90,7 @@ jobs: PY ls -la "${SRV_DIR}" || true uv --version || true - uv run --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - name: Run Claude NL/T test suite if: success() @@ -111,6 +111,7 @@ jobs: "command": "uv", "args": [ "run", + "--active", "--directory", "UnityMcpBridge/UnityMcpServer~/src", "python", From e44e841d75948e69abfe7e8c03492efa257a182c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 17:59:34 -0700 Subject: [PATCH 051/311] Bridge: resolve Debug ambiguity by removing System.Diagnostics using; qualify Stopwatch --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 43 ++++++++++++++++++------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index feb631ba..277e64b6 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -473,27 +473,46 @@ private static async Task HandleClientAsync(TcpClient client) } } - // Timeout-aware exact read helper; avoids indefinite stalls - private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs) + // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) { - byte[] data = new byte[count]; + byte[] buffer = new byte[count]; int offset = 0; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + while (offset < count) { - var readTask = stream.ReadAsync(data, offset, count - offset); - var completed = await System.Threading.Tasks.Task.WhenAny(readTask, System.Threading.Tasks.Task.Delay(timeoutMs)); - if (completed != readTask) + int remaining = count - offset; + int remainingTimeout = timeoutMs <= 0 ? Timeout.Infinite : Math.Max(1, timeoutMs - (int)stopwatch.ElapsedMilliseconds); + + using var cts = remainingTimeout == Timeout.Infinite + ? CancellationTokenSource.CreateLinkedTokenSource(cancel) + : CancellationTokenSource.CreateLinkedTokenSource(cancel); + if (remainingTimeout != Timeout.Infinite) { - throw new System.IO.IOException("Read timed out"); + cts.CancelAfter(remainingTimeout); + } + + try + { +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); +#endif + if (read == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += read; } - int r = readTask.Result; - if (r == 0) + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) { - throw new System.IO.IOException("Connection closed before reading expected bytes"); + throw new System.IO.IOException("Read timed out"); } - offset += r; } - return data; + + return buffer; } private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) From a3738d6dd09c2e5d1529d8122d95e06a354eb02d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 19 Aug 2025 18:26:44 -0700 Subject: [PATCH 052/311] CI: expand allowed tools (find/head/tail) and raise max_turns to 20 --- .github/workflows/claude-nl-suite.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index ff801d35..95d37966 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -100,8 +100,8 @@ jobs: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Tight tool allowlist (permit basic shell ops + git) - allowed_tools: "Bash(mkdir:*,ls:*,ps:*,cat:*,tee:*,git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + # Tight tool allowlist (permit basic shell ops + git + mcp unity tools) + allowed_tools: "Bash(mkdir:*,ls:*,ps:*,cat:*,tee:*,find:*,head:*,tail:*,git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" # MCP server path mcp_config: | @@ -125,7 +125,7 @@ jobs: # Guardrails model: "claude-3-7-sonnet-20250219" - max_turns: "10" + max_turns: "20" timeout_minutes: "20" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 6a3c68b5876e9499baf8cac2f6f19da5126c941a Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 21:05:37 -0700 Subject: [PATCH 053/311] Fix MCP server launch and tool permissions --- .github/workflows/claude-nl-suite.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 95d37966..7bcfbfc5 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -90,7 +90,7 @@ jobs: PY ls -la "${SRV_DIR}" || true uv --version || true - uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + uv run --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - name: Run Claude NL/T test suite if: success() @@ -100,8 +100,8 @@ jobs: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Tight tool allowlist (permit basic shell ops + git + mcp unity tools) - allowed_tools: "Bash(mkdir:*,ls:*,ps:*,cat:*,tee:*,find:*,head:*,tail:*,git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + # Tight tool allowlist (permit git and essential MCP tooling) + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" # MCP server path mcp_config: | @@ -111,14 +111,16 @@ jobs: "command": "uv", "args": [ "run", - "--active", "--directory", "UnityMcpBridge/UnityMcpServer~/src", "python", "server.py" ], - "type": "stdio", - "env": { "UNITY_MCP_LOG": "debug" } + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } } } } From 8e93adf3acfd81a3dfbd0661dc983f74ef386e47 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 21:17:19 -0700 Subject: [PATCH 054/311] Clarify uv launch comment --- .github/workflows/claude-nl-suite.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 95d37966..b7e5f324 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -90,7 +90,7 @@ jobs: PY ls -la "${SRV_DIR}" || true uv --version || true - uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + uv run --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - name: Run Claude NL/T test suite if: success() @@ -100,10 +100,10 @@ jobs: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Tight tool allowlist (permit basic shell ops + git + mcp unity tools) - allowed_tools: "Bash(mkdir:*,ls:*,ps:*,cat:*,tee:*,find:*,head:*,tail:*,git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" + # Tight tool allowlist (permit git and essential MCP tooling) + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" - # MCP server path + # MCP server path (launched via uv without --active) mcp_config: | { "mcpServers": { @@ -111,14 +111,16 @@ jobs: "command": "uv", "args": [ "run", - "--active", "--directory", "UnityMcpBridge/UnityMcpServer~/src", "python", "server.py" ], - "type": "stdio", - "env": { "UNITY_MCP_LOG": "debug" } + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } } } } From 18e1721998c088a16e25e7477d0bb8092a6e8122 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 21:48:18 -0700 Subject: [PATCH 055/311] Auto-approve Unity tools and mark permission skips --- .github/scripts/mark_skipped.py | 38 +++++++++++++++++++++++++++ .github/workflows/claude-nl-suite.yml | 12 +++++++++ 2 files changed, 50 insertions(+) create mode 100755 .github/scripts/mark_skipped.py diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py new file mode 100755 index 00000000..0ef1bca7 --- /dev/null +++ b/.github/scripts/mark_skipped.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Convert permission-related failures in JUnit reports to skipped tests. + +The Claude NL/T suite can emit failures with messages like "Test skipped..." when +MCP tool approval blocks execution. GitHub dashboards should treat these as +skipped, not failed. This script rewrites the JUnit XML in-place, replacing any + element containing "approval required" or "MCP not usable" with +. +""" +import sys +import pathlib +import xml.etree.ElementTree as ET + +def main(path: str) -> None: + p = pathlib.Path(path) + if not p.exists(): + return + tree = ET.parse(p) + root = tree.getroot() + changed = False + for case in root.iter("testcase"): + failure = case.find("failure") + if failure is None: + continue + msg = (failure.get("message") or "") + (failure.text or "") + if "approval required" in msg.lower() or "mcp not usable" in msg.lower(): + case.remove(failure) + skipped = ET.Element("skipped") + if failure.get("message"): + skipped.set("message", failure.get("message")) + case.append(skipped) + changed = True + if changed: + tree.write(p, encoding="utf-8", xml_declaration=True) + +if __name__ == "__main__": + if len(sys.argv) > 1: + main(sys.argv[1]) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b7e5f324..0fe5381c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -125,12 +125,24 @@ jobs: } } + # Auto-approve required tools for non-interactive runs + settings: | + { + "permissionMode": "allow", + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] + } + # Guardrails model: "claude-3-7-sonnet-20250219" max_turns: "20" timeout_minutes: "20" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Mark permission issues as skipped in JUnit + if: always() + run: | + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml + - name: Upload JUnit (Claude NL/T) if: always() uses: actions/upload-artifact@v4 From d0e4df92ac091ed1887c8d50d304559691e6eed8 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 22:16:09 -0700 Subject: [PATCH 056/311] Ensure artifact dir exists before agent runs --- .github/scripts/mark_skipped.py | 6 +++++- .github/workflows/claude-nl-suite.yml | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index 0ef1bca7..1eeb656f 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -15,7 +15,11 @@ def main(path: str) -> None: p = pathlib.Path(path) if not p.exists(): return - tree = ET.parse(p) + try: + tree = ET.parse(p) + except (ET.ParseError, FileNotFoundError) as e: + print(f"[mark_skipped] Failed to parse XML at {p}: {e}", file=sys.stderr) + return root = tree.getroot() changed = False for case in root.iter("testcase"): diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0fe5381c..13c9611f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -71,10 +71,8 @@ jobs: print("MCP tool modules present:", sorted(required)) PY - - name: Prepare reports dir - run: | - set -eux - mkdir -p reports + - name: Ensure artifact dirs exist + run: mkdir -p reports - name: Log MCP server location (diagnostic) run: | From 278270e3b647da50a6eeb86d169cb1840ddb64df Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 22:44:33 -0700 Subject: [PATCH 057/311] Update claude-nl-suite.yml --- .github/workflows/claude-nl-suite.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 13c9611f..60d1f7ed 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -123,12 +123,11 @@ jobs: } } - # Auto-approve required tools for non-interactive runs - settings: | - { - "permissionMode": "allow", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] - } + # Make CI non-interactive; names vary by action version: + # e.g. permission_mode: "auto" OR permissions: "approveAll" OR auto_approve_tools: "mcp__unity__*" + permission_mode: "auto" + auto_approve_tools: "mcp__unity__*,Read,Write" + allowed_tools: "Read,Write,Glob,Grep,mcp__unity__*" # Guardrails model: "claude-3-7-sonnet-20250219" From a8aea7759d69a424679c22114891eb006454d8df Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 19 Aug 2025 23:06:52 -0700 Subject: [PATCH 058/311] Align Claude NL test workflow with latest action options --- .github/workflows/claude-nl-suite.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 60d1f7ed..85f629d8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -123,11 +123,9 @@ jobs: } } - # Make CI non-interactive; names vary by action version: - # e.g. permission_mode: "auto" OR permissions: "approveAll" OR auto_approve_tools: "mcp__unity__*" - permission_mode: "auto" - auto_approve_tools: "mcp__unity__*,Read,Write" - allowed_tools: "Read,Write,Glob,Grep,mcp__unity__*" + # Make CI non-interactive; names vary by action version + permission_mode: "auto" # or use the action’s supported key + auto_approve_tools: "mcp__unity__*,Read,Write" # Guardrails model: "claude-3-7-sonnet-20250219" From 04eacf024d63764f1d8e6204769ba265644471e8 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 20 Aug 2025 07:02:39 -0700 Subject: [PATCH 059/311] chore: refine CI permissions for claude tests --- .github/workflows/claude-nl-suite.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 85f629d8..d4bb083d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -59,7 +59,7 @@ jobs: - name: Preflight MCP modules (fail fast) run: | set -eux - uv run --directory UnityMcpBridge/UnityMcpServer~/src python - <<'PY' + uv run --active --directory UnityMcpBridge/UnityMcpServer~/src python - <<'PY' import sys, pkgutil import tools mods = {name for _, name, _ in pkgutil.iter_modules(tools.__path__)} @@ -88,7 +88,7 @@ jobs: PY ls -la "${SRV_DIR}" || true uv --version || true - uv run --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - name: Run Claude NL/T test suite if: success() @@ -98,10 +98,10 @@ jobs: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Tight tool allowlist (permit git and essential MCP tooling) + # Restrict which tools the agent may attempt (approvals handled via settings) allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" - # MCP server path (launched via uv without --active) + # MCP server path (use active venv) mcp_config: | { "mcpServers": { @@ -109,6 +109,7 @@ jobs: "command": "uv", "args": [ "run", + "--active", "--directory", "UnityMcpBridge/UnityMcpServer~/src", "python", @@ -123,9 +124,19 @@ jobs: } } - # Make CI non-interactive; names vary by action version - permission_mode: "auto" # or use the action’s supported key - auto_approve_tools: "mcp__unity__*,Read,Write" + # Auto-approve in CI (bypass prompts) + settings: | + { + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "permissions": { + "allow": [ + "Read", "Write", "LS", "Glob", "Grep", + "Bash(git:*)", + "mcp__unity" + ] + } + } # Guardrails model: "claude-3-7-sonnet-20250219" From 7d99a7ec6f336f53500b50109e1413b80e60a7a6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 07:55:01 -0700 Subject: [PATCH 060/311] Server: register resources for both FastMCP.resource/resources; always expose script-edits spec; add logging --- UnityMcpBridge/UnityMcpServer~/src/server.py | 86 ++++++++++++-------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 56f26f6a..1670023a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -96,12 +96,17 @@ def asset_creation_strategy() -> str: "- 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 +""" +Resources support: publish native MCP resources in a way that is +compatible with FastMCP variants exposing either `.resource` or `.resources`. +Always include a synthetic spec at `unity://spec/script-edits`. +""" + # Guard for older MCP versions without 'capabilities' API if hasattr(mcp, "capabilities"): @mcp.capabilities(resources={"listChanged": True}) - class _: - pass + class _ResourcesCaps: + ... PROJECT_ROOT = Path(os.environ.get("UNITY_PROJECT_ROOT", Path.cwd())).resolve() ASSETS_ROOT = (PROJECT_ROOT / "Assets").resolve() @@ -124,29 +129,27 @@ def _resolve_safe_path_from_uri(uri: str) -> Path | None: return p -if hasattr(mcp, "resource") and hasattr(getattr(mcp, "resource"), "list"): - @mcp.resource.list() - def list_resources(ctx: Context) -> list[dict]: - assets = [] - try: +def _list_resources_impl() -> list[dict]: + assets: list[dict] = [] + try: + # Enumerate C# scripts in Assets/ if present + if ASSETS_ROOT.exists(): 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 - # 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 = ( + except Exception as e: + logger.debug(f"Assets enumeration failed: {e}") + # Always include the scripted spec resource + assets.append({ + "uri": "unity://spec/script-edits", + "name": "Unity Script Edits – Required JSON", + }) + return assets + + +def _read_resource_impl(uri: str) -> dict: + if uri == "unity://spec/script-edits": + spec_json = ( '{\n' ' "name": "Unity MCP — Script Edits v1",\n' ' "target_tool": "script_apply_edits",\n' @@ -202,17 +205,32 @@ def read_resource(ctx: Context, uri: str) -> dict: ' }\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}"} - 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}"} + ) + 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}"} + 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}"} + + +# Choose resource API variant (singular vs plural) and register +_res_api = getattr(mcp, "resource", None) or getattr(mcp, "resources", None) +if _res_api and hasattr(_res_api, "list") and hasattr(_res_api, "read"): + @_res_api.list() + def list_resources(ctx: Context) -> list[dict]: # type: ignore[override] + logger.debug("Resources API registered via %s.list", _res_api.__class__.__name__ if hasattr(_res_api, "__class__") else "resource") + return _list_resources_impl() + + @_res_api.read() + def read_resource(ctx: Context, uri: str) -> dict: # type: ignore[override] + return _read_resource_impl(uri) +else: + logger.warning("MCP resources API not available; native resource listing will be empty for ListMcpResourcesTool") # Run the server if __name__ == "__main__": From 89daa650ac556fce9ee05e4b6ecda8378ec464c3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 08:28:07 -0700 Subject: [PATCH 061/311] CI: add nl-startuptest to quickly verify MCP resources and one preview edit --- .claude/prompts/nl-startuptest.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .claude/prompts/nl-startuptest.md diff --git a/.claude/prompts/nl-startuptest.md b/.claude/prompts/nl-startuptest.md new file mode 100644 index 00000000..7b9399b1 --- /dev/null +++ b/.claude/prompts/nl-startuptest.md @@ -0,0 +1,19 @@ +# CLAUDE TASK: Unity MCP startup checks + +You are running in CI at the repository root. Use only allowed tools. + +- Verify that the MCP server `unity` is connected. +- List tools and assert presence of these IDs: + - mcp__unity__script_apply_edits + - mcp__unity__manage_script + - mcp__unity__list_resources + - mcp__unity__read_resource +- Try native resources: call ListMcpResourcesTool. If it returns [], fall back to mcp__unity__list_resources. +- Read one resource: + - Prefer `unity://spec/script-edits` via native read; otherwise use mcp__unity__read_resource. +- Perform one minimal structured edit preview (no write): + - Use mcp__unity__script_apply_edits with `options.preview=true` to insert a comment above `Update` in `ClaudeTests/longUnityScript-claudeTest.cs`, then stop. + +Output: +- Write a short JUnit at `reports/claude-nl-tests.xml` with a single `` containing 2–3 `` entries (tools present, resources readable, preview edit ok). On failure, include a `` element with concise reason. +- Also write a brief markdown summary at `reports/claude-nl-tests.md`. From 8dca4bfc059ffc6964e013a1f13f7acadf29839b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 08:33:33 -0700 Subject: [PATCH 062/311] CI: gate full NL/T on startup success; include ClaudeTests in resources --- .github/workflows/claude-nl-suite.yml | 55 ++++++++++++++++++-- UnityMcpBridge/UnityMcpServer~/src/server.py | 6 +++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index d4bb083d..260564aa 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -90,16 +90,46 @@ jobs: uv --version || true uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - - name: Run Claude NL/T test suite + - name: Run Claude startup test suite if: success() + id: claude_startup + uses: anthropics/claude-code-base-action@beta + with: + prompt_file: .claude/prompts/nl-startuptest.md + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,mcp__unity__*" + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": [ + "run", + "--active", + "--directory", + "UnityMcpBridge/UnityMcpServer~/src", + "python", + "server.py" + ], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + model: "claude-3-7-sonnet-20250219" + max_turns: "12" + timeout_minutes: "10" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Run Claude NL/T test suite + if: steps.claude_startup.outcome == 'success' id: claude uses: anthropics/claude-code-base-action@beta with: # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - # Restrict which tools the agent may attempt (approvals handled via settings) - allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" + # Restrict which tools the agent may attempt (prefer server tools over generic aggregators) + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,mcp__unity__*" # MCP server path (use active venv) mcp_config: | @@ -149,6 +179,25 @@ jobs: run: | python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml + - name: Ensure JUnit exists (fallback) + if: always() + run: | + set -eux + mkdir -p reports + if [ ! -f reports/claude-nl-tests.xml ]; then + cat > reports/claude-nl-tests.xml <<'XML' + + + + + Claude ran but the startup checks failed to detect usable MCP resources; NL/T flow did not execute. + + + + +XML + fi + - name: Upload JUnit (Claude NL/T) if: always() uses: actions/upload-artifact@v4 diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 1670023a..63d092d0 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -137,6 +137,12 @@ def _list_resources_impl() -> list[dict]: 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}) + # Also include CI fixtures under ClaudeTests/ + ct_root = (PROJECT_ROOT / "ClaudeTests").resolve() + if ct_root.exists(): + for p in ct_root.rglob("*.cs"): + rel = p.relative_to(PROJECT_ROOT).as_posix() + assets.append({"uri": f"unity://path/{rel}", "name": p.name}) except Exception as e: logger.debug(f"Assets enumeration failed: {e}") # Always include the scripted spec resource From bbca727b6138a33e75b2954e0057aa61663708ee Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 08:35:29 -0700 Subject: [PATCH 063/311] CI: fix YAML by using printf for fallback JUnit heredoc --- .github/workflows/claude-nl-suite.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 260564aa..a0be319e 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -185,17 +185,17 @@ jobs: set -eux mkdir -p reports if [ ! -f reports/claude-nl-tests.xml ]; then - cat > reports/claude-nl-tests.xml <<'XML' - - - - - Claude ran but the startup checks failed to detect usable MCP resources; NL/T flow did not execute. - - - - -XML + printf '%s\n' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + ' Claude ran but the startup checks failed to detect usable MCP resources; NL/T flow did not execute.' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > reports/claude-nl-tests.xml fi - name: Upload JUnit (Claude NL/T) From 117fbbcda8bf371d6b8d3c440fbbde5903506b5a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 09:18:23 -0700 Subject: [PATCH 064/311] CI: widen Unity tool auto-approval to mcp__unity__*; keep aggregator tools out --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index a0be319e..fb1941ba 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -163,7 +163,7 @@ jobs: "allow": [ "Read", "Write", "LS", "Glob", "Grep", "Bash(git:*)", - "mcp__unity" + "mcp__unity__*" ] } } From 67831c5e4a59f18496de8f9c06fa0e017a470133 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 10:05:48 -0700 Subject: [PATCH 065/311] CI: guard Anthropic key via env; startup and NL/T use ANTHROPIC_API_KEY only if present --- .github/workflows/claude-nl-suite.yml | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index fb1941ba..8d2b7f5e 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -5,8 +5,9 @@ on: permissions: contents: write # allow Claude to write test artifacts - pull-requests: write # allow annotations / comments + pull-requests: write # allow PR comments if needed issues: write + checks: write # REQUIRED for dorny/test-reporter to publish annotations concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -34,7 +35,6 @@ jobs: run: | set -eux if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then - uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" @@ -46,7 +46,6 @@ jobs: uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" else echo "No Python deps found (skipping)" - fi - name: Verify Python env @@ -115,6 +114,12 @@ jobs: } } } + # Auto-approve in CI (no interactive prompts) + settings: | + { + "permissionMode": "allow", + "autoApprove": ["Bash","Read","Write","Edit","MultiEdit","LS","Glob","Grep","mcp__unity__*"] + } model: "claude-3-7-sonnet-20250219" max_turns: "12" timeout_minutes: "10" @@ -157,15 +162,8 @@ jobs: # Auto-approve in CI (bypass prompts) settings: | { - "defaultMode": "bypassPermissions", - "permissionStorage": "none", - "permissions": { - "allow": [ - "Read", "Write", "LS", "Glob", "Grep", - "Bash(git:*)", - "mcp__unity__*" - ] - } + "permissionMode": "allow", + "autoApprove": ["Bash","Read","Write","Edit","MultiEdit","LS","Glob","Grep","mcp__unity__*"] } # Guardrails @@ -177,7 +175,12 @@ jobs: - name: Mark permission issues as skipped in JUnit if: always() run: | - python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml + set -eux + if [ -f .github/scripts/mark_skipped.py ]; then + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml || true + else + echo "mark_skipped.py not present; skipping" + fi - name: Ensure JUnit exists (fallback) if: always() @@ -222,12 +225,9 @@ jobs: 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) --- - name: Unity compile (Project) if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' From 5bf37bb469fa809bc3ddde16112e472e619270f2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 10:31:52 -0700 Subject: [PATCH 066/311] CI: refine claude suite (seed settings, clarify permissions, startup gating, diagnostics) --- .github/workflows/claude-nl-suite.yml | 99 +++++++++++++++++++-------- 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8d2b7f5e..9f7fc9d1 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -5,9 +5,8 @@ on: permissions: contents: write # allow Claude to write test artifacts - pull-requests: write # allow PR comments if needed + pull-requests: write # allow annotations / comments issues: write - checks: write # REQUIRED for dorny/test-reporter to publish annotations concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -73,9 +72,40 @@ jobs: - name: Ensure artifact dirs exist run: mkdir -p reports + # Seed Claude settings on-disk so CI never prompts for permissions. + # (We include both the legacy and current keys; the action will pick up what it understands.) + - name: Seed Claude settings (auto-approve MCP + filesystem tools) + run: | + set -eux + mkdir -p "$HOME/.claude" + cat > "$HOME/.claude/settings.json" <<'JSON' + { + "enableAllProjectMcpServers": true, + + /* Legacy keys used by some builds */ + "permissionMode": "allow", + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], + + /* Current keys used by newer builds */ + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "permissions": { + "allow": [ + "Read", "Write", "LS", "Glob", "Grep", + "Bash(git:*)", + "mcp__unity__*", + "ListMcpResourcesTool", + "ReadMcpResourceTool" + ] + } + } + JSON + echo "Seeded settings:" + cat "$HOME/.claude/settings.json" + - name: Log MCP server location (diagnostic) run: | - set -ux # do not -e; we want logs even if missing + set -ux SRV_DIR="UnityMcpBridge/UnityMcpServer~/src" echo "MCP server dir := ${SRV_DIR}" python - <<'PY' @@ -95,7 +125,7 @@ jobs: uses: anthropics/claude-code-base-action@beta with: prompt_file: .claude/prompts/nl-startuptest.md - allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,mcp__unity__*" + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" mcp_config: | { "mcpServers": { @@ -114,12 +144,28 @@ jobs: } } } - # Auto-approve in CI (no interactive prompts) + # Force auto-approval in the runner (both legacy and current schema) settings: | { "permissionMode": "allow", - "autoApprove": ["Bash","Read","Write","Edit","MultiEdit","LS","Glob","Grep","mcp__unity__*"] + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "permissions": { + "allow": [ + "Read", "Write", "LS", "Glob", "Grep", + "Bash(git:*)", + "mcp__unity__*", + "ListMcpResourcesTool", + "ReadMcpResourceTool" + ] + } } + # Nudge the agent to use the correct base dir for this repo (no Assets/) + append_system_prompt: | + IMPORTANT: This repository root does not contain an Assets/ folder. + When using mcp__unity__list_resources or read_resource, use under: "." or under: "ClaudeTests". + Do NOT rely on ListMcpResourcesTool; prefer the Unity-specific tools. model: "claude-3-7-sonnet-20250219" max_turns: "12" timeout_minutes: "10" @@ -130,13 +176,8 @@ jobs: id: claude uses: anthropics/claude-code-base-action@beta with: - # Test instructions live here prompt_file: .claude/prompts/nl-unity-suite.md - - # Restrict which tools the agent may attempt (prefer server tools over generic aggregators) - allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,mcp__unity__*" - - # MCP server path (use active venv) + allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" mcp_config: | { "mcpServers": { @@ -151,22 +192,29 @@ jobs: "server.py" ], "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" - } + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } } } } - - # Auto-approve in CI (bypass prompts) settings: | { "permissionMode": "allow", - "autoApprove": ["Bash","Read","Write","Edit","MultiEdit","LS","Glob","Grep","mcp__unity__*"] + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "permissions": { + "allow": [ + "Read", "Write", "LS", "Glob", "Grep", + "Bash(git:*)", + "mcp__unity__*", + "ListMcpResourcesTool", + "ReadMcpResourceTool" + ] + } } - - # Guardrails + append_system_prompt: | + IMPORTANT: The workspace is not a Unity project; there is no Assets/ directory. + Use mcp__unity__list_resources with under: "." (or "ClaudeTests") and pattern: "*" to discover files. model: "claude-3-7-sonnet-20250219" max_turns: "20" timeout_minutes: "20" @@ -175,12 +223,7 @@ jobs: - name: Mark permission issues as skipped in JUnit if: always() run: | - set -eux - if [ -f .github/scripts/mark_skipped.py ]; then - python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml || true - else - echo "mark_skipped.py not present; skipping" - fi + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml - name: Ensure JUnit exists (fallback) if: always() @@ -250,7 +293,7 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: packageMode: true - unityVersion: 2022.3.45f1 # set your exact version + unityVersion: 2022.3.45f1 projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} From 184df2362c88b0f230a1cb034466424cdedf758c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 10:54:15 -0700 Subject: [PATCH 067/311] CI: pass ANTHROPIC_API_KEY via env; refine permissions; keep startup gate --- .github/workflows/claude-nl-suite.yml | 212 ++++++++++++++++++-------- 1 file changed, 151 insertions(+), 61 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9f7fc9d1..b7736f34 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -7,6 +7,7 @@ permissions: contents: write # allow Claude to write test artifacts pull-requests: write # allow annotations / comments issues: write + checks: write # required for PR annotations concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -72,40 +73,9 @@ jobs: - name: Ensure artifact dirs exist run: mkdir -p reports - # Seed Claude settings on-disk so CI never prompts for permissions. - # (We include both the legacy and current keys; the action will pick up what it understands.) - - name: Seed Claude settings (auto-approve MCP + filesystem tools) - run: | - set -eux - mkdir -p "$HOME/.claude" - cat > "$HOME/.claude/settings.json" <<'JSON' - { - "enableAllProjectMcpServers": true, - - /* Legacy keys used by some builds */ - "permissionMode": "allow", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], - - /* Current keys used by newer builds */ - "defaultMode": "bypassPermissions", - "permissionStorage": "none", - "permissions": { - "allow": [ - "Read", "Write", "LS", "Glob", "Grep", - "Bash(git:*)", - "mcp__unity__*", - "ListMcpResourcesTool", - "ReadMcpResourceTool" - ] - } - } - JSON - echo "Seeded settings:" - cat "$HOME/.claude/settings.json" - - name: Log MCP server location (diagnostic) run: | - set -ux + set -ux # do not -e; we want logs even if missing SRV_DIR="UnityMcpBridge/UnityMcpServer~/src" echo "MCP server dir := ${SRV_DIR}" python - <<'PY' @@ -119,13 +89,37 @@ jobs: uv --version || true uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + # ---------- Claude: startup sanity (ensures Unity MCP server connects) ---------- - name: Run Claude startup test suite - if: success() id: claude_startup uses: anthropics/claude-code-base-action@beta + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} with: prompt_file: .claude/prompts/nl-startuptest.md - allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" + + # Be explicit: list every Unity MCP tool and the resource helpers + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep, + ListMcpResourcesTool,ReadMcpResourceTool, + mcp__unity__script_apply_edits, + mcp__unity__apply_text_edits, + mcp__unity__create_script, + mcp__unity__delete_script, + mcp__unity__validate_script, + mcp__unity__manage_script, + mcp__unity__manage_scene, + mcp__unity__manage_editor, + mcp__unity__manage_gameobject, + mcp__unity__manage_asset, + mcp__unity__manage_shader, + mcp__unity__read_console, + mcp__unity__execute_menu_item, + mcp__unity__list_resources, + mcp__unity__read_resource, + mcp__unity__find_in_file + + # Use the active venv and stdio transport mcp_config: | { "mcpServers": { @@ -140,44 +134,106 @@ jobs: "server.py" ], "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } } } } - # Force auto-approval in the runner (both legacy and current schema) + + # IMPORTANT: use both schemas; explicit per-tool approvals (no wildcards) settings: | { "permissionMode": "allow", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], + "autoApprove": [ + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits", + "mcp__unity__apply_text_edits", + "mcp__unity__create_script", + "mcp__unity__delete_script", + "mcp__unity__validate_script", + "mcp__unity__manage_script", + "mcp__unity__manage_scene", + "mcp__unity__manage_editor", + "mcp__unity__manage_gameobject", + "mcp__unity__manage_asset", + "mcp__unity__manage_shader", + "mcp__unity__read_console", + "mcp__unity__execute_menu_item", + "mcp__unity__list_resources", + "mcp__unity__read_resource", + "mcp__unity__find_in_file" + ], "defaultMode": "bypassPermissions", "permissionStorage": "none", "permissions": { "allow": [ - "Read", "Write", "LS", "Glob", "Grep", - "Bash(git:*)", - "mcp__unity__*", - "ListMcpResourcesTool", - "ReadMcpResourceTool" + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits", + "mcp__unity__apply_text_edits", + "mcp__unity__create_script", + "mcp__unity__delete_script", + "mcp__unity__validate_script", + "mcp__unity__manage_script", + "mcp__unity__manage_scene", + "mcp__unity__manage_editor", + "mcp__unity__manage_gameobject", + "mcp__unity__manage_asset", + "mcp__unity__manage_shader", + "mcp__unity__read_console", + "mcp__unity__execute_menu_item", + "mcp__unity__list_resources", + "mcp__unity__read_resource", + "mcp__unity__find_in_file" ] } } - # Nudge the agent to use the correct base dir for this repo (no Assets/) - append_system_prompt: | - IMPORTANT: This repository root does not contain an Assets/ folder. - When using mcp__unity__list_resources or read_resource, use under: "." or under: "ClaudeTests". - Do NOT rely on ListMcpResourcesTool; prefer the Unity-specific tools. + model: "claude-3-7-sonnet-20250219" max_turns: "12" timeout_minutes: "10" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Optional: dump the settings Claude actually wrote (helps verify permissionMode) + - name: Show effective Claude settings (diagnostic) + if: always() + run: | + set -ux + cat /home/runner/.claude/settings.json || true + + # ---------- Claude: full NL/T suite ---------- - name: Run Claude NL/T test suite if: steps.claude_startup.outcome == 'success' id: claude uses: anthropics/claude-code-base-action@beta + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} with: prompt_file: .claude/prompts/nl-unity-suite.md - allowed_tools: "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__*" + + # Same explicit tool list; no wildcards + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep, + ListMcpResourcesTool,ReadMcpResourceTool, + mcp__unity__script_apply_edits, + mcp__unity__apply_text_edits, + mcp__unity__create_script, + mcp__unity__delete_script, + mcp__unity__validate_script, + mcp__unity__manage_script, + mcp__unity__manage_scene, + mcp__unity__manage_editor, + mcp__unity__manage_gameobject, + mcp__unity__manage_asset, + mcp__unity__manage_shader, + mcp__unity__read_console, + mcp__unity__execute_menu_item, + mcp__unity__list_resources, + mcp__unity__read_resource, + mcp__unity__find_in_file + mcp_config: | { "mcpServers": { @@ -192,33 +248,67 @@ jobs: "server.py" ], "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } } } } + + # Same permission model here too settings: | { "permissionMode": "allow", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*","ListMcpResourcesTool","ReadMcpResourceTool","Read","LS","Glob","Grep"], + "autoApprove": [ + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits", + "mcp__unity__apply_text_edits", + "mcp__unity__create_script", + "mcp__unity__delete_script", + "mcp__unity__validate_script", + "mcp__unity__manage_script", + "mcp__unity__manage_scene", + "mcp__unity__manage_editor", + "mcp__unity__manage_gameobject", + "mcp__unity__manage_asset", + "mcp__unity__manage_shader", + "mcp__unity__read_console", + "mcp__unity__execute_menu_item", + "mcp__unity__list_resources", + "mcp__unity__read_resource", + "mcp__unity__find_in_file" + ], "defaultMode": "bypassPermissions", "permissionStorage": "none", "permissions": { "allow": [ - "Read", "Write", "LS", "Glob", "Grep", - "Bash(git:*)", - "mcp__unity__*", - "ListMcpResourcesTool", - "ReadMcpResourceTool" + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits", + "mcp__unity__apply_text_edits", + "mcp__unity__create_script", + "mcp__unity__delete_script", + "mcp__unity__validate_script", + "mcp__unity__manage_script", + "mcp__unity__manage_scene", + "mcp__unity__manage_editor", + "mcp__unity__manage_gameobject", + "mcp__unity__manage_asset", + "mcp__unity__manage_shader", + "mcp__unity__read_console", + "mcp__unity__execute_menu_item", + "mcp__unity__list_resources", + "mcp__unity__read_resource", + "mcp__unity__find_in_file" ] } } - append_system_prompt: | - IMPORTANT: The workspace is not a Unity project; there is no Assets/ directory. - Use mcp__unity__list_resources with under: "." (or "ClaudeTests") and pattern: "*" to discover files. + model: "claude-3-7-sonnet-20250219" max_turns: "20" timeout_minutes: "20" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Mark permission issues as skipped in JUnit if: always() @@ -293,7 +383,7 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: packageMode: true - unityVersion: 2022.3.45f1 + unityVersion: 2022.3.45f1 # set your exact version projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} From e1203015887fff194f2d7b1832fdd1346b11bbf2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 11:01:33 -0700 Subject: [PATCH 068/311] CI: detect Anthropic key and gate Claude steps; skip on forks --- .github/workflows/claude-nl-suite.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b7736f34..e84c1938 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -7,7 +7,6 @@ permissions: contents: write # allow Claude to write test artifacts pull-requests: write # allow annotations / comments issues: write - checks: write # required for PR annotations concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -89,12 +88,17 @@ jobs: uv --version || true uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- + - name: Detect Anthropic key + id: detect_key + run: | + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "has_key=true" >> "$GITHUB_OUTPUT"; else echo "has_key=false" >> "$GITHUB_OUTPUT"; fi + # ---------- Claude: startup sanity (ensures Unity MCP server connects) ---------- - name: Run Claude startup test suite id: claude_startup uses: anthropics/claude-code-base-action@beta - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + if: steps.detect_key.outputs.has_key == 'true' with: prompt_file: .claude/prompts/nl-startuptest.md @@ -195,6 +199,7 @@ jobs: model: "claude-3-7-sonnet-20250219" max_turns: "12" timeout_minutes: "10" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Optional: dump the settings Claude actually wrote (helps verify permissionMode) - name: Show effective Claude settings (diagnostic) @@ -205,11 +210,9 @@ jobs: # ---------- Claude: full NL/T suite ---------- - name: Run Claude NL/T test suite - if: steps.claude_startup.outcome == 'success' + if: steps.detect_key.outputs.has_key == 'true' && steps.claude_startup.outcome == 'success' id: claude uses: anthropics/claude-code-base-action@beta - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} with: prompt_file: .claude/prompts/nl-unity-suite.md @@ -309,6 +312,7 @@ jobs: model: "claude-3-7-sonnet-20250219" max_turns: "20" timeout_minutes: "20" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Mark permission issues as skipped in JUnit if: always() From a0fc515d7f9bd328d16a3c67481115aee5f18d3a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 14:54:12 -0700 Subject: [PATCH 069/311] CI: refine startup tool list comments; explicit approvals; tidy unityVersion comment --- .github/workflows/claude-nl-suite.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e84c1938..b087b543 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -102,7 +102,7 @@ jobs: with: prompt_file: .claude/prompts/nl-startuptest.md - # Be explicit: list every Unity MCP tool and the resource helpers + # Be explicit: list every Unity MCP tool and the resource helpers (no wildcards) allowed_tools: > Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, @@ -146,7 +146,7 @@ jobs: } } - # IMPORTANT: use both schemas; explicit per-tool approvals (no wildcards) + # IMPORTANT: explicit per-tool approvals (still no wildcards) settings: | { "permissionMode": "allow", @@ -387,7 +387,7 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: packageMode: true - unityVersion: 2022.3.45f1 # set your exact version + unityVersion: 2022.3.45f1 projectPath: . githubToken: ${{ secrets.GITHUB_TOKEN }} From ea39fa808cb4476e88f799dd2268356808376879 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 14:57:59 -0700 Subject: [PATCH 070/311] Prompts/scripts: update startup and NL suite prompts; add skipped marker tweaks --- .claude/prompts/nl-startuptest.md | 35 ++++---- .claude/prompts/nl-unity-suite.md | 142 +++++++----------------------- .github/scripts/mark_skipped.py | 123 +++++++++++++++++++------- 3 files changed, 146 insertions(+), 154 deletions(-) diff --git a/.claude/prompts/nl-startuptest.md b/.claude/prompts/nl-startuptest.md index 7b9399b1..f102bcbf 100644 --- a/.claude/prompts/nl-startuptest.md +++ b/.claude/prompts/nl-startuptest.md @@ -1,19 +1,22 @@ -# CLAUDE TASK: Unity MCP startup checks +# Goal +Fast preflight to confirm the Unity MCP server is reachable and usable in CI. -You are running in CI at the repository root. Use only allowed tools. +# What to do +1) Use **ListMcpResourcesTool** first to probe the Unity MCP server for any resources. + - If it returns `[]`, try Unity’s direct tools **mcp__unity__list_resources** with just `under` and `pattern`. + - **Do not** pass `ctx: ""`. If a `ctx` object is required, pass `{}` (an empty JSON object) or omit it entirely. -- Verify that the MCP server `unity` is connected. -- List tools and assert presence of these IDs: - - mcp__unity__script_apply_edits - - mcp__unity__manage_script - - mcp__unity__list_resources - - mcp__unity__read_resource -- Try native resources: call ListMcpResourcesTool. If it returns [], fall back to mcp__unity__list_resources. -- Read one resource: - - Prefer `unity://spec/script-edits` via native read; otherwise use mcp__unity__read_resource. -- Perform one minimal structured edit preview (no write): - - Use mcp__unity__script_apply_edits with `options.preview=true` to insert a comment above `Update` in `ClaudeTests/longUnityScript-claudeTest.cs`, then stop. +2) Locate a test C# file under `ClaudeTests/` (e.g., `ClaudeTests/longUnityScript-claudeTest.cs`) using Bash/Glob and **Read** a small window of lines to confirm anchors like `Update()` exist. -Output: -- Write a short JUnit at `reports/claude-nl-tests.xml` with a single `` containing 2–3 `` entries (tools present, resources readable, preview edit ok). On failure, include a `` element with concise reason. -- Also write a brief markdown summary at `reports/claude-nl-tests.md`. +3) Do **not** make destructive edits here. This step is only a smoke test to ensure we can list/read resources successfully before the full NL/T suite. + +# Guardrails +- No wildcards in tool names were enabled; you must work with the explicit tools allowed by the workflow. +- Prefer aggregator tools (ListMcpResourcesTool / ReadMcpResourceTool) first; drop down to `mcp__unity__*` tools only when necessary and with correct argument shapes. +- Keep logs short and actionable. + +# Output +- Print a brief bullet summary to stdout that includes: + - Whether resources were detected. + - The path of the target file you’ll use later. + - Any issues to watch for (e.g., permission prompts). diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md index 752e2180..51be0a08 100644 --- a/.claude/prompts/nl-unity-suite.md +++ b/.claude/prompts/nl-unity-suite.md @@ -1,108 +1,34 @@ -# 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: `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. - -### Preflight (must run before NL/T tests) -- List available MCP tools from server `unity`. -- Assert that at least these tools are present: `script_apply_edits` (structured), `manage_script` (with `apply_text_edits` action available via arguments), and resource wrappers. -- If missing, fail fast with a concise message so logs clearly show the server didn’t advertise edit endpoints. - -## 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 `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) -- 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 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 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 -- 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. 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. -- **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/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. -- **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. +# Unity NL Editing Suite — Natural Mode + +You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate end‑to‑end **natural‑language code editing** on a representative Unity C# script using whatever capabilities and servers are already available in this session. Work autonomously. Do not ask the user for input. + +## Mission +1) **Discover capabilities.** Quietly inspect the tools and any connected servers that are available to you at session start. If the server offers a primer or capabilities resource, read it before acting. +2) **Choose a target file.** Prefer `ClaudeTests/longUnityScript-claudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `ClaudeTests/` or `Assets/`. +3) **Perform a small set of realistic edits** using minimal, precise changes (not full-file rewrites). Examples of small edits you may choose from (pick 3–6 total): + - Insert a new, small helper method (e.g., a logger or counter) in a sensible location. + - Add a short anchor comment near a key method (e.g., above `Update()`), then add or modify a few lines nearby. + - Append an end‑of‑class utility method (e.g., formatting or clamping helper). + - Make a safe, localized tweak to an existing method body (e.g., add a guard or a simple accumulator). + - Optionally include one idempotency/no‑op check (re‑apply an edit and confirm nothing breaks). +4) **Validate your edits.** Re‑read the modified regions and verify the changes exist, compile‑risk is low, and surrounding structure remains intact. +5) **Report results.** Produce both: + - A JUnit XML at `reports/claude-nl-tests.xml` containing a single suite named `UnityMCP.NL` with one testcase per sub‑test you executed (mark pass/fail and include helpful failure text). + - A summary markdown at `reports/claude-nl-summary.md` that explains what you attempted, what succeeded/failed, and any follow‑ups you would try. +6) **Be gentle and reversible.** Prefer targeted, minimal edits; avoid wide refactors or non‑deterministic changes. + +## Assumptions & Hints (non‑prescriptive) +- A Unity‑oriented MCP server is expected to be connected. If a server‑provided **primer/capabilities** resource exists, read it first. If no primer is available, infer capabilities from your visible tools in the session. +- If the preferred file isn’t present, locate a fallback C# file with simple, local methods you can edit safely. +- If a compile command is available in this environment, you may optionally trigger it; if not, rely on structural checks and localized validation. + +## Output Requirements +- `reports/claude-nl-tests.xml` — JUnit XML, suite `UnityMCP.NL`, each sub‑test a separate `` with `` text when relevant. +- `reports/claude-nl-summary.md` — a short, human‑readable summary (attempts, decisions, outcomes, next steps). + +## Guardrails +- No destructive operations. Keep changes minimal and well‑scoped. +- Don’t leak secrets or environment details beyond what’s needed in the reports. +- Work without user interaction; do not prompt for approval mid‑flow. + +> If capabilities discovery fails, still produce the two reports that clearly explain why you could not proceed and what evidence you gathered. diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index 1eeb656f..dc06a020 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -1,42 +1,105 @@ #!/usr/bin/env python3 -"""Convert permission-related failures in JUnit reports to skipped tests. +""" +Post-processes a JUnit XML so that "expected"/environmental failures +(e.g., permission prompts, empty MCP resources, or schema hiccups) +are converted to . Leaves real failures intact. -The Claude NL/T suite can emit failures with messages like "Test skipped..." when -MCP tool approval blocks execution. GitHub dashboards should treat these as -skipped, not failed. This script rewrites the JUnit XML in-place, replacing any - element containing "approval required" or "MCP not usable" with -. +Usage: + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml """ + +from __future__ import annotations import sys -import pathlib +import os +import re import xml.etree.ElementTree as ET -def main(path: str) -> None: - p = pathlib.Path(path) - if not p.exists(): - return +PATTERNS = [ + r"\bpermission\b", + r"\bpermissions\b", + r"\bautoApprove\b", + r"\bapproval\b", + r"\bdenied\b", + r"requested\s+permissions", + r"^MCP resources list is empty$", + r"No MCP resources detected", + r"aggregator.*returned\s*\[\s*\]", + r"Unknown resource:\s*unity://", + r"Input should be a valid dictionary.*ctx", + r"validation error .* ctx", +] + +def should_skip(msg: str) -> bool: + if not msg: + return False + msg_l = msg.strip() + for pat in PATTERNS: + if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE): + return True + return False + +def summarize_counts(ts): + tests = 0 + failures = 0 + errors = 0 + skipped = 0 + for case in ts.findall("testcase"): + tests += 1 + if case.find("failure") is not None: + failures += 1 + if case.find("error") is not None: + errors += 1 + if case.find("skipped") is not None: + skipped += 1 + return tests, failures, errors, skipped + +def main(path: str) -> int: + if not os.path.exists(path): + print(f"[mark_skipped] No JUnit at {path}; nothing to do.") + return 0 + try: - tree = ET.parse(p) - except (ET.ParseError, FileNotFoundError) as e: - print(f"[mark_skipped] Failed to parse XML at {p}: {e}", file=sys.stderr) - return + tree = ET.parse(path) + except ET.ParseError as e: + print(f"[mark_skipped] Could not parse {path}: {e}") + return 0 + root = tree.getroot() + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + changed = False - for case in root.iter("testcase"): - failure = case.find("failure") - if failure is None: - continue - msg = (failure.get("message") or "") + (failure.text or "") - if "approval required" in msg.lower() or "mcp not usable" in msg.lower(): - case.remove(failure) - skipped = ET.Element("skipped") - if failure.get("message"): - skipped.set("message", failure.get("message")) - case.append(skipped) - changed = True + for ts in suites: + for case in list(ts.findall("testcase")): + for node_name in ("failure", "error"): + node = case.find(node_name) + if node is None: + continue + msg = (node.get("message") or "") + "\n" + (node.text or "") + if should_skip(msg): + # Replace with + reason = "Marked skipped: environment/permission precondition not met" + case.remove(node) + skip = ET.SubElement(case, "skipped") + skip.set("message", reason) + skip.text = (node.text or "").strip() or reason + changed = True + break # only one conversion per case + + # Recompute tallies per testsuite + tests, failures, errors, skipped = summarize_counts(ts) + ts.set("tests", str(tests)) + ts.set("failures", str(failures)) + ts.set("errors", str(errors)) + ts.set("skipped", str(skipped)) + if changed: - tree.write(p, encoding="utf-8", xml_declaration=True) + tree.write(path, encoding="utf-8", xml_declaration=True) + print(f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") + else: + print(f"[mark_skipped] No environmental failures detected in {path}.") + + return 0 if __name__ == "__main__": - if len(sys.argv) > 1: - main(sys.argv[1]) + target = sys.argv[1] if len(sys.argv) > 1 else "reports/claude-nl-tests.xml" + raise SystemExit(main(target)) From 27b4a7e935987a5be3461be07c810332017e8f2b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 15:21:16 -0700 Subject: [PATCH 071/311] CI: add Unity MCP smoke workflow (single tool call to verify server) --- .github/workflows/unity-mcp-smoke.yml | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/unity-mcp-smoke.yml diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml new file mode 100644 index 00000000..50f4c802 --- /dev/null +++ b/.github/workflows/unity-mcp-smoke.yml @@ -0,0 +1,91 @@ +name: Unity MCP — Smoke + +on: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Minimal Python + uv so the server can run in its own project env + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + # Optional but cheap: set up a venv and install the server in editable mode + - name: Install UnityMcpServer (editable) + run: | + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + fi + + - name: Make prompt file (smoke) + run: | + mkdir -p .claude/prompts + cat > .claude/prompts/mcp-smoke.md <<'MD' + You are running a one-shot smoke check. Do exactly one Unity MCP tool call and nothing else. + + 1) Call the tool named: mcp__unity__list_resources + 2) Use this exact JSON input: + { + "ctx": {}, // IMPORTANT: ctx must be a dictionary (not a string) + "under": "ClaudeTests", // keep it local/fast + "pattern": "*.cs" + } + 3) Print the raw tool result to the console. Do not transform it. + 4) If the call raises a validation error, print the exception type and message exactly. + 5) Stop. + MD + + - name: Run smoke (single tool call) + uses: anthropics/claude-code-base-action@beta + with: + prompt_file: .claude/prompts/mcp-smoke.md + # Only allow the one Unity tool needed for the smoke (no wildcards) + allowed_tools: mcp__unity__list_resources + # Start the Unity MCP server via stdio using uv + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": [ + "run", + "--active", + "--directory", + "UnityMcpBridge/UnityMcpServer~/src", + "python", + "server.py" + ], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } + } + } + } + # Remove permission friction; the agent only has one tool anyway + settings: | + { + "permissionMode": "allow", + "autoApprove": ["mcp__unity__list_resources"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none" + } + model: claude-3-7-sonnet-20250219 + max_turns: 3 + timeout_minutes: 3 + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From ea864806c55cff5e94c79b251e9c73b3abf25c23 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 15:21:16 -0700 Subject: [PATCH 072/311] CI: add Unity MCP smoke workflow (single tool call to verify server) --- .github/workflows/unity-mcp-smoke.yml | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/unity-mcp-smoke.yml diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml new file mode 100644 index 00000000..50f4c802 --- /dev/null +++ b/.github/workflows/unity-mcp-smoke.yml @@ -0,0 +1,91 @@ +name: Unity MCP — Smoke + +on: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Minimal Python + uv so the server can run in its own project env + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + # Optional but cheap: set up a venv and install the server in editable mode + - name: Install UnityMcpServer (editable) + run: | + set -eux + if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + fi + + - name: Make prompt file (smoke) + run: | + mkdir -p .claude/prompts + cat > .claude/prompts/mcp-smoke.md <<'MD' + You are running a one-shot smoke check. Do exactly one Unity MCP tool call and nothing else. + + 1) Call the tool named: mcp__unity__list_resources + 2) Use this exact JSON input: + { + "ctx": {}, // IMPORTANT: ctx must be a dictionary (not a string) + "under": "ClaudeTests", // keep it local/fast + "pattern": "*.cs" + } + 3) Print the raw tool result to the console. Do not transform it. + 4) If the call raises a validation error, print the exception type and message exactly. + 5) Stop. + MD + + - name: Run smoke (single tool call) + uses: anthropics/claude-code-base-action@beta + with: + prompt_file: .claude/prompts/mcp-smoke.md + # Only allow the one Unity tool needed for the smoke (no wildcards) + allowed_tools: mcp__unity__list_resources + # Start the Unity MCP server via stdio using uv + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": [ + "run", + "--active", + "--directory", + "UnityMcpBridge/UnityMcpServer~/src", + "python", + "server.py" + ], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug" + } + } + } + } + # Remove permission friction; the agent only has one tool anyway + settings: | + { + "permissionMode": "allow", + "autoApprove": ["mcp__unity__list_resources"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none" + } + model: claude-3-7-sonnet-20250219 + max_turns: 3 + timeout_minutes: 3 + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 6731bcb50cf30aab002a6bb3b8c88bae784a6750 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 16:19:36 -0700 Subject: [PATCH 073/311] CI: add QuickProbe workflow and prompt --- .claude/prompts/mcp-quickprobe.md | 23 ++++++++ .github/workflows/unity-mcp-smoke.yml | 82 +++++++++++++++++---------- 2 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 .claude/prompts/mcp-quickprobe.md diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md new file mode 100644 index 00000000..2306fda7 --- /dev/null +++ b/.claude/prompts/mcp-quickprobe.md @@ -0,0 +1,23 @@ +# Unity MCP QuickProbe (no planning, 2 calls max) + +You must perform **exactly two** tool calls against the MCP server named "unity". +Do not use any tools except the Unity MCP tools listed below. +Output only the raw tool results (no prose before/after). + +**Call 1 — Read spec** +- Tool: `mcp__unity__read_resource` +- Goal: Read the server’s script-edit spec resource at `unity://spec/script-edits`. + +**Call 2 — File discovery** +- Prefer to list Unity-exposed resources under the repo’s test area OR search for a known class. +- EITHER: + - Tool: `mcp__unity__list_resources` with a pattern that finds C# files beneath `ClaudeTests`. + - OR, if listing isn’t applicable per tool schema, use: + - Tool: `mcp__unity__find_in_file` to search `ClaudeTests/longUnityScript-claudeTest.cs` + for the string `class LongUnityScriptClaudeTest`. + +**Rules** +- Do not use: Bash, Read, Write, ListMcpResourcesTool, ReadMcpResourceTool. +- Let the tool schemas from the MCP handshake dictate exact argument names/types. +- If Call 1 fails, still attempt Call 2 and return its raw result. +- Print each result exactly as returned by the tool (JSON or text). diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index 50f4c802..e1d724f1 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -1,4 +1,4 @@ -name: Unity MCP — Smoke +name: Unity MCP — QuickProbe on: workflow_dispatch: {} @@ -7,6 +7,51 @@ permissions: contents: read jobs: + quickprobe: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Run QuickProbe + uses: anthropics/claude-code-base-action@beta + with: + prompt_file: .claude/prompts/mcp-quickprobe.md + allowed_tools: mcp__unity__read_resource,mcp__unity__list_resources,mcp__unity__find_in_file + max_turns: "2" + timeout_minutes: "5" + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + settings: | + { + "permissionMode": "allow", + "autoApprove": ["mcp__unity__*"] + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Upload QuickProbe log + if: always() + uses: actions/upload-artifact@v4 + with: + name: quickprobe-claude-execution + path: /home/runner/work/_temp/claude-execution-output.json + if-no-files-found: warn + smoke: runs-on: ubuntu-latest timeout-minutes: 5 @@ -14,13 +59,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Minimal Python + uv so the server can run in its own project env - name: Install Python + uv uses: astral-sh/setup-uv@v4 with: python-version: '3.11' - # Optional but cheap: set up a venv and install the server in editable mode - name: Install UnityMcpServer (editable) run: | set -eux @@ -36,14 +79,9 @@ jobs: mkdir -p .claude/prompts cat > .claude/prompts/mcp-smoke.md <<'MD' You are running a one-shot smoke check. Do exactly one Unity MCP tool call and nothing else. - 1) Call the tool named: mcp__unity__list_resources 2) Use this exact JSON input: - { - "ctx": {}, // IMPORTANT: ctx must be a dictionary (not a string) - "under": "ClaudeTests", // keep it local/fast - "pattern": "*.cs" - } + { "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } 3) Print the raw tool result to the console. Do not transform it. 4) If the call raises a validation error, print the exception type and message exactly. 5) Stop. @@ -53,39 +91,21 @@ jobs: uses: anthropics/claude-code-base-action@beta with: prompt_file: .claude/prompts/mcp-smoke.md - # Only allow the one Unity tool needed for the smoke (no wildcards) allowed_tools: mcp__unity__list_resources - # Start the Unity MCP server via stdio using uv mcp_config: | { "mcpServers": { "unity": { "command": "uv", - "args": [ - "run", - "--active", - "--directory", - "UnityMcpBridge/UnityMcpServer~/src", - "python", - "server.py" - ], + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" - } + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } } } } - # Remove permission friction; the agent only has one tool anyway settings: | - { - "permissionMode": "allow", - "autoApprove": ["mcp__unity__list_resources"], - "defaultMode": "bypassPermissions", - "permissionStorage": "none" - } + { "permissionMode": "allow", "autoApprove": ["mcp__unity__list_resources"], "defaultMode": "bypassPermissions", "permissionStorage": "none" } model: claude-3-7-sonnet-20250219 max_turns: 3 timeout_minutes: 3 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} \ No newline at end of file From adeab4166b71da99eb78cc5d296ad0eeccb431f5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 16:34:00 -0700 Subject: [PATCH 074/311] CI: update QuickProbe prompt and workflow wiring --- .claude/prompts/mcp-quickprobe.md | 32 +++++-------- .github/workflows/unity-mcp-smoke.yml | 69 ++++----------------------- 2 files changed, 20 insertions(+), 81 deletions(-) diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md index 2306fda7..4a0e4f4e 100644 --- a/.claude/prompts/mcp-quickprobe.md +++ b/.claude/prompts/mcp-quickprobe.md @@ -1,23 +1,15 @@ -# Unity MCP QuickProbe (no planning, 2 calls max) +You are running a two-step Unity MCP wiring check. Do exactly the two tool calls below, print their raw results, and stop. -You must perform **exactly two** tool calls against the MCP server named "unity". -Do not use any tools except the Unity MCP tools listed below. -Output only the raw tool results (no prose before/after). +1) Call mcp__unity__find_in_file with this JSON (exactly): + { + "project_relative_file": "ClaudeTests/longUnityScript-claudeTest.cs", + "pattern": "class\\s+LongUnityScriptClaudeTest" + } -**Call 1 — Read spec** -- Tool: `mcp__unity__read_resource` -- Goal: Read the server’s script-edit spec resource at `unity://spec/script-edits`. +2) Call mcp__unity__list_resources with this JSON (exactly): + { "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } -**Call 2 — File discovery** -- Prefer to list Unity-exposed resources under the repo’s test area OR search for a known class. -- EITHER: - - Tool: `mcp__unity__list_resources` with a pattern that finds C# files beneath `ClaudeTests`. - - OR, if listing isn’t applicable per tool schema, use: - - Tool: `mcp__unity__find_in_file` to search `ClaudeTests/longUnityScript-claudeTest.cs` - for the string `class LongUnityScriptClaudeTest`. - -**Rules** -- Do not use: Bash, Read, Write, ListMcpResourcesTool, ReadMcpResourceTool. -- Let the tool schemas from the MCP handshake dictate exact argument names/types. -- If Call 1 fails, still attempt Call 2 and return its raw result. -- Print each result exactly as returned by the tool (JSON or text). +Rules: +- Print the raw tool results verbatim to the console (no reformatting). +- If a call throws a validation or runtime error, print the exception type and message exactly. +- Do not run any other tools or commands. Stop after step 2. diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index e1d724f1..af4873c3 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -23,7 +23,7 @@ jobs: uses: anthropics/claude-code-base-action@beta with: prompt_file: .claude/prompts/mcp-quickprobe.md - allowed_tools: mcp__unity__read_resource,mcp__unity__list_resources,mcp__unity__find_in_file + allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource max_turns: "2" timeout_minutes: "5" mcp_config: | @@ -31,9 +31,14 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "args": ["run","--directory",".","python","UnityMcpBridge/UnityMcpServer~/src/server.py"], "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "PYTHONPATH": "UnityMcpBridge/UnityMcpServer~/src", + "UNITY_MCP_PROJECT_ROOT": "." + } } } } @@ -51,61 +56,3 @@ jobs: name: quickprobe-claude-execution path: /home/runner/work/_temp/claude-execution-output.json if-no-files-found: warn - - smoke: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Install UnityMcpServer (editable) - run: | - set -eux - if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" - fi - - - name: Make prompt file (smoke) - run: | - mkdir -p .claude/prompts - cat > .claude/prompts/mcp-smoke.md <<'MD' - You are running a one-shot smoke check. Do exactly one Unity MCP tool call and nothing else. - 1) Call the tool named: mcp__unity__list_resources - 2) Use this exact JSON input: - { "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } - 3) Print the raw tool result to the console. Do not transform it. - 4) If the call raises a validation error, print the exception type and message exactly. - 5) Stop. - MD - - - name: Run smoke (single tool call) - uses: anthropics/claude-code-base-action@beta - with: - prompt_file: .claude/prompts/mcp-smoke.md - allowed_tools: mcp__unity__list_resources - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } - } - } - settings: | - { "permissionMode": "allow", "autoApprove": ["mcp__unity__list_resources"], "defaultMode": "bypassPermissions", "permissionStorage": "none" } - model: claude-3-7-sonnet-20250219 - max_turns: 3 - timeout_minutes: 3 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} \ No newline at end of file From 260e2367761f1c6946a9ee7f64c8effafd33cb20 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 16:44:05 -0700 Subject: [PATCH 075/311] CI: minor updates to QuickProbe prompt and smoke workflow --- .claude/prompts/mcp-quickprobe.md | 15 ++++++--------- .github/workflows/unity-mcp-smoke.yml | 13 +++++++------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md index 4a0e4f4e..feea9cad 100644 --- a/.claude/prompts/mcp-quickprobe.md +++ b/.claude/prompts/mcp-quickprobe.md @@ -1,15 +1,12 @@ -You are running a two-step Unity MCP wiring check. Do exactly the two tool calls below, print their raw results, and stop. +You are running a two-step Unity MCP connectivity probe. Do exactly the two tool calls below, print their raw results, and stop. -1) Call mcp__unity__find_in_file with this JSON (exactly): - { - "project_relative_file": "ClaudeTests/longUnityScript-claudeTest.cs", - "pattern": "class\\s+LongUnityScriptClaudeTest" - } +1) Call mcp__unity__list_resources with this JSON (exactly): + { "ctx": {}, "under": "", "pattern": "*" } -2) Call mcp__unity__list_resources with this JSON (exactly): - { "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } +2) Call mcp__unity__read_resource with this JSON (exactly): + { "uri": "unity://spec/script-edits" } Rules: -- Print the raw tool results verbatim to the console (no reformatting). +- Print raw tool results verbatim to the console (no reformatting). - If a call throws a validation or runtime error, print the exception type and message exactly. - Do not run any other tools or commands. Stop after step 2. diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index af4873c3..7a2113fa 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -19,11 +19,13 @@ jobs: with: python-version: '3.11' - - name: Run QuickProbe + # Keep the launch path pointed at the server project dir. + # This is the key to consistent tool registration. + - name: Run QuickProbe (two MCP calls) uses: anthropics/claude-code-base-action@beta with: prompt_file: .claude/prompts/mcp-quickprobe.md - allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource + allowed_tools: mcp__unity__list_resources,mcp__unity__read_resource max_turns: "2" timeout_minutes: "5" mcp_config: | @@ -31,13 +33,11 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--directory",".","python","UnityMcpBridge/UnityMcpServer~/src/server.py"], + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "PYTHONPATH": "UnityMcpBridge/UnityMcpServer~/src", - "UNITY_MCP_PROJECT_ROOT": "." + "MCP_LOG_LEVEL": "debug" } } } @@ -47,6 +47,7 @@ jobs: "permissionMode": "allow", "autoApprove": ["mcp__unity__*"] } + model: claude-3-7-sonnet-20250219 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Upload QuickProbe log From e562721a6be7ad50e222a0db4e51b69edcee455f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 16:55:15 -0700 Subject: [PATCH 076/311] CI: finalize smoke workflow --- .github/workflows/unity-mcp-smoke.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index 7a2113fa..73e08720 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -19,25 +19,28 @@ jobs: with: python-version: '3.11' - # Keep the launch path pointed at the server project dir. - # This is the key to consistent tool registration. - - name: Run QuickProbe (two MCP calls) + - name: Run QuickProbe uses: anthropics/claude-code-base-action@beta with: + model: claude-3-7-sonnet-20250219 prompt_file: .claude/prompts/mcp-quickprobe.md - allowed_tools: mcp__unity__list_resources,mcp__unity__read_resource - max_turns: "2" - timeout_minutes: "5" + # Only the two tools we require: + allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources + # Give room for 2 calls + responses: + max_turns: 4 + timeout_minutes: 5 mcp_config: | { "mcpServers": { "unity": { "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "args": ["run","--directory",".","python","UnityMcpBridge/UnityMcpServer~/src/server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" + "MCP_LOG_LEVEL": "debug", + "PYTHONPATH": "UnityMcpBridge/UnityMcpServer~/src", + "UNITY_MCP_PROJECT_ROOT": "." } } } @@ -45,9 +48,10 @@ jobs: settings: | { "permissionMode": "allow", - "autoApprove": ["mcp__unity__*"] + "autoApprove": ["mcp__unity__find_in_file","mcp__unity__list_resources"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none" } - model: claude-3-7-sonnet-20250219 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Upload QuickProbe log From ec19dcdd493d2d104caab6ea76fdf835335738f7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 17:05:37 -0700 Subject: [PATCH 077/311] CI: refine QuickProbe workflow (env + permissions + wildcard tools) --- .github/workflows/unity-mcp-smoke.yml | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index 73e08720..78ddf7cb 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -19,27 +19,34 @@ jobs: with: python-version: '3.11' + # Optional but makes server startup snappier and consistent + - name: Editable install of UnityMcpServer + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + - name: Run QuickProbe uses: anthropics/claude-code-base-action@beta with: - model: claude-3-7-sonnet-20250219 prompt_file: .claude/prompts/mcp-quickprobe.md - # Only the two tools we require: - allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources - # Give room for 2 calls + responses: - max_turns: 4 + # Wildcard so sub-sessions/subagents also have access + allowed_tools: mcp__unity__*,ListMcpResourcesTool,ReadMcpResourceTool + model: claude-3-7-sonnet-20250219 + max_turns: 6 timeout_minutes: 5 mcp_config: | { "mcpServers": { "unity": { "command": "uv", - "args": ["run","--directory",".","python","UnityMcpBridge/UnityMcpServer~/src/server.py"], + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug", - "PYTHONPATH": "UnityMcpBridge/UnityMcpServer~/src", "UNITY_MCP_PROJECT_ROOT": "." } } @@ -48,9 +55,9 @@ jobs: settings: | { "permissionMode": "allow", - "autoApprove": ["mcp__unity__find_in_file","mcp__unity__list_resources"], "defaultMode": "bypassPermissions", - "permissionStorage": "none" + "permissionStorage": "none", + "autoApprove": ["mcp__unity__*"] } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 2fc1101b7d6468dc3725d660ce4e701a490d0cee Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 18:12:43 -0700 Subject: [PATCH 078/311] CI: add Claude Desktop parity workflow --- .github/workflows/claude-desktop-parity.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/claude-desktop-parity.yml diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml new file mode 100644 index 00000000..569062b5 --- /dev/null +++ b/.github/workflows/claude-desktop-parity.yml @@ -0,0 +1,59 @@ +name: Unity MCP — Desktop Parity + +on: + workflow_dispatch: {} + +jobs: + desktop-parity: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Run Claude (desktop-parity) + uses: anthropics/claude-code-base-action@beta + with: + # Use the same model Desktop uses today in your logs + model: claude-3-7-sonnet-20250219 + + # Let it actually think & iterate like Desktop + max_turns: 60 + timeout_minutes: 15 + + # Give it the standard code tools + your Unity MCP tools + allowed_tools: > + Bash(git:*),Read,Write,Edit,MultiEdit,LS,Glob,Grep,BashOutput,KillBash, + mcp__unity__* + + # Prevent planner/subagent drift that doesn't carry MCP tools + disallowed_tools: TodoWrite + + # Keep the exact MCP wiring you already use + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + + # Map Desktop’s “click allow” to CI: auto-approve only what you intend + settings: | + { + "permissionMode": "allow", + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] + } + + prompt_file: .claude/prompts/nl-unity-suite.md From d36945726823eddaa21761653d023585d0e67a3d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 20 Aug 2025 18:12:43 -0700 Subject: [PATCH 079/311] CI: add Claude Desktop parity workflow --- .github/workflows/claude-desktop-parity.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/claude-desktop-parity.yml diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml new file mode 100644 index 00000000..569062b5 --- /dev/null +++ b/.github/workflows/claude-desktop-parity.yml @@ -0,0 +1,59 @@ +name: Unity MCP — Desktop Parity + +on: + workflow_dispatch: {} + +jobs: + desktop-parity: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Run Claude (desktop-parity) + uses: anthropics/claude-code-base-action@beta + with: + # Use the same model Desktop uses today in your logs + model: claude-3-7-sonnet-20250219 + + # Let it actually think & iterate like Desktop + max_turns: 60 + timeout_minutes: 15 + + # Give it the standard code tools + your Unity MCP tools + allowed_tools: > + Bash(git:*),Read,Write,Edit,MultiEdit,LS,Glob,Grep,BashOutput,KillBash, + mcp__unity__* + + # Prevent planner/subagent drift that doesn't carry MCP tools + disallowed_tools: TodoWrite + + # Keep the exact MCP wiring you already use + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + + # Map Desktop’s “click allow” to CI: auto-approve only what you intend + settings: | + { + "permissionMode": "allow", + "defaultMode": "bypassPermissions", + "permissionStorage": "none", + "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] + } + + prompt_file: .claude/prompts/nl-unity-suite.md From 5f080e0271a0c22dcdca6a8a62c501f5bb41d229 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 21 Aug 2025 13:37:45 -0700 Subject: [PATCH 080/311] CI: gate desktop-parity on Anthropic key; pass anthropic_api_key like NL suite --- .github/workflows/claude-desktop-parity.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index 569062b5..5cd9577b 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -16,8 +16,14 @@ jobs: with: python-version: '3.11' + - name: Detect Anthropic key + id: detect_key + run: | + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "has_key=true" >> "$GITHUB_OUTPUT"; else echo "has_key=false" >> "$GITHUB_OUTPUT"; fi + - name: Run Claude (desktop-parity) uses: anthropics/claude-code-base-action@beta + if: steps.detect_key.outputs.has_key == 'true' with: # Use the same model Desktop uses today in your logs model: claude-3-7-sonnet-20250219 @@ -57,3 +63,4 @@ jobs: } prompt_file: .claude/prompts/nl-unity-suite.md + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 0b423b5ce3b2a0c863801bdbca7ccc2ae7ccdbe6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 21 Aug 2025 14:25:32 -0700 Subject: [PATCH 081/311] Add quickprobe prompt and CI workflow (mcp-quickprobe.md, unity-mcp-quickprobe.yml) --- .claude/prompts/mcp-quickprobe.md | 23 +++++++++++++--------- .github/workflows/unity-mcp-quickprobe.yml | 0 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/unity-mcp-quickprobe.yml diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md index feea9cad..e2533e17 100644 --- a/.claude/prompts/mcp-quickprobe.md +++ b/.claude/prompts/mcp-quickprobe.md @@ -1,12 +1,17 @@ -You are running a two-step Unity MCP connectivity probe. Do exactly the two tool calls below, print their raw results, and stop. +You are running a strict, two-step Unity MCP wiring check. -1) Call mcp__unity__list_resources with this JSON (exactly): - { "ctx": {}, "under": "", "pattern": "*" } +Rules (must follow exactly): +- Do not plan, narrate, or print any text besides raw tool results. +- Make exactly the two tool calls below, in order, with the exact JSON shown. +- If a call fails, print the exception type and message exactly, then stop. -2) Call mcp__unity__read_resource with this JSON (exactly): - { "uri": "unity://spec/script-edits" } +1) Call mcp__unity__find_in_file with: +{ + "project_relative_file": "ClaudeTests/longUnityScript-claudeTest.cs", + "pattern": "class\\s+LongUnityScriptClaudeTest" +} -Rules: -- Print raw tool results verbatim to the console (no reformatting). -- If a call throws a validation or runtime error, print the exception type and message exactly. -- Do not run any other tools or commands. Stop after step 2. +2) Call mcp__unity__list_resources with: +{ "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } + +Stop after step 2. diff --git a/.github/workflows/unity-mcp-quickprobe.yml b/.github/workflows/unity-mcp-quickprobe.yml new file mode 100644 index 00000000..e69de29b From 204b9fc738d87a3e4b6c24fbb84e36f9fd84d54e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 03:04:48 -0700 Subject: [PATCH 082/311] strictier tool use to prevent subagent spawning and force mcp tools --- .github/workflows/claude-desktop-parity.yml | 27 +++++++++------------ 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index 5cd9577b..420a1be6 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -29,16 +29,18 @@ jobs: model: claude-3-7-sonnet-20250219 # Let it actually think & iterate like Desktop - max_turns: 60 + max_turns: 2 timeout_minutes: 15 - # Give it the standard code tools + your Unity MCP tools - allowed_tools: > - Bash(git:*),Read,Write,Edit,MultiEdit,LS,Glob,Grep,BashOutput,KillBash, - mcp__unity__* - - # Prevent planner/subagent drift that doesn't carry MCP tools - disallowed_tools: TodoWrite + allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource + disallowed_tools: TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash + settings: | + { + "permissionMode": "allow", + "autoApprove": ["mcp__unity__*"], + "defaultMode": "bypassPermissions", + "permissionStorage": "none" + } # Keep the exact MCP wiring you already use mcp_config: | @@ -53,14 +55,7 @@ jobs: } } - # Map Desktop’s “click allow” to CI: auto-approve only what you intend - settings: | - { - "permissionMode": "allow", - "defaultMode": "bypassPermissions", - "permissionStorage": "none", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] - } + prompt_file: .claude/prompts/nl-unity-suite.md anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 0c60f43da1c6b687db7442101557972089cf9382 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 03:23:34 -0700 Subject: [PATCH 083/311] update workflow filesto reduce likelihood of subagent spawning --- .claude/prompts/nl-unity-suite.md | 2 +- .github/workflows/claude-desktop-parity.yml | 2 +- .github/workflows/unity-mcp-quickprobe.yml | 41 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md index 51be0a08..d89c1b7d 100644 --- a/.claude/prompts/nl-unity-suite.md +++ b/.claude/prompts/nl-unity-suite.md @@ -1,6 +1,6 @@ # Unity NL Editing Suite — Natural Mode -You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate end‑to‑end **natural‑language code editing** on a representative Unity C# script using whatever capabilities and servers are already available in this session. Work autonomously. Do not ask the user for input. +You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate end‑to‑end **natural‑language code editing** on a representative Unity C# script using whatever capabilities and servers are already available in this session. Work autonomously. Do not ask the user for input. Do NOT spawn subagents, as they will not have access to the mcp server process on the top-level agent. ## Mission 1) **Discover capabilities.** Quietly inspect the tools and any connected servers that are available to you at session start. If the server offers a primer or capabilities resource, read it before acting. diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index 420a1be6..134a9c6f 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -33,7 +33,7 @@ jobs: timeout_minutes: 15 allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource - disallowed_tools: TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash + disallowed_tools: TodoWrite,PlannerTool,SubagentSpawn,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash settings: | { "permissionMode": "allow", diff --git a/.github/workflows/unity-mcp-quickprobe.yml b/.github/workflows/unity-mcp-quickprobe.yml index e69de29b..e108f7d2 100644 --- a/.github/workflows/unity-mcp-quickprobe.yml +++ b/.github/workflows/unity-mcp-quickprobe.yml @@ -0,0 +1,41 @@ +name: Unity MCP — Quickprobe + +on: + workflow_dispatch: {} + +jobs: + quickprobe: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Install Python + uv + uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Run Claude quickprobe + uses: anthropics/claude-code-base-action@beta + with: + model: claude-3-7-sonnet-20250219 + max_turns: 2 + timeout_minutes: 5 + allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources + disallowed_tools: TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash + mcp_config: | + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + prompt_file: .claude/prompts/mcp-quickprobe.md + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + From 08911ed80851d6b19438a6c4c64a71ddacbb551f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 03:54:36 -0700 Subject: [PATCH 084/311] improve permissions for claude agent, fix mcpbridge timeout/token issue --- .github/workflows/claude-desktop-parity.yml | 8 ++++---- UnityMcpBridge/Editor/UnityMcpBridge.cs | 14 ++++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index 134a9c6f..d4419140 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -36,10 +36,10 @@ jobs: disallowed_tools: TodoWrite,PlannerTool,SubagentSpawn,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash settings: | { - "permissionMode": "allow", - "autoApprove": ["mcp__unity__*"], - "defaultMode": "bypassPermissions", - "permissionStorage": "none" + "permissions": { + "allow": ["mcp__unity__*"] + }, + "permissions.defaultMode": "acceptEdits" } # Keep the exact MCP wiring you already use diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 277e64b6..7652fd80 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -483,11 +483,17 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS while (offset < count) { int remaining = count - offset; - int remainingTimeout = timeoutMs <= 0 ? Timeout.Infinite : Math.Max(1, timeoutMs - (int)stopwatch.ElapsedMilliseconds); + int remainingTimeout = timeoutMs <= 0 + ? Timeout.Infinite + : timeoutMs - (int)stopwatch.ElapsedMilliseconds; - using var cts = remainingTimeout == Timeout.Infinite - ? CancellationTokenSource.CreateLinkedTokenSource(cancel) - : CancellationTokenSource.CreateLinkedTokenSource(cancel); + // If a finite timeout is configured and already elapsed, fail immediately + if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) + { + throw new System.IO.IOException("Read timed out"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); if (remainingTimeout != Timeout.Infinite) { cts.CancelAfter(remainingTimeout); From 33c7aec8782f1cc7aa0f22d99b3d74cb7b1d4ce2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 04:09:42 -0700 Subject: [PATCH 085/311] increase max turns to 10 --- .github/workflows/claude-desktop-parity.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index d4419140..98b067cf 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -29,7 +29,7 @@ jobs: model: claude-3-7-sonnet-20250219 # Let it actually think & iterate like Desktop - max_turns: 2 + max_turns: 10 timeout_minutes: 15 allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource From b7478f06a796a35ccce5d1b5fea813b80fe23eb3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 04:32:44 -0700 Subject: [PATCH 086/311] ci: align NL suite to new permissions schema; prevent subagent drift --- .github/workflows/claude-nl-suite.yml | 60 ++++----------------------- 1 file changed, 8 insertions(+), 52 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b087b543..10274488 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -146,35 +146,12 @@ jobs: } } - # IMPORTANT: explicit per-tool approvals (still no wildcards) + # IMPORTANT: explicit per-tool approvals (new permissions schema only) settings: | { - "permissionMode": "allow", - "autoApprove": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits", - "mcp__unity__apply_text_edits", - "mcp__unity__create_script", - "mcp__unity__delete_script", - "mcp__unity__validate_script", - "mcp__unity__manage_script", - "mcp__unity__manage_scene", - "mcp__unity__manage_editor", - "mcp__unity__manage_gameobject", - "mcp__unity__manage_asset", - "mcp__unity__manage_shader", - "mcp__unity__read_console", - "mcp__unity__execute_menu_item", - "mcp__unity__list_resources", - "mcp__unity__read_resource", - "mcp__unity__find_in_file" - ], - "defaultMode": "bypassPermissions", - "permissionStorage": "none", "permissions": { "allow": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash(git:*)", "ListMcpResourcesTool","ReadMcpResourceTool", "mcp__unity__script_apply_edits", "mcp__unity__apply_text_edits", @@ -193,7 +170,8 @@ jobs: "mcp__unity__read_resource", "mcp__unity__find_in_file" ] - } + }, + "permissions.defaultMode": "acceptEdits" } model: "claude-3-7-sonnet-20250219" @@ -259,35 +237,12 @@ jobs: } } - # Same permission model here too + # Same permission model here too (new schema only) settings: | { - "permissionMode": "allow", - "autoApprove": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits", - "mcp__unity__apply_text_edits", - "mcp__unity__create_script", - "mcp__unity__delete_script", - "mcp__unity__validate_script", - "mcp__unity__manage_script", - "mcp__unity__manage_scene", - "mcp__unity__manage_editor", - "mcp__unity__manage_gameobject", - "mcp__unity__manage_asset", - "mcp__unity__manage_shader", - "mcp__unity__read_console", - "mcp__unity__execute_menu_item", - "mcp__unity__list_resources", - "mcp__unity__read_resource", - "mcp__unity__find_in_file" - ], - "defaultMode": "bypassPermissions", - "permissionStorage": "none", "permissions": { "allow": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash", + "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash(git:*)", "ListMcpResourcesTool","ReadMcpResourceTool", "mcp__unity__script_apply_edits", "mcp__unity__apply_text_edits", @@ -306,7 +261,8 @@ jobs: "mcp__unity__read_resource", "mcp__unity__find_in_file" ] - } + }, + "permissions.defaultMode": "acceptEdits" } model: "claude-3-7-sonnet-20250219" From 288f566fd365a1f00030bc693a535847a1084de9 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 05:56:33 -0700 Subject: [PATCH 087/311] ci: NL suite -> mini prompt for e2e; add full NL/T prompt; server: ctx optional + project_root fallback; workflows: set UNITY_PROJECT_ROOT for CI --- ...suite.md => nl-unity-claude-tests-mini.md} | 6 +- .claude/prompts/nl-unity-suite-full.md | 99 +++++++++++++++++++ .github/workflows/claude-nl-suite.yml | 13 ++- .../Scripts/LongUnityScriptClaudeTest.cs | 0 .../Scripts/LongUnityScriptClaudeTest.cs.meta | 2 + .../UnityMCPTests/Packages/manifest.json | 23 +++-- .../UnityMCPTests/Packages/packages-lock.json | 96 +++++++++--------- .../Settings.json | 2 - .../ProjectSettings/ProjectVersion.txt | 4 +- .../UnityMCPTests/ProjectSettings/boot.config | 0 UnityMcpBridge/Editor/UnityMcpBridge.cs | 13 +-- .../src/tools/resource_tools.py | 23 ++++- 12 files changed, 203 insertions(+), 78 deletions(-) rename .claude/prompts/{nl-unity-suite.md => nl-unity-claude-tests-mini.md} (86%) create mode 100644 .claude/prompts/nl-unity-suite-full.md rename ClaudeTests/longUnityScript-claudeTest.cs => TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs (100%) create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta delete mode 100644 TestProjects/UnityMCPTests/ProjectSettings/boot.config diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-claude-tests-mini.md similarity index 86% rename from .claude/prompts/nl-unity-suite.md rename to .claude/prompts/nl-unity-claude-tests-mini.md index d89c1b7d..d86fa184 100644 --- a/.claude/prompts/nl-unity-suite.md +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -4,7 +4,7 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo ## Mission 1) **Discover capabilities.** Quietly inspect the tools and any connected servers that are available to you at session start. If the server offers a primer or capabilities resource, read it before acting. -2) **Choose a target file.** Prefer `ClaudeTests/longUnityScript-claudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `ClaudeTests/` or `Assets/`. +2) **Choose a target file.** Prefer `TestProjects/UnityMCPTests/Assets/Scripts/longUnityScript-claudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `TestProjects/UnityMCPTests/Assets/`. 3) **Perform a small set of realistic edits** using minimal, precise changes (not full-file rewrites). Examples of small edits you may choose from (pick 3–6 total): - Insert a new, small helper method (e.g., a logger or counter) in a sensible location. - Add a short anchor comment near a key method (e.g., above `Update()`), then add or modify a few lines nearby. @@ -19,6 +19,10 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo ## Assumptions & Hints (non‑prescriptive) - A Unity‑oriented MCP server is expected to be connected. If a server‑provided **primer/capabilities** resource exists, read it first. If no primer is available, infer capabilities from your visible tools in the session. +- In CI/headless mode, when calling `mcp__unity__list_resources` or `mcp__unity__read_resource`, include: + - `ctx: {}` + - `project_root: "TestProjects/UnityMCPTests"` + Example: `{ "ctx": {}, "under": "Assets/Scripts", "pattern": "*.cs", "project_root": "TestProjects/UnityMCPTests" }` - If the preferred file isn’t present, locate a fallback C# file with simple, local methods you can edit safely. - If a compile command is available in this environment, you may optionally trigger it; if not, rely on structural checks and localized validation. diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md new file mode 100644 index 00000000..18bdc45a --- /dev/null +++ b/.claude/prompts/nl-unity-suite-full.md @@ -0,0 +1,99 @@ +# Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) + +You are running in CI at the repository root. Use only the tools allowed by the workflow. + +## Test target +- Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` +- Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. +- Include `precondition_sha256` for any text-path write. In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. + +## Output requirements +- JUnit XML at `reports/claude-nl-tests.xml`; each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. +- On failure: include `` with concise message and last evidence window (10–20 lines). +- Summary markdown at `reports/claude-nl-tests.md` with checkboxes and windowed reads. +- Restore workspace at end (clean tree). + +## Safety & hygiene +- Make edits in-place, then revert after validation (git stash/reset or counter-edits) so the workspace is clean. +- Never push commits from CI. + +## CI headless hints +- For `mcp__unity__list_resources`/`read_resource`, include `project_root: "TestProjects/UnityMCPTests"`. `ctx` is optional. + +## NL-0. Sanity Reads (windowed) +- Tail 120 lines of the target file. +- Show 40 lines around method `Update`. +- Pass if both windows render expected anchors. + +## 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 on matched diffs and windows. + +## NL-2. Anchor comment insertion +- Add comment `Build marker OK` immediately above `Update`. +- Pass if comment appears directly above `public void Update()`. + +## NL-3. End-of-class insertion +- Insert a 3-line comment `Tail test A/B/C` before the final class brace. +- Pass if windowed read shows three lines at intended location. + +## NL-4. Compile trigger (record-only) +- After an edit, ensure no obvious syntax issues; record as INFO. Unity compile runs in a separate step. + +## T-A. Anchor insert (text path) +- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via range-based text edit. +- Verify; then delete with `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. Header/region preservation +- For `ApplyBlend`, change only interior lines via `replace_range`; signature and region markers must remain untouched. +- Pass if unchanged. + +## T-D. End-of-class insertion (anchor) +- Find final class brace; insert 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 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/Scripts/LongUnityScriptClaudeTest.cs` and once with `Assets/Scripts/LongUnityScriptClaudeTest.cs` (if supported). +- Pass if both target the same file and no 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 the Unity step. + +## T-I. Failure surfaces (expected) +- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. +- Stale file: resend with old `precondition_sha256` after external change → expect `{status:"stale_file"}`. +- 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 clear error. +- Pass when each negative case returns 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/post windows (±20–40 lines) as evidence in JUnit or system-out. +- For any file write, include `precondition_sha256` and verify post-hash in logs. +- Restore repository to original state at end (`git status` must be clean). + + diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 10274488..e46ff185 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -140,7 +140,8 @@ jobs: "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "TestProjects/UnityMCPTests" } } } @@ -192,7 +193,7 @@ jobs: id: claude uses: anthropics/claude-code-base-action@beta with: - prompt_file: .claude/prompts/nl-unity-suite.md + prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md # Same explicit tool list; no wildcards allowed_tools: > @@ -231,7 +232,8 @@ jobs: "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "TestProjects/UnityMCPTests" } } } @@ -319,18 +321,19 @@ jobs: 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 "TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt" ]; then echo "is_test_project=true" >> "$GITHUB_OUTPUT"; else echo "is_test_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) --- - name: Unity compile (Project) - if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' + if: always() && steps.detect.outputs.has_license == 'true' && (steps.detect.outputs.is_project == 'true' || steps.detect.outputs.is_test_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: . + projectPath: ${{ steps.detect.outputs.is_test_project == 'true' && 'TestProjects/UnityMCPTests' || '.' }} githubToken: ${{ secrets.GITHUB_TOKEN }} testMode: EditMode diff --git a/ClaudeTests/longUnityScript-claudeTest.cs b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs similarity index 100% rename from ClaudeTests/longUnityScript-claudeTest.cs rename to TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta new file mode 100644 index 00000000..3d95d986 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfbabf507ab1245178d1a8e745d8d283 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Packages/manifest.json b/TestProjects/UnityMCPTests/Packages/manifest.json index d378a0a8..8b9bc6b5 100644 --- a/TestProjects/UnityMCPTests/Packages/manifest.json +++ b/TestProjects/UnityMCPTests/Packages/manifest.json @@ -1,17 +1,20 @@ { "dependencies": { - "com.coplaydev.unity-mcp": "file:../../../UnityMcpBridge", - "com.unity.collab-proxy": "2.5.2", - "com.unity.feature.development": "1.0.1", - "com.unity.ide.rider": "3.0.31", - "com.unity.ide.visualstudio": "2.0.22", + "com.coplaydev.unity-mcp": "file:/Users/davidsarno/unity-mcp/UnityMcpBridge", + + "com.unity.ai.navigation": "2.0.7", + "com.unity.collab-proxy": "2.8.2", + "com.unity.feature.development": "1.0.2", + "com.unity.ide.rider": "3.0.36", + "com.unity.ide.visualstudio": "2.0.23", "com.unity.ide.vscode": "1.2.5", "com.unity.ide.windsurf": "https://github.com/Asuta/com.unity.ide.windsurf.git", - "com.unity.test-framework": "1.1.33", - "com.unity.textmeshpro": "3.0.6", - "com.unity.timeline": "1.6.5", - "com.unity.ugui": "1.0.0", - "com.unity.visualscripting": "1.9.4", + "com.unity.multiplayer.center": "1.0.0", + "com.unity.test-framework": "1.5.1", + "com.unity.timeline": "1.8.7", + "com.unity.ugui": "2.0.0", + "com.unity.visualscripting": "1.9.6", + "com.unity.modules.accessibility": "1.0.0", "com.unity.modules.ai": "1.0.0", "com.unity.modules.androidjni": "1.0.0", "com.unity.modules.animation": "1.0.0", diff --git a/TestProjects/UnityMCPTests/Packages/packages-lock.json b/TestProjects/UnityMCPTests/Packages/packages-lock.json index 51cb01d4..f7dace51 100644 --- a/TestProjects/UnityMCPTests/Packages/packages-lock.json +++ b/TestProjects/UnityMCPTests/Packages/packages-lock.json @@ -1,15 +1,24 @@ { "dependencies": { "com.coplaydev.unity-mcp": { - "version": "file:../../../UnityMcpBridge", + "version": "file:/Users/davidsarno/unity-mcp/UnityMcpBridge", "depth": 0, "source": "local", "dependencies": { "com.unity.nuget.newtonsoft-json": "3.0.2" } }, + "com.unity.ai.navigation": { + "version": "2.0.7", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.modules.ai": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.collab-proxy": { - "version": "2.5.2", + "version": "2.8.2", "depth": 0, "source": "registry", "dependencies": {}, @@ -23,28 +32,26 @@ "url": "https://packages.unity.com" }, "com.unity.ext.nunit": { - "version": "1.0.6", + "version": "2.0.5", "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" + "source": "builtin", + "dependencies": {} }, "com.unity.feature.development": { - "version": "1.0.1", + "version": "1.0.2", "depth": 0, "source": "builtin", "dependencies": { - "com.unity.ide.visualstudio": "2.0.22", - "com.unity.ide.rider": "3.0.31", - "com.unity.ide.vscode": "1.2.5", + "com.unity.ide.visualstudio": "2.0.23", + "com.unity.ide.rider": "3.0.36", "com.unity.editorcoroutines": "1.0.0", - "com.unity.performance.profile-analyzer": "1.2.2", - "com.unity.test-framework": "1.1.33", + "com.unity.performance.profile-analyzer": "1.2.3", + "com.unity.test-framework": "1.5.1", "com.unity.testtools.codecoverage": "1.2.6" } }, "com.unity.ide.rider": { - "version": "3.0.31", + "version": "3.0.36", "depth": 0, "source": "registry", "dependencies": { @@ -53,7 +60,7 @@ "url": "https://packages.unity.com" }, "com.unity.ide.visualstudio": { - "version": "2.0.22", + "version": "2.0.23", "depth": 0, "source": "registry", "dependencies": { @@ -77,6 +84,14 @@ }, "hash": "6161accf3e7beab96341813913e714c7e2fb5c5d" }, + "com.unity.multiplayer.center": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.uielements": "1.0.0" + } + }, "com.unity.nuget.newtonsoft-json": { "version": "3.2.1", "depth": 1, @@ -85,29 +100,28 @@ "url": "https://packages.unity.com" }, "com.unity.performance.profile-analyzer": { - "version": "1.2.2", + "version": "1.2.3", "depth": 1, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" }, "com.unity.settings-manager": { - "version": "1.0.3", + "version": "2.1.0", "depth": 2, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" }, "com.unity.test-framework": { - "version": "1.1.33", + "version": "1.5.1", "depth": 0, - "source": "registry", + "source": "builtin", "dependencies": { - "com.unity.ext.nunit": "1.0.6", + "com.unity.ext.nunit": "2.0.3", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" + } }, "com.unity.testtools.codecoverage": { "version": "1.2.6", @@ -119,17 +133,8 @@ }, "url": "https://packages.unity.com" }, - "com.unity.textmeshpro": { - "version": "3.0.6", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ugui": "1.0.0" - }, - "url": "https://packages.unity.com" - }, "com.unity.timeline": { - "version": "1.6.5", + "version": "1.8.7", "depth": 0, "source": "registry", "dependencies": { @@ -141,7 +146,7 @@ "url": "https://packages.unity.com" }, "com.unity.ugui": { - "version": "1.0.0", + "version": "2.0.0", "depth": 0, "source": "builtin", "dependencies": { @@ -150,7 +155,7 @@ } }, "com.unity.visualscripting": { - "version": "1.9.4", + "version": "1.9.6", "depth": 0, "source": "registry", "dependencies": { @@ -159,6 +164,12 @@ }, "url": "https://packages.unity.com" }, + "com.unity.modules.accessibility": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.ai": { "version": "1.0.0", "depth": 0, @@ -206,6 +217,12 @@ "com.unity.modules.animation": "1.0.0" } }, + "com.unity.modules.hierarchycore": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.imageconversion": { "version": "1.0.0", "depth": 0, @@ -295,17 +312,8 @@ "com.unity.modules.ui": "1.0.0", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.uielementsnative": "1.0.0" - } - }, - "com.unity.modules.uielementsnative": { - "version": "1.0.0", - "depth": 1, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" + "com.unity.modules.hierarchycore": "1.0.0", + "com.unity.modules.physics": "1.0.0" } }, "com.unity.modules.umbra": { diff --git a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json index ad11087f..3c7b4c18 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json +++ b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json @@ -1,6 +1,4 @@ { - "m_Name": "Settings", - "m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json", "m_Dictionary": { "m_DictionaryValues": [] } diff --git a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt index 8386a052..4353f6cb 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt +++ b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 2021.3.45f1 -m_EditorVersionWithRevision: 2021.3.45f1 (0da89fac8e79) +m_EditorVersion: 6000.2.0b3 +m_EditorVersionWithRevision: 6000.2.0b3 (4504a57e80f0) diff --git a/TestProjects/UnityMCPTests/ProjectSettings/boot.config b/TestProjects/UnityMCPTests/ProjectSettings/boot.config deleted file mode 100644 index e69de29b..00000000 diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 7652fd80..a0336bc7 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -424,20 +424,13 @@ private static async Task HandleClientAsync(TcpClient client) { try { - // Strict framed mode - string commandText = null; - bool usedFraming = true; - - if (true) - { - // Enforced framed mode for this connection - commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); - } + // Strict framed mode only: enforced framed I/O for this connection + string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); try { var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; - Debug.Log($"UNITY-MCP: recv {(usedFraming ? "framed" : "legacy")}: {preview}"); + Debug.Log($"UNITY-MCP: recv framed: {preview}"); } catch { } string commandId = Guid.NewGuid().ToString(); diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index a23bcad3..31e73b58 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -46,7 +46,22 @@ def _resolve_project_root(override: str | None) -> Path: if cur.parent == cur: break cur = cur.parent - # 5) Fallback: CWD + # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings + try: + root = Path.cwd().resolve() + max_depth = 3 + for path in root.rglob("*"): + try: + rel_depth = len(path.relative_to(root).parts) + except Exception: + continue + if rel_depth > max_depth: + continue + if path.is_dir() and (path / "Assets").exists() and (path / "ProjectSettings").exists(): + return path + except Exception: + pass + # 6) Fallback: CWD return Path.cwd().resolve() @@ -73,7 +88,7 @@ def register_resource_tools(mcp: FastMCP) -> None: @mcp.tool() async def list_resources( - ctx: Context, + ctx: Context | None = None, pattern: str | None = "*.cs", under: str = "Assets", limit: int = 200, @@ -114,8 +129,8 @@ async def list_resources( @mcp.tool() async def read_resource( - ctx: Context, uri: str, + ctx: Context | None = None, start_line: int | None = None, line_count: int | None = None, head_bytes: int | None = None, @@ -251,9 +266,9 @@ async def read_resource( @mcp.tool() async def find_in_file( - ctx: Context, uri: str, pattern: str, + ctx: Context | None = None, ignore_case: bool | None = True, project_root: str | None = None, max_results: int | None = 200, From 47f3c7b6d3be5f0adf111335883c190b453e2c88 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 06:18:09 -0700 Subject: [PATCH 088/311] ci: add checks:write; revert local project hardcodes (manifest, ProjectVersion.txt) --- .github/workflows/claude-nl-suite.yml | 1 + .../UnityMCPTests/Packages/manifest.json | 23 ++++++++----------- .../ProjectSettings/ProjectVersion.txt | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e46ff185..0a1b2750 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -7,6 +7,7 @@ permissions: contents: write # allow Claude to write test artifacts pull-requests: write # allow annotations / comments issues: write + checks: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/TestProjects/UnityMCPTests/Packages/manifest.json b/TestProjects/UnityMCPTests/Packages/manifest.json index 8b9bc6b5..d378a0a8 100644 --- a/TestProjects/UnityMCPTests/Packages/manifest.json +++ b/TestProjects/UnityMCPTests/Packages/manifest.json @@ -1,20 +1,17 @@ { "dependencies": { - "com.coplaydev.unity-mcp": "file:/Users/davidsarno/unity-mcp/UnityMcpBridge", - - "com.unity.ai.navigation": "2.0.7", - "com.unity.collab-proxy": "2.8.2", - "com.unity.feature.development": "1.0.2", - "com.unity.ide.rider": "3.0.36", - "com.unity.ide.visualstudio": "2.0.23", + "com.coplaydev.unity-mcp": "file:../../../UnityMcpBridge", + "com.unity.collab-proxy": "2.5.2", + "com.unity.feature.development": "1.0.1", + "com.unity.ide.rider": "3.0.31", + "com.unity.ide.visualstudio": "2.0.22", "com.unity.ide.vscode": "1.2.5", "com.unity.ide.windsurf": "https://github.com/Asuta/com.unity.ide.windsurf.git", - "com.unity.multiplayer.center": "1.0.0", - "com.unity.test-framework": "1.5.1", - "com.unity.timeline": "1.8.7", - "com.unity.ugui": "2.0.0", - "com.unity.visualscripting": "1.9.6", - "com.unity.modules.accessibility": "1.0.0", + "com.unity.test-framework": "1.1.33", + "com.unity.textmeshpro": "3.0.6", + "com.unity.timeline": "1.6.5", + "com.unity.ugui": "1.0.0", + "com.unity.visualscripting": "1.9.4", "com.unity.modules.ai": "1.0.0", "com.unity.modules.androidjni": "1.0.0", "com.unity.modules.animation": "1.0.0", diff --git a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt index 4353f6cb..8386a052 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt +++ b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 6000.2.0b3 -m_EditorVersionWithRevision: 6000.2.0b3 (4504a57e80f0) +m_EditorVersion: 2021.3.45f1 +m_EditorVersionWithRevision: 2021.3.45f1 (0da89fac8e79) From 1be0f606c052a3f6ebf3827918a568eba9f2d839 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 07:13:03 -0700 Subject: [PATCH 089/311] tools: text-edit routing fixes (anchor_insert via text, CRLF calc); prompts: mini NL/T clarifications --- .claude/prompts/nl-unity-claude-tests-mini.md | 2 +- .../src/tools/manage_script_edits.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.claude/prompts/nl-unity-claude-tests-mini.md b/.claude/prompts/nl-unity-claude-tests-mini.md index d86fa184..573b8812 100644 --- a/.claude/prompts/nl-unity-claude-tests-mini.md +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -4,7 +4,7 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo ## Mission 1) **Discover capabilities.** Quietly inspect the tools and any connected servers that are available to you at session start. If the server offers a primer or capabilities resource, read it before acting. -2) **Choose a target file.** Prefer `TestProjects/UnityMCPTests/Assets/Scripts/longUnityScript-claudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `TestProjects/UnityMCPTests/Assets/`. +2) **Choose a target file.** Prefer `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `TestProjects/UnityMCPTests/Assets/`. 3) **Perform a small set of realistic edits** using minimal, precise changes (not full-file rewrites). Examples of small edits you may choose from (pick 3–6 total): - Insert a new, small helper method (e.g., a logger or counter) in a sensible location. - Add a short anchor comment near a key method (e.g., above `Update()`), then add or modify a few lines nearby. diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 5dec05b1..59c4d8f4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -384,7 +384,7 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str ) # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert","anchor_delete","anchor_replace"} + STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","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) @@ -484,11 +484,12 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 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 + # Insert at true EOF position (handles both \n and \r\n correctly) + eof_idx = len(current_text) + sl, sc = line_col_from_index(eof_idx) + new_text = ("\n" if not current_text.endswith("\n") else "") + text_field + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + current_text = current_text + new_text 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") From 70ece54cb26b92cacd90163369932ab5efee2d8a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 07:40:08 -0700 Subject: [PATCH 090/311] ci: use absolute UNITY_PROJECT_ROOT; prompts target TestProjects; server: accept relative UNITY_PROJECT_ROOT and bare spec URI --- .claude/prompts/nl-startuptest.md | 6 +++++- .claude/prompts/nl-unity-claude-tests-mini.md | 2 +- .github/workflows/claude-nl-suite.yml | 4 ++-- .../UnityMcpServer~/src/tools/resource_tools.py | 8 +++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.claude/prompts/nl-startuptest.md b/.claude/prompts/nl-startuptest.md index f102bcbf..351493ae 100644 --- a/.claude/prompts/nl-startuptest.md +++ b/.claude/prompts/nl-startuptest.md @@ -6,7 +6,7 @@ Fast preflight to confirm the Unity MCP server is reachable and usable in CI. - If it returns `[]`, try Unity’s direct tools **mcp__unity__list_resources** with just `under` and `pattern`. - **Do not** pass `ctx: ""`. If a `ctx` object is required, pass `{}` (an empty JSON object) or omit it entirely. -2) Locate a test C# file under `ClaudeTests/` (e.g., `ClaudeTests/longUnityScript-claudeTest.cs`) using Bash/Glob and **Read** a small window of lines to confirm anchors like `Update()` exist. +2) Locate the test C# file under `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` and **Read** a small window of lines to confirm anchors like `Update()` exist. If unavailable, list `Assets/**/*.cs` within that project and choose a simple alternative. 3) Do **not** make destructive edits here. This step is only a smoke test to ensure we can list/read resources successfully before the full NL/T suite. @@ -20,3 +20,7 @@ Fast preflight to confirm the Unity MCP server is reachable and usable in CI. - Whether resources were detected. - The path of the target file you’ll use later. - Any issues to watch for (e.g., permission prompts). + +# Quick self-check (do this early) +- Read `unity://spec/script-edits` using the direct Unity resource tool with a proper URI (`unity://spec/script-edits`). +- Then try listing `Assets/Scripts/*.cs` with `{ "ctx": {}, "project_root": "TestProjects/UnityMCPTests" }`. If nothing found, include the absolute project root passed via environment if available. diff --git a/.claude/prompts/nl-unity-claude-tests-mini.md b/.claude/prompts/nl-unity-claude-tests-mini.md index 573b8812..d8ca22cd 100644 --- a/.claude/prompts/nl-unity-claude-tests-mini.md +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -21,7 +21,7 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo - A Unity‑oriented MCP server is expected to be connected. If a server‑provided **primer/capabilities** resource exists, read it first. If no primer is available, infer capabilities from your visible tools in the session. - In CI/headless mode, when calling `mcp__unity__list_resources` or `mcp__unity__read_resource`, include: - `ctx: {}` - - `project_root: "TestProjects/UnityMCPTests"` + - `project_root: "TestProjects/UnityMCPTests"` (the server will also accept the absolute path passed via env) Example: `{ "ctx": {}, "under": "Assets/Scripts", "pattern": "*.cs", "project_root": "TestProjects/UnityMCPTests" }` - If the preferred file isn’t present, locate a fallback C# file with simple, local methods you can edit safely. - If a compile command is available in this environment, you may optionally trigger it; if not, rely on structural checks and localized validation. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0a1b2750..c9db28a1 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -142,7 +142,7 @@ jobs: "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "TestProjects/UnityMCPTests" + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" } } } @@ -234,7 +234,7 @@ jobs: "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "TestProjects/UnityMCPTests" + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" } } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 31e73b58..08b1f15c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -25,7 +25,9 @@ def _resolve_project_root(override: str | None) -> Path: # 2) Environment env = os.environ.get("UNITY_PROJECT_ROOT") if env: - pr = Path(env).expanduser().resolve() + env_path = Path(env).expanduser() + # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir + pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() if (pr / "Assets").exists(): return pr # 3) Ask Unity via manage_editor.get_project_root @@ -143,8 +145,8 @@ 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": + # Serve the canonical spec directly when requested (allow bare or with scheme) + if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): spec_json = ( '{\n' ' "name": "Unity MCP — Script Edits v1",\n' From 1f18340180cc324c702f084b782483e58159c466 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 07:51:01 -0700 Subject: [PATCH 091/311] ci: ignore Unity test project's packages-lock.json; remove from repo to avoid absolute paths --- .gitignore | 4 +- .../UnityMCPTests/Packages/packages-lock.json | 425 ------------------ 2 files changed, 3 insertions(+), 426 deletions(-) delete mode 100644 TestProjects/UnityMCPTests/Packages/packages-lock.json diff --git a/.gitignore b/.gitignore index 13a3c021..c78679e1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ CONTRIBUTING.md.meta .idea/ .vscode/ .aider* -.DS_Store* \ No newline at end of file +.DS_Store* +# Unity test project lock files +TestProjects/UnityMCPTests/Packages/packages-lock.json diff --git a/TestProjects/UnityMCPTests/Packages/packages-lock.json b/TestProjects/UnityMCPTests/Packages/packages-lock.json deleted file mode 100644 index f7dace51..00000000 --- a/TestProjects/UnityMCPTests/Packages/packages-lock.json +++ /dev/null @@ -1,425 +0,0 @@ -{ - "dependencies": { - "com.coplaydev.unity-mcp": { - "version": "file:/Users/davidsarno/unity-mcp/UnityMcpBridge", - "depth": 0, - "source": "local", - "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.0.2" - } - }, - "com.unity.ai.navigation": { - "version": "2.0.7", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.modules.ai": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.collab-proxy": { - "version": "2.8.2", - "depth": 0, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.editorcoroutines": { - "version": "1.0.0", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.ext.nunit": { - "version": "2.0.5", - "depth": 1, - "source": "builtin", - "dependencies": {} - }, - "com.unity.feature.development": { - "version": "1.0.2", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.ide.visualstudio": "2.0.23", - "com.unity.ide.rider": "3.0.36", - "com.unity.editorcoroutines": "1.0.0", - "com.unity.performance.profile-analyzer": "1.2.3", - "com.unity.test-framework": "1.5.1", - "com.unity.testtools.codecoverage": "1.2.6" - } - }, - "com.unity.ide.rider": { - "version": "3.0.36", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ext.nunit": "1.0.6" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ide.visualstudio": { - "version": "2.0.23", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.test-framework": "1.1.9" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ide.vscode": { - "version": "1.2.5", - "depth": 0, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.ide.windsurf": { - "version": "https://github.com/Asuta/com.unity.ide.windsurf.git", - "depth": 0, - "source": "git", - "dependencies": { - "com.unity.test-framework": "1.1.9" - }, - "hash": "6161accf3e7beab96341813913e714c7e2fb5c5d" - }, - "com.unity.multiplayer.center": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.uielements": "1.0.0" - } - }, - "com.unity.nuget.newtonsoft-json": { - "version": "3.2.1", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.performance.profile-analyzer": { - "version": "1.2.3", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.settings-manager": { - "version": "2.1.0", - "depth": 2, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.test-framework": { - "version": "1.5.1", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.ext.nunit": "2.0.3", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.testtools.codecoverage": { - "version": "1.2.6", - "depth": 1, - "source": "registry", - "dependencies": { - "com.unity.test-framework": "1.0.16", - "com.unity.settings-manager": "1.0.1" - }, - "url": "https://packages.unity.com" - }, - "com.unity.timeline": { - "version": "1.8.7", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.director": "1.0.0", - "com.unity.modules.animation": "1.0.0", - "com.unity.modules.particlesystem": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ugui": { - "version": "2.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0" - } - }, - "com.unity.visualscripting": { - "version": "1.9.6", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ugui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.modules.accessibility": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.ai": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.androidjni": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.animation": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.assetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.audio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.cloth": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0" - } - }, - "com.unity.modules.director": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.animation": "1.0.0" - } - }, - "com.unity.modules.hierarchycore": { - "version": "1.0.0", - "depth": 1, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.imageconversion": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.imgui": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.jsonserialize": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.particlesystem": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.physics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.physics2d": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.screencapture": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.subsystems": { - "version": "1.0.0", - "depth": 1, - "source": "builtin", - "dependencies": { - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.terrain": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.terrainphysics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.terrain": "1.0.0" - } - }, - "com.unity.modules.tilemap": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics2d": "1.0.0" - } - }, - "com.unity.modules.ui": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.uielements": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.hierarchycore": "1.0.0", - "com.unity.modules.physics": "1.0.0" - } - }, - "com.unity.modules.umbra": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unityanalytics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.unitywebrequest": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unitywebrequestassetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestaudio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.audio": "1.0.0" - } - }, - "com.unity.modules.unitywebrequesttexture": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestwww": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.unitywebrequestassetbundle": "1.0.0", - "com.unity.modules.unitywebrequestaudio": "1.0.0", - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.vehicles": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0" - } - }, - "com.unity.modules.video": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.vr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.xr": "1.0.0" - } - }, - "com.unity.modules.wind": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.xr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.subsystems": "1.0.0" - } - } - } -} From 9bb0be8a60439755b20b6fa714ed8fdd1c05096d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 09:12:47 -0700 Subject: [PATCH 092/311] CI: start persistent Unity Editor for MCP (guarded by license) + allow batch-mode bridge via UNITY_MCP_ALLOW_BATCH --- .github/workflows/claude-nl-suite.yml | 49 +++++++++++++++++++++++++ UnityMcpBridge/Editor/UnityMcpBridge.cs | 5 ++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index c9db28a1..78e22968 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -89,6 +89,48 @@ jobs: uv --version || true uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true + # --- Detect Unity license for conditional Editor start --- + - name: Detect Unity license (for Editor start) + id: detect_unity_start + 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 + + # --- Start a persistent headless Unity Editor so the MCP bridge is live --- + - name: Start Unity Editor (persistent MCP bridge) + if: steps.detect_unity_start.outputs.has_license == 'true' + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + docker run -d --name unity-mcp \ + --network host \ + -e UNITY_LICENSE="${{ secrets.UNITY_LICENSE }}" \ + -e UNITY_EMAIL="${{ secrets.UNITY_EMAIL }}" \ + -e UNITY_PASSWORD="${{ secrets.UNITY_PASSWORD }}" \ + -e UNITY_MCP_ALLOW_BATCH=1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + unityci/editor:2021.3.45f1-base-3 \ + /opt/unity/Editor/Unity \ + -batchmode -nographics -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect + + - name: Wait for Unity bridge (readiness probe) + if: steps.detect_unity_start.outputs.has_license == 'true' + run: | + set -eux + for i in $(seq 1 90); do + if docker logs unity-mcp 2>&1 | grep -q -E "UnityMcpBridge started on port"; then + echo "Bridge ready"; exit 0 + fi + sleep 2 + done + echo "Bridge did not signal ready in time" + docker logs unity-mcp || true + exit 1 + # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- - name: Detect Anthropic key id: detect_key @@ -314,6 +356,13 @@ jobs: reporter: java-junit fail-on-empty: false + - name: Stop Unity Editor + if: always() + run: | + set -eux + docker logs unity-mcp || true + docker rm -f unity-mcp || true + # Detect secrets + project/package mode WITHOUT using secrets in `if:` - name: Detect Unity mode & secrets id: detect diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index a0336bc7..5b2cdf94 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -98,8 +98,9 @@ public static bool FolderExists(string path) static UnityMcpBridge() { - // Skip bridge in headless/batch environments (CI/builds) - if (Application.isBatchMode) + // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env + // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode + if (Application.isBatchMode && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } From 838eb754ca319c1f2572bb79d3b85a38fb5eb9bd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 09:22:47 -0700 Subject: [PATCH 093/311] CI: hide license and pass via env to docker; fix invalid ref format --- .github/workflows/claude-nl-suite.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 78e22968..97dbd03c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -100,14 +100,18 @@ jobs: # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) if: steps.detect_unity_start.outputs.has_license == 'true' + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} run: | - set -eux + set -e mkdir -p "$HOME/.unity-mcp" docker run -d --name unity-mcp \ --network host \ - -e UNITY_LICENSE="${{ secrets.UNITY_LICENSE }}" \ - -e UNITY_EMAIL="${{ secrets.UNITY_EMAIL }}" \ - -e UNITY_PASSWORD="${{ secrets.UNITY_PASSWORD }}" \ + --env UNITY_LICENSE \ + --env UNITY_EMAIL \ + --env UNITY_PASSWORD \ -e UNITY_MCP_ALLOW_BATCH=1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ From 5acb5399b5b7f4c919627e6989eac27680ce09fb Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 09:34:47 -0700 Subject: [PATCH 094/311] CI: readiness probe uses handshake on Unity MCP port (deterministic) --- .github/workflows/claude-nl-suite.yml | 52 +++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 97dbd03c..9b065d93 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -121,19 +121,49 @@ jobs: -projectPath /workspace/TestProjects/UnityMCPTests \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - - name: Wait for Unity bridge (readiness probe) + - name: Wait for Unity bridge (handshake check) if: steps.detect_unity_start.outputs.has_license == 'true' run: | - set -eux - for i in $(seq 1 90); do - if docker logs unity-mcp 2>&1 | grep -q -E "UnityMcpBridge started on port"; then - echo "Bridge ready"; exit 0 - fi - sleep 2 - done - echo "Bridge did not signal ready in time" - docker logs unity-mcp || true - exit 1 + set -e + python - <<'PY' +import os, glob, json, socket, time, sys +base = os.path.expanduser('~/.unity-mcp') +deadline = time.time() + 240 # 4 minutes +def latest_status(): + files = sorted(glob.glob(os.path.join(base, 'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True) + if not files: + return None + try: + with open(files[0], 'r') as f: + return json.load(f) + except Exception: + return None +def handshake_ok(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), 1.0) as s: + s.settimeout(1.0) + greet = s.recv(256).decode('ascii', 'ignore') + return 'FRAMING=1' in greet + except Exception: + return False +while time.time() < deadline: + st = latest_status() + if st and isinstance(st.get('unity_port'), int): + port = st['unity_port'] + if handshake_ok(port): + print(f"Bridge ready on port {port}") + sys.exit(0) + time.sleep(2) +print("Bridge did not become ready in time", file=sys.stderr) +sys.exit(1) +PY + if [ $? -ne 0 ]; then + echo "Bridge did not signal ready in time; dumping recent logs and status files" + docker logs unity-mcp || true + ls -la "$HOME/.unity-mcp" || true + tail -n +1 "$HOME/.unity-mcp"/unity-mcp-*.json 2>/dev/null || true + exit 1 + fi # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- - name: Detect Anthropic key From 298e376e7000dcb4a8c4db9528ed70833074ce34 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 09:40:37 -0700 Subject: [PATCH 095/311] CI: fix YAML; use TCP handshake readiness probe (FRAMING=1) --- .github/workflows/claude-nl-suite.yml | 54 ++++++++------------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9b065d93..68bd4834 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -125,45 +125,21 @@ jobs: if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -e - python - <<'PY' -import os, glob, json, socket, time, sys -base = os.path.expanduser('~/.unity-mcp') -deadline = time.time() + 240 # 4 minutes -def latest_status(): - files = sorted(glob.glob(os.path.join(base, 'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True) - if not files: - return None - try: - with open(files[0], 'r') as f: - return json.load(f) - except Exception: - return None -def handshake_ok(port: int) -> bool: - try: - with socket.create_connection(("127.0.0.1", port), 1.0) as s: - s.settimeout(1.0) - greet = s.recv(256).decode('ascii', 'ignore') - return 'FRAMING=1' in greet - except Exception: - return False -while time.time() < deadline: - st = latest_status() - if st and isinstance(st.get('unity_port'), int): - port = st['unity_port'] - if handshake_ok(port): - print(f"Bridge ready on port {port}") - sys.exit(0) - time.sleep(2) -print("Bridge did not become ready in time", file=sys.stderr) -sys.exit(1) -PY - if [ $? -ne 0 ]; then - echo "Bridge did not signal ready in time; dumping recent logs and status files" - docker logs unity-mcp || true - ls -la "$HOME/.unity-mcp" || true - tail -n +1 "$HOME/.unity-mcp"/unity-mcp-*.json 2>/dev/null || true - exit 1 - fi + end=$((SECONDS+240)) + while [ $SECONDS -lt $end ]; do + PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); f=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(f[0]))['unity_port']) if f else sys.exit(1)" 2>/dev/null || true) + if [ -n "$PORT" ]; then + if timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | grep -q 'FRAMING=1'"; then + echo "Bridge ready on port $PORT"; exit 0 + fi + fi + sleep 2 + done + echo "Bridge did not signal ready in time; dumping recent logs and status files" + docker logs unity-mcp || true + ls -la "$HOME/.unity-mcp" || true + tail -n +1 "$HOME/.unity-mcp"/unity-mcp-*.json 2>/dev/null || true + exit 1 # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- - name: Detect Anthropic key From 321559383900deedcccb1c14febdf46b09b8f82b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 09:56:43 -0700 Subject: [PATCH 096/311] CI: prime Unity license via game-ci; mount ULF into container; extend readiness timeout --- .github/workflows/claude-nl-suite.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 68bd4834..1babd03f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -97,6 +97,19 @@ jobs: run: | if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi + # --- Prime Unity license on host so the container sees a ULF --- + - name: Prime Unity license (one-shot activation) + if: steps.detect_unity_start.outputs.has_license == '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: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __DoesNotExist__ + # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) if: steps.detect_unity_start.outputs.has_license == 'true' @@ -114,6 +127,7 @@ jobs: --env UNITY_PASSWORD \ -e UNITY_MCP_ALLOW_BATCH=1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ unityci/editor:2021.3.45f1-base-3 \ /opt/unity/Editor/Unity \ @@ -125,7 +139,7 @@ jobs: if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -e - end=$((SECONDS+240)) + end=$((SECONDS+480)) while [ $SECONDS -lt $end ]; do PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); f=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(f[0]))['unity_port']) if f else sys.exit(1)" 2>/dev/null || true) if [ -n "$PORT" ]; then From 7f7505a3b1e5e2f79c1e181453bd55078ed6078f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 10:08:14 -0700 Subject: [PATCH 097/311] CI: use ULF write + mount for Unity licensing; remove serial/email/pass from container --- .github/workflows/claude-nl-suite.yml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 1babd03f..055c996a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -97,34 +97,23 @@ jobs: run: | if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi - # --- Prime Unity license on host so the container sees a ULF --- - - name: Prime Unity license (one-shot activation) + # --- Write Unity license file (ULF) on host so container sees it --- + - name: Write Unity license file (ULF) if: steps.detect_unity_start.outputs.has_license == '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: TestProjects/UnityMCPTests - testMode: EditMode - customParameters: -runTests -testFilter __DoesNotExist__ + run: | + set -eux + mkdir -p "$HOME/.local/share/unity3d" + printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) if: steps.detect_unity_start.outputs.has_license == 'true' - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} run: | set -e mkdir -p "$HOME/.unity-mcp" docker run -d --name unity-mcp \ --network host \ - --env UNITY_LICENSE \ - --env UNITY_EMAIL \ - --env UNITY_PASSWORD \ -e UNITY_MCP_ALLOW_BATCH=1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ From 3905fcfccd14abe7071ea312650f2ba2b72b1981 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 10:25:43 -0700 Subject: [PATCH 098/311] CI: entitlement activation (UNITY_SERIAL=''); verify host ULF cache; keep mount --- .github/workflows/claude-nl-suite.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 055c996a..c07a0108 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -97,14 +97,24 @@ jobs: run: | if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi - # --- Write Unity license file (ULF) on host so container sees it --- - - name: Write Unity license file (ULF) + # --- Prime Unity license on host (entitlement; avoid serial path) --- + - name: Prime Unity license (entitlement activation) + if: steps.detect_unity_start.outputs.has_license == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: "" + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __DoesNotExist__ + + - name: Verify host license cache if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -eux - mkdir -p "$HOME/.local/share/unity3d" - printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" - test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" + ls -la "$HOME/.local/share/unity3d" || true # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) @@ -128,7 +138,7 @@ jobs: if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -e - end=$((SECONDS+480)) + end=$((SECONDS+240)) while [ $SECONDS -lt $end ]; do PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); f=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(f[0]))['unity_port']) if f else sys.exit(1)" 2>/dev/null || true) if [ -n "$PORT" ]; then From 152b866e248dc59d4ecf600fc660c8f0819fac96 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 10:35:49 -0700 Subject: [PATCH 099/311] CI: write ULF from secret and verify; drop entitlement activation step --- .github/workflows/claude-nl-suite.yml | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index c07a0108..cc45880d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -97,24 +97,15 @@ jobs: run: | if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi - # --- Prime Unity license on host (entitlement; avoid serial path) --- - - name: Prime Unity license (entitlement activation) - if: steps.detect_unity_start.outputs.has_license == 'true' - uses: game-ci/unity-test-runner@v4 - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: "" - with: - projectPath: TestProjects/UnityMCPTests - testMode: EditMode - customParameters: -runTests -testFilter __DoesNotExist__ - - - name: Verify host license cache + # --- Write Unity license file (ULF) on host so container sees it --- + - name: Write Unity license file (ULF) if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -eux - ls -la "$HOME/.local/share/unity3d" || true + mkdir -p "$HOME/.local/share/unity3d" + printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + ls -la "$HOME/.local/share/unity3d" + test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) From 753c929261fa6465a1e3107919abb6c6294a9106 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 10:46:44 -0700 Subject: [PATCH 100/311] CI: detect any licensing path; GameCI prime; status dir env; log+probe readiness; fix YAML --- .github/workflows/claude-nl-suite.yml | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cc45880d..15943215 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -89,13 +89,20 @@ jobs: uv --version || true uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - # --- Detect Unity license for conditional Editor start --- + # --- Detect any viable licensing path (ULF, email/password, or serial) --- - name: Detect Unity license (for Editor start) id: detect_unity_start env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | - if [ -n "$UNITY_LICENSE" ]; then echo "has_license=true" >> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "has_license=true" >> "$GITHUB_OUTPUT" + else + echo "has_license=false" >> "$GITHUB_OUTPUT" + fi # --- Write Unity license file (ULF) on host so container sees it --- - name: Write Unity license file (ULF) @@ -116,6 +123,7 @@ jobs: docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ + -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ @@ -125,24 +133,34 @@ jobs: -projectPath /workspace/TestProjects/UnityMCPTests \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - - name: Wait for Unity bridge (handshake check) + - name: Wait for Unity bridge (logs + probe) if: steps.detect_unity_start.outputs.has_license == 'true' run: | - set -e - end=$((SECONDS+240)) - while [ $SECONDS -lt $end ]; do - PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); f=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(f[0]))['unity_port']) if f else sys.exit(1)" 2>/dev/null || true) - if [ -n "$PORT" ]; then - if timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | grep -q 'FRAMING=1'"; then - echo "Bridge ready on port $PORT"; exit 0 + set -eux + docker logs -f unity-mcp & LOGPID=$! + deadline=$((SECONDS+420)) + while [ $SECONDS -lt $deadline ]; do + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + echo "Detected bridge ready via logs" + kill $LOGPID || true + exit 0 + fi + PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) + if [ -n "${PORT:-}" ]; then + if timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then + echo "Bridge ready on port $PORT" + kill $LOGPID || true + exit 0 fi fi sleep 2 done - echo "Bridge did not signal ready in time; dumping recent logs and status files" + echo "Timeout waiting for bridge" + kill $LOGPID || true + docker ps -a || true + docker inspect -f 'status={{.State.Status}} exitCode={{.State.ExitCode}}' unity-mcp || true docker logs unity-mcp || true ls -la "$HOME/.unity-mcp" || true - tail -n +1 "$HOME/.unity-mcp"/unity-mcp-*.json 2>/dev/null || true exit 1 # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- From a8c42978c08fdb69cbdd4af37046e36e1aee52f6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 11:04:25 -0700 Subject: [PATCH 101/311] CI: add GameCI license prime; conditional ULF write; one-shot license validation; explicit status dir + license env --- .github/workflows/claude-nl-suite.yml | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 15943215..799a2da4 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -104,15 +104,43 @@ jobs: echo "has_license=false" >> "$GITHUB_OUTPUT" fi + # --- Prime Unity license on host (GameCI) --- + - name: Prime Unity license on host (GameCI) + if: steps.detect_unity_start.outputs.has_license == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: TestProjects/UnityMCPTests + testMode: editmode + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + # --- Write Unity license file (ULF) on host so container sees it --- - name: Write Unity license file (ULF) if: steps.detect_unity_start.outputs.has_license == 'true' run: | set -eux mkdir -p "$HOME/.local/share/unity3d" - printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf"; fi ls -la "$HOME/.local/share/unity3d" - test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" + if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then test -s "$HOME/.local/share/unity3d/Unity_lic.ulf"; fi + + # --- Validate license in a one-shot container --- + - name: Validate Unity license (one-shot container) + if: steps.detect_unity_start.outputs.has_license == 'true' + run: | + set -eux + docker run --rm \ + -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + unityci/editor:2021.3.45f1-base-3 \ + /opt/unity/Editor/Unity \ + -batchmode -nographics -quit -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - name: Start Unity Editor (persistent MCP bridge) @@ -124,6 +152,7 @@ jobs: --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_LICENSE_FILE=/root/.local/share/unity3d/Unity_lic.ulf \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ From 0732e9270a0bb9a7475020edbcbef14902395a74 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 11:26:35 -0700 Subject: [PATCH 102/311] CI: fix YAML (inline python), add Anthropic key detect via GITHUB_ENV; ready to run happy path --- .github/workflows/claude-nl-suite.yml | 414 ++++---------------------- 1 file changed, 61 insertions(+), 353 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 799a2da4..fe078242 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,11 +1,11 @@ -name: Claude NL suite + (optional) Unity compile +name: Claude NL suite (Unity live) on: workflow_dispatch: {} permissions: - contents: write # allow Claude to write test artifacts - pull-requests: write # allow annotations / comments + contents: write + pull-requests: write issues: write checks: write @@ -20,139 +20,49 @@ jobs: timeout-minutes: 60 steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Python + uv for the Unity MCP server - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Install UnityMcpServer deps - run: | - set -eux - if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" - elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" - else - echo "No Python deps found (skipping)" - fi - - - name: Verify Python env + - name: Detect Anthropic key (env) run: | - set -eux - which python - python -V - python -c "import mcp; print('mcp ok')" + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "ANTHROPIC_OK=true" >> "$GITHUB_ENV"; else echo "ANTHROPIC_OK=false" >> "$GITHUB_ENV"; fi + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } - - name: Preflight MCP modules (fail fast) + # --- Python env for MCP server (uv) --- + - uses: astral-sh/setup-uv@v4 + with: { python-version: '3.11' } + - name: Install MCP server run: | set -eux - uv run --active --directory UnityMcpBridge/UnityMcpServer~/src python - <<'PY' - import sys, pkgutil - import tools - mods = {name for _, name, _ in pkgutil.iter_modules(tools.__path__)} - required = {"manage_script","manage_script_edits","resource_tools"} - missing = required - mods - if missing: - print(f"Missing MCP tool modules: {sorted(missing)}") - sys.exit(1) - print("MCP tool modules present:", sorted(required)) - PY - - - name: Ensure artifact dirs exist - run: mkdir -p reports - - - name: Log MCP server location (diagnostic) - run: | - set -ux # do not -e; we want logs even if missing - SRV_DIR="UnityMcpBridge/UnityMcpServer~/src" - echo "MCP server dir := ${SRV_DIR}" - python - <<'PY' - import pathlib - p = pathlib.Path('UnityMcpBridge/UnityMcpServer~/src').resolve() - print('Resolved path:', p) - print('Exists:', p.exists()) - print('server.py present:', (p / 'server.py').exists()) - PY - ls -la "${SRV_DIR}" || true - uv --version || true - uv run --active --directory "${SRV_DIR}" python -c "import os,sys,pathlib; print('uv cwd:', os.getcwd()); print('server.py exists:', pathlib.Path('server.py').exists())" || true - - # --- Detect any viable licensing path (ULF, email/password, or serial) --- - - name: Detect Unity license (for Editor start) - id: detect_unity_start - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then - echo "has_license=true" >> "$GITHUB_OUTPUT" - else - echo "has_license=false" >> "$GITHUB_OUTPUT" + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt fi - # --- Prime Unity license on host (GameCI) --- - - name: Prime Unity license on host (GameCI) - if: steps.detect_unity_start.outputs.has_license == 'true' + # --- Prime Unity license on host (fast no-op) --- + - name: Prime Unity license (host) uses: game-ci/unity-test-runner@v4 env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # optional ULF contents + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} # optional sign-in UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} # optional serial with: projectPath: TestProjects/UnityMCPTests testMode: editmode customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics - # --- Write Unity license file (ULF) on host so container sees it --- - - name: Write Unity license file (ULF) - if: steps.detect_unity_start.outputs.has_license == 'true' - run: | - set -eux - mkdir -p "$HOME/.local/share/unity3d" - if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf"; fi - ls -la "$HOME/.local/share/unity3d" - if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then test -s "$HOME/.local/share/unity3d/Unity_lic.ulf"; fi - - # --- Validate license in a one-shot container --- - - name: Validate Unity license (one-shot container) - if: steps.detect_unity_start.outputs.has_license == 'true' + # --- Start headless Unity that stays up (bridge enabled) --- + - name: Start Unity (persistent bridge) run: | set -eux - docker run --rm \ - -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - unityci/editor:2021.3.45f1-base-3 \ - /opt/unity/Editor/Unity \ - -batchmode -nographics -quit -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf - - # --- Start a persistent headless Unity Editor so the MCP bridge is live --- - - name: Start Unity Editor (persistent MCP bridge) - if: steps.detect_unity_start.outputs.has_license == 'true' - run: | - set -e mkdir -p "$HOME/.unity-mcp" docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ - -e UNITY_LICENSE_FILE=/root/.local/share/unity3d/Unity_lic.ulf \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ @@ -162,85 +72,52 @@ jobs: -projectPath /workspace/TestProjects/UnityMCPTests \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - - name: Wait for Unity bridge (logs + probe) - if: steps.detect_unity_start.outputs.has_license == 'true' + - name: Wait for Unity bridge run: | set -eux docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do + # Prefer a log marker; adjust to your actual log text: if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then - echo "Detected bridge ready via logs" - kill $LOGPID || true - exit 0 + echo "Bridge ready (logs)"; kill $LOGPID || true; exit 0 fi + # Fallback: JSON + socket probe (optional) PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) - if [ -n "${PORT:-}" ]; then - if timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then - echo "Bridge ready on port $PORT" - kill $LOGPID || true - exit 0 - fi + if [ -n "${PORT:-}" ] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then + echo "Bridge ready on port $PORT"; kill $LOGPID || true; exit 0 fi sleep 2 done - echo "Timeout waiting for bridge" + echo "Bridge not ready in time" kill $LOGPID || true - docker ps -a || true - docker inspect -f 'status={{.State.Status}} exitCode={{.State.ExitCode}}' unity-mcp || true docker logs unity-mcp || true - ls -la "$HOME/.unity-mcp" || true exit 1 - # --- Detect Anthropic key and skip Claude steps if not present (e.g., forks) --- - - name: Detect Anthropic key - id: detect_key - run: | - if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "has_key=true" >> "$GITHUB_OUTPUT"; else echo "has_key=false" >> "$GITHUB_OUTPUT"; fi - - # ---------- Claude: startup sanity (ensures Unity MCP server connects) ---------- - - name: Run Claude startup test suite + # --- Claude suites --- + - name: Claude startup sanity id: claude_startup uses: anthropics/claude-code-base-action@beta - if: steps.detect_key.outputs.has_key == 'true' + if: env.ANTHROPIC_OK == 'true' with: prompt_file: .claude/prompts/nl-startuptest.md - - # Be explicit: list every Unity MCP tool and the resource helpers (no wildcards) allowed_tools: > Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, - mcp__unity__script_apply_edits, - mcp__unity__apply_text_edits, - mcp__unity__create_script, - mcp__unity__delete_script, - mcp__unity__validate_script, - mcp__unity__manage_script, - mcp__unity__manage_scene, - mcp__unity__manage_editor, - mcp__unity__manage_gameobject, - mcp__unity__manage_asset, - mcp__unity__manage_shader, - mcp__unity__read_console, - mcp__unity__execute_menu_item, - mcp__unity__list_resources, - mcp__unity__read_resource, - mcp__unity__find_in_file - - # Use the active venv and stdio transport + mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, + mcp__unity__create_script,mcp__unity__delete_script, + mcp__unity__validate_script,mcp__unity__manage_script, + mcp__unity__manage_scene,mcp__unity__manage_editor, + mcp__unity__manage_gameobject,mcp__unity__manage_asset, + mcp__unity__manage_shader,mcp__unity__read_console, + mcp__unity__execute_menu_item,mcp__unity__list_resources, + mcp__unity__read_resource,mcp__unity__find_in_file mcp_config: | { "mcpServers": { "unity": { "command": "uv", - "args": [ - "run", - "--active", - "--directory", - "UnityMcpBridge/UnityMcpServer~/src", - "python", - "server.py" - ], + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -250,89 +127,33 @@ jobs: } } } - - # IMPORTANT: explicit per-tool approvals (new permissions schema only) - settings: | - { - "permissions": { - "allow": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash(git:*)", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits", - "mcp__unity__apply_text_edits", - "mcp__unity__create_script", - "mcp__unity__delete_script", - "mcp__unity__validate_script", - "mcp__unity__manage_script", - "mcp__unity__manage_scene", - "mcp__unity__manage_editor", - "mcp__unity__manage_gameobject", - "mcp__unity__manage_asset", - "mcp__unity__manage_shader", - "mcp__unity__read_console", - "mcp__unity__execute_menu_item", - "mcp__unity__list_resources", - "mcp__unity__read_resource", - "mcp__unity__find_in_file" - ] - }, - "permissions.defaultMode": "acceptEdits" - } - model: "claude-3-7-sonnet-20250219" - max_turns: "12" - timeout_minutes: "10" + max_turns: "8" + timeout_minutes: "7" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Optional: dump the settings Claude actually wrote (helps verify permissionMode) - - name: Show effective Claude settings (diagnostic) - if: always() - run: | - set -ux - cat /home/runner/.claude/settings.json || true - - # ---------- Claude: full NL/T suite ---------- - - name: Run Claude NL/T test suite - if: steps.detect_key.outputs.has_key == 'true' && steps.claude_startup.outcome == 'success' - id: claude + - name: Claude NL/T suite uses: anthropics/claude-code-base-action@beta + if: env.ANTHROPIC_OK == 'true' && steps.claude_startup.outcome == 'success' with: prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md - - # Same explicit tool list; no wildcards allowed_tools: > Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, - mcp__unity__script_apply_edits, - mcp__unity__apply_text_edits, - mcp__unity__create_script, - mcp__unity__delete_script, - mcp__unity__validate_script, - mcp__unity__manage_script, - mcp__unity__manage_scene, - mcp__unity__manage_editor, - mcp__unity__manage_gameobject, - mcp__unity__manage_asset, - mcp__unity__manage_shader, - mcp__unity__read_console, - mcp__unity__execute_menu_item, - mcp__unity__list_resources, - mcp__unity__read_resource, - mcp__unity__find_in_file - + mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, + mcp__unity__create_script,mcp__unity__delete_script, + mcp__unity__validate_script,mcp__unity__manage_script, + mcp__unity__manage_scene,mcp__unity__manage_editor, + mcp__unity__manage_gameobject,mcp__unity__manage_asset, + mcp__unity__manage_shader,mcp__unity__read_console, + mcp__unity__execute_menu_item,mcp__unity__list_resources, + mcp__unity__read_resource,mcp__unity__find_in_file mcp_config: | { "mcpServers": { "unity": { "command": "uv", - "args": [ - "run", - "--active", - "--directory", - "UnityMcpBridge/UnityMcpServer~/src", - "python", - "server.py" - ], + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -342,127 +163,14 @@ jobs: } } } - - # Same permission model here too (new schema only) - settings: | - { - "permissions": { - "allow": [ - "Read","Write","LS","Glob","Grep","Edit","MultiEdit","Bash(git:*)", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits", - "mcp__unity__apply_text_edits", - "mcp__unity__create_script", - "mcp__unity__delete_script", - "mcp__unity__validate_script", - "mcp__unity__manage_script", - "mcp__unity__manage_scene", - "mcp__unity__manage_editor", - "mcp__unity__manage_gameobject", - "mcp__unity__manage_asset", - "mcp__unity__manage_shader", - "mcp__unity__read_console", - "mcp__unity__execute_menu_item", - "mcp__unity__list_resources", - "mcp__unity__read_resource", - "mcp__unity__find_in_file" - ] - }, - "permissions.defaultMode": "acceptEdits" - } - model: "claude-3-7-sonnet-20250219" - max_turns: "20" - timeout_minutes: "20" + max_turns: "16" + timeout_minutes: "12" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Mark permission issues as skipped in JUnit + # --- Always stop Unity --- + - name: Stop Unity if: always() run: | - python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml - - - name: Ensure JUnit exists (fallback) - if: always() - run: | - set -eux - mkdir -p reports - if [ ! -f reports/claude-nl-tests.xml ]; then - printf '%s\n' \ - '' \ - ' ' \ - ' ' \ - ' ' \ - ' Claude ran but the startup checks failed to detect usable MCP resources; NL/T flow did not execute.' \ - ' ' \ - ' ' \ - ' ' \ - '' \ - > reports/claude-nl-tests.xml - fi - - - name: Upload JUnit (Claude NL/T) - if: always() - uses: actions/upload-artifact@v4 - with: - name: claude-nl-tests - path: reports/claude-nl-tests.xml - if-no-files-found: ignore - - - 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 - fail-on-empty: false - - - name: Stop Unity Editor - if: always() - run: | - set -eux docker logs unity-mcp || true docker rm -f unity-mcp || true - - # Detect secrets + project/package mode WITHOUT using secrets in `if:` - - 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 "TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt" ]; then echo "is_test_project=true" >> "$GITHUB_OUTPUT"; else echo "is_test_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) --- - - name: Unity compile (Project) - if: always() && steps.detect.outputs.has_license == 'true' && (steps.detect.outputs.is_project == 'true' || steps.detect.outputs.is_test_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: ${{ steps.detect.outputs.is_test_project == 'true' && 'TestProjects/UnityMCPTests' || '.' }} - githubToken: ${{ secrets.GITHUB_TOKEN }} - testMode: EditMode - - - name: Unity compile (Package) - 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 - projectPath: . - githubToken: ${{ secrets.GITHUB_TOKEN }} - - - name: Clean working tree (discard temp edits) - if: always() - run: | - git restore -SW :/ - git clean -fd From 1478aa792510bc4ae5e16607697f81c13bac8fe2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 11:38:26 -0700 Subject: [PATCH 103/311] CI: mount Unity token/ulf/cache dirs into container to share host license; create dirs before start --- .github/workflows/claude-nl-suite.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index fe078242..47a680f0 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -58,13 +58,18 @@ jobs: - name: Start Unity (persistent bridge) run: | set -eux - mkdir -p "$HOME/.unity-mcp" + mkdir -p "$HOME/.unity-mcp" \ + "$HOME/.local/share/unity3d" \ + "$HOME/.config/unity3d" \ + "$HOME/.cache/unity3d" docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ + -v "$HOME/.config/unity3d:/root/.config/unity3d" \ + -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ unityci/editor:2021.3.45f1-base-3 \ /opt/unity/Editor/Unity \ From 62da1aa55a340132c13d8fa6df72b40cf26e7bd1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:02:27 -0700 Subject: [PATCH 104/311] CI: fix YAML indentation; write ULF on host; activate in container with shared mounts; mount .config and .cache too --- .github/workflows/claude-nl-suite.yml | 50 ++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 47a680f0..ad4b347a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -23,6 +23,8 @@ jobs: - name: Detect Anthropic key (env) run: | if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "ANTHROPIC_OK=true" >> "$GITHUB_ENV"; else echo "ANTHROPIC_OK=false" >> "$GITHUB_ENV"; fi + if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then echo "UNITY_LIC_OK=true" >> "$GITHUB_ENV"; else echo "UNITY_LIC_OK=false" >> "$GITHUB_ENV"; fi + echo "UNITY_LICENSE=${{ secrets.UNITY_LICENSE }}" >> "$GITHUB_ENV" - uses: actions/checkout@v4 with: { fetch-depth: 0 } @@ -41,19 +43,29 @@ jobs: uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt fi - # --- Prime Unity license on host (fast no-op) --- - - name: Prime Unity license (host) - uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # optional ULF contents - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} # optional sign-in - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} # optional serial - with: - projectPath: TestProjects/UnityMCPTests - testMode: editmode - customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + - name: Write Unity license (ULF) to host + if: env.UNITY_LIC_OK == 'true' + run: | + set -eux + mkdir -p "$HOME/.local/share/unity3d" "$HOME/.config/unity3d" "$HOME/.cache/unity3d" + printf '%s' "$UNITY_LICENSE" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" + - name: Activate Unity license (one-shot container) + if: env.UNITY_LIC_OK == 'true' + run: | + set -eux + docker run --rm \ + -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ + -v "$HOME/.config/unity3d:/root/.config/unity3d" \ + -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + unityci/editor:2021.3.45f1-base-3 \ + /opt/unity/Editor/Unity \ + -batchmode -nographics -quit -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf + # --- Start headless Unity that stays up (bridge enabled) --- - name: Start Unity (persistent bridge) run: | @@ -99,6 +111,20 @@ jobs: docker logs unity-mcp || true exit 1 + - name: Verify license paths (host) + run: | + ls -la $HOME/.local/share/unity3d || true + ls -la $HOME/.config/unity3d || true + ls -la $HOME/.cache/unity3d || true + + - name: Verify license paths (container) + run: | + docker exec $(docker ps -q --filter name=unity-mcp) bash -lc ' + ls -la /root/.local/share/unity3d || true + ls -la /root/.config/unity3d || true + ls -la /root/.cache/unity3d || true + ' + # --- Claude suites --- - name: Claude startup sanity id: claude_startup From d96ea2e3352688a69e33f4ed7ec4af3a88bed553 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:08:41 -0700 Subject: [PATCH 105/311] CI: gate Claude via outputs; mount all Unity license dirs; fix inline probe python; stabilize licensing flow --- .github/workflows/claude-nl-suite.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index ad4b347a..3cfcde37 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -20,11 +20,11 @@ jobs: timeout-minutes: 60 steps: - - name: Detect Anthropic key (env) + - name: Detect secrets (outputs) + id: detect run: | - if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "ANTHROPIC_OK=true" >> "$GITHUB_ENV"; else echo "ANTHROPIC_OK=false" >> "$GITHUB_ENV"; fi - if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then echo "UNITY_LIC_OK=true" >> "$GITHUB_ENV"; else echo "UNITY_LIC_OK=false" >> "$GITHUB_ENV"; fi - echo "UNITY_LICENSE=${{ secrets.UNITY_LICENSE }}" >> "$GITHUB_ENV" + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then echo "unity_lic_ok=true" >> "$GITHUB_OUTPUT"; else echo "unity_lic_ok=false" >> "$GITHUB_OUTPUT"; fi - uses: actions/checkout@v4 with: { fetch-depth: 0 } @@ -44,15 +44,15 @@ jobs: fi - name: Write Unity license (ULF) to host - if: env.UNITY_LIC_OK == 'true' + if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux mkdir -p "$HOME/.local/share/unity3d" "$HOME/.config/unity3d" "$HOME/.cache/unity3d" - printf '%s' "$UNITY_LICENSE" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" - name: Activate Unity license (one-shot container) - if: env.UNITY_LIC_OK == 'true' + if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux docker run --rm \ @@ -129,7 +129,7 @@ jobs: - name: Claude startup sanity id: claude_startup uses: anthropics/claude-code-base-action@beta - if: env.ANTHROPIC_OK == 'true' + if: steps.detect.outputs.anthropic_ok == 'true' with: prompt_file: .claude/prompts/nl-startuptest.md allowed_tools: > @@ -165,7 +165,7 @@ jobs: - name: Claude NL/T suite uses: anthropics/claude-code-base-action@beta - if: env.ANTHROPIC_OK == 'true' && steps.claude_startup.outcome == 'success' + if: steps.detect.outputs.anthropic_ok == 'true' && steps.claude_startup.outcome == 'success' with: prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md allowed_tools: > From 307f48946f46990cd637dcfa755aaec70388c08c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:16:24 -0700 Subject: [PATCH 106/311] CI: normalize detect to step outputs; ensure license dirs mounted and validated; fix indentation --- .github/workflows/claude-nl-suite.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 3cfcde37..a7122291 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -25,12 +25,16 @@ jobs: run: | if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then echo "unity_lic_ok=true" >> "$GITHUB_OUTPUT"; else echo "unity_lic_ok=false" >> "$GITHUB_OUTPUT"; fi + - uses: actions/checkout@v4 - with: { fetch-depth: 0 } + with: + fetch-depth: 0 # --- Python env for MCP server (uv) --- - uses: astral-sh/setup-uv@v4 - with: { python-version: '3.11' } + with: + python-version: '3.11' + - name: Install MCP server run: | set -eux @@ -43,6 +47,7 @@ jobs: uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt fi + # --- License: write ULF to host and activate in a one-shot container --- - name: Write Unity license (ULF) to host if: steps.detect.outputs.unity_lic_ok == 'true' run: | @@ -65,9 +70,10 @@ jobs: -batchmode -nographics -quit -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf - + # --- Start headless Unity that stays up (bridge enabled) --- - name: Start Unity (persistent bridge) + if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux mkdir -p "$HOME/.unity-mcp" \ @@ -90,6 +96,7 @@ jobs: -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Wait for Unity bridge + if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux docker logs -f unity-mcp & LOGPID=$! @@ -112,19 +119,21 @@ jobs: exit 1 - name: Verify license paths (host) + if: steps.detect.outputs.unity_lic_ok == 'true' run: | - ls -la $HOME/.local/share/unity3d || true - ls -la $HOME/.config/unity3d || true - ls -la $HOME/.cache/unity3d || true + ls -la "$HOME/.local/share/unity3d" || true + ls -la "$HOME/.config/unity3d" || true + ls -la "$HOME/.cache/unity3d" || true - name: Verify license paths (container) + if: steps.detect.outputs.unity_lic_ok == 'true' run: | - docker exec $(docker ps -q --filter name=unity-mcp) bash -lc ' + docker exec "$(docker ps -q --filter name=unity-mcp)" bash -lc ' ls -la /root/.local/share/unity3d || true ls -la /root/.config/unity3d || true ls -la /root/.cache/unity3d || true ' - + # --- Claude suites --- - name: Claude startup sanity id: claude_startup From c908f8be345e0f179c8500ccc316a5fbc3195892 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:17:49 -0700 Subject: [PATCH 107/311] Bridge: honor UNITY_MCP_STATUS_DIR for heartbeat/status file (CI-friendly) --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 41 +++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 5b2cdf94..329a5365 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -415,10 +415,19 @@ private static async Task HandleClientAsync(TcpClient client) { string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); - await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length); + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); +#else + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); +#endif + Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + } + catch (Exception ex) + { + Debug.LogWarning($"UNITY-MCP: Handshake failed: {ex.Message}"); + return; // abort this client } - catch { /* ignore */ } - Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); byte[] buffer = new byte[8192]; while (isRunning) @@ -517,14 +526,29 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + await WriteFrameAsync(stream, payload, cts.Token); + } + + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + { + if (payload == null) + { + throw new System.ArgumentNullException(nameof(payload)); + } if ((ulong)payload.LongLength > MaxFrameBytes) { throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); } byte[] header = new byte[8]; WriteUInt64BigEndian(header, (ulong)payload.LongLength); - await stream.WriteAsync(header, 0, header.Length); - await stream.WriteAsync(payload, 0, payload.Length); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); + await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); +#else + await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); +#endif } private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs) @@ -836,7 +860,12 @@ private static void WriteHeartbeat(bool reloading, string reason = null) { try { - string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + // Allow override of status directory (useful in CI/containers) + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); var payload = new From 8a456bda988671e2a91144ab06dac2fa91b3ae8f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:21:34 -0700 Subject: [PATCH 108/311] CI: guard project path for activation/start; align tool allowlist; run MCP server with python; tighten secret scoping --- .github/workflows/claude-nl-suite.yml | 32 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index a7122291..85333771 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -45,21 +45,34 @@ jobs: uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No Python deps found (skipping explicit install)" fi + python -c "import mcp; print('mcp import OK')" # --- License: write ULF to host and activate in a one-shot container --- - name: Write Unity license (ULF) to host if: steps.detect.outputs.unity_lic_ok == 'true' + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} run: | set -eux mkdir -p "$HOME/.local/share/unity3d" "$HOME/.config/unity3d" "$HOME/.cache/unity3d" - printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + printf '%s' "$UNITY_LICENSE" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" - name: Activate Unity license (one-shot container) if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity test project not found; skipping activation."; + exit 0; + fi docker run --rm \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.config/unity3d:/root/.config/unity3d" \ @@ -76,10 +89,15 @@ jobs: if: steps.detect.outputs.unity_lic_ok == 'true' run: | set -eux + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity test project not found; skipping Unity start."; + exit 0; + fi mkdir -p "$HOME/.unity-mcp" \ "$HOME/.local/share/unity3d" \ "$HOME/.config/unity3d" \ "$HOME/.cache/unity3d" + docker rm -f unity-mcp || true docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ @@ -142,7 +160,7 @@ jobs: with: prompt_file: .claude/prompts/nl-startuptest.md allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep, + Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, @@ -156,8 +174,8 @@ jobs: { "mcpServers": { "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "command": "python", + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -178,7 +196,7 @@ jobs: with: prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep, + Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, @@ -192,8 +210,8 @@ jobs: { "mcpServers": { "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "command": "python", + "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", From 88545b0f304d7e0441baefcf3875d031a16b9d6b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 15:33:40 -0700 Subject: [PATCH 109/311] CI: finalize Unity licensing mounts + status dir; mode-detect (ULF/EBL); readiness logs+probe; Claude gating via outputs --- .github/workflows/claude-nl-suite.yml | 102 +++++++++++++++----------- 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 85333771..f8b8c8ce 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -20,17 +20,35 @@ jobs: timeout-minutes: 60 steps: + # ---------- Detect secrets & choose licensing mode ---------- - name: Detect secrets (outputs) id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | - if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi - if [ -n "${{ secrets.UNITY_LICENSE }}" ]; then echo "unity_lic_ok=true" >> "$GITHUB_OUTPUT"; else echo "unity_lic_ok=false" >> "$GITHUB_OUTPUT"; fi + set -e + # Anthropic + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + # License mode detection + if [ -n "$UNITY_LICENSE" ]; then + mode=ulf + elif { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + mode=ebl + else + mode=none + fi + echo "license_mode=$mode" >> "$GITHUB_OUTPUT" + echo "Detected license_mode=$mode" - uses: actions/checkout@v4 with: fetch-depth: 0 - # --- Python env for MCP server (uv) --- + # ---------- Python env for MCP server (uv) ---------- - uses: astral-sh/setup-uv@v4 with: python-version: '3.11' @@ -45,34 +63,21 @@ jobs: uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt - elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then - uv pip install -e UnityMcpBridge/UnityMcpServer~/ - elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then - uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt - else - echo "No Python deps found (skipping explicit install)" fi - python -c "import mcp; print('mcp import OK')" - # --- License: write ULF to host and activate in a one-shot container --- + # ---------- License activation (ULF path) ---------- - name: Write Unity license (ULF) to host - if: steps.detect.outputs.unity_lic_ok == 'true' - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + if: steps.detect.outputs.license_mode == 'ulf' run: | set -eux mkdir -p "$HOME/.local/share/unity3d" "$HOME/.config/unity3d" "$HOME/.cache/unity3d" - printf '%s' "$UNITY_LICENSE" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" + printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" - - name: Activate Unity license (one-shot container) - if: steps.detect.outputs.unity_lic_ok == 'true' + - name: Activate Unity license (one-shot container, ULF) + if: steps.detect.outputs.license_mode == 'ulf' run: | set -eux - if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then - echo "Unity test project not found; skipping activation."; - exit 0; - fi docker run --rm \ -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ -v "$HOME/.config/unity3d:/root/.config/unity3d" \ @@ -84,20 +89,37 @@ jobs: -projectPath /workspace/TestProjects/UnityMCPTests \ -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf - # --- Start headless Unity that stays up (bridge enabled) --- + # ---------- License activation (EBL path: email/password and/or serial) ---------- + - name: Activate Unity license (one-shot container, EBL) + if: steps.detect.outputs.license_mode == 'ebl' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eux + docker run --rm \ + -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ + -v "$HOME/.config/unity3d:/root/.config/unity3d" \ + -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + unityci/editor:2021.3.45f1-base-3 \ + /opt/unity/Editor/Unity \ + -batchmode -nographics -quit -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + ${UNITY_SERIAL:+-serial "$UNITY_SERIAL"} \ + ${UNITY_EMAIL:+-username "$UNITY_EMAIL"} \ + ${UNITY_PASSWORD:+-password "$UNITY_PASSWORD"} + + # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) - if: steps.detect.outputs.unity_lic_ok == 'true' + if: steps.detect.outputs.license_mode != 'none' run: | set -eux - if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then - echo "Unity test project not found; skipping Unity start."; - exit 0; - fi mkdir -p "$HOME/.unity-mcp" \ "$HOME/.local/share/unity3d" \ "$HOME/.config/unity3d" \ "$HOME/.cache/unity3d" - docker rm -f unity-mcp || true docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ @@ -114,17 +136,15 @@ jobs: -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Wait for Unity bridge - if: steps.detect.outputs.unity_lic_ok == 'true' + if: steps.detect.outputs.license_mode != 'none' run: | set -eux docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do - # Prefer a log marker; adjust to your actual log text: if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then echo "Bridge ready (logs)"; kill $LOGPID || true; exit 0 fi - # Fallback: JSON + socket probe (optional) PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) if [ -n "${PORT:-}" ] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then echo "Bridge ready on port $PORT"; kill $LOGPID || true; exit 0 @@ -137,14 +157,14 @@ jobs: exit 1 - name: Verify license paths (host) - if: steps.detect.outputs.unity_lic_ok == 'true' + if: steps.detect.outputs.license_mode != 'none' run: | ls -la "$HOME/.local/share/unity3d" || true ls -la "$HOME/.config/unity3d" || true ls -la "$HOME/.cache/unity3d" || true - name: Verify license paths (container) - if: steps.detect.outputs.unity_lic_ok == 'true' + if: steps.detect.outputs.license_mode != 'none' run: | docker exec "$(docker ps -q --filter name=unity-mcp)" bash -lc ' ls -la /root/.local/share/unity3d || true @@ -152,7 +172,7 @@ jobs: ls -la /root/.cache/unity3d || true ' - # --- Claude suites --- + # ---------- Claude suites ---------- - name: Claude startup sanity id: claude_startup uses: anthropics/claude-code-base-action@beta @@ -160,7 +180,7 @@ jobs: with: prompt_file: .claude/prompts/nl-startuptest.md allowed_tools: > - Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, + Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, @@ -174,8 +194,8 @@ jobs: { "mcpServers": { "unity": { - "command": "python", - "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"], + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -196,7 +216,7 @@ jobs: with: prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md allowed_tools: > - Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, + Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, @@ -210,8 +230,8 @@ jobs: { "mcpServers": { "unity": { - "command": "python", - "args": ["UnityMcpBridge/UnityMcpServer~/src/server.py"], + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -226,7 +246,7 @@ jobs: timeout_minutes: "12" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # --- Always stop Unity --- + # ---------- Always stop Unity ---------- - name: Stop Unity if: always() run: | From 6ab5d129e65c6cd66cb2cd58e72f01214db836c3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 16:03:35 -0700 Subject: [PATCH 110/311] CI: fix YAML probe (inline python -c) and finalize happy-path Unity licensing and MCP/Claude wiring --- .github/workflows/claude-nl-suite.yml | 78 ++++++++------------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index f8b8c8ce..a6ad3c56 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 60 steps: - # ---------- Detect secrets & choose licensing mode ---------- + # ---------- Detect secrets ---------- - name: Detect secrets (outputs) id: detect env: @@ -31,18 +31,12 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | set -e - # Anthropic if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi - # License mode detection - if [ -n "$UNITY_LICENSE" ]; then - mode=ulf - elif { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then - mode=ebl + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" else - mode=none + echo "unity_ok=false" >> "$GITHUB_OUTPUT" fi - echo "license_mode=$mode" >> "$GITHUB_OUTPUT" - echo "Detected license_mode=$mode" - uses: actions/checkout@v4 with: @@ -65,55 +59,24 @@ jobs: uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt fi - # ---------- License activation (ULF path) ---------- - - name: Write Unity license (ULF) to host - if: steps.detect.outputs.license_mode == 'ulf' - run: | - set -eux - mkdir -p "$HOME/.local/share/unity3d" "$HOME/.config/unity3d" "$HOME/.cache/unity3d" - printf '%s' "${{ secrets.UNITY_LICENSE }}" | tr -d '\r' > "$HOME/.local/share/unity3d/Unity_lic.ulf" - test -s "$HOME/.local/share/unity3d/Unity_lic.ulf" - - - name: Activate Unity license (one-shot container, ULF) - if: steps.detect.outputs.license_mode == 'ulf' - run: | - set -eux - docker run --rm \ - -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ - -v "$HOME/.config/unity3d:/root/.config/unity3d" \ - -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - unityci/editor:2021.3.45f1-base-3 \ - /opt/unity/Editor/Unity \ - -batchmode -nographics -quit -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf - - # ---------- License activation (EBL path: email/password and/or serial) ---------- - - name: Activate Unity license (one-shot container, EBL) - if: steps.detect.outputs.license_mode == 'ebl' + # ---------- License prime on host (handles ULF or EBL) ---------- + - name: Prime Unity license on host (GameCI) + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # optional ULF (raw or base64) + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} # optional sign-in UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -eux - docker run --rm \ - -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ - -v "$HOME/.config/unity3d:/root/.config/unity3d" \ - -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - unityci/editor:2021.3.45f1-base-3 \ - /opt/unity/Editor/Unity \ - -batchmode -nographics -quit -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - ${UNITY_SERIAL:+-serial "$UNITY_SERIAL"} \ - ${UNITY_EMAIL:+-username "$UNITY_EMAIL"} \ - ${UNITY_PASSWORD:+-password "$UNITY_PASSWORD"} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} # optional serial + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + # Run no tests; just force activation & cache warmup + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) - if: steps.detect.outputs.license_mode != 'none' + if: steps.detect.outputs.unity_ok == 'true' run: | set -eux mkdir -p "$HOME/.unity-mcp" \ @@ -136,7 +99,7 @@ jobs: -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Wait for Unity bridge - if: steps.detect.outputs.license_mode != 'none' + if: steps.detect.outputs.unity_ok == 'true' run: | set -eux docker logs -f unity-mcp & LOGPID=$! @@ -156,15 +119,16 @@ jobs: docker logs unity-mcp || true exit 1 + # ---------- Optional diagnostics ---------- - name: Verify license paths (host) - if: steps.detect.outputs.license_mode != 'none' + if: steps.detect.outputs.unity_ok == 'true' run: | ls -la "$HOME/.local/share/unity3d" || true ls -la "$HOME/.config/unity3d" || true ls -la "$HOME/.cache/unity3d" || true - name: Verify license paths (container) - if: steps.detect.outputs.license_mode != 'none' + if: steps.detect.outputs.unity_ok == 'true' run: | docker exec "$(docker ps -q --filter name=unity-mcp)" bash -lc ' ls -la /root/.local/share/unity3d || true From 36ea1425dfdc52d528ec2aa96fdb7d381d8179c3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 16:37:40 -0700 Subject: [PATCH 111/311] CI: inline python probe; unify Unity image and cache mounts; ready to test --- .github/workflows/claude-nl-suite.yml | 48 ++++++++++++--------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index a6ad3c56..89e5bf6c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -13,6 +13,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + UNITY_VERSION: 2021.3.45f1 + # Use the SAME image everywhere to avoid a second pull and env drift + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 + # Where GameCI writes /root in its container on the host: + UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home + jobs: nl-suite: if: github.event_name == 'workflow_dispatch' @@ -71,28 +78,34 @@ jobs: with: projectPath: TestProjects/UnityMCPTests testMode: EditMode - # Run no tests; just force activation & cache warmup customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + # Ensure GameCI uses the same editor version + unityVersion: ${{ env.UNITY_VERSION }} + + # (Optional) Show where the license actually got written + - name: Inspect GameCI license caches (host) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 -type f \( -name '*.ulf' -o -name 'user.json' \) -print || true # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) if: steps.detect.outputs.unity_ok == 'true' run: | set -eux - mkdir -p "$HOME/.unity-mcp" \ - "$HOME/.local/share/unity3d" \ - "$HOME/.config/unity3d" \ - "$HOME/.cache/unity3d" + mkdir -p "$HOME/.unity-mcp" docker run -d --name unity-mcp \ --network host \ -e UNITY_MCP_ALLOW_BATCH=1 \ -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "$HOME/.local/share/unity3d:/root/.local/share/unity3d" \ - -v "$HOME/.config/unity3d:/root/.config/unity3d" \ - -v "$HOME/.cache/unity3d:/root/.cache/unity3d" \ + # Mount the EXACT paths GameCI wrote: + -v "${{ env.UNITY_CACHE_ROOT }}/.local/share/unity3d:/root/.local/share/unity3d" \ + -v "${{ env.UNITY_CACHE_ROOT }}/.config/unity3d:/root/.config/unity3d" \ + -v "${{ env.UNITY_CACHE_ROOT }}/.cache/unity3d:/root/.cache/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ - unityci/editor:2021.3.45f1-base-3 \ + ${{ env.UNITY_IMAGE }} \ /opt/unity/Editor/Unity \ -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ @@ -119,23 +132,6 @@ jobs: docker logs unity-mcp || true exit 1 - # ---------- Optional diagnostics ---------- - - name: Verify license paths (host) - if: steps.detect.outputs.unity_ok == 'true' - run: | - ls -la "$HOME/.local/share/unity3d" || true - ls -la "$HOME/.config/unity3d" || true - ls -la "$HOME/.cache/unity3d" || true - - - name: Verify license paths (container) - if: steps.detect.outputs.unity_ok == 'true' - run: | - docker exec "$(docker ps -q --filter name=unity-mcp)" bash -lc ' - ls -la /root/.local/share/unity3d || true - ls -la /root/.config/unity3d || true - ls -la /root/.cache/unity3d || true - ' - # ---------- Claude suites ---------- - name: Claude startup sanity id: claude_startup From ef996213b2bc8db20c9cea5ce2e0094a267026b7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 16:48:51 -0700 Subject: [PATCH 112/311] CI: fix docker run IMAGE placement; ignore cache find perms; keep same editor image --- .github/workflows/claude-nl-suite.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 89e5bf6c..334b5cff 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -87,7 +87,7 @@ jobs: if: steps.detect.outputs.unity_ok == 'true' run: | set -eux - find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 -type f \( -name '*.ulf' -o -name 'user.json' \) -print || true + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) @@ -95,19 +95,14 @@ jobs: run: | set -eux mkdir -p "$HOME/.unity-mcp" - docker run -d --name unity-mcp \ - --network host \ - -e UNITY_MCP_ALLOW_BATCH=1 \ - -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + docker run -d --name unity-mcp --network host \ + -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - # Mount the EXACT paths GameCI wrote: -v "${{ env.UNITY_CACHE_ROOT }}/.local/share/unity3d:/root/.local/share/unity3d" \ -v "${{ env.UNITY_CACHE_ROOT }}/.config/unity3d:/root/.config/unity3d" \ -v "${{ env.UNITY_CACHE_ROOT }}/.cache/unity3d:/root/.cache/unity3d" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ - ${{ env.UNITY_IMAGE }} \ - /opt/unity/Editor/Unity \ - -batchmode -nographics -logFile - \ + ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect From 9b923c32e5b4cdedc85a6f76b1e4179a4490de94 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:00:06 -0700 Subject: [PATCH 113/311] CI: pass -manualLicenseFile to persistent Editor; keep mounts and single image --- .github/workflows/claude-nl-suite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 334b5cff..22054df2 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -104,6 +104,7 @@ jobs: -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ + -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Wait for Unity bridge From e020cbc4dc07d5d7d192b0ce9567a3925bf4353b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:05:52 -0700 Subject: [PATCH 114/311] CI: mount full GameCI cache to /root in persistent Unity; set HOME=/root; add optional license check --- .github/workflows/claude-nl-suite.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 22054df2..3b459279 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -96,17 +96,25 @@ jobs: set -eux mkdir -p "$HOME/.unity-mcp" docker run -d --name unity-mcp --network host \ + -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "${{ env.UNITY_CACHE_ROOT }}/.local/share/unity3d:/root/.local/share/unity3d" \ - -v "${{ env.UNITY_CACHE_ROOT }}/.config/unity3d:/root/.config/unity3d" \ - -v "${{ env.UNITY_CACHE_ROOT }}/.cache/unity3d:/root/.cache/unity3d" \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect + - name: Check license inside persistent container (optional) + if: steps.detect.outputs.unity_ok == 'true' + run: | + docker exec unity-mcp bash -lc ' + set -eux + ls -ld /root /root/.local/share/unity3d /root/.config/unity3d /root/.cache/unity3d || true + stat -c "%n %s bytes" /root/.local/share/unity3d/Unity_lic.ulf || true + ' + - name: Wait for Unity bridge if: steps.detect.outputs.unity_ok == 'true' run: | From 0916ad2469cfe7a6d4503c87705cf5b567e87791 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:19:39 -0700 Subject: [PATCH 115/311] CI: make -manualLicenseFile conditional; keep full /root mount and license check --- .github/workflows/claude-nl-suite.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 3b459279..cf6600af 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -95,6 +95,11 @@ jobs: run: | set -eux mkdir -p "$HOME/.unity-mcp" + # Only pass manualLicenseFile if the ULF actually exists from priming + MANUAL_ARG=() + if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) + fi docker run -d --name unity-mcp --network host \ -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ @@ -103,7 +108,7 @@ jobs: -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ - -manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf \ + "${MANUAL_ARG[@]}" \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Check license inside persistent container (optional) From 9a37cc73cab04df11aec8397d87b60a1e3184f69 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:29:28 -0700 Subject: [PATCH 116/311] CI: set HOME=/github/home; mount GameCI cache there; adjust manualLicenseFile path; expand license check --- .github/workflows/claude-nl-suite.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cf6600af..9b3ff874 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -98,13 +98,13 @@ jobs: # Only pass manualLicenseFile if the ULF actually exists from priming MANUAL_ARG=() if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then - MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) + MANUAL_ARG=(-manualLicenseFile /github/home/.local/share/unity3d/Unity_lic.ulf) fi docker run -d --name unity-mcp --network host \ - -e HOME=/root \ + -e HOME=/github/home \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ + -v "${{ env.UNITY_CACHE_ROOT }}:/github/home" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ @@ -116,8 +116,10 @@ jobs: run: | docker exec unity-mcp bash -lc ' set -eux - ls -ld /root /root/.local/share/unity3d /root/.config/unity3d /root/.cache/unity3d || true - stat -c "%n %s bytes" /root/.local/share/unity3d/Unity_lic.ulf || true + echo "HOME is: $HOME" || true + ls -ld /github/home /github/home/.local/share/unity3d /github/home/.config/unity3d /github/home/.cache/unity3d || true + ls -l /github/home/.config/unity3d || true + stat -c "%n %s bytes" /github/home/.local/share/unity3d/Unity_lic.ulf || true ' - name: Wait for Unity bridge From 2a80d12eccf76f7252632de3e7f88ed5336e7c06 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:42:44 -0700 Subject: [PATCH 117/311] CI: EBL sign-in for persistent Unity (email/password/serial); revert HOME=/root and full /root mount; keep conditional manualLicenseFile and improved readiness --- .github/workflows/claude-nl-suite.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9b3ff874..4d8736a4 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -92,23 +92,33 @@ jobs: # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) if: steps.detect.outputs.unity_ok == 'true' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | - set -eux + set -eu mkdir -p "$HOME/.unity-mcp" # Only pass manualLicenseFile if the ULF actually exists from priming MANUAL_ARG=() if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then - MANUAL_ARG=(-manualLicenseFile /github/home/.local/share/unity3d/Unity_lic.ulf) + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) fi + # Only pass EBL args that are present + EBL_ARGS=() + [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") + [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") + [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") docker run -d --name unity-mcp --network host \ - -e HOME=/github/home \ + -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "${{ env.UNITY_CACHE_ROOT }}:/github/home" \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${MANUAL_ARG[@]}" \ + "${EBL_ARGS[@]}" \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - name: Check license inside persistent container (optional) @@ -117,9 +127,9 @@ jobs: docker exec unity-mcp bash -lc ' set -eux echo "HOME is: $HOME" || true - ls -ld /github/home /github/home/.local/share/unity3d /github/home/.config/unity3d /github/home/.cache/unity3d || true - ls -l /github/home/.config/unity3d || true - stat -c "%n %s bytes" /github/home/.local/share/unity3d/Unity_lic.ulf || true + ls -ld /root /root/.local/share/unity3d /root/.config/unity3d /root/.cache/unity3d || true + ls -l /root/.config/unity3d || true + stat -c "%n %s bytes" /root/.local/share/unity3d/Unity_lic.ulf || true ' - name: Wait for Unity bridge @@ -129,7 +139,7 @@ jobs: docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do - if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started|Pro License: YES"; then echo "Bridge ready (logs)"; kill $LOGPID || true; exit 0 fi PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) From db562c3c12562623c2266cd8b39c07df6dc8174b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 17:59:22 -0700 Subject: [PATCH 118/311] CI: run full NL/T suite prompt (nl-unity-suite-full.md) instead of mini --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 4d8736a4..a5c4a0ac 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -195,7 +195,7 @@ jobs: uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.claude_startup.outcome == 'success' with: - prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md + prompt_file: .claude/prompts/nl-unity-suite-full.md allowed_tools: > Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, From daca6cc64f8e1f7996ccf680fd41991330725f6b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 19:54:14 -0700 Subject: [PATCH 119/311] NL/T: require unified diffs + explicit verdicts in JUnit; CI: remove short sanity step, publish JUnit, upload artifacts --- .claude/prompts/nl-unity-suite-full.md | 6 ++- .github/workflows/claude-nl-suite.yml | 60 +++++++++----------------- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 18bdc45a..704b9d2d 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -9,8 +9,10 @@ You are running in CI at the repository root. Use only the tools allowed by the ## Output requirements - JUnit XML at `reports/claude-nl-tests.xml`; each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. -- On failure: include `` with concise message and last evidence window (10–20 lines). -- Summary markdown at `reports/claude-nl-tests.md` with checkboxes and windowed reads. +- For any test that performs changes, include a compact unified diff of the changes in `` (preferred) or in the ``/`` body when applicable. Use standard unified diff format with context (e.g., starting with `---`/`+++` and `@@` hunks). +- Each test must write an explicit verdict string in `` as the last line: `VERDICT: PASS` or `VERDICT: FAIL`. +- On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. +- Summary markdown at `reports/claude-nl-tests.md` with checkboxes, windowed reads, and inline diffs for changed tests. - Restore workspace at end (clean tree). ## Safety & hygiene diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index a5c4a0ac..50b9d2c9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -154,46 +154,11 @@ jobs: exit 1 # ---------- Claude suites ---------- - - name: Claude startup sanity - id: claude_startup - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - with: - prompt_file: .claude/prompts/nl-startuptest.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep, - ListMcpResourcesTool,ReadMcpResourceTool, - mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, - mcp__unity__create_script,mcp__unity__delete_script, - mcp__unity__validate_script,mcp__unity__manage_script, - mcp__unity__manage_scene,mcp__unity__manage_editor, - mcp__unity__manage_gameobject,mcp__unity__manage_asset, - mcp__unity__manage_shader,mcp__unity__read_console, - mcp__unity__execute_menu_item,mcp__unity__list_resources, - mcp__unity__read_resource,mcp__unity__find_in_file - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - } - } - model: "claude-3-7-sonnet-20250219" - max_turns: "8" - timeout_minutes: "7" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Removed short sanity suite; full suite below contains coverage - - name: Claude NL/T suite + - name: Claude NL/T suite (full) uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' && steps.claude_startup.outcome == 'success' + if: steps.detect.outputs.anthropic_ok == 'true' with: prompt_file: .claude/prompts/nl-unity-suite-full.md allowed_tools: > @@ -223,10 +188,27 @@ jobs: } } model: "claude-3-7-sonnet-20250219" - max_turns: "16" + max_turns: "50" timeout_minutes: "12" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Publish JUnit report + if: always() + uses: mikepenz/action-junit-report@v4 + with: + report_paths: 'reports/claude-nl-tests.xml' + require_tests: false + annotate_notice: true + + - name: Upload NL/T artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-suite-artifacts + path: | + reports/claude-nl-tests.xml + reports/claude-nl-tests.md + # ---------- Always stop Unity ---------- - name: Stop Unity if: always() From 59e3326b69e563d9905db7b376647a9471a31396 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 20:13:30 -0700 Subject: [PATCH 120/311] NL/T prompt: require CDATA wrapping for JUnit XML fields; guidance for splitting embedded ]]>; keep VERDICT in CDATA only --- .claude/prompts/nl-unity-suite-full.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 704b9d2d..3a22f7c5 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -13,6 +13,21 @@ You are running in CI at the repository root. Use only the tools allowed by the - Each test must write an explicit verdict string in `` as the last line: `VERDICT: PASS` or `VERDICT: FAIL`. - On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. - Summary markdown at `reports/claude-nl-tests.md` with checkboxes, windowed reads, and inline diffs for changed tests. +- XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: + + ```xml + + + + ``` + + JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. - Restore workspace at end (clean tree). ## Safety & hygiene From 9b772f623aeb1a766a5e804a6933bb44867aea20 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 20:16:13 -0700 Subject: [PATCH 121/311] CI: remove in-container license check step; keep readiness and full suite --- .github/workflows/claude-nl-suite.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 50b9d2c9..6889a152 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -15,7 +15,7 @@ concurrency: env: UNITY_VERSION: 2021.3.45f1 - # Use the SAME image everywhere to avoid a second pull and env drift + # Use the SAME image everywhere to avoid a s econd pull and env drift UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 # Where GameCI writes /root in its container on the host: UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home @@ -121,17 +121,7 @@ jobs: "${EBL_ARGS[@]}" \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - - name: Check license inside persistent container (optional) - if: steps.detect.outputs.unity_ok == 'true' - run: | - docker exec unity-mcp bash -lc ' - set -eux - echo "HOME is: $HOME" || true - ls -ld /root /root/.local/share/unity3d /root/.config/unity3d /root/.cache/unity3d || true - ls -l /root/.config/unity3d || true - stat -c "%n %s bytes" /root/.local/share/unity3d/Unity_lic.ulf || true - ' - + # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge if: steps.detect.outputs.unity_ok == 'true' run: | @@ -188,7 +178,7 @@ jobs: } } model: "claude-3-7-sonnet-20250219" - max_turns: "50" + max_turns: "250" timeout_minutes: "12" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 7fa2da96f035a91dbd81934ef6ab17c321b03a17 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 20:19:49 -0700 Subject: [PATCH 122/311] NL/T prompt: add version header, stricter JUnit schema, hashing/normalization, anchors, statuses, atomic semantics, tool logging --- .claude/prompts/nl-unity-suite-full.md | 101 ++++++++++++++++++------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 3a22f7c5..b8ffe4c3 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,16 +1,25 @@ # Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) +Version: 1.0.0 (update this when the prompt changes materially) +Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) -You are running in CI at the repository root. Use only the tools allowed by the workflow. +You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). +At the start of the first test, log the effective `allowed_tools` list into the `` for easier troubleshooting. ## Test target - Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` - Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. -- Include `precondition_sha256` for any text-path write. In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. +- Include `precondition_sha256` for any write (text-path or structured edit). In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. + - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). ## Output requirements -- JUnit XML at `reports/claude-nl-tests.xml`; each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. -- For any test that performs changes, include a compact unified diff of the changes in `` (preferred) or in the ``/`` body when applicable. Use standard unified diff format with context (e.g., starting with `---`/`+++` and `@@` hunks). -- Each test must write an explicit verdict string in `` as the last line: `VERDICT: PASS` or `VERDICT: FAIL`. +- JUnit XML at `reports/claude-nl-tests.xml`. Create the `reports/` directory if missing. +- One `` wrapping all `` elements. +- Each `` must set: + - `classname` ∈ {`UnityMCP.NL`, `UnityMCP.T`} + - `name` = short, unique id (e.g., `NL-1.ReplaceHasTarget`, `T-F.AtomicBatch`) + - `time` in seconds (float) +- Emit `` with evidence and end with a single terminal line: `VERDICT: PASS` or `VERDICT: FAIL` (uppercase, exact match). +- For any test that performs changes, include a compact unified diff in `` using the standard format and cap to 300 lines. If truncated, include `...diff truncated...` before `VERDICT: ...`. - On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. - Summary markdown at `reports/claude-nl-tests.md` with checkboxes, windowed reads, and inline diffs for changed tests. - XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: @@ -28,40 +37,51 @@ VERDICT: PASS ``` JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. +- Upload both `reports/claude-nl-tests.xml` and `reports/claude-nl-tests.md` as workflow artifacts. - Restore workspace at end (clean tree). ## Safety & hygiene -- Make edits in-place, then revert after validation (git stash/reset or counter-edits) so the workspace is clean. +- Make edits in-place, then revert after validation so the workspace is clean. + - Preferred: `git restore --staged --worktree :/` (or `git checkout -- .` on older Git) to discard all changes. + - Avoid `git stash` in CI unless you also clear stashes, as they may complicate cleanup. - Never push commits from CI. ## CI headless hints -- For `mcp__unity__list_resources`/`read_resource`, include `project_root: "TestProjects/UnityMCPTests"`. `ctx` is optional. +- For `mcp__unity__list_resources`/`read_resource`, specify: + - `project_root`: string (required—no default), e.g., `"TestProjects/UnityMCPTests"` + - `ctx`: object (optional, defaults to `{}`) +- Canonical URIs: + - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + - `Assets/Scripts/LongUnityScriptClaudeTest.cs` (normalized by the server) ## NL-0. Sanity Reads (windowed) -- Tail 120 lines of the target file. -- Show 40 lines around method `Update`. -- Pass if both windows render expected anchors. +- Tail 120 lines of the target file; expect to find the class closing brace `^\s*}\s*$` and at least one `Debug\\.Log` call. +- Show 40 lines around method `Update` (anchor: `^\s*public\s+void\s+Update\s*\(`). +- Pass if: + - Tail window contains the final class brace. + - The `Update` window contains the method signature line and at least one statement. ## 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`. +- Insert `PrintSeries()` after `GetCurrentTarget` that logs `1,2,3` via `UnityEngine.Debug.Log("1,2,3");`. - Verify by reading 20 lines around the anchor. -- Delete `PrintSeries()` and verify removal. +- Delete `PrintSeries()` and verify removal; confirm file hash equals the pre-edit hash. - Pass on matched diffs and windows. ## NL-2. Anchor comment insertion -- Add comment `Build marker OK` immediately above `Update`. -- Pass if comment appears directly above `public void Update()`. +- Insert a single-line C# comment `// Build marker OK` on the line immediately preceding the `public void Update(...)` signature (ignoring XML doc comments). +- Pass if the comment is adjacent to the signature with no blank line in between. ## NL-3. End-of-class insertion -- Insert a 3-line comment `Tail test A/B/C` before the final class brace. -- Pass if windowed read shows three lines at intended location. +- Insert a 3-line comment `// Tail test A`, `// Tail test B`, `// Tail test C` immediately before the final class brace. +- Preserve existing indentation; ensure the file ends with a single trailing newline. ## NL-4. Compile trigger (record-only) - After an edit, ensure no obvious syntax issues; record as INFO. Unity compile runs in a separate step. ## T-A. Anchor insert (text path) -- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via range-based text edit. +- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via a single `replace_range` at the exact insertion point (range start=end). +- Normalize line endings to LF (`\n`) for hashing and diff emission; preserve original on write if required by the server. - Verify; then delete with `regex_replace` targeting only that helper block. - Pass if round-trip leaves the file exactly as before. @@ -70,7 +90,8 @@ VERDICT: PASS - Pass on exact-range change + revert. ## T-C. Header/region preservation -- For `ApplyBlend`, change only interior lines via `replace_range`; signature and region markers must remain untouched. +- For `ApplyBlend`, change only interior lines via `replace_range`. + - Do not modify: method signature line, attributes, XML docs, `#region`/`#endregion` markers, or surrounding whitespace outside the body braces. - Pass if unchanged. ## T-D. End-of-class insertion (anchor) @@ -82,15 +103,23 @@ VERDICT: PASS - 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 class end; verify all-or-nothing behavior. -- Pass if either all 3 apply or none. +- In one call, perform two `replace_range` tweaks and one comment insert at class end. +- The server must apply all edits atomically or reject the entire batch. +- On rejection, respond with `{ status: "atomic_reject", reason, conflicts: [...] }` and leave the file unchanged (hash equals precondition). +- Pass if either all 3 apply or `status == "atomic_reject"` with unchanged file hash. ## T-G. Path normalization -- Run the same edit once with `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` and once with `Assets/Scripts/LongUnityScriptClaudeTest.cs` (if supported). -- Pass if both target the same file and no duplication. +- Run the same edit with both URIs: + 1) `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + 2) `Assets/Scripts/LongUnityScriptClaudeTest.cs` +- The server must canonicalize both to the same absolute path under `project_root` and reject duplicate-application within a single batch. +- Pass if both map to the same file path and the second attempt returns `{ status: "no_change" }`. ## T-H. Validation levels -- After edits, run `validate` with `level: "standard"`, then `"basic"` for temporarily unbalanced text ops; final state must be valid. +- Validation levels: + - `basic`: lexical checks (UTF-8, balanced quotes, no NULs), can tolerate temporarily unbalanced braces. + - `standard`: `basic` + C# tokenization and brace balance + forbid edits before first `using`. +- After edits, run `validate` with `level: "standard"`. If a text op is intentionally transiently unbalanced, allow `basic` only for the intermediate step; final state must pass `standard`. - Pass if validation OK and final file compiles in the Unity step. ## T-I. Failure surfaces (expected) @@ -98,19 +127,33 @@ VERDICT: PASS - Stale file: resend with old `precondition_sha256` after external change → expect `{status:"stale_file"}`. - 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`). +- Using-directives guard: attempt insert before the first `using` → expect `{status:"using_guard"}`. +- Parameter aliasing: accept `insert`/`content` as aliases for `text` in insertion APIs → expect success. Server should echo the canonical key `text` in responses. - Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return clear error. - Pass when each negative case returns expected failure without persisting changes. + +- Permitted statuses (string enum): + - "ok" + - "no_change" + - "too_large" + - "stale_file" + - "overlap" + - "unbalanced" + - "using_guard" + - "atomic_reject" + - "unsupported" +- All non-"ok"/"no_change" statuses MUST NOT modify files (verify via unchanged post-hash). + ## 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. +- Re-run the same `replace_range` with identical content → expect `{ status: "no_change" }` and unchanged hash. +- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op with `{ status: "no_change" }`. - Pass if both behave idempotently. ### Implementation notes - Always capture pre/post windows (±20–40 lines) as evidence in JUnit or system-out. -- For any file write, include `precondition_sha256` and verify post-hash in logs. -- Restore repository to original state at end (`git status` must be clean). +- For any file write, include `precondition_sha256` computed over file bytes after normalizing line endings to LF (`\n`) and ensuring UTF-8 without BOM, unless the server specifies otherwise. +- Verify the post-edit file hash in logs and include both pre- and post-hashes in ``. +- Restore repository to original state at end (`git status` must be clean). If not clean, mark the suite as FAIL. From cb1371334a489a3e525aa09988ec4e8e68511338 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 20:53:31 -0700 Subject: [PATCH 123/311] CI: increase Claude NL/T suite timeout to 30 minutes --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 6889a152..09b62cff 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -179,7 +179,7 @@ jobs: } model: "claude-3-7-sonnet-20250219" max_turns: "250" - timeout_minutes: "12" + timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Publish JUnit report From c629855a59998c51abb9f075931b334a9a8ead3a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 21:06:45 -0700 Subject: [PATCH 124/311] CI: pre-create reports dir and result files to avoid tool approval prompts --- .github/workflows/claude-nl-suite.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 09b62cff..9cdb82cb 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -146,6 +146,13 @@ jobs: # ---------- Claude suites ---------- # Removed short sanity suite; full suite below contains coverage + - name: Prepare NL/T reports dir + run: | + set -eux + mkdir -p reports + : > reports/claude-nl-tests.xml + : > reports/claude-nl-tests.md + - name: Claude NL/T suite (full) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' From fc5730343aa827f0fc421f3922544c5a2cdb16a4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 21:09:50 -0700 Subject: [PATCH 125/311] CI: skip wait if container not running; skip Editor start if project missing; broaden MCP deps detection; expand allowed tools --- .github/workflows/claude-nl-suite.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9cdb82cb..5098d520 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -64,6 +64,12 @@ jobs: uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found under UnityMcpBridge/UnityMcpServer~/{src,/} (skipping install)" fi # ---------- License prime on host (handles ULF or EBL) ---------- @@ -98,6 +104,10 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; skipping Editor start." + exit 0 + fi mkdir -p "$HOME/.unity-mcp" # Only pass manualLicenseFile if the ULF actually exists from priming MANUAL_ARG=() @@ -126,6 +136,10 @@ jobs: if: steps.detect.outputs.unity_ok == 'true' run: | set -eux + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container not running; skipping wait." + exit 0 + fi docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do @@ -159,7 +173,7 @@ jobs: with: prompt_file: .claude/prompts/nl-unity-suite-full.md allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep, + Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, From 33b7ef2b49d1b94e09a5f81089ccff98a864fea5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 22:12:10 -0700 Subject: [PATCH 126/311] fixies to harden ManageScript --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 56bd83d0..c8d42a39 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -525,17 +525,22 @@ 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); + int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present + // Find first top-level using (supports alias, static, and dotted namespaces) + var mUsing = System.Text.RegularExpressions.Regex.Match( + original, + @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", + System.Text.RegularExpressions.RegexOptions.CultureInvariant + ); 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." }); + return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); } } From 7acd5514614fa2732804fd8c09ca632f919409c1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 22 Aug 2025 22:14:22 -0700 Subject: [PATCH 127/311] CI: sanitize NL/T markdown report to avoid NUL/encoding issues --- .github/workflows/claude-nl-suite.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 5098d520..64e85e52 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -203,6 +203,14 @@ jobs: timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Sanitize NL/T markdown (UTF-8, strip NULs) + if: always() + run: | + set -eu + if [ -f reports/claude-nl-tests.md ]; then + python -c "from pathlib import Path; p=Path('reports/claude-nl-tests.md'); b=p.read_bytes().replace(b'\x00', b''); s=b.decode('utf-8','replace').replace('\r\n','\n'); p.write_text(s, encoding='utf-8', newline='\n')" + fi + - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v4 From cecbb0662524de4a25dd2d33c243dbd5027580ca Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 23 Aug 2025 09:56:24 -0700 Subject: [PATCH 128/311] revert breaking yyaml changes --- .github/workflows/claude-nl-suite.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 64e85e52..9cdb82cb 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -64,12 +64,6 @@ jobs: uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt - elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then - uv pip install -e UnityMcpBridge/UnityMcpServer~/ - elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then - uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt - else - echo "No MCP Python deps found under UnityMcpBridge/UnityMcpServer~/{src,/} (skipping install)" fi # ---------- License prime on host (handles ULF or EBL) ---------- @@ -104,10 +98,6 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -eu - if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then - echo "Unity project not found; skipping Editor start." - exit 0 - fi mkdir -p "$HOME/.unity-mcp" # Only pass manualLicenseFile if the ULF actually exists from priming MANUAL_ARG=() @@ -136,10 +126,6 @@ jobs: if: steps.detect.outputs.unity_ok == 'true' run: | set -eux - if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then - echo "Unity container not running; skipping wait." - exit 0 - fi docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do @@ -173,7 +159,7 @@ jobs: with: prompt_file: .claude/prompts/nl-unity-suite-full.md allowed_tools: > - Bash(git:*),Bash,Read,Write,LS,Glob,Grep,Edit,MultiEdit, + Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, mcp__unity__create_script,mcp__unity__delete_script, @@ -203,14 +189,6 @@ jobs: timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Sanitize NL/T markdown (UTF-8, strip NULs) - if: always() - run: | - set -eu - if [ -f reports/claude-nl-tests.md ]; then - python -c "from pathlib import Path; p=Path('reports/claude-nl-tests.md'); b=p.read_bytes().replace(b'\x00', b''); s=b.decode('utf-8','replace').replace('\r\n','\n'); p.write_text(s, encoding='utf-8', newline='\n')" - fi - - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v4 From ef194ace82a32699524f829b27c10ef7970028d5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 23 Aug 2025 10:02:59 -0700 Subject: [PATCH 129/311] CI: prime license, robust Unity start/wait, sanitize markdown via heredoc --- .github/workflows/claude-nl-suite.yml | 48 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9cdb82cb..43171379 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -15,9 +15,7 @@ concurrency: env: UNITY_VERSION: 2021.3.45f1 - # Use the SAME image everywhere to avoid a s econd pull and env drift UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 - # Where GameCI writes /root in its container on the host: UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home jobs: @@ -64,6 +62,12 @@ jobs: uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found (skipping)" fi # ---------- License prime on host (handles ULF or EBL) ---------- @@ -71,15 +75,14 @@ jobs: if: steps.detect.outputs.unity_ok == 'true' uses: game-ci/unity-test-runner@v4 env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} # optional ULF (raw or base64) - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} # optional sign-in + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} # optional serial + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: projectPath: TestProjects/UnityMCPTests testMode: EditMode customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics - # Ensure GameCI uses the same editor version unityVersion: ${{ env.UNITY_VERSION }} # (Optional) Show where the license actually got written @@ -98,13 +101,15 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; failing fast." + exit 1 + fi mkdir -p "$HOME/.unity-mcp" - # Only pass manualLicenseFile if the ULF actually exists from priming MANUAL_ARG=() if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) fi - # Only pass EBL args that are present EBL_ARGS=() [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") @@ -121,11 +126,14 @@ jobs: "${EBL_ARGS[@]}" \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect - # ---------- Wait for Unity bridge ---------- + # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- - name: Wait for Unity bridge if: steps.detect.outputs.unity_ok == 'true' run: | set -eux + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container failed to start"; docker ps -a || true; exit 1 + fi docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do @@ -143,9 +151,7 @@ jobs: docker logs unity-mcp || true exit 1 - # ---------- Claude suites ---------- - # Removed short sanity suite; full suite below contains coverage - + # ---------- Prepare reports ---------- - name: Prepare NL/T reports dir run: | set -eux @@ -153,12 +159,13 @@ jobs: : > reports/claude-nl-tests.xml : > reports/claude-nl-tests.md + # ---------- Claude NL/T suite (full) ---------- - name: Claude NL/T suite (full) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > + allowed_tools: > # RESTRICTED like “71” Bash(git:*),Read,Write,LS,Glob,Grep, ListMcpResourcesTool,ReadMcpResourceTool, mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, @@ -189,6 +196,21 @@ jobs: timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # sanitize only the markdown (does not touch JUnit xml) + - name: Sanitize NL/T markdown (UTF-8, strip NULs) + if: always() + run: | + set -eu + if [ -f reports/claude-nl-tests.md ]; then + python - <<'PY' + from pathlib import Path + p=Path('reports/claude-nl-tests.md') + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY + fi + - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v4 From 9c53d7b173971e3a70e91f793be74bf86c614a62 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 10:58:05 -0700 Subject: [PATCH 130/311] Resolve merge: accept upstream renames/installer (fix/installer-cleanup-v2) and keep local framing/script-editing - Restored upstream server.py, EditorWindow, uv.lock\n- Preserved ManageScript editing/validation; switched to atomic write + debounced refresh\n- Updated tools/__init__.py to keep script_edits/resources and adopt new logger name\n- All Python tests via uv: 7 passed, 6 skipped, 9 xpassed; Unity compile OK --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 42 +--- .../Editor/Windows/MCPForUnityEditorWindow.cs | 165 ++------------- UnityMcpBridge/UnityMcpServer~/src/server.py | 194 ------------------ .../UnityMcpServer~/src/tools/__init__.py | 14 +- UnityMcpBridge/UnityMcpServer~/src/uv.lock | 34 ++- 5 files changed, 44 insertions(+), 405 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 55c7fd71..5122babf 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -6,13 +6,9 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; -<<<<<<< HEAD -using UnityMcpBridge.Editor.Helpers; +using MCPForUnity.Editor.Helpers; using System.Threading; using System.Security.Cryptography; -======= -using MCPForUnity.Editor.Helpers; ->>>>>>> fix/installer-cleanup-v2 #if USE_ROSLYN using Microsoft.CodeAnalysis; @@ -304,31 +300,22 @@ string namespaceName try { -<<<<<<< HEAD - // Atomic-ish create - var enc = System.Text.Encoding.UTF8; + // Atomic create without BOM; schedule refresh after reply + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, contents, enc); try { - // Prefer atomic move within same volume File.Move(tmp, fullPath); } catch (IOException) { - // Cross-volume or other IO constraint: fallback to copy File.Copy(tmp, fullPath, overwrite: true); try { File.Delete(tmp); } catch { } } var uri = $"unity://path/{relativePath}"; var ok = Response.Success( -======= - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); // Ensure Unity recognizes the new script - return Response.Success( ->>>>>>> fix/installer-cleanup-v2 $"Script '{name}.cs' created successfully at '{relativePath}'.", new { uri, scheduledRefresh = true } ); @@ -412,9 +399,8 @@ string contents try { -<<<<<<< HEAD - // Safe write with atomic replace when available - var encoding = System.Text.Encoding.UTF8; + // Safe write with atomic replace when available, without BOM + var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); string tempPath = fullPath + ".tmp"; File.WriteAllText(tempPath, contents, encoding); @@ -422,16 +408,7 @@ string contents try { File.Replace(tempPath, fullPath, backupPath); - // Clean up backup to avoid stray assets inside the project - try - { - if (File.Exists(backupPath)) - File.Delete(backupPath); - } - catch - { - // ignore failures deleting the backup - } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } catch (PlatformNotSupportedException) { @@ -441,7 +418,6 @@ string contents } catch (IOException) { - // Cross-volume moves can throw IOException; fallback to copy File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } @@ -450,12 +426,6 @@ string contents // Prepare success response BEFORE any operation that can trigger a domain reload var uri = $"unity://path/{relativePath}"; var ok = Response.Success( -======= - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes - AssetDatabase.Refresh(); - return Response.Success( ->>>>>>> fix/installer-cleanup-v2 $"Script '{name}.cs' updated successfully at '{relativePath}'.", new { uri, path = relativePath, scheduledRefresh = true } ); diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f14c0d0b..f29a1920 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1181,32 +1181,12 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC serverSrc = ServerInstaller.GetServerPath(); } - // 2) Canonical args order (add quiet flag to prevent stdout noise breaking MCP stdio) - var newArgs = new[] { "-q", "run", "--directory", serverSrc, "server.py" }; + // 2) Canonical args order + var newArgs = new[] { "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 @@ -1219,32 +1199,6 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC else existingRoot = JObject.FromObject(existingConfig); -<<<<<<< HEAD:UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs - string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); - - // 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}"); - } -======= existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); @@ -1255,7 +1209,6 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC System.IO.File.Replace(tmp, configPath, null); else System.IO.File.Move(tmp, configPath); ->>>>>>> fix/installer-cleanup-v2:UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); @@ -1266,23 +1219,6 @@ 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 @@ -1436,13 +1372,10 @@ private string ConfigureMcpClient(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { configPath = mcpClient.linuxConfigPath; } @@ -1465,22 +1398,6 @@ 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") { @@ -1609,13 +1526,10 @@ private void CheckMcpConfiguration(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { configPath = mcpClient.linuxConfigPath; } @@ -1627,26 +1541,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) if (!File.Exists(configPath)) { - // 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; - } + mcpClient.SetStatus(McpStatus.NotConfigured); + return; } string configJson = File.ReadAllText(configPath); @@ -1699,50 +1595,29 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else { - // Attempt auto-rewrite once if the package path changed, but only when explicitly enabled - bool autoManage = UnityEditor.EditorPrefs.GetBool("UnityMCP.AutoManageIDEConfig", false); - if (autoManage) + // Attempt auto-rewrite once if the package path changed + try { - try + string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") { - string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); - if (rewriteResult == "Configured successfully") - { -<<<<<<< HEAD:UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs - if (debugLogsEnabled) - { - UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); - } - mcpClient.SetStatus(McpStatus.Configured); - } - else + if (debugLogsEnabled) { - mcpClient.SetStatus(McpStatus.IncorrectPath); -======= UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); ->>>>>>> fix/installer-cleanup-v2:UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs } + mcpClient.SetStatus(McpStatus.Configured); } - catch (Exception ex) + else { mcpClient.SetStatus(McpStatus.IncorrectPath); - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); - } } } - else + catch (Exception ex) { - // Surface mismatch even if auto-manage is disabled mcpClient.SetStatus(McpStatus.IncorrectPath); if (debugLogsEnabled) { -<<<<<<< HEAD:UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs - UnityEngine.Debug.Log($"UnityMCP: IDE config mismatch for '{mcpClient.name}' and auto-manage disabled"); -======= UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); ->>>>>>> fix/installer-cleanup-v2:UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs } } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index cb2da7e1..29c7b6a7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,54 +1,18 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging -import sys -from logging.handlers import RotatingFileHandler from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection -from pathlib import Path -import os -import hashlib -<<<<<<< HEAD -# Configure logging: strictly stderr/file only (never stdout) -stderr_handler = logging.StreamHandler(stream=sys.stderr) -stderr_handler.setFormatter(logging.Formatter(config.log_format)) - -handlers = [stderr_handler] -logger = logging.getLogger("unity-mcp-server") -logger.setLevel(getattr(logging, config.log_level)) -for h in list(logger.handlers): - logger.removeHandler(h) -for h in list(logging.getLogger().handlers): - logging.getLogger().removeHandler(h) -logger.addHandler(stderr_handler) -logging.getLogger().addHandler(stderr_handler) -logging.getLogger().setLevel(getattr(logging, config.log_level)) - -# File logging to avoid stdout interference with MCP stdio -try: - log_dir = Path.home() / ".unity-mcp" - log_dir.mkdir(parents=True, exist_ok=True) - file_handler = RotatingFileHandler(str(log_dir / "server.log"), maxBytes=5*1024*1024, backupCount=3) - file_handler.setFormatter(logging.Formatter(config.log_format)) - file_handler.setLevel(getattr(logging, config.log_level)) - logger.addHandler(file_handler) - # Prevent duplicate propagation to root handlers - logger.propagate = False -except Exception: - # If file logging setup fails, continue with stderr logging only - pass -======= # Configure logging using settings from config logging.basicConfig( level=getattr(logging, config.log_level), format=config.log_format ) logger = logging.getLogger("mcp-for-unity-server") ->>>>>>> fix/installer-cleanup-v2 # Global connection state _unity_connection: UnityConnection = None @@ -90,21 +54,6 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" return ( -<<<<<<< HEAD - "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" -======= "Available MCP for Unity Server Tools:\\n\\n" "- `manage_editor`: Controls editor state and queries info.\\n" "- `execute_menu_item`: Executes Unity Editor menu items by path.\\n" @@ -117,151 +66,8 @@ def asset_creation_strategy() -> str: "Tips:\\n" "- Create prefabs for reusable GameObjects.\\n" "- Always include a camera and main light in your scenes.\\n" ->>>>>>> fix/installer-cleanup-v2 ) -""" -Resources support: publish native MCP resources in a way that is -compatible with FastMCP variants exposing either `.resource` or `.resources`. -Always include a synthetic spec at `unity://spec/script-edits`. -""" - -# Guard for older MCP versions without 'capabilities' API -if hasattr(mcp, "capabilities"): - @mcp.capabilities(resources={"listChanged": True}) - class _ResourcesCaps: - ... - -PROJECT_ROOT = Path(os.environ.get("UNITY_PROJECT_ROOT", Path.cwd())).resolve() -ASSETS_ROOT = (PROJECT_ROOT / "Assets").resolve() - -def _resolve_safe_path_from_uri(uri: str) -> 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_ROOT / raw).resolve() - try: - p.relative_to(PROJECT_ROOT) - except ValueError: - return None - return p - - -def _list_resources_impl() -> list[dict]: - assets: list[dict] = [] - try: - # Enumerate C# scripts in Assets/ if present - if ASSETS_ROOT.exists(): - 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}) - # Also include CI fixtures under ClaudeTests/ - ct_root = (PROJECT_ROOT / "ClaudeTests").resolve() - if ct_root.exists(): - for p in ct_root.rglob("*.cs"): - rel = p.relative_to(PROJECT_ROOT).as_posix() - assets.append({"uri": f"unity://path/{rel}", "name": p.name}) - except Exception as e: - logger.debug(f"Assets enumeration failed: {e}") - # Always include the scripted spec resource - assets.append({ - "uri": "unity://spec/script-edits", - "name": "Unity Script Edits – Required JSON", - }) - return assets - - -def _read_resource_impl(uri: str) -> dict: - 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"],"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' - ' ],\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' - ) - 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}"} - 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}"} - - -# Choose resource API variant (singular vs plural) and register -_res_api = getattr(mcp, "resource", None) or getattr(mcp, "resources", None) -if _res_api and hasattr(_res_api, "list") and hasattr(_res_api, "read"): - @_res_api.list() - def list_resources(ctx: Context) -> list[dict]: # type: ignore[override] - logger.debug("Resources API registered via %s.list", _res_api.__class__.__name__ if hasattr(_res_api, "__class__") else "resource") - return _list_resources_impl() - - @_res_api.read() - def read_resource(ctx: Context, uri: str) -> dict: # type: ignore[override] - return _read_resource_impl(uri) -else: - logger.warning("MCP resources API not available; native resource listing will be empty for ListMcpResourcesTool") - # Run the server if __name__ == "__main__": mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 068c8ad5..43b53096 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -10,17 +10,13 @@ from .execute_menu_item import register_execute_menu_item_tools from .resource_tools import register_resource_tools -logger = logging.getLogger("unity-mcp-server") +logger = logging.getLogger("mcp-for-unity-server") def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" -<<<<<<< HEAD - logger.info("Registering Unity MCP Server refactored tools...") # Prefer the surgical edits tool so LLMs discover it first + logger.info("Registering MCP for Unity Server refactored tools...") register_manage_script_edits_tools(mcp) -======= - print("Registering MCP for Unity Server refactored tools...") ->>>>>>> fix/installer-cleanup-v2 register_manage_script_tools(mcp) register_manage_scene_tools(mcp) register_manage_editor_tools(mcp) @@ -29,10 +25,6 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) -<<<<<<< HEAD # 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.") -======= - print("MCP for Unity Server tool registration complete.") ->>>>>>> fix/installer-cleanup-v2 + logger.info("MCP for Unity Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index 314d7df3..9c59867d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -160,6 +160,21 @@ cli = [ { name = "typer" }, ] +[[package]] +name = "mcpforunityserver" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -370,25 +385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] -[[package]] -<<<<<<< HEAD -name = "unitymcpserver" -======= -name = "mcpforunityserver" ->>>>>>> fix/installer-cleanup-v2 -version = "2.1.2" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, -] - [[package]] name = "uvicorn" version = "0.34.0" From 37acd715a699aaed381d1307c84ad96c742c7b35 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 14:51:03 -0700 Subject: [PATCH 131/311] Fix Claude Desktop config path and atomic write issues - Fix macOS path for Claude Desktop config: use ~/Library/Application Support/Claude/ instead of ~/.config/Claude/ - Improve atomic write pattern with backup/restore safety - Replace File.Replace() with File.Move() for better macOS compatibility - Add proper error handling and cleanup for file operations - Resolves issue where installer couldn't find Claude Desktop config on macOS --- UnityMcpBridge/Editor/Data/McpClients.cs | 2 +- .../Editor/Windows/MCPForUnityEditorWindow.cs | 49 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index de7e1883..9a9d3000 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -71,7 +71,7 @@ public class McpClients "claude_desktop_config.json" ), macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f29a1920..12a83030 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1202,13 +1202,52 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + // Use a more robust atomic write pattern string tmp = configPath + ".tmp"; - // Write UTF-8 without BOM to avoid issues on Windows editors/tools - System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - if (System.IO.File.Exists(configPath)) - System.IO.File.Replace(tmp, configPath, null); - else + string backup = configPath + ".backup"; + + try + { + // Write to temp file first + System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); + + // Create backup of existing file if it exists + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Copy(configPath, backup, true); + } + + // Atomic move operation (more reliable than Replace on macOS) + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Delete(configPath); + } System.IO.File.Move(tmp, configPath); + + // Clean up backup + if (System.IO.File.Exists(backup)) + { + System.IO.File.Delete(backup); + } + } + catch (Exception ex) + { + // Clean up temp file + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + // Restore backup if it exists + try { + if (System.IO.File.Exists(backup)) + { + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Delete(configPath); + } + System.IO.File.Move(backup, configPath); + } + } catch { } + throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); + } try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); From 2bf18bef0226b9a02b14e2cb3af04e3bda222c4a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 15:22:04 -0700 Subject: [PATCH 132/311] Editor: use macConfigPath on macOS for MCP client config writes (Claude Desktop, etc.). Fallback to linuxConfigPath only if mac path missing. --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 12a83030..96d5038c 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1413,7 +1413,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1455,7 +1462,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1567,7 +1581,14 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; From be38c047dc237c7a654278de1f83dedbea612f4e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 16:49:42 -0700 Subject: [PATCH 133/311] Models: add macConfigPath to McpClient for macOS config path selection (fixes CS1061 in editor window). --- UnityMcpBridge/Editor/Models/McpClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index a32f7f59..7d57a86f 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -6,6 +6,7 @@ public class McpClient public string windowsConfigPath; public string macConfigPath; public string linuxConfigPath; + public string macConfigPath; // optional macOS-specific config path public McpTypes mcpType; public string configStatus; public McpStatus status = McpStatus.NotConfigured; From 5b7ab24a73a7025dfd98ec17257d2f858dad57d1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 16:56:43 -0700 Subject: [PATCH 134/311] Editor: on macOS, prefer macConfigPath in ManualConfigEditorWindow (fallback to linux path); Linux/Windows unchanged. --- .../Editor/Windows/ManualConfigEditorWindow.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index e7806a94..9fe776a9 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -116,10 +116,13 @@ protected virtual void OnGUI() { displayPath = mcpClient.windowsConfigPath; } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { displayPath = mcpClient.linuxConfigPath; } From a31009153ec0b2c3594fe8be0798e47991845c30 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 25 Aug 2025 17:21:44 -0700 Subject: [PATCH 135/311] Fix McpClient: align with upstream/main, prep for framing split --- UnityMcpBridge/Editor/Models/McpClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index 7d57a86f..a32f7f59 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -6,7 +6,6 @@ public class McpClient public string windowsConfigPath; public string macConfigPath; public string linuxConfigPath; - public string macConfigPath; // optional macOS-specific config path public McpTypes mcpType; public string configStatus; public McpStatus status = McpStatus.NotConfigured; From a7de7cca4ac33d0918af178d2dc8b971790f8ae7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 10:49:56 -0700 Subject: [PATCH 136/311] NL suite: shard workflow; tighten bridge readiness; add MCP preflight; use env-based shard vars --- .claude/prompts/nl-unity-suite-full.md | 32 +++- .github/workflows/claude-nl-suite.yml | 252 ++++++++++++++++++++----- 2 files changed, 233 insertions(+), 51 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index b8ffe4c3..cf457e12 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,10 +1,29 @@ # Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) -Version: 1.0.0 (update this when the prompt changes materially) +Version: 1.1.0 (update this when the prompt changes materially) Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). At the start of the first test, log the effective `allowed_tools` list into the `` for easier troubleshooting. +## Sharding and filtering +- Honor a `TEST_FILTER` variable (passed via the workflow `vars` JSON) of the form `group:`. +- Supported groups: `edits`, `scenes`, `assets`, `menu`, `shader`, `validate`. +- Default if missing or unrecognized: `group:edits`. +- Only run tests mapped to the selected group. For other groups, emit a minimal JUnit with zero or informational testcases and a markdown note indicating no applicable tests for the group. + +### Variables +- `TEST_FILTER`: selection filter (e.g., `group:edits`). +- `JUNIT_OUT`: path for JUnit XML output. Default: `reports/claude-nl-tests.xml`. +- `MD_OUT`: path for summary markdown. Default: `reports/claude-nl-tests.md`. + +### MCP connectivity preflight +- Before running any tests in a shard, perform a quick MCP connectivity check with retries (60–90s total): + 1. Attempt `mcp__unity__manage_editor` with `{ action: "get_state" }`. + 2. If unsupported, attempt `mcp__unity__list_resources` with `{ project_root: "TestProjects/UnityMCPTests", under: "Assets", pattern: "*.cs", limit: 5 }`. + 3. Treat transient "Could not connect to Unity" as retryable until the window expires. +- On success: record an INFO testcase noting attempts and elapsed time and continue. +- On failure: emit a single failing testcase (e.g., `NL-Preflight.MCPConnect`) with `` message and stop the shard. + ## Test target - Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` - Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. @@ -12,7 +31,7 @@ At the start of the first test, log the effective `allowed_tools` list into the - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). ## Output requirements -- JUnit XML at `reports/claude-nl-tests.xml`. Create the `reports/` directory if missing. +- JUnit XML at `JUNIT_OUT` (or `reports/claude-nl-tests.xml` if unset). Create the `reports/` directory if missing. - One `` wrapping all `` elements. - Each `` must set: - `classname` ∈ {`UnityMCP.NL`, `UnityMCP.T`} @@ -21,7 +40,7 @@ At the start of the first test, log the effective `allowed_tools` list into the - Emit `` with evidence and end with a single terminal line: `VERDICT: PASS` or `VERDICT: FAIL` (uppercase, exact match). - For any test that performs changes, include a compact unified diff in `` using the standard format and cap to 300 lines. If truncated, include `...diff truncated...` before `VERDICT: ...`. - On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. -- Summary markdown at `reports/claude-nl-tests.md` with checkboxes, windowed reads, and inline diffs for changed tests. +- Summary markdown at `MD_OUT` (or `reports/claude-nl-tests.md` if unset) with checkboxes, windowed reads, and inline diffs for changed tests. - XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: ```xml @@ -37,7 +56,7 @@ VERDICT: PASS ``` JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. -- Upload both `reports/claude-nl-tests.xml` and `reports/claude-nl-tests.md` as workflow artifacts. +- Upload both JUnit and markdown outputs for the shard as workflow artifacts. - Restore workspace at end (clean tree). ## Safety & hygiene @@ -45,6 +64,11 @@ VERDICT: PASS - Preferred: `git restore --staged --worktree :/` (or `git checkout -- .` on older Git) to discard all changes. - Avoid `git stash` in CI unless you also clear stashes, as they may complicate cleanup. - Never push commits from CI. +- Do not start/stop Unity or modify licensing/activation steps; assume Unity is already running and licensed by the workflow. If a license error is detected in logs, record failure in JUnit and stop the shard. + +## Group mapping +- `group:edits`: Run all NL-* and T-* tests defined below (NL-0 … NL-4, T-A … T-J). +- `group:scenes`, `group:assets`, `group:menu`, `group:shader`, `group:validate`: No-op for this prompt version; emit a minimal report with an informational `` indicating no applicable tests for the selected group. ## CI headless hints - For `mcp__unity__list_resources`/`read_resource`, specify: diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 43171379..8e249919 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -137,13 +137,17 @@ jobs: docker logs -f unity-mcp & LOGPID=$! deadline=$((SECONDS+420)) while [ $SECONDS -lt $deadline ]; do - if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started|Pro License: YES"; then + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then echo "Bridge ready (logs)"; kill $LOGPID || true; exit 0 fi PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) if [ -n "${PORT:-}" ] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then echo "Bridge ready on port $PORT"; kill $LOGPID || true; exit 0 fi + # Fail fast on clear license errors + if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then + echo "Licensing error detected"; kill $LOGPID || true; docker logs unity-mcp || true; exit 1 + fi sleep 2 done echo "Bridge not ready in time" @@ -151,82 +155,236 @@ jobs: docker logs unity-mcp || true exit 1 + # ---------- Make MCP config available to the beta action ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <<'JSON' + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + } + } + JSON + # ---------- Prepare reports ---------- - - name: Prepare NL/T reports dir + - name: Prepare reports run: | set -eux mkdir -p reports - : > reports/claude-nl-tests.xml - : > reports/claude-nl-tests.md - # ---------- Claude NL/T suite (full) ---------- - - name: Claude NL/T suite (full) + # ---------- Claude shards (beta-friendly: settings + claude_env only) ---------- + - name: "Claude shard: edits" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:edits + JUNIT_OUT: reports/junit-edits.xml + MD_OUT: reports/edits.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > # RESTRICTED like “71” - Bash(git:*),Read,Write,LS,Glob,Grep, - ListMcpResourcesTool,ReadMcpResourceTool, - mcp__unity__script_apply_edits,mcp__unity__apply_text_edits, - mcp__unity__create_script,mcp__unity__delete_script, - mcp__unity__validate_script,mcp__unity__manage_script, - mcp__unity__manage_scene,mcp__unity__manage_editor, - mcp__unity__manage_gameobject,mcp__unity__manage_asset, - mcp__unity__manage_shader,mcp__unity__read_console, - mcp__unity__execute_menu_item,mcp__unity__list_resources, - mcp__unity__read_resource,mcp__unity__find_in_file - mcp_config: | + settings: | { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } + "model": "claude-3-7-sonnet-20250219", + "permissions": { + "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } } - model: "claude-3-7-sonnet-20250219" - max_turns: "250" - timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: "Claude shard: scenes" + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:scenes + JUNIT_OUT: reports/junit-scenes.xml + MD_OUT: reports/scenes.md + with: + prompt_file: .claude/prompts/nl-unity-suite-full.md + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: "Claude shard: assets" + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:assets + JUNIT_OUT: reports/junit-assets.xml + MD_OUT: reports/assets.md + with: + prompt_file: .claude/prompts/nl-unity-suite-full.md + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: "Claude shard: menu" + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:menu + JUNIT_OUT: reports/junit-menu.xml + MD_OUT: reports/menu.md + with: + prompt_file: .claude/prompts/nl-unity-suite-full.md + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: "Claude shard: shader" + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:shader + JUNIT_OUT: reports/junit-shader.xml + MD_OUT: reports/shader.md + with: + prompt_file: .claude/prompts/nl-unity-suite-full.md + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: "Claude shard: validate" + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + continue-on-error: true + env: + TEST_FILTER: group:validate + JUNIT_OUT: reports/junit-validate.xml + MD_OUT: reports/validate.md + with: + prompt_file: .claude/prompts/nl-unity-suite-full.md + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } + } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # sanitize only the markdown (does not touch JUnit xml) - - name: Sanitize NL/T markdown (UTF-8, strip NULs) + - name: Sanitize markdown (all shards) if: always() run: | set -eu - if [ -f reports/claude-nl-tests.md ]; then - python - <<'PY' - from pathlib import Path - p=Path('reports/claude-nl-tests.md') - b=p.read_bytes().replace(b'\x00', b'') - s=b.decode('utf-8','replace').replace('\r\n','\n') - p.write_text(s, encoding='utf-8', newline='\n') - PY - fi + python - <<'PY' + from pathlib import Path + rp=Path('reports') + rp.mkdir(parents=True, exist_ok=True) + for p in rp.glob('*.md'): + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY - - name: Publish JUnit report + - name: Publish JUnit reports if: always() uses: mikepenz/action-junit-report@v4 with: - report_paths: 'reports/claude-nl-tests.xml' + report_paths: 'reports/junit-*.xml' require_tests: false annotate_notice: true - - name: Upload NL/T artifacts + - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 with: name: claude-nl-suite-artifacts path: | - reports/claude-nl-tests.xml - reports/claude-nl-tests.md + reports/junit-*.xml + reports/*.md # ---------- Always stop Unity ---------- - name: Stop Unity From 20827e02f9286999e2f8913d954afe709c95d57d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 11:02:33 -0700 Subject: [PATCH 137/311] NL suite: fix shard step indentation; move shard vars to env; remove invalid inputs --- .github/workflows/claude-nl-suite.yml | 116 +++++++++++++------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8e249919..fdbd8793 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -183,50 +183,50 @@ jobs: set -eux mkdir -p reports - # ---------- Claude shards (beta-friendly: settings + claude_env only) ---------- - - name: "Claude shard: edits" + # ---------- Shard 1: preflight & quick reads ---------- + - name: "Claude shard: edits / preflight" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: TEST_FILTER: group:edits - JUNIT_OUT: reports/junit-edits.xml - MD_OUT: reports/edits.md + EDITS_SUBSET: preflight + JUNIT_OUT: reports/junit-edits-preflight.xml + MD_OUT: reports/edits-preflight.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", - "permissions": { - "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] - } + "permissions": { "allow": [ + "Bash(git:*)","Read","Write","LS","Glob","Grep", + "ListMcpResourcesTool","ReadMcpResourceTool", + "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", + "mcp__unity__create_script","mcp__unity__delete_script", + "mcp__unity__validate_script","mcp__unity__manage_script", + "mcp__unity__manage_scene","mcp__unity__manage_editor", + "mcp__unity__manage_gameobject","mcp__unity__manage_asset", + "mcp__unity__manage_shader","mcp__unity__read_console", + "mcp__unity__execute_menu_item","mcp__unity__list_resources", + "mcp__unity__read_resource","mcp__unity__find_in_file" + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: "Claude shard: scenes" + # ---------- Shard 2: NL core ---------- + - name: "Claude shard: edits / nl-core" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: - TEST_FILTER: group:scenes - JUNIT_OUT: reports/junit-scenes.xml - MD_OUT: reports/scenes.md + TEST_FILTER: group:edits + EDITS_SUBSET: nl-core + JUNIT_OUT: reports/junit-edits-nl-core.xml + MD_OUT: reports/edits-nl-core.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | - { - "model": "claude-3-7-sonnet-20250219", + { "model": "claude-3-7-sonnet-20250219", "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -238,23 +238,23 @@ jobs: "mcp__unity__manage_shader","mcp__unity__read_console", "mcp__unity__execute_menu_item","mcp__unity__list_resources", "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: "Claude shard: assets" + # ---------- Shard 3: text-basic (T-A..D) ---------- + - name: "Claude shard: edits / text-basic" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: - TEST_FILTER: group:assets - JUNIT_OUT: reports/junit-assets.xml - MD_OUT: reports/assets.md + TEST_FILTER: group:edits + EDITS_SUBSET: text-basic + JUNIT_OUT: reports/junit-edits-text-basic.xml + MD_OUT: reports/edits-text-basic.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | - { - "model": "claude-3-7-sonnet-20250219", + { "model": "claude-3-7-sonnet-20250219", "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -266,23 +266,23 @@ jobs: "mcp__unity__manage_shader","mcp__unity__read_console", "mcp__unity__execute_menu_item","mcp__unity__list_resources", "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: "Claude shard: menu" + # ---------- Shard 4: text-adv (T-E..H) ---------- + - name: "Claude shard: edits / text-adv" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: - TEST_FILTER: group:menu - JUNIT_OUT: reports/junit-menu.xml - MD_OUT: reports/menu.md + TEST_FILTER: group:edits + EDITS_SUBSET: text-adv + JUNIT_OUT: reports/junit-edits-text-adv.xml + MD_OUT: reports/edits-text-adv.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | - { - "model": "claude-3-7-sonnet-20250219", + { "model": "claude-3-7-sonnet-20250219", "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -294,23 +294,23 @@ jobs: "mcp__unity__manage_shader","mcp__unity__read_console", "mcp__unity__execute_menu_item","mcp__unity__list_resources", "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: "Claude shard: shader" + # ---------- Shard 5: negatives (T-I) ---------- + - name: "Claude shard: edits / negatives" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: - TEST_FILTER: group:shader - JUNIT_OUT: reports/junit-shader.xml - MD_OUT: reports/shader.md + TEST_FILTER: group:edits + EDITS_SUBSET: negatives + JUNIT_OUT: reports/junit-edits-negatives.xml + MD_OUT: reports/edits-negatives.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | - { - "model": "claude-3-7-sonnet-20250219", + { "model": "claude-3-7-sonnet-20250219", "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -322,23 +322,23 @@ jobs: "mcp__unity__manage_shader","mcp__unity__read_console", "mcp__unity__execute_menu_item","mcp__unity__list_resources", "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: "Claude shard: validate" + # ---------- Shard 6: meta (T-J) ---------- + - name: "Claude shard: edits / meta" uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: - TEST_FILTER: group:validate - JUNIT_OUT: reports/junit-validate.xml - MD_OUT: reports/validate.md + TEST_FILTER: group:edits + EDITS_SUBSET: meta + JUNIT_OUT: reports/junit-edits-meta.xml + MD_OUT: reports/edits-meta.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | - { - "model": "claude-3-7-sonnet-20250219", + { "model": "claude-3-7-sonnet-20250219", "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -350,10 +350,10 @@ jobs: "mcp__unity__manage_shader","mcp__unity__read_console", "mcp__unity__execute_menu_item","mcp__unity__list_resources", "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + ] } } anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # sanitize only the markdown (does not touch JUnit xml) - name: Sanitize markdown (all shards) if: always() From d32fbb508f94374c595847130a4f4e588bd262a7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 11:13:16 -0700 Subject: [PATCH 138/311] MCP clients: split VSCode Copilot config paths into macConfigPath and linuxConfigPath --- UnityMcpBridge/Editor/Data/McpClients.cs | 35 ++++++++++++------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index 9a9d3000..cbc36f3d 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -97,24 +97,23 @@ public class McpClients "User", "mcp.json" ), - // For macOS, VSCode stores user config under ~/Library/Application Support/Code/User - // For Linux, it remains under ~/.config/Code/User - linuxConfigPath = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Code", - "User", - "mcp.json" - ) - : Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Code", - "User", - "mcp.json" - ), + // macOS: ~/Library/Application Support/Code/User/mcp.json + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "mcp.json" + ), + // Linux: ~/.config/Code/User/mcp.json + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "Code", + "User", + "mcp.json" + ), mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, From d637c5896df8fb16839c0e2ac7e5142b526d91e7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 12:08:47 -0700 Subject: [PATCH 139/311] Unity bridge: clean stale status; bind host; robust wait probe with IPv4/IPv6 + diagnostics --- .github/workflows/claude-nl-suite.yml | 49 ++++++++++++++++++++------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index fdbd8793..7ff27b5b 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -92,6 +92,13 @@ jobs: set -eux find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true + # ---------- Clean any stale MCP status from previous runs ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) if: steps.detect.outputs.unity_ok == 'true' @@ -117,43 +124,61 @@ jobs: docker run -d --name unity-mcp --network host \ -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${MANUAL_ARG[@]}" \ "${EBL_ARGS[@]}" \ -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- - - name: Wait for Unity bridge + - name: Wait for Unity bridge (robust) if: steps.detect.outputs.unity_ok == 'true' run: | - set -eux + set -euo pipefail if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then echo "Unity container failed to start"; docker ps -a || true; exit 1 fi docker logs -f unity-mcp & LOGPID=$! - deadline=$((SECONDS+420)) + deadline=$((SECONDS+420)); READY=0 + + try_connect_host() { + P="$1" + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true + if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi + return 1 + } + + # in-container probe will try IPv4 then IPv6 via nc or /dev/tcp + while [ $SECONDS -lt $deadline ]; do if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then - echo "Bridge ready (logs)"; kill $LOGPID || true; exit 0 + READY=1; echo "Bridge ready (log markers)"; break fi - PORT=$(python -c "import os,glob,json,sys; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(json.load(open(fs[0]))['unity_port']) if fs else sys.exit(1)" 2>/dev/null || true) - if [ -n "${PORT:-}" ] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$PORT && head -c 256 <&3 | tr -d '\0' | grep -q 'FRAMING=1'"; then - echo "Bridge ready on port $PORT"; kill $LOGPID || true; exit 0 + PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) + if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then + READY=1; echo "Bridge ready on port $PORT"; break fi - # Fail fast on clear license errors if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then - echo "Licensing error detected"; kill $LOGPID || true; docker logs unity-mcp || true; exit 1 + echo "Licensing error detected"; break fi sleep 2 done - echo "Bridge not ready in time" + kill $LOGPID || true - docker logs unity-mcp || true - exit 1 + + if [ "$READY" != "1" ]; then + echo "Bridge not ready; diagnostics:" + echo "== status files =="; ls -la "$HOME/.unity-mcp" || true + echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done + echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' + echo "== tail of Unity log =="; docker logs --tail 200 unity-mcp || true + exit 1 + fi # ---------- Make MCP config available to the beta action ---------- - name: Write MCP config (.claude/mcp.json) From 8837cf9acfa74cb5c09e374aa75b089d55d713b5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 12:17:35 -0700 Subject: [PATCH 140/311] CI: use MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect as executeMethod --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 7ff27b5b..abeb86d9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -133,7 +133,7 @@ jobs: -projectPath /workspace/TestProjects/UnityMCPTests \ "${MANUAL_ARG[@]}" \ "${EBL_ARGS[@]}" \ - -executeMethod UnityMcpBridge.Editor.UnityMcpBridge.StartAutoConnect + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- - name: Wait for Unity bridge (robust) From 92b57c0b54b52e20c816ea3b204c04d50bcfd66b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 12:47:05 -0700 Subject: [PATCH 141/311] Action wiring: inline mcpServers in settings for all shards; remove redundant .claude/mcp.json step --- .github/workflows/claude-nl-suite.yml | 94 ++++++++++++++++++++------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index abeb86d9..debaa017 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -180,28 +180,6 @@ jobs: exit 1 fi - # ---------- Make MCP config available to the beta action ---------- - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json <<'JSON' - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - } - } - JSON - # ---------- Prepare reports ---------- - name: Prepare reports run: | @@ -223,6 +201,18 @@ jobs: settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -252,6 +242,18 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -280,6 +282,18 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -308,6 +322,18 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -336,6 +362,18 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", @@ -364,6 +402,18 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md settings: | { "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + }, "permissions": { "allow": [ "Bash(git:*)","Read","Write","LS","Glob","Grep", "ListMcpResourcesTool","ReadMcpResourceTool", From 84e412d42363f6ad784130f109418651a9e23fc8 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 13:48:34 -0700 Subject: [PATCH 142/311] CI: embed mcpServers in settings for all shards; fix startup sanity step; lint clean --- .github/workflows/claude-nl-suite.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index debaa017..d28266c4 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -180,6 +180,31 @@ jobs: exit 1 fi + - name: Claude startup sanity (MCP discovery) + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' + with: + prompt: | + Print the list of available tools you see. If any tool id starts with "mcp__unity__", say "UNITY-MCP: OK". + Otherwise say "UNITY-MCP: MISSING" and stop. + settings: | + { + "model": "claude-3-7-sonnet-20250219", + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + } + } + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # ---------- Prepare reports ---------- - name: Prepare reports run: | From 70ac26dcc559e58363edbaf1b7f6bbffb33da5c1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 14:37:47 -0700 Subject: [PATCH 143/311] CI: pin claude-code-base-action to e6f32c8; use claude_args --mcp-config; switch to allowed_tools; ensure MCP config per step --- .github/workflows/claude-nl-suite.yml | 245 +++++++------------------- 1 file changed, 64 insertions(+), 181 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index d28266c4..51fa60af 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -180,29 +180,40 @@ jobs: exit 1 fi + # ---------- Make MCP config available to the action ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <<'JSON' + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" + } + } + } + } + JSON + - name: Claude startup sanity (MCP discovery) - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' with: prompt: | Print the list of available tools you see. If any tool id starts with "mcp__unity__", say "UNITY-MCP: OK". Otherwise say "UNITY-MCP: MISSING" and stop. - settings: | - { - "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - } - } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Prepare reports ---------- @@ -213,7 +224,7 @@ jobs: # ---------- Shard 1: preflight & quick reads ---------- - name: "Claude shard: edits / preflight" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -223,39 +234,16 @@ jobs: MD_OUT: reports/edits-preflight.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { - "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } - } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 2: NL core ---------- - name: "Claude shard: edits / nl-core" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -265,37 +253,16 @@ jobs: MD_OUT: reports/edits-nl-core.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 3: text-basic (T-A..D) ---------- - name: "Claude shard: edits / text-basic" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -305,37 +272,16 @@ jobs: MD_OUT: reports/edits-text-basic.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 4: text-adv (T-E..H) ---------- - name: "Claude shard: edits / text-adv" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -345,37 +291,16 @@ jobs: MD_OUT: reports/edits-text-adv.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 5: negatives (T-I) ---------- - name: "Claude shard: edits / negatives" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -385,37 +310,16 @@ jobs: MD_OUT: reports/edits-negatives.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 6: meta (T-J) ---------- - name: "Claude shard: edits / meta" - uses: anthropics/claude-code-base-action@beta + uses: anthropics/claude-code-base-action@e6f32c8 if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -425,32 +329,11 @@ jobs: MD_OUT: reports/edits-meta.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - settings: | - { "model": "claude-3-7-sonnet-20250219", - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" - } - } - }, - "permissions": { "allow": [ - "Bash(git:*)","Read","Write","LS","Glob","Grep", - "ListMcpResourcesTool","ReadMcpResourceTool", - "mcp__unity__script_apply_edits","mcp__unity__apply_text_edits", - "mcp__unity__create_script","mcp__unity__delete_script", - "mcp__unity__validate_script","mcp__unity__manage_script", - "mcp__unity__manage_scene","mcp__unity__manage_editor", - "mcp__unity__manage_gameobject","mcp__unity__manage_asset", - "mcp__unity__manage_shader","mcp__unity__read_console", - "mcp__unity__execute_menu_item","mcp__unity__list_resources", - "mcp__unity__read_resource","mcp__unity__find_in_file" - ] } } + allowed_tools: > + Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file + claude_args: | + --mcp-config .claude/mcp.json + model: "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 3b4ff216e3a001ed4a51601c97dacf3f6fd58fc0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 14:41:29 -0700 Subject: [PATCH 144/311] CI: unpin claude-code-base-action to @beta (commit ref not found) --- .github/workflows/claude-nl-suite.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 51fa60af..36bf42bd 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -203,7 +203,7 @@ jobs: JSON - name: Claude startup sanity (MCP discovery) - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' with: prompt: | @@ -224,7 +224,7 @@ jobs: # ---------- Shard 1: preflight & quick reads ---------- - name: "Claude shard: edits / preflight" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -243,7 +243,7 @@ jobs: # ---------- Shard 2: NL core ---------- - name: "Claude shard: edits / nl-core" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -262,7 +262,7 @@ jobs: # ---------- Shard 3: text-basic (T-A..D) ---------- - name: "Claude shard: edits / text-basic" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -281,7 +281,7 @@ jobs: # ---------- Shard 4: text-adv (T-E..H) ---------- - name: "Claude shard: edits / text-adv" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -300,7 +300,7 @@ jobs: # ---------- Shard 5: negatives (T-I) ---------- - name: "Claude shard: edits / negatives" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: @@ -319,7 +319,7 @@ jobs: # ---------- Shard 6: meta (T-J) ---------- - name: "Claude shard: edits / meta" - uses: anthropics/claude-code-base-action@e6f32c8 + uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' continue-on-error: true env: From 798db05e8f034c7d86978d3d66a3144a5a3aa21b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 14:54:17 -0700 Subject: [PATCH 145/311] CI: align with claude-code-base-action @beta; pass MCP via claude_args and allowedTools --- .github/workflows/claude-nl-suite.yml | 35 +++++++++++---------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 36bf42bd..2b61ecd4 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -209,11 +209,10 @@ jobs: prompt: | Print the list of available tools you see. If any tool id starts with "mcp__unity__", say "UNITY-MCP: OK". Otherwise say "UNITY-MCP: MISSING" and stop. - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Prepare reports ---------- @@ -234,11 +233,10 @@ jobs: MD_OUT: reports/edits-preflight.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 2: NL core ---------- @@ -253,11 +251,10 @@ jobs: MD_OUT: reports/edits-nl-core.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 3: text-basic (T-A..D) ---------- @@ -272,11 +269,10 @@ jobs: MD_OUT: reports/edits-text-basic.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 4: text-adv (T-E..H) ---------- @@ -291,11 +287,10 @@ jobs: MD_OUT: reports/edits-text-adv.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 5: negatives (T-I) ---------- @@ -310,11 +305,10 @@ jobs: MD_OUT: reports/edits-negatives.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Shard 6: meta (T-J) ---------- @@ -329,11 +323,10 @@ jobs: MD_OUT: reports/edits-meta.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md - allowed_tools: > - Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file claude_args: | --mcp-config .claude/mcp.json - model: "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 3ad1caf4de18173999f70811e4d30f373b48dac1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 15:03:50 -0700 Subject: [PATCH 146/311] Editor: Fix apply_text_edits heuristic when edits shift positions; recompute method span on candidate text with fallback delta adjustment --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 5122babf..82d81aca 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -554,12 +554,39 @@ private static object ApplyTextEdits( if (sp.start <= mStart + 2 && sp.end >= mStart + 1) { var structEdits = new JArray(); + + // Apply the edit to get a candidate string, then recompute method span on the edited text + string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string replacementText; + if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) + && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) + { + replacementText = candidate.Substring(m2Start, m2Len); + } + else + { + // Fallback: adjust method start by the net delta if the edit was before the method + int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); + int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); + adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); + + // If the edit was within the original method span, adjust the length by the delta within-method + int withinMethodDelta = 0; + if (sp.start >= mStart && sp.start <= mStart + mLen) + { + withinMethodDelta = delta; + } + int adjustedLen = mLen + withinMethodDelta; + adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); + replacementText = candidate.Substring(adjustedStart, adjustedLen); + } + 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))) + ["replacement"] = replacementText }; structEdits.Add(op); // Reuse structured path From 5c62b4012d9229a9e00d9b45c70e32c89d976285 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 15:34:38 -0700 Subject: [PATCH 147/311] CI: unify MCP wiring across workflows; write .claude/mcp.json; switch to claude_args with --mcp-config/--allowedTools; remove unsupported inputs --- .github/workflows/claude-desktop-parity.yml | 54 +++++++++------------ .github/workflows/unity-mcp-quickprobe.yml | 40 +++++++++------ .github/workflows/unity-mcp-smoke.yml | 54 ++++++++++----------- 3 files changed, 74 insertions(+), 74 deletions(-) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml index 98b067cf..7a48db9e 100644 --- a/.github/workflows/claude-desktop-parity.yml +++ b/.github/workflows/claude-desktop-parity.yml @@ -25,37 +25,29 @@ jobs: uses: anthropics/claude-code-base-action@beta if: steps.detect_key.outputs.has_key == 'true' with: - # Use the same model Desktop uses today in your logs - model: claude-3-7-sonnet-20250219 - - # Let it actually think & iterate like Desktop - max_turns: 10 - timeout_minutes: 15 - - allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource - disallowed_tools: TodoWrite,PlannerTool,SubagentSpawn,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash - settings: | - { - "permissions": { - "allow": ["mcp__unity__*"] - }, - "permissions.defaultMode": "acceptEdits" - } + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools "mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource" + --disallowedTools "TodoWrite,PlannerTool,SubagentSpawn,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash" + --model "claude-3-7-sonnet-20250219" + --max-turns 10 + --timeout-minutes 15 + prompt_file: .claude/prompts/nl-unity-suite.md + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Keep the exact MCP wiring you already use - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <<'JSON' + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } } } - - - - prompt_file: .claude/prompts/nl-unity-suite.md - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + } + JSON diff --git a/.github/workflows/unity-mcp-quickprobe.yml b/.github/workflows/unity-mcp-quickprobe.yml index e108f7d2..09a04b0c 100644 --- a/.github/workflows/unity-mcp-quickprobe.yml +++ b/.github/workflows/unity-mcp-quickprobe.yml @@ -16,25 +16,33 @@ jobs: with: python-version: '3.11' + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <<'JSON' + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } + } + } + } + JSON + - name: Run Claude quickprobe uses: anthropics/claude-code-base-action@beta with: - model: claude-3-7-sonnet-20250219 - max_turns: 2 - timeout_minutes: 5 - allowed_tools: mcp__unity__find_in_file,mcp__unity__list_resources - disallowed_tools: TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } - } - } + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools "mcp__unity__find_in_file,mcp__unity__list_resources" + --disallowedTools "TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash" + --model "claude-3-7-sonnet-20250219" + --max-turns 2 + --timeout-minutes 5 prompt_file: .claude/prompts/mcp-quickprobe.md anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml index 78ddf7cb..43d12be9 100644 --- a/.github/workflows/unity-mcp-smoke.yml +++ b/.github/workflows/unity-mcp-smoke.yml @@ -28,37 +28,37 @@ jobs: echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <<'JSON' + { + "mcpServers": { + "unity": { + "command": "uv", + "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], + "transport": { "type": "stdio" }, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_MCP_PROJECT_ROOT": "." + } + } + } + } + JSON + - name: Run QuickProbe uses: anthropics/claude-code-base-action@beta with: prompt_file: .claude/prompts/mcp-quickprobe.md - # Wildcard so sub-sessions/subagents also have access - allowed_tools: mcp__unity__*,ListMcpResourcesTool,ReadMcpResourceTool - model: claude-3-7-sonnet-20250219 - max_turns: 6 - timeout_minutes: 5 - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_MCP_PROJECT_ROOT": "." - } - } - } - } - settings: | - { - "permissionMode": "allow", - "defaultMode": "bypassPermissions", - "permissionStorage": "none", - "autoApprove": ["mcp__unity__*"] - } + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools "mcp__unity__*,ListMcpResourcesTool,ReadMcpResourceTool" + --model "claude-3-7-sonnet-20250219" + --max-turns 6 + --timeout-minutes 5 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Upload QuickProbe log From 1bda4974090f188ebf554c97537afcad27102b32 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 15:37:27 -0700 Subject: [PATCH 148/311] CI: collapse NL suite shards into a single run to avoid repeated test execution --- .github/workflows/claude-nl-suite.yml | 109 +------------------------- 1 file changed, 4 insertions(+), 105 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2b61ecd4..ca5de588 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -215,118 +215,17 @@ jobs: --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # ---------- Prepare reports ---------- - - name: Prepare reports - run: | - set -eux - mkdir -p reports - - # ---------- Shard 1: preflight & quick reads ---------- - - name: "Claude shard: edits / preflight" - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: preflight - JUNIT_OUT: reports/junit-edits-preflight.xml - MD_OUT: reports/edits-preflight.md - with: - prompt_file: .claude/prompts/nl-unity-suite-full.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # ---------- Shard 2: NL core ---------- - - name: "Claude shard: edits / nl-core" - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: nl-core - JUNIT_OUT: reports/junit-edits-nl-core.xml - MD_OUT: reports/edits-nl-core.md - with: - prompt_file: .claude/prompts/nl-unity-suite-full.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # ---------- Shard 3: text-basic (T-A..D) ---------- - - name: "Claude shard: edits / text-basic" - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: text-basic - JUNIT_OUT: reports/junit-edits-text-basic.xml - MD_OUT: reports/edits-text-basic.md - with: - prompt_file: .claude/prompts/nl-unity-suite-full.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # ---------- Shard 4: text-adv (T-E..H) ---------- - - name: "Claude shard: edits / text-adv" + # ---------- Run full NL suite once ---------- + - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: text-adv - JUNIT_OUT: reports/junit-edits-text-adv.xml - MD_OUT: reports/edits-text-adv.md - with: - prompt_file: .claude/prompts/nl-unity-suite-full.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # ---------- Shard 5: negatives (T-I) ---------- - - name: "Claude shard: edits / negatives" - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: negatives - JUNIT_OUT: reports/junit-edits-negatives.xml - MD_OUT: reports/edits-negatives.md - with: - prompt_file: .claude/prompts/nl-unity-suite-full.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # ---------- Shard 6: meta (T-J) ---------- - - name: "Claude shard: edits / meta" - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - continue-on-error: true - env: - TEST_FILTER: group:edits - EDITS_SUBSET: meta - JUNIT_OUT: reports/junit-edits-meta.xml - MD_OUT: reports/edits-meta.md with: prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" --model "claude-3-7-sonnet-20250219" + --timeout-minutes 45 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 2d9a83f2d7899c8d77365ca79b4b509f335d8ed4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 15:50:28 -0700 Subject: [PATCH 149/311] CI: minimize allowedTools for NL suite to essential Unity MCP + Bash("git:*") + Write --- .github/workflows/claude-nl-suite.yml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index ca5de588..ff60dfa8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -201,20 +201,7 @@ jobs: } } JSON - - - name: Claude startup sanity (MCP discovery) - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' - with: - prompt: | - Print the list of available tools you see. If any tool id starts with "mcp__unity__", say "UNITY-MCP: OK". - Otherwise say "UNITY-MCP: MISSING" and stop. - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool" - --model "claude-3-7-sonnet-20250219" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - + # ---------- Run full NL suite once ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta @@ -223,7 +210,7 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Read,Write,LS,Glob,Grep,Edit,MultiEdit,ListMcpResourcesTool,ReadMcpResourceTool,mcp__unity__script_apply_edits,mcp__unity__apply_text_edits,mcp__unity__create_script,mcp__unity__delete_script,mcp__unity__validate_script,mcp__unity__manage_script,mcp__unity__manage_scene,mcp__unity__manage_editor,mcp__unity__manage_gameobject,mcp__unity__manage_asset,mcp__unity__manage_shader,mcp__unity__read_console,mcp__unity__execute_menu_item,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__find_in_file" + --allowedTools "Bash(git:*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script" --model "claude-3-7-sonnet-20250219" --timeout-minutes 45 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 87e69f081bbfd7aaf88eaa980ef39fa7f563bd78 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 15:58:05 -0700 Subject: [PATCH 150/311] CI: mkdir -p reports before run; remove unsupported --timeout-minutes from claude_args --- .github/workflows/claude-nl-suite.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index ff60dfa8..cafd8b00 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -202,6 +202,12 @@ jobs: } JSON + # ---------- Ensure reports dir exists ---------- + - name: Prepare reports + run: | + set -eux + mkdir -p reports + # ---------- Run full NL suite once ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta @@ -212,7 +218,6 @@ jobs: --mcp-config .claude/mcp.json --allowedTools "Bash(git:*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script" --model "claude-3-7-sonnet-20250219" - --timeout-minutes 45 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From eb7227f56d0eb1ad2b9629484bdfa65dd6b417e7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 16:38:26 -0700 Subject: [PATCH 151/311] CI: broaden allowedTools to include find_in_file and mcp__unity__* --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cafd8b00..901b1f51 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -216,7 +216,7 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script" + --allowedTools "Bash(git:*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script, mcp__unity__find_in_file, mcp__unity__*" --model "claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From f851b708bfd760f6b3c7d11fae80be80fa020721 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 17:43:06 -0700 Subject: [PATCH 152/311] CI: enable use_node_cache and switch NL suite model to claude-3-7-haiku-20250219 --- .github/workflows/claude-nl-suite.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 901b1f51..b50d68d7 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -212,12 +212,16 @@ jobs: - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md with: + use_node_cache: true prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script, mcp__unity__find_in_file, mcp__unity__*" - --model "claude-3-7-sonnet-20250219" + --allowedTools "Bash(git:*,mkdir:*,cat:*,grep:*),Write,mcp__unity__*" + --model "claude-3-7-haiku-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 29d781ab261aa5edbff7ada8392de9efd8e1be71 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 17:57:23 -0700 Subject: [PATCH 153/311] CI: disable use_node_cache to avoid setup-node lockfile error --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b50d68d7..8e29c1d0 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -216,7 +216,7 @@ jobs: JUNIT_OUT: reports/junit-nl-suite.xml MD_OUT: reports/junit-nl-suite.md with: - use_node_cache: true + use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json From 7e60640021e4172c3f0ef490828135f8e291fc91 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 18:07:54 -0700 Subject: [PATCH 154/311] CI: set NL suite model to claude-3-haiku-20240307 --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8e29c1d0..e348ba33 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -221,7 +221,7 @@ jobs: claude_args: | --mcp-config .claude/mcp.json --allowedTools "Bash(git:*,mkdir:*,cat:*,grep:*),Write,mcp__unity__*" - --model "claude-3-7-haiku-20250219" + --model "claude-3-haiku-20240307" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From bf9ffbd63207d92145678629e441d51646cbe615 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 18:16:26 -0700 Subject: [PATCH 155/311] CI: cap Haiku output with --max-tokens 2048 for NL suite --- .github/workflows/claude-nl-suite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e348ba33..e6b6f44f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -222,6 +222,7 @@ jobs: --mcp-config .claude/mcp.json --allowedTools "Bash(git:*,mkdir:*,cat:*,grep:*),Write,mcp__unity__*" --model "claude-3-haiku-20240307" + --max-tokens 2048 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 1e1db0831a92fe4aee5cb69f33abdf0534109ded Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 18:22:08 -0700 Subject: [PATCH 156/311] CI: switch to claude-3-7-sonnet-latest and remove unsupported --max-tokens --- .github/workflows/claude-nl-suite.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e6b6f44f..8a0276e8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -221,8 +221,7 @@ jobs: claude_args: | --mcp-config .claude/mcp.json --allowedTools "Bash(git:*,mkdir:*,cat:*,grep:*),Write,mcp__unity__*" - --model "claude-3-haiku-20240307" - --max-tokens 2048 + --model "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From ce4c6e68fdc2b24c3e95f09ac89f83de5ad8519f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 18:37:29 -0700 Subject: [PATCH 157/311] CI: update allowedTools to Bash(*) and explicit Unity MCP tool list --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8a0276e8..52a87126 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -220,7 +220,7 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools "Bash(git:*,mkdir:*,cat:*,grep:*),Write,mcp__unity__*" + --allowedTools 'Bash(*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file' --model "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 7f86daf7c1a593862084f7c9dc5a019e35c9356d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 19:04:18 -0700 Subject: [PATCH 158/311] CI: update NL suite workflow (latest tweaks) --- .github/workflows/claude-nl-suite.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 52a87126..d312cd7f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -220,7 +220,7 @@ jobs: prompt_file: .claude/prompts/nl-unity-suite-full.md claude_args: | --mcp-config .claude/mcp.json - --allowedTools 'Bash(*),Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file' + --allowedTools Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*) --model "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -244,18 +244,20 @@ jobs: if: always() uses: mikepenz/action-junit-report@v4 with: - report_paths: 'reports/junit-*.xml' - require_tests: false - annotate_notice: true - + report_paths: | + reports/junit-*.xml + reports/claude-*.xml + - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 with: - name: claude-nl-suite-artifacts - path: | - reports/junit-*.xml - reports/*.md + name: claude-nl-suite-artifacts + path: | + reports/junit-*.xml + reports/claude-*.xml + reports/*.md + # ---------- Always stop Unity ---------- - name: Stop Unity From 83931a13d3a6304dd00144bbcd321888594873db Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 26 Aug 2025 19:56:10 -0700 Subject: [PATCH 159/311] Tests: tighten NL suite prompt for logging, hash discipline, stale retry, evidence windows, diff cap, and VERDICT line --- .claude/prompts/nl-unity-suite-full.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index cf457e12..52c7475c 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -3,7 +3,8 @@ Version: 1.1.0 (update this when the prompt changes materially) Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). -At the start of the first test, log the effective `allowed_tools` list into the `` for easier troubleshooting. +At the start of the run, log the effective tools ONCE as a single line in ``: +`AllowedTools: `. ## Sharding and filtering - Honor a `TEST_FILTER` variable (passed via the workflow `vars` JSON) of the form `group:`. @@ -30,6 +31,15 @@ At the start of the first test, log the effective `allowed_tools` list into the - Include `precondition_sha256` for any write (text-path or structured edit). In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). +## Execution discipline +- Log allowed tools once as `AllowedTools: ...` (single line) at suite start. +- For every edit: Read → Write → Re-read. Compute `precondition_sha256` from the just-read bytes; never reuse an old hash. +- If you get `{ status: "stale_file" }`: re-read and retry the edit ONCE. If it still fails, record failure and immediately write JUnit and MD outputs. +- For each test: perform the edit, verify windows/diff, then revert to a clean tree before proceeding to the next test. +- Evidence windows only (±20–40 lines). Never dump entire files. +- Cap unified diffs to 300 lines; if truncated, include `...diff truncated...` before the `VERDICT:` line. +- End `` with `VERDICT: PASS` or `VERDICT: FAIL`. + ## Output requirements - JUnit XML at `JUNIT_OUT` (or `reports/claude-nl-tests.xml` if unset). Create the `reports/` directory if missing. - One `` wrapping all `` elements. From 7758c4f27a2ff2d516218035d617748e2861e667 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 26 Aug 2025 20:47:03 -0700 Subject: [PATCH 160/311] Add disallowed tools to NL suite workflow --- .github/workflows/claude-nl-suite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index d312cd7f..7ce72150 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -221,6 +221,7 @@ jobs: claude_args: | --mcp-config .claude/mcp.json --allowedTools Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*) + --disallowedTools "TodoWrite,Task" --model "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 1119eea8a6f755a7e2cbb41ef9b712fa5bab9a68 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 26 Aug 2025 20:51:38 -0700 Subject: [PATCH 161/311] docs: clarify stale write retry --- .claude/prompts/nl-unity-suite-full.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 52c7475c..844d6915 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,5 +1,5 @@ # Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) -Version: 1.1.0 (update this when the prompt changes materially) +Version: 1.1.1 (update this when the prompt changes materially) Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). @@ -187,6 +187,10 @@ VERDICT: PASS ### Implementation notes - Always capture pre/post windows (±20–40 lines) as evidence in JUnit or system-out. - For any file write, include `precondition_sha256` computed over file bytes after normalizing line endings to LF (`\n`) and ensuring UTF-8 without BOM, unless the server specifies otherwise. +- Stale write retry: If any write returns `{ success:false, status:"stale_file", actual_current_sha256 }`, immediately: + (a) re-read the file, + (b) recompute `precondition_sha256` using LF normalization, + (c) re-apply the same edit once using the returned `actual_current_sha256`. If it stales again, record a failing testcase with the evidence window and continue to the next test. - Verify the post-edit file hash in logs and include both pre- and post-hashes in ``. - Restore repository to original state at end (`git status` must be clean). If not clean, mark the suite as FAIL. From dbdd7a34e943ed2478a5ab76139926e99d4c1179 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 26 Aug 2025 20:59:57 -0700 Subject: [PATCH 162/311] Add fallback JUnit report and adjust publisher --- .github/workflows/claude-nl-suite.yml | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 7ce72150..3d548f63 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -241,13 +241,35 @@ jobs: p.write_text(s, encoding='utf-8', newline='\n') PY + - name: Fallback JUnit if missing + if: always() + run: | + set -eu + mkdir -p reports + if ! ls reports/junit-*.xml >/dev/null 2>&1; then + echo "No JUnit found; writing fallback." + { + cat <<'XML' + + + + + + +XML + } > reports/junit-fallback.xml + fi + - name: Publish JUnit reports if: always() uses: mikepenz/action-junit-report@v4 with: - report_paths: | - reports/junit-*.xml - reports/claude-*.xml + report_paths: 'reports/junit-*.xml' + require_tests: false + annotate_notice: true - name: Upload artifacts if: always() From 48334cbecae3b225a612ff14dce75a12eedeb429 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 26 Aug 2025 21:20:11 -0700 Subject: [PATCH 163/311] Indent fallback JUnit XML in workflow --- .github/workflows/claude-nl-suite.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 3d548f63..58374352 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -250,16 +250,16 @@ jobs: echo "No JUnit found; writing fallback." { cat <<'XML' - - - - - - -XML + + + + + + + XML } > reports/junit-fallback.xml fi From cab5e927dd6f0ce12519ef5d81d039cfcf97586d Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 26 Aug 2025 21:54:06 -0700 Subject: [PATCH 164/311] fix: correct fallback JUnit report generation --- .github/workflows/claude-nl-suite.yml | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 58374352..44968dba 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -248,19 +248,17 @@ jobs: mkdir -p reports if ! ls reports/junit-*.xml >/dev/null 2>&1; then echo "No JUnit found; writing fallback." - { - cat <<'XML' - - - - - - - XML - } > reports/junit-fallback.xml + cat > reports/junit-fallback.xml <<'XML' + + + + + + + XML fi - name: Publish JUnit reports From 2b10b1add632ce783129c01c36ffba36a99ae65f Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:14:59 -0700 Subject: [PATCH 165/311] Update mcp-quickprobe.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .claude/prompts/mcp-quickprobe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md index e2533e17..bc3ab135 100644 --- a/.claude/prompts/mcp-quickprobe.md +++ b/.claude/prompts/mcp-quickprobe.md @@ -7,7 +7,7 @@ Rules (must follow exactly): 1) Call mcp__unity__find_in_file with: { - "project_relative_file": "ClaudeTests/longUnityScript-claudeTest.cs", + "project_relative_file": "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs", "pattern": "class\\s+LongUnityScriptClaudeTest" } From 832634ba124be1a6b9515e0bf9322867c3362154 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:15:21 -0700 Subject: [PATCH 166/311] Update mcp-quickprobe.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .claude/prompts/mcp-quickprobe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md index bc3ab135..ccfdae5d 100644 --- a/.claude/prompts/mcp-quickprobe.md +++ b/.claude/prompts/mcp-quickprobe.md @@ -12,6 +12,6 @@ Rules (must follow exactly): } 2) Call mcp__unity__list_resources with: -{ "ctx": {}, "under": "ClaudeTests", "pattern": "*.cs" } +{ "ctx": {}, "under": "TestProjects/UnityMCPTests/Assets/Scripts", "pattern": "*.cs" } Stop after step 2. From 8c6291252b0f9b080ca38ca660067d2d5fcda6a7 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:33:16 -0700 Subject: [PATCH 167/311] Update Response.cs Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- UnityMcpBridge/Editor/Helpers/Response.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs b/UnityMcpBridge/Editor/Helpers/Response.cs index b2909b9f..1a3bd520 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs +++ b/UnityMcpBridge/Editor/Helpers/Response.cs @@ -35,7 +35,7 @@ public static object Success(string message, object data = null) /// /// Creates a standardized error response object. /// - /// A message describing the error. + /// 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 errorCodeOrMessage, object data = null) From b0a82157e8446787f897f858af3308f767c6ed9e Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:35:49 -0700 Subject: [PATCH 168/311] Update MCPForUnityBridge.cs Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 9276b0a5..1a979d57 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -100,7 +100,7 @@ static MCPForUnityBridge() { // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode - if (Application.isBatchMode && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) + if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } From c25c2afb0afe554cb9606edc502471b29ac26d1e Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:38:00 -0700 Subject: [PATCH 169/311] fix: correct McpTypes reference --- UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index deb29708..1a48413b 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -54,7 +54,7 @@ private static void PopulateUnityNode(JObject unity, string uvPath, string direc // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners string effectiveDir = directory; #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX - bool isCursor = !isVSCode && (client == null || client.mcpType != Models.McpTypes.VSCode); + bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); if (isCursor && !string.IsNullOrEmpty(directory)) { // Replace canonical path segment with the symlink path if present From da4ce7d2c89c9ca3ef6be90d6e575d0a1a31694a Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:38:30 -0700 Subject: [PATCH 170/311] Add directory existence checks for symlink and XDG paths --- UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index deb29708..16dad3ec 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -65,7 +65,11 @@ private static void PopulateUnityNode(JObject unity, string uvPath, string direc // Normalize to full path style if (directory.Contains(canonical)) { - effectiveDir = directory.Replace(canonical, symlinkSeg); + var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } } else { @@ -76,7 +80,11 @@ private static void PopulateUnityNode(JObject unity, string uvPath, string direc { string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - effectiveDir = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); + string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } } } } From faa403c3ddbba917513b5333de1555b3d1d7c04a Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:38:46 -0700 Subject: [PATCH 171/311] fix: only set installation flag after successful server install --- UnityMcpBridge/Editor/Helpers/PackageDetector.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs index 0a672003..cb350d16 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -30,15 +30,12 @@ static PackageDetector() try { ServerInstaller.EnsureServerInstalled(); + EditorPrefs.SetBool(key, true); } catch (System.Exception ex) { Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message); } - finally - { - EditorPrefs.SetBool(key, true); - } }; } } From 030edb670f180206d58ac84b6179d0ced46c2a51 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 06:57:11 -0700 Subject: [PATCH 172/311] Update resource_tools.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 08b1f15c..2d4a47bf 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -282,7 +282,7 @@ async def find_in_file( - ignore_case: case-insensitive by default - max_results: cap results to avoid huge payloads """ - import re + # re is already imported at module level try: project = _resolve_project_root(project_root) p = _resolve_safe_path_from_uri(uri, project) From 7f170586414d9322a0ad57462f77bb03511b60e6 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 07:00:34 -0700 Subject: [PATCH 173/311] fix: respect mac config paths --- UnityMcpBridge/Editor/Data/McpClients.cs | 21 +++++++++++++++++++ .../Windows/ManualConfigEditorWindow.cs | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index cbc36f3d..b341c0d7 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -19,6 +19,11 @@ public class McpClients ".cursor", "mcp.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", @@ -35,6 +40,10 @@ public class McpClients Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".claude.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" @@ -52,6 +61,12 @@ public class McpClients "windsurf", "mcp_config.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codeium", + "windsurf", + "mcp_config.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", @@ -127,6 +142,12 @@ public class McpClients "settings", "mcp.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".kiro", + "settings", + "mcp.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index 9fe776a9..554dacc1 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -119,7 +119,7 @@ protected virtual void OnGUI() else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath + ? configPath : mcpClient.macConfigPath; } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) From a52656796f81b7bedade9708e0aaef54db9c905a Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 07:00:57 -0700 Subject: [PATCH 174/311] Use File.Replace for atomic config write --- UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 96d5038c..239f993e 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1221,9 +1221,12 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC // Atomic move operation (more reliable than Replace on macOS) if (System.IO.File.Exists(configPath)) { - System.IO.File.Delete(configPath); + System.IO.File.Replace(tmp, configPath, backup); + } + else + { + System.IO.File.Move(tmp, configPath); } - System.IO.File.Move(tmp, configPath); // Clean up backup if (System.IO.File.Exists(backup)) From 086a13d5aca697f1cb553861c7c1fd14dee48b60 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 07:01:15 -0700 Subject: [PATCH 175/311] Remove unused imports in manage_script --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index b44dd743..ac19795d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,10 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time -import os +from unity_connection import send_command_with_retry import base64 +import os def register_manage_script_tools(mcp: FastMCP): From 3e055c88d696df158b2d58855f97182b58e7dc8f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 07:39:55 -0700 Subject: [PATCH 176/311] bump server version --- UnityMcpBridge/UnityMcpServer~/src/server_version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index cb2b00e4..b5021469 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.0.1 +3.0.2 From 0fb2e3be5bdfb77bdf02b6805d7c687112cd0277 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 08:03:00 -0700 Subject: [PATCH 177/311] Tests: update NL suite prompt and workflows; remove deprecated smoke/desktop-parity; quickprobe tidy --- .github/workflows/claude-desktop-parity.yml | 53 ---------------- .github/workflows/claude-nl-suite.yml | 38 +++++------ .github/workflows/unity-mcp-smoke.yml | 70 --------------------- 3 files changed, 17 insertions(+), 144 deletions(-) delete mode 100644 .github/workflows/claude-desktop-parity.yml delete mode 100644 .github/workflows/unity-mcp-smoke.yml diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml deleted file mode 100644 index 7a48db9e..00000000 --- a/.github/workflows/claude-desktop-parity.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Unity MCP — Desktop Parity - -on: - workflow_dispatch: {} - -jobs: - desktop-parity: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v4 - - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Detect Anthropic key - id: detect_key - run: | - if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then echo "has_key=true" >> "$GITHUB_OUTPUT"; else echo "has_key=false" >> "$GITHUB_OUTPUT"; fi - - - name: Run Claude (desktop-parity) - uses: anthropics/claude-code-base-action@beta - if: steps.detect_key.outputs.has_key == 'true' - with: - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "mcp__unity__find_in_file,mcp__unity__list_resources,mcp__unity__read_resource" - --disallowedTools "TodoWrite,PlannerTool,SubagentSpawn,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash" - --model "claude-3-7-sonnet-20250219" - --max-turns 10 - --timeout-minutes 15 - prompt_file: .claude/prompts/nl-unity-suite.md - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json <<'JSON' - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } - } - } - JSON diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 44968dba..0b32ca6b 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -244,40 +244,36 @@ jobs: - name: Fallback JUnit if missing if: always() run: | - set -eu - mkdir -p reports - if ! ls reports/junit-*.xml >/dev/null 2>&1; then - echo "No JUnit found; writing fallback." - cat > reports/junit-fallback.xml <<'XML' - - - - - - - XML - fi + set -eu + mkdir -p reports + if ! ls reports/*.xml >/dev/null 2>&1; then + cat > reports/junit-fallback.xml <<'XML' + + + + + + + XML + fi + - name: Publish JUnit reports if: always() uses: mikepenz/action-junit-report@v4 with: - report_paths: 'reports/junit-*.xml' + report_paths: 'reports/*.xml' require_tests: false annotate_notice: true + - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 with: name: claude-nl-suite-artifacts - path: | - reports/junit-*.xml - reports/claude-*.xml - reports/*.md + path: reports/** + # ---------- Always stop Unity ---------- diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml deleted file mode 100644 index 43d12be9..00000000 --- a/.github/workflows/unity-mcp-smoke.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Unity MCP — QuickProbe - -on: - workflow_dispatch: {} - -permissions: - contents: read - -jobs: - quickprobe: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - # Optional but makes server startup snappier and consistent - - name: Editable install of UnityMcpServer - run: | - set -eux - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" - - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json <<'JSON' - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug", - "UNITY_MCP_PROJECT_ROOT": "." - } - } - } - } - JSON - - - name: Run QuickProbe - uses: anthropics/claude-code-base-action@beta - with: - prompt_file: .claude/prompts/mcp-quickprobe.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "mcp__unity__*,ListMcpResourcesTool,ReadMcpResourceTool" - --model "claude-3-7-sonnet-20250219" - --max-turns 6 - --timeout-minutes 5 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - - name: Upload QuickProbe log - if: always() - uses: actions/upload-artifact@v4 - with: - name: quickprobe-claude-execution - path: /home/runner/work/_temp/claude-execution-output.json - if-no-files-found: warn From baf51ea45dbdc78309817aa73bb5760746714b9f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 08:06:09 -0700 Subject: [PATCH 178/311] Editor: atomic config write via File.Replace fallback; remove redundant backups and racey exists checks --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 239f993e..72fcf798 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1203,54 +1203,48 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - // Use a more robust atomic write pattern + // Robust atomic write without redundant backup or race on existence string tmp = configPath + ".tmp"; string backup = configPath + ".backup"; - try { - // Write to temp file first + // Write to temp file first (in same directory for atomicity) System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - - // Create backup of existing file if it exists - if (System.IO.File.Exists(configPath)) - { - System.IO.File.Copy(configPath, backup, true); - } - - // Atomic move operation (more reliable than Replace on macOS) - if (System.IO.File.Exists(configPath)) + + try { + // Try atomic replace; creates 'backup' only on success System.IO.File.Replace(tmp, configPath, backup); + // Replace succeeded; remove backup copy as we don't need to keep it + try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } } - else + catch (System.IO.FileNotFoundException) { + // Destination didn't exist; fall back to atomic move System.IO.File.Move(tmp, configPath); } - - // Clean up backup - if (System.IO.File.Exists(backup)) - { - System.IO.File.Delete(backup); - } } catch (Exception ex) { - // Clean up temp file - try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } - // Restore backup if it exists - try { - if (System.IO.File.Exists(backup)) + // Attempt restore from backup if replace had succeeded before any later failure + try + { + if (System.IO.File.Exists(backup)) { - if (System.IO.File.Exists(configPath)) - { - System.IO.File.Delete(configPath); - } - System.IO.File.Move(backup, configPath); + try { if (System.IO.File.Exists(configPath)) System.IO.File.Delete(configPath); } catch { } + System.IO.File.Move(backup, configPath); } - } catch { } + } + catch { } + // Ensure temp is cleaned + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); } + finally + { + // Best-effort cleanup + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + } try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); From c9c73d8f02b52f1dcec0b836ddbf6b8a1ced9b4a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 08:18:00 -0700 Subject: [PATCH 179/311] CI: harden NL suite - idempotent docker, gate on unity_ok, safer port probe, least-priv perms --- .github/workflows/claude-nl-suite.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0b32ca6b..19774d34 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -4,9 +4,7 @@ on: workflow_dispatch: {} permissions: - contents: write - pull-requests: write - issues: write + contents: read checks: write concurrency: @@ -121,6 +119,7 @@ jobs: [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") + docker rm -f unity-mcp >/dev/null 2>&1 || true docker run -d --name unity-mcp --network host \ -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ @@ -211,7 +210,7 @@ jobs: # ---------- Run full NL suite once ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' env: JUNIT_OUT: reports/junit-nl-suite.xml MD_OUT: reports/junit-nl-suite.md From 9b546fdc31c07823efbe177fd9a78df1c3ffed7a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 08:32:54 -0700 Subject: [PATCH 180/311] Editor: make atomic config write restoration safe (flag writeDone; copy-overwrite restore; cleanup in finally) --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 72fcf798..6cec5673 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1206,6 +1206,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC // Robust atomic write without redundant backup or race on existence string tmp = configPath + ".tmp"; string backup = configPath + ".backup"; + bool writeDone = false; try { // Write to temp file first (in same directory for atomicity) @@ -1215,35 +1216,34 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC { // Try atomic replace; creates 'backup' only on success System.IO.File.Replace(tmp, configPath, backup); - // Replace succeeded; remove backup copy as we don't need to keep it - try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } + writeDone = true; } catch (System.IO.FileNotFoundException) { // Destination didn't exist; fall back to atomic move System.IO.File.Move(tmp, configPath); + writeDone = true; } } catch (Exception ex) { - // Attempt restore from backup if replace had succeeded before any later failure + // If write did not complete, attempt restore from backup without deleting current file first try { - if (System.IO.File.Exists(backup)) + if (!writeDone && System.IO.File.Exists(backup)) { - try { if (System.IO.File.Exists(configPath)) System.IO.File.Delete(configPath); } catch { } - System.IO.File.Move(backup, configPath); + try { System.IO.File.Copy(backup, configPath, true); } catch { } } } catch { } - // Ensure temp is cleaned - try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); } finally { - // Best-effort cleanup + // Best-effort cleanup of temp try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + // Only remove backup after a confirmed successful write + try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } } try { From ff7e24e622a676ed122fe1e920efc1983ee0a9e1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 08:42:39 -0700 Subject: [PATCH 181/311] CI: fix fallback JUnit heredoc by using printf lines (no EOF delimiter issues) --- .github/workflows/claude-nl-suite.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 19774d34..8daa7a66 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -246,14 +246,14 @@ jobs: set -eu mkdir -p reports if ! ls reports/*.xml >/dev/null 2>&1; then - cat > reports/junit-fallback.xml <<'XML' - - - - - - - XML + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > reports/junit-fallback.xml fi From 1da70338c559f66d67c18e1a4045bd97a4aba2f4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 09:44:01 -0700 Subject: [PATCH 182/311] CI: switch NL suite to mini prompt; mini prompt honors / and NL discipline --- .claude/prompts/nl-unity-claude-tests-mini.md | 13 +++-- .github/workflows/claude-nl-suite.yml | 52 +++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.claude/prompts/nl-unity-claude-tests-mini.md b/.claude/prompts/nl-unity-claude-tests-mini.md index d8ca22cd..79471f7f 100644 --- a/.claude/prompts/nl-unity-claude-tests-mini.md +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -26,9 +26,16 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo - If the preferred file isn’t present, locate a fallback C# file with simple, local methods you can edit safely. - If a compile command is available in this environment, you may optionally trigger it; if not, rely on structural checks and localized validation. -## Output Requirements -- `reports/claude-nl-tests.xml` — JUnit XML, suite `UnityMCP.NL`, each sub‑test a separate `` with `` text when relevant. -- `reports/claude-nl-summary.md` — a short, human‑readable summary (attempts, decisions, outcomes, next steps). +## Output Requirements (match NL suite conventions) +- JUnit XML at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. + - Single suite named `UnityMCP.NL`, one `` per sub‑test; include `` on errors. +- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. + +Constraints (for fast publishing): +- Log allowed tools once as a single line: `AllowedTools: ...`. +- For every edit: Read → Write (with precondition hash) → Re‑read; on `{status:"stale_file"}` retry once after re‑read. +- Keep evidence to ±20–40 lines windows; cap unified diffs to 300 lines and note truncation. +- End `` with `VERDICT: PASS` or `VERDICT: FAIL`. ## Guardrails - No destructive operations. Keep changes minimal and well‑scoped. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8daa7a66..926325b7 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -216,7 +216,7 @@ jobs: MD_OUT: reports/junit-nl-suite.md with: use_node_cache: false - prompt_file: .claude/prompts/nl-unity-suite-full.md + prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md claude_args: | --mcp-config .claude/mcp.json --allowedTools Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*) @@ -224,6 +224,31 @@ jobs: --model "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Normalize JUnit for consumer actions + if: always() + run: | + python - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import sys + + src = Path('reports/junit-nl-suite.xml') + out = Path('reports/junit-for-actions.xml') + + if not src.exists(): + sys.exit(0) + + tree = ET.parse(src) + root = tree.getroot() + + if root.tag == 'testsuites' and len(root) == 1 and root[0].tag == 'testsuite': + new_root = root[0] + ET.ElementTree(new_root).write(out, encoding='utf-8', xml_declaration=True) + else: + # Already suitable; copy/rename to target + out.write_bytes(src.read_bytes()) + PY + # sanitize only the markdown (does not touch JUnit xml) - name: Sanitize markdown (all shards) @@ -240,6 +265,23 @@ jobs: p.write_text(s, encoding='utf-8', newline='\n') PY + - name: NL/T details → Job Summary + if: always() + run: | + echo "## Unity NL/T Editing Suite — Full Coverage" >> $GITHUB_STEP_SUMMARY + python - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + p = Path('reports/junit-nl-suite.md') if Path('reports/junit-nl-suite.md').exists() else Path('reports/claude-nl-tests.md') + if p.exists(): + text = p.read_bytes().decode('utf-8', 'replace') + MAX = 65000 + print(text[:MAX]) + if len(text) > MAX: + print("\n\n_…truncated in summary; full report is in artifacts._") + else: + print("_No markdown report found._") + PY + - name: Fallback JUnit if missing if: always() run: | @@ -259,11 +301,13 @@ jobs: - name: Publish JUnit reports if: always() - uses: mikepenz/action-junit-report@v4 + uses: mikepenz/action-junit-report@v5 with: - report_paths: 'reports/*.xml' - require_tests: false + report_paths: 'reports/junit-for-actions.xml' + include_passed: true + detailed_summary: true annotate_notice: true + require_tests: false - name: Upload artifacts From 60c6c074b67800c08c4a10d8905bce5308ecf49c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 09:56:54 -0700 Subject: [PATCH 183/311] CI: replace claude_args with allowed_tools/model/mcp_config per action schema --- .github/workflows/claude-nl-suite.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 926325b7..4b50292d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -217,11 +217,10 @@ jobs: with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*) - --disallowedTools "TodoWrite,Task" - --model "claude-3-7-sonnet-latest" + mcp_config: .claude/mcp.json + allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" + disallowed_tools: "TodoWrite,Task" + model: "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Normalize JUnit for consumer actions From 1fa42a5df95212d2b1c2e0bf1af8d6a8c551f6f7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 10:13:40 -0700 Subject: [PATCH 184/311] CI: expand UNITY_PROJECT_ROOT via in MCP config heredoc --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 4b50292d..a6efc1b3 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -184,7 +184,7 @@ jobs: run: | set -eux mkdir -p .claude - cat > .claude/mcp.json <<'JSON' + cat > .claude/mcp.json < Date: Wed, 27 Aug 2025 10:26:29 -0700 Subject: [PATCH 185/311] EditorWindow: add cross-platform fallback for File.Replace; macOS-insensitive PathsEqual; safer uv resolve; honor macConfigPath --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 6cec5673..9c21eafc 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -722,7 +722,8 @@ private static bool PathsEqual(string a, string b) { string na = System.IO.Path.GetFullPath(a.Trim()); string nb = System.IO.Path.GetFullPath(b.Trim()); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows) + || System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); } @@ -1134,8 +1135,18 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } catch { } - // 1) Start from existing, only fill gaps - string uvPath = (ValidateUvBinarySafe(existingCommand) ? existingCommand : FindUvPath()); + // 1) Start from existing, only fill gaps (prefer trusted resolver) + string uvPath = FindUvPath(); + // Optionally trust existingCommand if it looks like uv/uv.exe + try + { + var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); + if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand)) + { + uvPath = existingCommand; + } + } + catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; string serverSrc = ExtractDirectoryArg(existingArgs); @@ -1214,13 +1225,24 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC try { - // Try atomic replace; creates 'backup' only on success + // Try atomic replace; creates 'backup' only on success (platform-dependent) System.IO.File.Replace(tmp, configPath, backup); writeDone = true; } catch (System.IO.FileNotFoundException) { - // Destination didn't exist; fall back to atomic move + // Destination didn't exist; fall back to move + System.IO.File.Move(tmp, configPath); + writeDone = true; + } + catch (System.PlatformNotSupportedException) + { + // Fallback: rename existing to backup, then move tmp into place + if (System.IO.File.Exists(configPath)) + { + try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } + System.IO.File.Move(configPath, backup); + } System.IO.File.Move(tmp, configPath); writeDone = true; } @@ -2120,10 +2142,14 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) string unityProjectDir = Application.dataPath; string projectDir = Path.GetDirectoryName(unityProjectDir); - // Read the global Claude config file - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? mcpClient.windowsConfigPath - : mcpClient.linuxConfigPath; + // Read the global Claude config file (honor macConfigPath on macOS) + string configPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + configPath = mcpClient.windowsConfigPath; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; + else + configPath = mcpClient.linuxConfigPath; if (debugLogsEnabled) { From c72b90a3915a5fcbc0776c647ef1022fcd5af1f7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 10:47:23 -0700 Subject: [PATCH 186/311] CI: strengthen JUnit publishing for NL mini suite (normalize, debug list, publish both, fail_on_parse_error) --- .github/workflows/claude-nl-suite.yml | 52 +++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index a6efc1b3..d9386bef 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -223,31 +223,52 @@ jobs: model: "claude-3-7-sonnet-latest" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Normalize JUnit for consumer actions + - name: Normalize JUnit for consumer actions (strong) if: always() + shell: bash run: | - python - <<'PY' + python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET - import sys + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path('reports/junit-nl-suite.xml') out = Path('reports/junit-for-actions.xml') + out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): - sys.exit(0) - - tree = ET.parse(src) - root = tree.getroot() - - if root.tag == 'testsuites' and len(root) == 1 and root[0].tag == 'testsuite': - new_root = root[0] - ET.ElementTree(new_root).write(out, encoding='utf-8', xml_declaration=True) + print("WARN: junit-nl-suite.xml not found") else: - # Already suitable; copy/rename to target - out.write_bytes(src.read_bytes()) + try: + root = ET.parse(src).getroot() + rtag = localname(root.tag) + if rtag == 'testsuites' and len(root) == 1 and localname(root[0].tag) == 'testsuite': + ET.ElementTree(root[0]).write(out, encoding='utf-8', xml_declaration=True) + else: + out.write_bytes(src.read_bytes()) + except Exception as e: + print("Normalization error:", e) + out.write_bytes(src.read_bytes()) + + # Always create a second copy with a junit-* name so wildcard patterns match too + if out.exists(): + Path('reports/junit-nl-suite-copy.xml').write_bytes(out.read_bytes()) PY + - name: "Debug: list report files" + if: always() + shell: bash + run: | + set -eux + ls -la reports || true + shopt -s nullglob + for f in reports/*.xml; do + echo "===== $f =====" + head -n 40 "$f" || true + done + # sanitize only the markdown (does not touch JUnit xml) - name: Sanitize markdown (all shards) @@ -302,11 +323,14 @@ jobs: if: always() uses: mikepenz/action-junit-report@v5 with: - report_paths: 'reports/junit-for-actions.xml' + report_paths: | + reports/junit-for-actions.xml + reports/junit-nl-suite.xml include_passed: true detailed_summary: true annotate_notice: true require_tests: false + fail_on_parse_error: true - name: Upload artifacts From 9d9216edb52c1d437ec23d77a356862aa8c36eef Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 11:04:52 -0700 Subject: [PATCH 187/311] CI: set job-wide JUNIT_OUT/MD_OUT; normalization uses env; publish references env and ungroup reports --- .github/workflows/claude-nl-suite.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index d9386bef..b3731e2a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -21,6 +21,9 @@ jobs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md steps: # ---------- Detect secrets ---------- @@ -230,11 +233,12 @@ jobs: python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET + import os def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag - src = Path('reports/junit-nl-suite.xml') + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) out = Path('reports/junit-for-actions.xml') out.parent.mkdir(parents=True, exist_ok=True) @@ -325,7 +329,8 @@ jobs: with: report_paths: | reports/junit-for-actions.xml - reports/junit-nl-suite.xml + ${{ env.JUNIT_OUT }} + group_reports: false include_passed: true detailed_summary: true annotate_notice: true From 6122c285408c8a5bab7d1972914c9fef15fe6b9c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 11:21:16 -0700 Subject: [PATCH 188/311] CI: publish a single normalized JUnit (reports/junit-for-actions.xml); fallback writes same; avoid checkName/reportPaths mismatch --- .github/workflows/claude-nl-suite.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b3731e2a..89ea7f35 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -243,8 +243,14 @@ jobs: out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): - print("WARN: junit-nl-suite.xml not found") - else: + # Try to use any existing XML as a source (e.g., claude-nl-tests.xml) + candidates = sorted(Path('reports').glob('*.xml')) + if candidates: + src = candidates[0] + else: + print("WARN: no XML source found for normalization") + + if src.exists(): try: root = ET.parse(src).getroot() rtag = localname(root.tag) @@ -311,7 +317,7 @@ jobs: run: | set -eu mkdir -p reports - if ! ls reports/*.xml >/dev/null 2>&1; then + if [ ! -f reports/junit-for-actions.xml ]; then printf '%s\n' \ '' \ '' \ @@ -319,7 +325,7 @@ jobs: ' ' \ ' ' \ '' \ - > reports/junit-fallback.xml + > reports/junit-for-actions.xml fi @@ -327,10 +333,7 @@ jobs: if: always() uses: mikepenz/action-junit-report@v5 with: - report_paths: | - reports/junit-for-actions.xml - ${{ env.JUNIT_OUT }} - group_reports: false + report_paths: 'reports/junit-for-actions.xml' include_passed: true detailed_summary: true annotate_notice: true From 0cfbd7b80203357377ff080561af639476c7706b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 11:33:36 -0700 Subject: [PATCH 189/311] CI: align mini prompt report filenames; redact Unity log tail in diagnostics --- .claude/prompts/nl-unity-claude-tests-mini.md | 4 ++-- .github/workflows/claude-nl-suite.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/prompts/nl-unity-claude-tests-mini.md b/.claude/prompts/nl-unity-claude-tests-mini.md index 79471f7f..35900b71 100644 --- a/.claude/prompts/nl-unity-claude-tests-mini.md +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -13,8 +13,8 @@ You are running inside CI for the **unity-mcp** repository. Your task is to demo - Optionally include one idempotency/no‑op check (re‑apply an edit and confirm nothing breaks). 4) **Validate your edits.** Re‑read the modified regions and verify the changes exist, compile‑risk is low, and surrounding structure remains intact. 5) **Report results.** Produce both: - - A JUnit XML at `reports/claude-nl-tests.xml` containing a single suite named `UnityMCP.NL` with one testcase per sub‑test you executed (mark pass/fail and include helpful failure text). - - A summary markdown at `reports/claude-nl-summary.md` that explains what you attempted, what succeeded/failed, and any follow‑ups you would try. + - A JUnit XML at `reports/junit-nl-suite.xml` containing a single suite named `UnityMCP.NL` with one test case per sub‑test you executed (mark pass/fail and include helpful failure text). + - A summary markdown at `reports/junit-nl-suite.md` that explains what you attempted, what succeeded/failed, and any follow‑ups you would try. 6) **Be gentle and reversible.** Prefer targeted, minimal edits; avoid wide refactors or non‑deterministic changes. ## Assumptions & Hints (non‑prescriptive) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 89ea7f35..43a47f3c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -178,7 +178,8 @@ jobs: echo "== status files =="; ls -la "$HOME/.unity-mcp" || true echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' - echo "== tail of Unity log =="; docker logs --tail 200 unity-mcp || true + echo "== tail of Unity log ==" + docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true exit 1 fi From e13c470fbdd318f644c44acc7cfe7860fd4d087b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 11:34:28 -0700 Subject: [PATCH 190/311] chore: sync workflow and mini prompt; redacted logs; JUnit normalization/publish tweaks --- .github/workflows/unity-mcp-quickprobe.yml | 49 ---------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/unity-mcp-quickprobe.yml diff --git a/.github/workflows/unity-mcp-quickprobe.yml b/.github/workflows/unity-mcp-quickprobe.yml deleted file mode 100644 index 09a04b0c..00000000 --- a/.github/workflows/unity-mcp-quickprobe.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Unity MCP — Quickprobe - -on: - workflow_dispatch: {} - -jobs: - quickprobe: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v4 - - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json <<'JSON' - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } - } - } - JSON - - - name: Run Claude quickprobe - uses: anthropics/claude-code-base-action@beta - with: - claude_args: | - --mcp-config .claude/mcp.json - --allowedTools "mcp__unity__find_in_file,mcp__unity__list_resources" - --disallowedTools "TodoWrite,WebSearch,Bash,Read,Write,Edit,MultiEdit,NotebookEdit,KillBash" - --model "claude-3-7-sonnet-20250219" - --max-turns 2 - --timeout-minutes 5 - prompt_file: .claude/prompts/mcp-quickprobe.md - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - From 252cc5c148aced801c805a8c86bb39a26811d47c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 12:37:04 -0700 Subject: [PATCH 191/311] CI: redact sensitive tokens in Stop Unity; docs: CI usage + edit tools --- .github/workflows/claude-nl-suite-mini.yml | 359 +++++++++++++++++++++ .github/workflows/claude-nl-suite.yml | 6 +- README-DEV.md | 36 +++ README.md | 3 + 4 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/claude-nl-suite-mini.yml diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml new file mode 100644 index 00000000..5bee722f --- /dev/null +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -0,0 +1,359 @@ +name: Claude Mini NL Test Suite (Unity live) + +on: + workflow_dispatch: {} + +permissions: + contents: read + checks: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + UNITY_VERSION: 2021.3.45f1 + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 + UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home + +jobs: + nl-suite: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md + + steps: + # ---------- Detect secrets ---------- + - name: Detect secrets (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -e + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ---------- Python env for MCP server (uv) ---------- + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install MCP server + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found (skipping)" + fi + + # ---------- License prime on host (handles ULF or EBL) ---------- + - name: Prime Unity license on host (GameCI) + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + unityVersion: ${{ env.UNITY_VERSION }} + + # (Optional) Show where the license actually got written + - name: Inspect GameCI license caches (host) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true + + # ---------- Clean any stale MCP status from previous runs ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + + # ---------- Start headless Unity that stays up (bridge enabled) ---------- + - name: Start Unity (persistent bridge) + if: steps.detect.outputs.unity_ok == 'true' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; failing fast." + exit 1 + fi + mkdir -p "$HOME/.unity-mcp" + MANUAL_ARG=() + if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) + fi + EBL_ARGS=() + [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") + [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") + [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") + docker rm -f unity-mcp >/dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${MANUAL_ARG[@]}" \ + "${EBL_ARGS[@]}" \ + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + + # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- + - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container failed to start"; docker ps -a || true; exit 1 + fi + docker logs -f unity-mcp & LOGPID=$! + deadline=$((SECONDS+420)); READY=0 + + try_connect_host() { + P="$1" + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true + if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi + return 1 + } + + # in-container probe will try IPv4 then IPv6 via nc or /dev/tcp + + while [ $SECONDS -lt $deadline ]; do + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + READY=1; echo "Bridge ready (log markers)"; break + fi + PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) + if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then + READY=1; echo "Bridge ready on port $PORT"; break + fi + if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then + echo "Licensing error detected"; break + fi + sleep 2 + done + + kill $LOGPID || true + + if [ "$READY" != "1" ]; then + echo "Bridge not ready; diagnostics:" + echo "== status files =="; ls -la "$HOME/.unity-mcp" || true + echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done + echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' + echo "== tail of Unity log ==" + docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + exit 1 + fi + + # ---------- Make MCP config available to the action ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json < str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + out = Path('reports/junit-for-actions.xml') + out.parent.mkdir(parents=True, exist_ok=True) + + if not src.exists(): + # Try to use any existing XML as a source (e.g., claude-nl-tests.xml) + candidates = sorted(Path('reports').glob('*.xml')) + if candidates: + src = candidates[0] + else: + print("WARN: no XML source found for normalization") + + if src.exists(): + try: + root = ET.parse(src).getroot() + rtag = localname(root.tag) + if rtag == 'testsuites' and len(root) == 1 and localname(root[0].tag) == 'testsuite': + ET.ElementTree(root[0]).write(out, encoding='utf-8', xml_declaration=True) + else: + out.write_bytes(src.read_bytes()) + except Exception as e: + print("Normalization error:", e) + out.write_bytes(src.read_bytes()) + + # Always create a second copy with a junit-* name so wildcard patterns match too + if out.exists(): + Path('reports/junit-nl-suite-copy.xml').write_bytes(out.read_bytes()) + PY + + - name: "Debug: list report files" + if: always() + shell: bash + run: | + set -eux + ls -la reports || true + shopt -s nullglob + for f in reports/*.xml; do + echo "===== $f =====" + head -n 40 "$f" || true + done + + + # sanitize only the markdown (does not touch JUnit xml) + - name: Sanitize markdown (all shards) + if: always() + run: | + set -eu + python - <<'PY' + from pathlib import Path + rp=Path('reports') + rp.mkdir(parents=True, exist_ok=True) + for p in rp.glob('*.md'): + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY + + - name: NL/T details → Job Summary + if: always() + run: | + echo "## Unity NL/T Editing Suite — Full Coverage" >> $GITHUB_STEP_SUMMARY + python - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + p = Path('reports/junit-nl-suite.md') if Path('reports/junit-nl-suite.md').exists() else Path('reports/claude-nl-tests.md') + if p.exists(): + text = p.read_bytes().decode('utf-8', 'replace') + MAX = 65000 + print(text[:MAX]) + if len(text) > MAX: + print("\n\n_…truncated in summary; full report is in artifacts._") + else: + print("_No markdown report found._") + PY + + - name: Fallback JUnit if missing + if: always() + run: | + set -eu + mkdir -p reports + if [ ! -f reports/junit-for-actions.xml ]; then + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > reports/junit-for-actions.xml + fi + + + - name: Publish JUnit reports + if: always() + uses: mikepenz/action-junit-report@v5 + with: + report_paths: 'reports/junit-for-actions.xml' + include_passed: true + detailed_summary: true + annotate_notice: true + require_tests: false + fail_on_parse_error: true + + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-suite-artifacts + path: reports/** + + + + # ---------- Always stop Unity ---------- + - name: Stop Unity + if: always() + run: | + docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + docker rm -f unity-mcp || true diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 43a47f3c..2e14ee6d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,4 +1,4 @@ -name: Claude NL suite (Unity live) +name: Claude Full NL Test Suite (Unity live) on: workflow_dispatch: {} @@ -220,7 +220,7 @@ jobs: MD_OUT: reports/junit-nl-suite.md with: use_node_cache: false - prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md + prompt_file: .claude/prompts/nl-unity-suite-full.md mcp_config: .claude/mcp.json allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" disallowed_tools: "TodoWrite,Task" @@ -355,5 +355,5 @@ jobs: - name: Stop Unity if: always() run: | - docker logs unity-mcp || true + docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true docker rm -f unity-mcp || true diff --git a/README-DEV.md b/README-DEV.md index eac08193..a136e708 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -66,6 +66,42 @@ To find it reliably: Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. +## CI Test Workflow (GitHub Actions) + +We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge. + +- Trigger: Workflow dispatch (`Claude NL suite (Unity live)`). +- Image: `UNITY_IMAGE` (UnityCI) pulled by tag; the job resolves a digest at runtime. Logs are sanitized. +- Reports: JUnit at `reports/junit-nl-suite.xml`, Markdown at `reports/junit-nl-suite.md`. +- Publishing: JUnit is normalized to `reports/junit-for-actions.xml` and published; artifacts upload all files under `reports/`. + +### Test target script +- The repo includes a long, standalone C# script used to exercise larger edits and windows: + - `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` + Use this file locally and in CI to validate multi-edit batches, anchor inserts, and windowed reads on a sizable script. + +### Add a new NL test +- Edit `.claude/prompts/nl-unity-claude-tests-mini.md` (or `nl-unity-suite-full.md` for the larger suite). +- Follow the conventions: single `` root, one `` per sub-test, end system-out with `VERDICT: PASS|FAIL`. +- Keep edits minimal and reversible; include evidence windows and compact diffs. + +### Run the suite +1) Push your branch, then manually run the workflow from the Actions tab. +2) The job writes reports into `reports/` and uploads artifacts. +3) The “JUnit Test Report” check summarizes results; open the Job Summary for full markdown. + +### View results +- Job Summary: inline markdown summary of the run on the Actions tab in GitHub +- Check: “JUnit Test Report” on the PR/commit. +- Artifacts: `claude-nl-suite-artifacts` includes XML and MD. + + +### MCP Connection Debugging +- *Enable debug logs* in Unity MCP window (inside the Editor) to see shows connection status, auto-setup results, and MCP client paths. It shows + - bridge startup/port, client connections, strict framing negotiation, and parsed frames + - auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and error surfaces +- In CI, the job tails Unity logs (redacted for serial/license/password/token) and shows socket and status JSON diagnostics if startup fails. + ## Workflow 1. **Make changes** to your source code in this directory diff --git a/README.md b/README.md index c3082f74..ae5e02dd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project"). + * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. + * `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. + * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. --- From 0938760af71effe74b5ef54aba93642c1a61402b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 13:54:34 -0700 Subject: [PATCH 192/311] prompts: update nl-unity-suite-full (mini-style setup + reporting discipline); remove obsolete prompts --- .claude/prompts/mcp-quickprobe.md | 17 -- .claude/prompts/nl-startuptest.md | 26 -- .claude/prompts/nl-unity-suite-full-old.md | 205 ++++++++++++++++ .claude/prompts/nl-unity-suite-full.md | 273 +++++++-------------- 4 files changed, 295 insertions(+), 226 deletions(-) delete mode 100644 .claude/prompts/mcp-quickprobe.md delete mode 100644 .claude/prompts/nl-startuptest.md create mode 100644 .claude/prompts/nl-unity-suite-full-old.md diff --git a/.claude/prompts/mcp-quickprobe.md b/.claude/prompts/mcp-quickprobe.md deleted file mode 100644 index ccfdae5d..00000000 --- a/.claude/prompts/mcp-quickprobe.md +++ /dev/null @@ -1,17 +0,0 @@ -You are running a strict, two-step Unity MCP wiring check. - -Rules (must follow exactly): -- Do not plan, narrate, or print any text besides raw tool results. -- Make exactly the two tool calls below, in order, with the exact JSON shown. -- If a call fails, print the exception type and message exactly, then stop. - -1) Call mcp__unity__find_in_file with: -{ - "project_relative_file": "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs", - "pattern": "class\\s+LongUnityScriptClaudeTest" -} - -2) Call mcp__unity__list_resources with: -{ "ctx": {}, "under": "TestProjects/UnityMCPTests/Assets/Scripts", "pattern": "*.cs" } - -Stop after step 2. diff --git a/.claude/prompts/nl-startuptest.md b/.claude/prompts/nl-startuptest.md deleted file mode 100644 index 351493ae..00000000 --- a/.claude/prompts/nl-startuptest.md +++ /dev/null @@ -1,26 +0,0 @@ -# Goal -Fast preflight to confirm the Unity MCP server is reachable and usable in CI. - -# What to do -1) Use **ListMcpResourcesTool** first to probe the Unity MCP server for any resources. - - If it returns `[]`, try Unity’s direct tools **mcp__unity__list_resources** with just `under` and `pattern`. - - **Do not** pass `ctx: ""`. If a `ctx` object is required, pass `{}` (an empty JSON object) or omit it entirely. - -2) Locate the test C# file under `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` and **Read** a small window of lines to confirm anchors like `Update()` exist. If unavailable, list `Assets/**/*.cs` within that project and choose a simple alternative. - -3) Do **not** make destructive edits here. This step is only a smoke test to ensure we can list/read resources successfully before the full NL/T suite. - -# Guardrails -- No wildcards in tool names were enabled; you must work with the explicit tools allowed by the workflow. -- Prefer aggregator tools (ListMcpResourcesTool / ReadMcpResourceTool) first; drop down to `mcp__unity__*` tools only when necessary and with correct argument shapes. -- Keep logs short and actionable. - -# Output -- Print a brief bullet summary to stdout that includes: - - Whether resources were detected. - - The path of the target file you’ll use later. - - Any issues to watch for (e.g., permission prompts). - -# Quick self-check (do this early) -- Read `unity://spec/script-edits` using the direct Unity resource tool with a proper URI (`unity://spec/script-edits`). -- Then try listing `Assets/Scripts/*.cs` with `{ "ctx": {}, "project_root": "TestProjects/UnityMCPTests" }`. If nothing found, include the absolute project root passed via environment if available. diff --git a/.claude/prompts/nl-unity-suite-full-old.md b/.claude/prompts/nl-unity-suite-full-old.md new file mode 100644 index 00000000..8d106e68 --- /dev/null +++ b/.claude/prompts/nl-unity-suite-full-old.md @@ -0,0 +1,205 @@ +# Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) +Version: 1.1.1 (update this when the prompt changes materially) +Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) + +You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). +At the start of the run, log the effective tools ONCE as a single line in ``: +`AllowedTools: `. + +## Sharding and filtering +- Honor a `TEST_FILTER` variable (passed via the workflow `vars` JSON) of the form `group:`. +- Supported groups: `edits`, `scenes`, `assets`, `menu`, `shader`, `validate`. +- Default if missing or unrecognized: `group:edits`. +- Only run tests mapped to the selected group. For other groups, emit a minimal JUnit with zero or informational testcases and a markdown note indicating no applicable tests for the group. + +### Variables +- `TEST_FILTER`: selection filter (e.g., `group:edits`). +- `JUNIT_OUT`: path for JUnit XML output. Default: `reports/claude-nl-tests.xml`. +- `MD_OUT`: path for summary markdown. Default: `reports/claude-nl-tests.md`. + +### MCP connectivity preflight +- Before running any tests in a shard, perform a quick MCP connectivity check with retries (60–90s total): + 1. Attempt `mcp__unity__manage_editor` with `{ action: "get_state" }`. + 2. If unsupported, attempt `mcp__unity__list_resources` with `{ project_root: "TestProjects/UnityMCPTests", under: "Assets", pattern: "*.cs", limit: 5 }`. + 3. Treat transient "Could not connect to Unity" as retryable until the window expires. +- On success: record an INFO testcase noting attempts and elapsed time and continue. +- On failure: emit a single failing testcase (e.g., `NL-Preflight.MCPConnect`) with `` message and stop the shard. + +## Test target +- Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` +- Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. +- Include `precondition_sha256` for any write (text-path or structured edit). In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. + - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). + +## Execution discipline +- Log allowed tools once as `AllowedTools: ...` (single line) at suite start. +- For every edit: Read → Write → Re-read. Compute `precondition_sha256` from the just-read bytes; never reuse an old hash. +- If you get `{ status: "stale_file" }`: re-read and retry the edit ONCE. If it still fails, record failure and immediately write JUnit and MD outputs. +- For each test: perform the edit, verify windows/diff, then revert to a clean tree before proceeding to the next test. +- Evidence windows only (±20–40 lines). Never dump entire files. +- Cap unified diffs to 300 lines; if truncated, include `...diff truncated...` before the `VERDICT:` line. +- End `` with `VERDICT: PASS` or `VERDICT: FAIL`. + +## Output requirements +### Reporting discipline (must-follow) +- Immediately at suite start, create a failing skeleton JUnit at `$JUNIT_OUT` and a summary markdown at `$MD_OUT` using the Write tool. + - JUnit: one suite named `UnityMCP.NL-T`, one testcase `NL-Suite.Bootstrap` marked failed with a short message like "bootstrap". + - Markdown: a stub header plus an empty checklist. +- After each test (NL-* and T-*), update both files on disk: + - Append/replace the testcase in the JUnit with proper `` and terminal `VERDICT: PASS|FAIL`. + - Expand the markdown with the evidence windows and inline unified diffs. +- On any fatal error or time/budget limit, flush whatever progress exists to both files so CI never sees an empty `reports/`. +- JUnit XML at `JUNIT_OUT` (or `reports/claude-nl-tests.xml` if unset). Create the `reports/` directory if missing. +- One `` wrapping all `` elements. +- Each `` must set: + - `classname` ∈ {`UnityMCP.NL`, `UnityMCP.T`} + - `name` = short, unique id (e.g., `NL-1.ReplaceHasTarget`, `T-F.AtomicBatch`) + - `time` in seconds (float) +- Emit `` with evidence and end with a single terminal line: `VERDICT: PASS` or `VERDICT: FAIL` (uppercase, exact match). +- For any test that performs changes, include a compact unified diff in `` using the standard format and cap to 300 lines. If truncated, include `...diff truncated...` before `VERDICT: ...`. +- On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. +- Summary markdown at `MD_OUT` (or `reports/claude-nl-tests.md` if unset) with checkboxes, windowed reads, and inline diffs for changed tests. +- XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: + + ```xml + + + + ``` + + JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. +- Upload both JUnit and markdown outputs for the shard as workflow artifacts. +- Restore workspace at end (clean tree). + +## Safety & hygiene +- Make edits in-place, then revert after validation so the workspace is clean. + - Preferred: `git restore --staged --worktree :/` (or `git checkout -- .` on older Git) to discard all changes. + - Avoid `git stash` in CI unless you also clear stashes, as they may complicate cleanup. +- Never push commits from CI. +- Do not start/stop Unity or modify licensing/activation steps; assume Unity is already running and licensed by the workflow. If a license error is detected in logs, record failure in JUnit and stop the shard. + +## Group mapping +- `group:edits`: Run all NL-* and T-* tests defined below (NL-0 … NL-4, T-A … T-J). +- `group:scenes`, `group:assets`, `group:menu`, `group:shader`, `group:validate`: No-op for this prompt version; emit a minimal report with an informational `` indicating no applicable tests for the selected group. + +## CI headless hints +- For `mcp__unity__list_resources`/`read_resource`, specify: + - `project_root`: string (required—no default), e.g., `"TestProjects/UnityMCPTests"` + - `ctx`: object (optional, defaults to `{}`) +- Canonical URIs: + - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + - `Assets/Scripts/LongUnityScriptClaudeTest.cs` (normalized by the server) + +## NL-0. Sanity Reads (windowed) +- Tail 120 lines of the target file; expect to find the class closing brace `^\s*}\s*$` and at least one `Debug\\.Log` call. +- Show 40 lines around method `Update` (anchor: `^\s*public\s+void\s+Update\s*\(`). +- Pass if: + - Tail window contains the final class brace. + - The `Update` window contains the method signature line and at least one statement. + +## NL-1. Method replace/insert/delete (natural-language) +- Replace `HasTarget` with block-bodied version returning `currentTarget != null`. +- Insert `PrintSeries()` after `GetCurrentTarget` that logs `1,2,3` via `UnityEngine.Debug.Log("1,2,3");`. +- Verify by reading 20 lines around the anchor. +- Delete `PrintSeries()` and verify removal; confirm file hash equals the pre-edit hash. +- Pass on matched diffs and windows. + +## NL-2. Anchor comment insertion +- Insert a single-line C# comment `// Build marker OK` on the line immediately preceding the `public void Update(...)` signature (ignoring XML doc comments). +- Pass if the comment is adjacent to the signature with no blank line in between. + +## NL-3. End-of-class insertion +- Insert a 3-line comment `// Tail test A`, `// Tail test B`, `// Tail test C` immediately before the final class brace. +- Preserve existing indentation; ensure the file ends with a single trailing newline. + +## NL-4. Compile trigger (record-only) +- After an edit, ensure no obvious syntax issues; record as INFO. Unity compile runs in a separate step. + +## T-A. Anchor insert (text path) +- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via a single `replace_range` at the exact insertion point (range start=end). +- Normalize line endings to LF (`\n`) for hashing and diff emission; preserve original on write if required by the server. +- Verify; then delete with `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. Header/region preservation +- For `ApplyBlend`, change only interior lines via `replace_range`. + - Do not modify: method signature line, attributes, XML docs, `#region`/`#endregion` markers, or surrounding whitespace outside the body braces. +- Pass if unchanged. + +## T-D. End-of-class insertion (anchor) +- Find final class brace; insert 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 class end. +- The server must apply all edits atomically or reject the entire batch. +- On rejection, respond with `{ status: "atomic_reject", reason, conflicts: [...] }` and leave the file unchanged (hash equals precondition). +- Pass if either all 3 apply or `status == "atomic_reject"` with unchanged file hash. + +## T-G. Path normalization +- Run the same edit with both URIs: + 1) `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + 2) `Assets/Scripts/LongUnityScriptClaudeTest.cs` +- The server must canonicalize both to the same absolute path under `project_root` and reject duplicate-application within a single batch. +- Pass if both map to the same file path and the second attempt returns `{ status: "no_change" }`. + +## T-H. Validation levels +- Validation levels: + - `basic`: lexical checks (UTF-8, balanced quotes, no NULs), can tolerate temporarily unbalanced braces. + - `standard`: `basic` + C# tokenization and brace balance + forbid edits before first `using`. +- After edits, run `validate` with `level: "standard"`. If a text op is intentionally transiently unbalanced, allow `basic` only for the intermediate step; final state must pass `standard`. +- Pass if validation OK and final file compiles in the Unity step. + +## T-I. Failure surfaces (expected) +- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. +- Stale file: resend with old `precondition_sha256` after external change → expect `{status:"stale_file"}`. +- Overlap: two overlapping ranges → expect rejection. +- Unbalanced braces: remove a closing `}` → expect validation failure and no write. +- Using-directives guard: attempt insert before the first `using` → expect `{status:"using_guard"}`. +- Parameter aliasing: accept `insert`/`content` as aliases for `text` in insertion APIs → expect success. Server should echo the canonical key `text` in responses. +- Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return clear error. +- Pass when each negative case returns expected failure without persisting changes. + + +- Permitted statuses (string enum): + - "ok" + - "no_change" + - "too_large" + - "stale_file" + - "overlap" + - "unbalanced" + - "using_guard" + - "atomic_reject" + - "unsupported" +- All non-"ok"/"no_change" statuses MUST NOT modify files (verify via unchanged post-hash). + +## T-J. Idempotency & no-op +- Re-run the same `replace_range` with identical content → expect `{ status: "no_change" }` and unchanged hash. +- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op with `{ status: "no_change" }`. +- Pass if both behave idempotently. + +### Implementation notes +- Always capture pre/post windows (±20–40 lines) as evidence in JUnit or system-out. +- For any file write, include `precondition_sha256` computed over file bytes after normalizing line endings to LF (`\n`) and ensuring UTF-8 without BOM, unless the server specifies otherwise. +- Stale write retry: If any write returns `{ success:false, status:"stale_file", actual_current_sha256 }`, immediately: + (a) re-read the file, + (b) recompute `precondition_sha256` using LF normalization, + (c) re-apply the same edit once using the returned `actual_current_sha256`. If it stales again, record a failing testcase with the evidence window and continue to the next test. +- Verify the post-edit file hash in logs and include both pre- and post-hashes in ``. +- Restore repository to original state at end (`git status` must be clean). If not clean, mark the suite as FAIL. + + diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 844d6915..56a26b90 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,197 +1,104 @@ -# Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) -Version: 1.1.1 (update this when the prompt changes materially) -Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) - -You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). -At the start of the run, log the effective tools ONCE as a single line in ``: -`AllowedTools: `. - -## Sharding and filtering -- Honor a `TEST_FILTER` variable (passed via the workflow `vars` JSON) of the form `group:`. -- Supported groups: `edits`, `scenes`, `assets`, `menu`, `shader`, `validate`. -- Default if missing or unrecognized: `group:edits`. -- Only run tests mapped to the selected group. For other groups, emit a minimal JUnit with zero or informational testcases and a markdown note indicating no applicable tests for the group. - -### Variables -- `TEST_FILTER`: selection filter (e.g., `group:edits`). -- `JUNIT_OUT`: path for JUnit XML output. Default: `reports/claude-nl-tests.xml`. -- `MD_OUT`: path for summary markdown. Default: `reports/claude-nl-tests.md`. - -### MCP connectivity preflight -- Before running any tests in a shard, perform a quick MCP connectivity check with retries (60–90s total): - 1. Attempt `mcp__unity__manage_editor` with `{ action: "get_state" }`. - 2. If unsupported, attempt `mcp__unity__list_resources` with `{ project_root: "TestProjects/UnityMCPTests", under: "Assets", pattern: "*.cs", limit: 5 }`. - 3. Treat transient "Could not connect to Unity" as retryable until the window expires. -- On success: record an INFO testcase noting attempts and elapsed time and continue. -- On failure: emit a single failing testcase (e.g., `NL-Preflight.MCPConnect`) with `` message and stop the shard. - -## Test target -- Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` -- Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. -- Include `precondition_sha256` for any write (text-path or structured edit). In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. - - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). - -## Execution discipline -- Log allowed tools once as `AllowedTools: ...` (single line) at suite start. -- For every edit: Read → Write → Re-read. Compute `precondition_sha256` from the just-read bytes; never reuse an old hash. -- If you get `{ status: "stale_file" }`: re-read and retry the edit ONCE. If it still fails, record failure and immediately write JUnit and MD outputs. -- For each test: perform the edit, verify windows/diff, then revert to a clean tree before proceeding to the next test. -- Evidence windows only (±20–40 lines). Never dump entire files. -- Cap unified diffs to 300 lines; if truncated, include `...diff truncated...` before the `VERDICT:` line. +# Unity NL/T Editing Suite — Hybrid (Mini setup + Full tests) + +You are running inside CI for the unity-mcp repository. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. + +## Mission +1) Discover capabilities (primer/capabilities if available). +2) Choose target file: prefer `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs`; otherwise a simple C# under `TestProjects/UnityMCPTests/Assets/`. +3) Execute the full NL/T test list below using minimal, precise edits. Keep changes small and reversible. +4) Validate each edit via re-read and windows/diffs. +5) Report results in JUnit XML and Markdown. + +## Assumptions & Hints +- Include `ctx: {}` and `project_root: "TestProjects/UnityMCPTests"` for list/read/edit operations in CI. +- If the preferred file is missing, select a safe alternative C# script under `Assets/`. +- If compilation is unavailable, rely on structural checks and validation tools. + +## Output Requirements (match NL suite conventions) +- JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL`. +- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. +- Log allowed tools once as a single line: `AllowedTools: ...`. +- For every edit: Read → Write (with precondition hash) → Re-read; on `{status:"stale_file"}` retry once after re-read. +- Evidence windows only (±20–40 lines); cap unified diffs to 300 lines and note truncation. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. -## Output requirements -- JUnit XML at `JUNIT_OUT` (or `reports/claude-nl-tests.xml` if unset). Create the `reports/` directory if missing. -- One `` wrapping all `` elements. -- Each `` must set: - - `classname` ∈ {`UnityMCP.NL`, `UnityMCP.T`} - - `name` = short, unique id (e.g., `NL-1.ReplaceHasTarget`, `T-F.AtomicBatch`) - - `time` in seconds (float) -- Emit `` with evidence and end with a single terminal line: `VERDICT: PASS` or `VERDICT: FAIL` (uppercase, exact match). -- For any test that performs changes, include a compact unified diff in `` using the standard format and cap to 300 lines. If truncated, include `...diff truncated...` before `VERDICT: ...`. -- On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. -- Summary markdown at `MD_OUT` (or `reports/claude-nl-tests.md` if unset) with checkboxes, windowed reads, and inline diffs for changed tests. -- XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: - - ```xml - - - - ``` - - JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. -- Upload both JUnit and markdown outputs for the shard as workflow artifacts. -- Restore workspace at end (clean tree). +### Reporting discipline (must-follow) +- At suite start, create a failing skeleton JUnit and Markdown via Write: + - JUnit: one suite `UnityMCP.NL-T`, testcase `NL-Suite.Bootstrap` failed with message `bootstrap`. + - Markdown: stub header and empty checklist. +- After each test, update both files: append/replace testcases with evidence windows and diffs; maintain terminal VERDICT line. +- On fatal error/time budget, flush current progress so CI never sees an empty reports/. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. - - Preferred: `git restore --staged --worktree :/` (or `git checkout -- .` on older Git) to discard all changes. - - Avoid `git stash` in CI unless you also clear stashes, as they may complicate cleanup. - Never push commits from CI. -- Do not start/stop Unity or modify licensing/activation steps; assume Unity is already running and licensed by the workflow. If a license error is detected in logs, record failure in JUnit and stop the shard. - -## Group mapping -- `group:edits`: Run all NL-* and T-* tests defined below (NL-0 … NL-4, T-A … T-J). -- `group:scenes`, `group:assets`, `group:menu`, `group:shader`, `group:validate`: No-op for this prompt version; emit a minimal report with an informational `` indicating no applicable tests for the selected group. +- Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. ## CI headless hints - For `mcp__unity__list_resources`/`read_resource`, specify: - - `project_root`: string (required—no default), e.g., `"TestProjects/UnityMCPTests"` - - `ctx`: object (optional, defaults to `{}`) + - `project_root`: `"TestProjects/UnityMCPTests"` + - `ctx`: `{}` - Canonical URIs: - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - - `Assets/Scripts/LongUnityScriptClaudeTest.cs` (normalized by the server) - -## NL-0. Sanity Reads (windowed) -- Tail 120 lines of the target file; expect to find the class closing brace `^\s*}\s*$` and at least one `Debug\\.Log` call. -- Show 40 lines around method `Update` (anchor: `^\s*public\s+void\s+Update\s*\(`). -- Pass if: - - Tail window contains the final class brace. - - The `Update` window contains the method signature line and at least one statement. - -## NL-1. Method replace/insert/delete (natural-language) -- Replace `HasTarget` with block-bodied version returning `currentTarget != null`. -- Insert `PrintSeries()` after `GetCurrentTarget` that logs `1,2,3` via `UnityEngine.Debug.Log("1,2,3");`. -- Verify by reading 20 lines around the anchor. -- Delete `PrintSeries()` and verify removal; confirm file hash equals the pre-edit hash. -- Pass on matched diffs and windows. - -## NL-2. Anchor comment insertion -- Insert a single-line C# comment `// Build marker OK` on the line immediately preceding the `public void Update(...)` signature (ignoring XML doc comments). -- Pass if the comment is adjacent to the signature with no blank line in between. - -## NL-3. End-of-class insertion -- Insert a 3-line comment `// Tail test A`, `// Tail test B`, `// Tail test C` immediately before the final class brace. -- Preserve existing indentation; ensure the file ends with a single trailing newline. - -## NL-4. Compile trigger (record-only) -- After an edit, ensure no obvious syntax issues; record as INFO. Unity compile runs in a separate step. - -## T-A. Anchor insert (text path) -- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via a single `replace_range` at the exact insertion point (range start=end). -- Normalize line endings to LF (`\n`) for hashing and diff emission; preserve original on write if required by the server. -- Verify; then delete with `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. Header/region preservation -- For `ApplyBlend`, change only interior lines via `replace_range`. - - Do not modify: method signature line, attributes, XML docs, `#region`/`#endregion` markers, or surrounding whitespace outside the body braces. -- Pass if unchanged. - -## T-D. End-of-class insertion (anchor) -- Find final class brace; insert 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 class end. -- The server must apply all edits atomically or reject the entire batch. -- On rejection, respond with `{ status: "atomic_reject", reason, conflicts: [...] }` and leave the file unchanged (hash equals precondition). -- Pass if either all 3 apply or `status == "atomic_reject"` with unchanged file hash. - -## T-G. Path normalization -- Run the same edit with both URIs: - 1) `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - 2) `Assets/Scripts/LongUnityScriptClaudeTest.cs` -- The server must canonicalize both to the same absolute path under `project_root` and reject duplicate-application within a single batch. -- Pass if both map to the same file path and the second attempt returns `{ status: "no_change" }`. - -## T-H. Validation levels -- Validation levels: - - `basic`: lexical checks (UTF-8, balanced quotes, no NULs), can tolerate temporarily unbalanced braces. - - `standard`: `basic` + C# tokenization and brace balance + forbid edits before first `using`. -- After edits, run `validate` with `level: "standard"`. If a text op is intentionally transiently unbalanced, allow `basic` only for the intermediate step; final state must pass `standard`. -- Pass if validation OK and final file compiles in the Unity step. - -## T-I. Failure surfaces (expected) -- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. -- Stale file: resend with old `precondition_sha256` after external change → expect `{status:"stale_file"}`. -- Overlap: two overlapping ranges → expect rejection. -- Unbalanced braces: remove a closing `}` → expect validation failure and no write. -- Using-directives guard: attempt insert before the first `using` → expect `{status:"using_guard"}`. -- Parameter aliasing: accept `insert`/`content` as aliases for `text` in insertion APIs → expect success. Server should echo the canonical key `text` in responses. -- Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return clear error. -- Pass when each negative case returns expected failure without persisting changes. - - -- Permitted statuses (string enum): - - "ok" - - "no_change" - - "too_large" - - "stale_file" - - "overlap" - - "unbalanced" - - "using_guard" - - "atomic_reject" - - "unsupported" -- All non-"ok"/"no_change" statuses MUST NOT modify files (verify via unchanged post-hash). - -## T-J. Idempotency & no-op -- Re-run the same `replace_range` with identical content → expect `{ status: "no_change" }` and unchanged hash. -- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op with `{ status: "no_change" }`. -- Pass if both behave idempotently. + - `Assets/Scripts/LongUnityScriptClaudeTest.cs` -### Implementation notes -- Always capture pre/post windows (±20–40 lines) as evidence in JUnit or system-out. -- For any file write, include `precondition_sha256` computed over file bytes after normalizing line endings to LF (`\n`) and ensuring UTF-8 without BOM, unless the server specifies otherwise. -- Stale write retry: If any write returns `{ success:false, status:"stale_file", actual_current_sha256 }`, immediately: - (a) re-read the file, - (b) recompute `precondition_sha256` using LF normalization, - (c) re-apply the same edit once using the returned `actual_current_sha256`. If it stales again, record a failing testcase with the evidence window and continue to the next test. -- Verify the post-edit file hash in logs and include both pre- and post-hashes in ``. -- Restore repository to original state at end (`git status` must be clean). If not clean, mark the suite as FAIL. +## Full NL/T Test List (imported) + +### NL-0. Sanity Reads (windowed) +- Tail 120 lines; read 40 lines around `Update()` signature. + +### NL-1. Method replace/insert/delete +- Replace `HasTarget` body to `return currentTarget != null;` +- Insert `PrintSeries()` after `GetCurrentTarget` logging `"1,2,3"`. +- Verify windows, then delete `PrintSeries()`; confirm original hash. + +### NL-2. Anchor comment insertion +- Insert `// Build marker OK` immediately above `public void Update(...)` (ignore XML docs). + +### NL-3. End-of-class insertion +- Insert three lines `// Tail test A/B/C` before final class brace; preserve indentation and trailing newline. + +### NL-4. Compile trigger (record-only) +- Ensure no obvious syntax issues; record INFO. + +### T-A. Anchor insert (text path) +- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via `replace_range` at insertion point; verify; then delete via `regex_replace`. + +### T-B. Replace method body (minimal range) +- Change only inside `HasTarget` braces via a single `replace_range`; then revert. +### T-C. Header/region preservation +- For `ApplyBlend`, modify interior lines only; keep signature/docs/regions unchanged. +### T-D. End-of-class insertion (anchor) +- Find final class brace; insert helper before; then remove. + +### T-E. Temporary method lifecycle +- Insert helper (T-A), update via `apply_text_edits`, then delete via `regex_replace`. + +### T-F. Multi-edit atomic batch +- In one call, do two `replace_range` tweaks + one end-of-class comment insert. Must be atomic or rejected as a whole. + +### T-G. Path normalization +- Run the same edit with both URIs; second attempt should return `{ status: "no_change" }`. + +### T-H. Validation levels +- Use `validate_script` with `level: "standard"` after edits; only allow `basic` for transient steps. + +### T-I. Failure surfaces (expected) +- Too large payload → `{status:"too_large"}` +- Stale file (old hash) → `{status:"stale_file"}` +- Overlap → rejection +- Unbalanced braces → validation failure +- Using-directives guard → `{status:"using_guard"}` +- Parameter aliasing accepted; server echoes canonical keys. +- Auto-upgrade: prefer structured edits or return clear error. + +### T-J. Idempotency & no-op +- Re-run identical `replace_range` → `{ status: "no_change" }` and unchanged hash. +- Re-run delete of already-removed helper via `regex_replace` → no-op. + +### Implementation notes +- Capture pre/post windows; include pre/post hashes in logs. +- Normalize line endings to LF when computing `precondition_sha256`. +- If a write returns `stale_file`, re-read and retry once with the returned hash; otherwise record failure and continue. From 2c2a26f40fdf330eb5895330aef2328a006eee3f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 14:40:19 -0700 Subject: [PATCH 193/311] CI: harden NL workflows (timeout_minutes, robust normalization); prompts: unify JUnit suite name and reporting discipline --- .claude/prompts/nl-unity-suite-full.md | 9 ++++++++- .github/workflows/claude-nl-suite-mini.yml | 1 + .github/workflows/claude-nl-suite.yml | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 56a26b90..7fbc9b29 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -14,12 +14,19 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow - If the preferred file is missing, select a safe alternative C# script under `Assets/`. - If compilation is unavailable, rely on structural checks and validation tools. ++## Tool mapping (use these APIs) ++When the tests say **replace_range** or **regex_replace**, call: ++- `mcp__unity__apply_text_edits` for single-range inserts/replacements. ++- `mcp__unity__script_apply_edits` for regex/anchor operations. ++- `mcp__unity__validate_script` for validation (`level: "standard"`). + + ## Output Requirements (match NL suite conventions) - JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL`. - Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. - Log allowed tools once as a single line: `AllowedTools: ...`. - For every edit: Read → Write (with precondition hash) → Re-read; on `{status:"stale_file"}` retry once after re-read. -- Evidence windows only (±20–40 lines); cap unified diffs to 300 lines and note truncation. +- Evidence windows only (±20–40 lines); cap unified diffs to 100 lines and note truncation. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. ### Reporting discipline (must-follow) diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml index 5bee722f..bac1fa0e 100644 --- a/.github/workflows/claude-nl-suite-mini.yml +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -225,6 +225,7 @@ jobs: allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" + timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Normalize JUnit for consumer actions (strong) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2e14ee6d..be50dbdf 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -225,6 +225,7 @@ jobs: allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" + timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Normalize JUnit for consumer actions (strong) From b252130d9cab67a2acada9d6126e4609a5c8873d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 15:12:56 -0700 Subject: [PATCH 194/311] prompts: add guarded write pattern (LF hash, stale_file retry) to full suite --- .claude/prompts/nl-unity-suite-full.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 7fbc9b29..acdaf81c 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,4 +1,4 @@ -# Unity NL/T Editing Suite — Hybrid (Mini setup + Full tests) +# Unity NL/T Editing Suite You are running inside CI for the unity-mcp repository. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. @@ -109,3 +109,21 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow - Capture pre/post windows; include pre/post hashes in logs. - Normalize line endings to LF when computing `precondition_sha256`. - If a write returns `stale_file`, re-read and retry once with the returned hash; otherwise record failure and continue. + +### Guarded write pattern (must use for every edit) +```pseudo +function guarded_write(uri, make_edit_from_text): + text = read(uri) # include ctx:{} and project_root + sha = sha256(LF(text)) # LF normalize before hashing + edit = make_edit_from_text(text) # compute ranges/anchors against *this* text + res = write(uri, edit, precondition_sha256=sha) + if res.status == "stale_file": + fresh = read(uri) + # Prefer server-provided expected_sha256 if present; else recompute + sha2 = res.expected_sha256 or sha256(LF(fresh)) + edit2 = make_edit_from_text(fresh) # recompute ranges vs fresh text + res2 = write(uri, edit2, precondition_sha256=sha2) + if res2.status != "ok": + record_failure_and_continue() # do not loop forever +``` +Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. From c35eed92e4b6a8b73e10cc96a07e8b43c7498fce Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 15:54:13 -0700 Subject: [PATCH 195/311] prompts: enforce continue-on-failure, driver flow, and status handling in full suite --- .claude/prompts/nl-unity-suite-full.md | 36 +++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index acdaf81c..ae47d23b 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -22,7 +22,7 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow ## Output Requirements (match NL suite conventions) -- JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL`. +- JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL-T`. - Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. - Log allowed tools once as a single line: `AllowedTools: ...`. - For every edit: Read → Write (with precondition hash) → Re-read; on `{status:"stale_file"}` retry once after re-read. @@ -38,6 +38,7 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. +- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the baseline `precondition_sha256`; re-read to confirm the hash matches. - Never push commits from CI. - Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. @@ -84,7 +85,7 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow - Insert helper (T-A), update via `apply_text_edits`, then delete via `regex_replace`. ### T-F. Multi-edit atomic batch -- In one call, do two `replace_range` tweaks + one end-of-class comment insert. Must be atomic or rejected as a whole. +- In a single `mcp__unity__apply_text_edits`/`mcp__unity__script_apply_edits` call, include two `replace_range` tweaks + one end-of-class comment insert using one `precondition_sha256` computed from the same snapshot. The server must apply all edits atomically or reject the entire batch (no partial application). ### T-G. Path normalization - Run the same edit with both URIs; second attempt should return `{ status: "no_change" }`. @@ -107,23 +108,40 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow ### Implementation notes - Capture pre/post windows; include pre/post hashes in logs. -- Normalize line endings to LF when computing `precondition_sha256`. +- Compute `precondition_sha256` over raw on-disk bytes (no normalization), unless the server explicitly documents and applies identical normalization on both sides. - If a write returns `stale_file`, re-read and retry once with the returned hash; otherwise record failure and continue. +- Never abort the suite on a single test failure; log the failure (including `{ status: ... }`) and proceed to the next test. + +### Test driver (must follow) +For each test NL-0..NL-4, then T-A..T-J: +1) READ → compute `pre_sha = sha256(read_bytes(uri))`. +2) RUN using the guarded write pattern for every mutation. +3) VALIDATE with `mcp__unity__validate_script(level:"standard")` unless the step is read-only. +4) RE-READ evidence windows; write JUnit + Markdown entries. +5) REVERT: if the test mutated the file, restore the exact pre-test content via a guarded full-file replace using `pre_sha` as `precondition_sha256`; re-read and confirm the hash matches. +6) Append `VERDICT: PASS` or `VERDICT: FAIL` to `` for that testcase. +7) Continue to the next test regardless of outcome. ### Guarded write pattern (must use for every edit) ```pseudo function guarded_write(uri, make_edit_from_text): - text = read(uri) # include ctx:{} and project_root - sha = sha256(LF(text)) # LF normalize before hashing - edit = make_edit_from_text(text) # compute ranges/anchors against *this* text + text = read(uri) # include ctx:{} and project_root + buf = read_bytes(uri) # raw on-disk bytes for hashing + sha = sha256(buf) # no normalization + edit = make_edit_from_text(text) # compute ranges/anchors against *this* text res = write(uri, edit, precondition_sha256=sha) if res.status == "stale_file": - fresh = read(uri) - # Prefer server-provided expected_sha256 if present; else recompute - sha2 = res.expected_sha256 or sha256(LF(fresh)) + fresh = read(uri) + fresh_bytes = read_bytes(uri) + # Prefer server-provided expected_sha256 if present; else recompute from raw bytes + sha2 = res.expected_sha256 or sha256(fresh_bytes) edit2 = make_edit_from_text(fresh) # recompute ranges vs fresh text res2 = write(uri, edit2, precondition_sha256=sha2) if res2.status != "ok": record_failure_and_continue() # do not loop forever ``` Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. + +### Status handling +- Treat expected safeguard statuses as non-fatal: `using_guard`, `unsupported`, and similar should record INFO in JUnit and continue. +- For idempotency cases (e.g., T-J), `{ status: "no_change" }` counts as PASS; for tests that require a real change, treat `{ status: "no_change" }` as SKIP/INFO and continue. From 791d6ffa07289ff51a5cdc71b6ac11d3b64e30c1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 16:37:41 -0700 Subject: [PATCH 196/311] Make test list more explicit in prompt. Get rid of old test prompts for hygeine. --- .claude/prompts/nl-unity-suite-full-old.md | 205 --------------------- .claude/prompts/nl-unity-suite-full.md | 9 + .claude/prompts/nl-unity-suite.md | 103 ----------- .github/workflows/claude-nl-suite.yml | 37 ++-- 4 files changed, 23 insertions(+), 331 deletions(-) delete mode 100644 .claude/prompts/nl-unity-suite-full-old.md delete mode 100644 .claude/prompts/nl-unity-suite.md diff --git a/.claude/prompts/nl-unity-suite-full-old.md b/.claude/prompts/nl-unity-suite-full-old.md deleted file mode 100644 index 8d106e68..00000000 --- a/.claude/prompts/nl-unity-suite-full-old.md +++ /dev/null @@ -1,205 +0,0 @@ -# Unity NL/T Editing Suite — Full Coverage (NL-0 … T-J) -Version: 1.1.1 (update this when the prompt changes materially) -Consumed by: .github/workflows/claude-nl-suite.yml (Unity NL suite job) - -You are running in CI at the repository root. Use only the tools allowed by the workflow (see `allowed_tools` in .github/workflows/claude-nl-suite.yml). -At the start of the run, log the effective tools ONCE as a single line in ``: -`AllowedTools: `. - -## Sharding and filtering -- Honor a `TEST_FILTER` variable (passed via the workflow `vars` JSON) of the form `group:`. -- Supported groups: `edits`, `scenes`, `assets`, `menu`, `shader`, `validate`. -- Default if missing or unrecognized: `group:edits`. -- Only run tests mapped to the selected group. For other groups, emit a minimal JUnit with zero or informational testcases and a markdown note indicating no applicable tests for the group. - -### Variables -- `TEST_FILTER`: selection filter (e.g., `group:edits`). -- `JUNIT_OUT`: path for JUnit XML output. Default: `reports/claude-nl-tests.xml`. -- `MD_OUT`: path for summary markdown. Default: `reports/claude-nl-tests.md`. - -### MCP connectivity preflight -- Before running any tests in a shard, perform a quick MCP connectivity check with retries (60–90s total): - 1. Attempt `mcp__unity__manage_editor` with `{ action: "get_state" }`. - 2. If unsupported, attempt `mcp__unity__list_resources` with `{ project_root: "TestProjects/UnityMCPTests", under: "Assets", pattern: "*.cs", limit: 5 }`. - 3. Treat transient "Could not connect to Unity" as retryable until the window expires. -- On success: record an INFO testcase noting attempts and elapsed time and continue. -- On failure: emit a single failing testcase (e.g., `NL-Preflight.MCPConnect`) with `` message and stop the shard. - -## Test target -- Primary file: `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` -- Prefer structured edit tools via MCP for method/class edits; use text-range ops when specified. -- Include `precondition_sha256` for any write (text-path or structured edit). In CI/headless, pass `project_root: "TestProjects/UnityMCPTests"` when reading/writing by URI. - - Hash must be the SHA-256 of the on-disk file bytes immediately before applying the edit (normalize line endings per Implementation notes). - -## Execution discipline -- Log allowed tools once as `AllowedTools: ...` (single line) at suite start. -- For every edit: Read → Write → Re-read. Compute `precondition_sha256` from the just-read bytes; never reuse an old hash. -- If you get `{ status: "stale_file" }`: re-read and retry the edit ONCE. If it still fails, record failure and immediately write JUnit and MD outputs. -- For each test: perform the edit, verify windows/diff, then revert to a clean tree before proceeding to the next test. -- Evidence windows only (±20–40 lines). Never dump entire files. -- Cap unified diffs to 300 lines; if truncated, include `...diff truncated...` before the `VERDICT:` line. -- End `` with `VERDICT: PASS` or `VERDICT: FAIL`. - -## Output requirements -### Reporting discipline (must-follow) -- Immediately at suite start, create a failing skeleton JUnit at `$JUNIT_OUT` and a summary markdown at `$MD_OUT` using the Write tool. - - JUnit: one suite named `UnityMCP.NL-T`, one testcase `NL-Suite.Bootstrap` marked failed with a short message like "bootstrap". - - Markdown: a stub header plus an empty checklist. -- After each test (NL-* and T-*), update both files on disk: - - Append/replace the testcase in the JUnit with proper `` and terminal `VERDICT: PASS|FAIL`. - - Expand the markdown with the evidence windows and inline unified diffs. -- On any fatal error or time/budget limit, flush whatever progress exists to both files so CI never sees an empty `reports/`. -- JUnit XML at `JUNIT_OUT` (or `reports/claude-nl-tests.xml` if unset). Create the `reports/` directory if missing. -- One `` wrapping all `` elements. -- Each `` must set: - - `classname` ∈ {`UnityMCP.NL`, `UnityMCP.T`} - - `name` = short, unique id (e.g., `NL-1.ReplaceHasTarget`, `T-F.AtomicBatch`) - - `time` in seconds (float) -- Emit `` with evidence and end with a single terminal line: `VERDICT: PASS` or `VERDICT: FAIL` (uppercase, exact match). -- For any test that performs changes, include a compact unified diff in `` using the standard format and cap to 300 lines. If truncated, include `...diff truncated...` before `VERDICT: ...`. -- On failure: include `` with a concise message and an evidence window (10–20 lines) from the target file around the anchor/edited region, in addition to the diff. -- Summary markdown at `MD_OUT` (or `reports/claude-nl-tests.md` if unset) with checkboxes, windowed reads, and inline diffs for changed tests. -- XML safety: Wrap all ``, ``, and `` contents in CDATA blocks to avoid XML escaping issues (e.g., `&` in code). Use the following rule for embedded CDATA terminators: if `]]>` appears in content, split as `]]]]>`. Example: - - ```xml - - - - ``` - - JUnit pass/fail is determined by the presence of `` or ``. Keep `VERDICT: ...` for human readability inside CDATA; do not rely on it for status. -- Upload both JUnit and markdown outputs for the shard as workflow artifacts. -- Restore workspace at end (clean tree). - -## Safety & hygiene -- Make edits in-place, then revert after validation so the workspace is clean. - - Preferred: `git restore --staged --worktree :/` (or `git checkout -- .` on older Git) to discard all changes. - - Avoid `git stash` in CI unless you also clear stashes, as they may complicate cleanup. -- Never push commits from CI. -- Do not start/stop Unity or modify licensing/activation steps; assume Unity is already running and licensed by the workflow. If a license error is detected in logs, record failure in JUnit and stop the shard. - -## Group mapping -- `group:edits`: Run all NL-* and T-* tests defined below (NL-0 … NL-4, T-A … T-J). -- `group:scenes`, `group:assets`, `group:menu`, `group:shader`, `group:validate`: No-op for this prompt version; emit a minimal report with an informational `` indicating no applicable tests for the selected group. - -## CI headless hints -- For `mcp__unity__list_resources`/`read_resource`, specify: - - `project_root`: string (required—no default), e.g., `"TestProjects/UnityMCPTests"` - - `ctx`: object (optional, defaults to `{}`) -- Canonical URIs: - - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - - `Assets/Scripts/LongUnityScriptClaudeTest.cs` (normalized by the server) - -## NL-0. Sanity Reads (windowed) -- Tail 120 lines of the target file; expect to find the class closing brace `^\s*}\s*$` and at least one `Debug\\.Log` call. -- Show 40 lines around method `Update` (anchor: `^\s*public\s+void\s+Update\s*\(`). -- Pass if: - - Tail window contains the final class brace. - - The `Update` window contains the method signature line and at least one statement. - -## NL-1. Method replace/insert/delete (natural-language) -- Replace `HasTarget` with block-bodied version returning `currentTarget != null`. -- Insert `PrintSeries()` after `GetCurrentTarget` that logs `1,2,3` via `UnityEngine.Debug.Log("1,2,3");`. -- Verify by reading 20 lines around the anchor. -- Delete `PrintSeries()` and verify removal; confirm file hash equals the pre-edit hash. -- Pass on matched diffs and windows. - -## NL-2. Anchor comment insertion -- Insert a single-line C# comment `// Build marker OK` on the line immediately preceding the `public void Update(...)` signature (ignoring XML doc comments). -- Pass if the comment is adjacent to the signature with no blank line in between. - -## NL-3. End-of-class insertion -- Insert a 3-line comment `// Tail test A`, `// Tail test B`, `// Tail test C` immediately before the final class brace. -- Preserve existing indentation; ensure the file ends with a single trailing newline. - -## NL-4. Compile trigger (record-only) -- After an edit, ensure no obvious syntax issues; record as INFO. Unity compile runs in a separate step. - -## T-A. Anchor insert (text path) -- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via a single `replace_range` at the exact insertion point (range start=end). -- Normalize line endings to LF (`\n`) for hashing and diff emission; preserve original on write if required by the server. -- Verify; then delete with `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. Header/region preservation -- For `ApplyBlend`, change only interior lines via `replace_range`. - - Do not modify: method signature line, attributes, XML docs, `#region`/`#endregion` markers, or surrounding whitespace outside the body braces. -- Pass if unchanged. - -## T-D. End-of-class insertion (anchor) -- Find final class brace; insert 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 class end. -- The server must apply all edits atomically or reject the entire batch. -- On rejection, respond with `{ status: "atomic_reject", reason, conflicts: [...] }` and leave the file unchanged (hash equals precondition). -- Pass if either all 3 apply or `status == "atomic_reject"` with unchanged file hash. - -## T-G. Path normalization -- Run the same edit with both URIs: - 1) `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - 2) `Assets/Scripts/LongUnityScriptClaudeTest.cs` -- The server must canonicalize both to the same absolute path under `project_root` and reject duplicate-application within a single batch. -- Pass if both map to the same file path and the second attempt returns `{ status: "no_change" }`. - -## T-H. Validation levels -- Validation levels: - - `basic`: lexical checks (UTF-8, balanced quotes, no NULs), can tolerate temporarily unbalanced braces. - - `standard`: `basic` + C# tokenization and brace balance + forbid edits before first `using`. -- After edits, run `validate` with `level: "standard"`. If a text op is intentionally transiently unbalanced, allow `basic` only for the intermediate step; final state must pass `standard`. -- Pass if validation OK and final file compiles in the Unity step. - -## T-I. Failure surfaces (expected) -- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. -- Stale file: resend with old `precondition_sha256` after external change → expect `{status:"stale_file"}`. -- Overlap: two overlapping ranges → expect rejection. -- Unbalanced braces: remove a closing `}` → expect validation failure and no write. -- Using-directives guard: attempt insert before the first `using` → expect `{status:"using_guard"}`. -- Parameter aliasing: accept `insert`/`content` as aliases for `text` in insertion APIs → expect success. Server should echo the canonical key `text` in responses. -- Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return clear error. -- Pass when each negative case returns expected failure without persisting changes. - - -- Permitted statuses (string enum): - - "ok" - - "no_change" - - "too_large" - - "stale_file" - - "overlap" - - "unbalanced" - - "using_guard" - - "atomic_reject" - - "unsupported" -- All non-"ok"/"no_change" statuses MUST NOT modify files (verify via unchanged post-hash). - -## T-J. Idempotency & no-op -- Re-run the same `replace_range` with identical content → expect `{ status: "no_change" }` and unchanged hash. -- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op with `{ status: "no_change" }`. -- Pass if both behave idempotently. - -### Implementation notes -- Always capture pre/post windows (±20–40 lines) as evidence in JUnit or system-out. -- For any file write, include `precondition_sha256` computed over file bytes after normalizing line endings to LF (`\n`) and ensuring UTF-8 without BOM, unless the server specifies otherwise. -- Stale write retry: If any write returns `{ success:false, status:"stale_file", actual_current_sha256 }`, immediately: - (a) re-read the file, - (b) recompute `precondition_sha256` using LF normalization, - (c) re-apply the same edit once using the returned `actual_current_sha256`. If it stales again, record a failing testcase with the evidence window and continue to the next test. -- Verify the post-edit file hash in logs and include both pre- and post-hashes in ``. -- Restore repository to original state at end (`git status` must be clean). If not clean, mark the suite as FAIL. - - diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index ae47d23b..a313343c 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -52,6 +52,15 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow ## Full NL/T Test List (imported) +### Execution order (must follow; do not regex-filter) +Run tests exactly in this order: +NL-0, NL-1, NL-2, NL-3, NL-4, +T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. +At suite start, emit a single line plan: +PLAN: NL-0,NL-1,NL-2,NL-3,NL-4,T-A,T-B,T-C,T-D,T-E,T-F,T-G,T-H,T-I,T-J (len=16 inc. bootstrap) +After each testcase, emit: +PROGRESS: /16 completed + ### NL-0. Sanity Reads (windowed) - Tail 120 lines; read 40 lines around `Update()` signature. diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md deleted file mode 100644 index 8d934939..00000000 --- a/.claude/prompts/nl-unity-suite.md +++ /dev/null @@ -1,103 +0,0 @@ -# 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: `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. - -## 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 `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) -- 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 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 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 -- 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. 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. -- **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/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. -- **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. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index be50dbdf..0eb0a289 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: - JUNIT_OUT: reports/junit-nl-suite.xml + JUNIT_OUT: reports/junit.xml MD_OUT: reports/junit-nl-suite.md steps: @@ -206,9 +206,10 @@ jobs: JSON # ---------- Ensure reports dir exists ---------- - - name: Prepare reports + - name: Prepare reports run: | set -eux + rm -f reports/*.xml reports/*.md || true mkdir -p reports # ---------- Run full NL suite once ---------- @@ -228,7 +229,7 @@ jobs: timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Normalize JUnit for consumer actions (strong) + - name: Normalize JUnit in-place (single file) if: always() shell: bash run: | @@ -240,18 +241,10 @@ jobs: def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag - src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) - out = Path('reports/junit-for-actions.xml') + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit.xml')) + out = src out.parent.mkdir(parents=True, exist_ok=True) - if not src.exists(): - # Try to use any existing XML as a source (e.g., claude-nl-tests.xml) - candidates = sorted(Path('reports').glob('*.xml')) - if candidates: - src = candidates[0] - else: - print("WARN: no XML source found for normalization") - if src.exists(): try: root = ET.parse(src).getroot() @@ -263,10 +256,6 @@ jobs: except Exception as e: print("Normalization error:", e) out.write_bytes(src.read_bytes()) - - # Always create a second copy with a junit-* name so wildcard patterns match too - if out.exists(): - Path('reports/junit-nl-suite-copy.xml').write_bytes(out.read_bytes()) PY - name: "Debug: list report files" @@ -319,7 +308,7 @@ jobs: run: | set -eu mkdir -p reports - if [ ! -f reports/junit-for-actions.xml ]; then + if [ ! -f "$JUNIT_OUT" ]; then printf '%s\n' \ '' \ '' \ @@ -327,15 +316,15 @@ jobs: ' ' \ ' ' \ '' \ - > reports/junit-for-actions.xml + > "$JUNIT_OUT" fi - - name: Publish JUnit reports + - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v5 with: - report_paths: 'reports/junit-for-actions.xml' + report_paths: '${{ env.JUNIT_OUT }}' include_passed: true detailed_summary: true annotate_notice: true @@ -343,12 +332,14 @@ jobs: fail_on_parse_error: true - - name: Upload artifacts + - name: Upload artifacts (lean) if: always() uses: actions/upload-artifact@v4 with: name: claude-nl-suite-artifacts - path: reports/** + path: | + ${{ env.JUNIT_OUT }} + ${{ env.MD_OUT }} From 5704354f59f4db2dab9f34151b3784c6e7a6c524 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 16:43:50 -0700 Subject: [PATCH 197/311] prompts: add stale fast-retry (server hash) + in-memory buf guidance --- .claude/prompts/nl-unity-suite-full.md | 48 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index a313343c..ffc1015e 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -117,8 +117,11 @@ PROGRESS: /16 completed ### Implementation notes - Capture pre/post windows; include pre/post hashes in logs. -- Compute `precondition_sha256` over raw on-disk bytes (no normalization), unless the server explicitly documents and applies identical normalization on both sides. -- If a write returns `stale_file`, re-read and retry once with the returned hash; otherwise record failure and continue. +- Maintain a per-test in-memory working buffer `buf` (text) and `pre_sha = sha256(read_bytes(uri))` (raw bytes; no normalization) at the start of each test. +- After a successful write, update `buf` locally by applying the same edit and recompute `pre_sha` from the on-disk bytes only if needed; prefer avoiding a re-read when positions are stable. +- If a write returns `stale_file`, prefer retrying once without reading using a server-provided hash (`data.current_sha256` or `data.expected_sha256`). Only if neither is present, perform a single re-read and retry; otherwise record failure and continue. +- Re-read only at well-defined points: (a) at the start of each test, (b) after a failed stale retry, or (c) when validation demands it. +- Always revert any mutations at the end of each test, then re-read to confirm clean state before the next test. - Never abort the suite on a single test failure; log the failure (including `{ status: ... }`) and proceed to the next test. ### Test driver (must follow) @@ -134,20 +137,33 @@ For each test NL-0..NL-4, then T-A..T-J: ### Guarded write pattern (must use for every edit) ```pseudo function guarded_write(uri, make_edit_from_text): - text = read(uri) # include ctx:{} and project_root - buf = read_bytes(uri) # raw on-disk bytes for hashing - sha = sha256(buf) # no normalization - edit = make_edit_from_text(text) # compute ranges/anchors against *this* text - res = write(uri, edit, precondition_sha256=sha) - if res.status == "stale_file": - fresh = read(uri) - fresh_bytes = read_bytes(uri) - # Prefer server-provided expected_sha256 if present; else recompute from raw bytes - sha2 = res.expected_sha256 or sha256(fresh_bytes) - edit2 = make_edit_from_text(fresh) # recompute ranges vs fresh text - res2 = write(uri, edit2, precondition_sha256=sha2) - if res2.status != "ok": - record_failure_and_continue() # do not loop forever + # Precondition: buf (text) and pre_sha (sha256 over raw bytes) are current for this test + edit = make_edit_from_text(buf) # compute ranges/anchors against in-memory buf + res = write(uri, edit, precondition_sha256=pre_sha) + if res.status == "ok": + buf = apply_local(edit, buf) # update buffer without re-read when possible + # Optionally refresh pre_sha by hashing on-disk bytes if subsequent ops require exact sync + # pre_sha = sha256(read_bytes(uri)) + elif res.status == "stale_file": + # Fast path: retry once using server-provided hash; avoid read if hash is present + next_sha = (res.data.current_sha256 or res.data.expected_sha256) if hasattr(res, 'data') else None + if next_sha: + edit2 = edit_or_recomputed(edit, buf) # often unchanged if anchors/ranges remain stable + res2 = write(uri, edit2, precondition_sha256=next_sha) + if res2.status == "ok": + buf = apply_local(edit2, buf) + else: + record_failure_and_continue() + else: + fresh_text = read(uri) + fresh_bytes = read_bytes(uri) + pre_sha = sha256(fresh_bytes) + edit2 = make_edit_from_text(fresh_text) + res2 = write(uri, edit2, precondition_sha256=pre_sha) + if res2.status == "ok": + buf = apply_local(edit2, fresh_text) + else: + record_failure_and_continue() # do not loop forever ``` Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. From 41a101255ac078138c57f42d17a819b4929594a7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 16:47:47 -0700 Subject: [PATCH 198/311] CI: standardize JUNIT_OUT to reports/junit-nl-suite.xml; fix artifact upload indentation; prompt copy cleanups --- .claude/prompts/nl-unity-suite-full.md | 11 ++++++----- .github/workflows/claude-nl-suite.yml | 17 +++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index ffc1015e..2ec15bdc 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -14,11 +14,12 @@ You are running inside CI for the unity-mcp repository. Use only the tools allow - If the preferred file is missing, select a safe alternative C# script under `Assets/`. - If compilation is unavailable, rely on structural checks and validation tools. -+## Tool mapping (use these APIs) -+When the tests say **replace_range** or **regex_replace**, call: -+- `mcp__unity__apply_text_edits` for single-range inserts/replacements. -+- `mcp__unity__script_apply_edits` for regex/anchor operations. -+- `mcp__unity__validate_script` for validation (`level: "standard"`). +## Tool mapping (use these APIs) +When the tests say **replace_range** or **regex_replace**, call: +- `mcp__unity__apply_text_edits` for precise text edits, including atomic multi-edit batches (multiple non-overlapping ranges applied together in one call). +- `mcp__unity__script_apply_edits` for regex/anchor or structured method/class edits (pattern- or symbol-based changes). +- `mcp__unity__validate_script` for validation (`level: "standard"`). +Edits within a batch are applied atomically; ranges must be non-overlapping. ## Output Requirements (match NL suite conventions) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0eb0a289..be718b67 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: - JUNIT_OUT: reports/junit.xml + JUNIT_OUT: reports/junit-nl-suite.xml MD_OUT: reports/junit-nl-suite.md steps: @@ -216,9 +216,6 @@ jobs: - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' - env: - JUNIT_OUT: reports/junit-nl-suite.xml - MD_OUT: reports/junit-nl-suite.md with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md @@ -241,7 +238,7 @@ jobs: def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag - src = Path(os.environ.get('JUNIT_OUT', 'reports/junit.xml')) + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) out = src out.parent.mkdir(parents=True, exist_ok=True) @@ -336,11 +333,11 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: claude-nl-suite-artifacts - path: | - ${{ env.JUNIT_OUT }} - ${{ env.MD_OUT }} - + name: claude-nl-suite-artifacts + path: | + ${{ env.JUNIT_OUT }} + ${{ env.MD_OUT }} + retention-days: 7 # ---------- Always stop Unity ---------- From 960eab086af2daa65b11f2811a6f2f1351daa1b3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 17:12:21 -0700 Subject: [PATCH 199/311] =?UTF-8?q?prompts:=20reporting=20discipline=20?= =?UTF-8?q?=E2=80=94=20append-only=20fragments,=20batch=20writes,=20no=20m?= =?UTF-8?q?odel=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/prompts/nl-unity-suite-full.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 2ec15bdc..6719f7ab 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -31,11 +31,12 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. ### Reporting discipline (must-follow) -- At suite start, create a failing skeleton JUnit and Markdown via Write: +- At suite start, create a failing skeleton JUnit and Markdown via Write (do not read existing files into model context): - JUnit: one suite `UnityMCP.NL-T`, testcase `NL-Suite.Bootstrap` failed with message `bootstrap`. - Markdown: stub header and empty checklist. -- After each test, update both files: append/replace testcases with evidence windows and diffs; maintain terminal VERDICT line. -- On fatal error/time budget, flush current progress so CI never sees an empty reports/. +- During the run, do not round-trip full report contents through the model. For each testcase, generate only the new testcase block (XML/MD) and append via a lightweight shell/python step. +- Batch writes to the report files (e.g., append every 3–5 tests) plus a final write at the end. Always maintain the terminal VERDICT line in each testcase. +- At suite end (or on fatal error/time budget), assemble any remaining fragments and flush once so CI never sees an empty `reports/`. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. From 87a67c4ed7cdbe9959690d94d06481b6a54cd1bb Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 17:17:49 -0700 Subject: [PATCH 200/311] prompts: stale fast-retry preference, buffer/sha carry, snapshot revert, essential logging --- .claude/prompts/nl-unity-suite-full.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 6719f7ab..f1516845 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -126,6 +126,11 @@ PROGRESS: /16 completed - Always revert any mutations at the end of each test, then re-read to confirm clean state before the next test. - Never abort the suite on a single test failure; log the failure (including `{ status: ... }`) and proceed to the next test. +Logging (print these around each write for CI clarity): +- `pre_sha=` before write +- on stale: `stale: expected=<...> current=<...> retry_pre_sha=` +- after success: `post_sha=` + ### Test driver (must follow) For each test NL-0..NL-4, then T-A..T-J: 1) READ → compute `pre_sha = sha256(read_bytes(uri))`. @@ -169,6 +174,9 @@ function guarded_write(uri, make_edit_from_text): ``` Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. +Revert guidance: +- At test start, snapshot the exact original bytes (including any BOM). For revert, prefer a full-file replace back to that snapshot (single edit). If that’s not available, compute the minimal edit against current `buf` to restore exact content, then confirm hash matches the baseline. + ### Status handling - Treat expected safeguard statuses as non-fatal: `using_guard`, `unsupported`, and similar should record INFO in JUnit and continue. - For idempotency cases (e.g., T-J), `{ status: "no_change" }` counts as PASS; for tests that require a real change, treat `{ status: "no_change" }` as SKIP/INFO and continue. From 46ee857280922941362a28cb352471c26d020a90 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 17:43:49 -0700 Subject: [PATCH 201/311] workflows(nl-suite): precreate report skeletons, assemble junit, synthesize markdown; restrict allowed_tools to append-only Bash + MCP tools --- .github/workflows/claude-nl-suite.yml | 142 ++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index be718b67..cb470d8f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -212,6 +212,38 @@ jobs: rm -f reports/*.xml reports/*.md || true mkdir -p reports + - name: Create report skeletons + run: | + set -eu + cat > "$JUNIT_OUT" <<'XML' + + + + Bootstrap placeholder; suite will append real tests. + + + XML + cat > "$MD_OUT" <<'MD' + # Unity NL/T Editing Suite Test Results + + ## Test Checklist + - [ ] NL-0 + - [ ] NL-1 + - [ ] NL-2 + - [ ] NL-3 + - [ ] NL-4 + - [ ] T-A + - [ ] T-B + - [ ] T-C + - [ ] T-D + - [ ] T-E + - [ ] T-F + - [ ] T-G + - [ ] T-H + - [ ] T-I + - [ ] T-J + MD + # ---------- Run full NL suite once ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta @@ -220,13 +252,13 @@ jobs: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md mcp_config: .claude/mcp.json - allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" + allowed_tools: "mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*),Bash(printf:*)" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Normalize JUnit in-place (single file) + - name: Normalize/assemble JUnit in-place (single file) if: always() shell: bash run: | @@ -244,17 +276,115 @@ jobs: if src.exists(): try: - root = ET.parse(src).getroot() + tree = ET.parse(src) + root = tree.getroot() rtag = localname(root.tag) - if rtag == 'testsuites' and len(root) == 1 and localname(root[0].tag) == 'testsuite': - ET.ElementTree(root[0]).write(out, encoding='utf-8', xml_declaration=True) + # Get node + if rtag == 'testsuites': + suite = root.find('./*') else: - out.write_bytes(src.read_bytes()) + suite = root + # Append any testcase fragments from reports/*_results.xml + fragments = sorted(Path('reports').glob('*_results.xml')) + for frag in fragments: + try: + froot = ET.parse(frag).getroot() + # Allow fragment to be either or contain it + if localname(froot.tag) == 'testcase': + suite.append(froot) + else: + for tc in froot.findall('.//testcase'): + suite.append(tc) + except Exception as fe: + print(f"Skip bad fragment {frag}: {fe}") + tree.write(out, encoding='utf-8', xml_declaration=True) except Exception as e: print("Normalization error:", e) out.write_bytes(src.read_bytes()) PY + - name: Build markdown summary from JUnit + if: always() + shell: bash + run: | + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + import os + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + + if not src.exists(): + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') + else: + tree = ET.parse(src) + root = tree.getroot() + if localname(root.tag) == 'testsuites': + suite = next(iter(list(root)), None) + else: + suite = root + + cases = [] + if suite is not None: + cases = [tc for tc in suite.findall('.//testcase')] + total = len(cases) + failures = 0 + for tc in cases: + if tc.find('failure') is not None or tc.find('error') is not None: + failures += 1 + passed = total - failures + + lines = [] + lines.append('# Unity NL/T Editing Suite Test Results') + lines.append('') + lines.append(f'Totals: {passed} passed, {failures} failed, {total} total') + lines.append('') + + desired_order = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] + name_to_case = { (tc.get('name') or ''): tc for tc in cases } + + def status_for(prefix: str): + for name, tc in name_to_case.items(): + if name.startswith(prefix): + failed = (tc.find('failure') is not None) or (tc.find('error') is not None) + return not failed + return None + + lines.append('## Test Checklist') + for p in desired_order: + st = status_for(p) + if st is True: + lines.append(f'- [x] {p}') + elif st is False: + lines.append(f'- [ ] {p} (fail)') + else: + lines.append(f'- [ ] {p} (not run)') + lines.append('') + + # Brief failure details + for name, tc in name_to_case.items(): + fail = tc.find('failure') + err = tc.find('error') + node = fail if fail is not None else err + if node is None: + continue + msg = (node.get('message') or '').strip() + text = (node.text or '').strip() + lines.append(f'### {name}') + if msg: + lines.append(f'- Message: {msg}') + if text: + first = text.splitlines()[0][:500] + lines.append(f'- Detail: {first}') + lines.append('') + + md_out.write_text('\n'.join(lines), encoding='utf-8') + PY + - name: "Debug: list report files" if: always() shell: bash From b8c1bc6f533ed7f4ca2bb40477f4b954596543dd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 17:44:37 -0700 Subject: [PATCH 202/311] thsis too --- .claude/prompts/nl-unity-suite-full.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index f1516845..f54a1f16 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -26,7 +26,7 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. - JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL-T`. - Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. - Log allowed tools once as a single line: `AllowedTools: ...`. -- For every edit: Read → Write (with precondition hash) → Re-read; on `{status:"stale_file"}` retry once after re-read. +- For every edit: Read → Write (with precondition hash). On `{status:"stale_file"}`, retry once using a server-provided hash (`current_sha256` or `expected_sha256`) if present; otherwise perform a single re-read and retry. - Evidence windows only (±20–40 lines); cap unified diffs to 100 lines and note truncation. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. From 6071b7019935775417ba0922c73e3a209206b539 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 18:16:37 -0700 Subject: [PATCH 203/311] Update README-DEV.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README-DEV.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index a136e708..debcffc7 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -97,11 +97,10 @@ We provide a CI job to run a Natural Language Editing mini-suite against the Uni ### MCP Connection Debugging -- *Enable debug logs* in Unity MCP window (inside the Editor) to see shows connection status, auto-setup results, and MCP client paths. It shows +- *Enable debug logs* in the Unity MCP window (inside the Editor) to view connection status, auto-setup results, and MCP client paths. It shows: - bridge startup/port, client connections, strict framing negotiation, and parsed frames - - auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and error surfaces -- In CI, the job tails Unity logs (redacted for serial/license/password/token) and shows socket and status JSON diagnostics if startup fails. - + - auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and surfaced errors +- In CI, the job tails Unity logs (redacted for serial/license/password/token) and prints socket/status JSON diagnostics if startup fails. ## Workflow 1. **Make changes** to your source code in this directory From 9b7de3cb43a41d8e9a00afb0704444c38215bb7e Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 18:16:51 -0700 Subject: [PATCH 204/311] Update .github/workflows/claude-nl-suite-mini.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/claude-nl-suite-mini.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml index bac1fa0e..f5b3136a 100644 --- a/.github/workflows/claude-nl-suite-mini.yml +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -145,9 +145,8 @@ jobs: if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then echo "Unity container failed to start"; docker ps -a || true; exit 1 fi - docker logs -f unity-mcp & LOGPID=$! + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! deadline=$((SECONDS+420)); READY=0 - try_connect_host() { P="$1" timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true From 12dd21c7bbec95bb98721eb6bceebd48d3ffc533 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 27 Aug 2025 18:17:56 -0700 Subject: [PATCH 205/311] Update .github/workflows/claude-nl-suite.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index cb470d8f..968df9d6 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -145,9 +145,9 @@ jobs: if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then echo "Unity container failed to start"; docker ps -a || true; exit 1 fi - docker logs -f unity-mcp & LOGPID=$! + # Stream Unity logs with real-time redaction, capture PID + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! deadline=$((SECONDS+420)); READY=0 - try_connect_host() { P="$1" timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true From b27a6b6bd88e8272700d9b76f420226aca32f504 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 18:20:32 -0700 Subject: [PATCH 206/311] workflows(nl-mini): fix YAML indentation/trailing spaces under with: and cleanup heredoc spacing --- .github/workflows/claude-nl-suite-mini.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml index f5b3136a..24291744 100644 --- a/.github/workflows/claude-nl-suite-mini.yml +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -203,7 +203,7 @@ jobs: } } JSON - + # ---------- Ensure reports dir exists ---------- - name: Prepare reports run: | @@ -340,16 +340,13 @@ jobs: annotate_notice: true require_tests: false fail_on_parse_error: true - - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 with: - name: claude-nl-suite-artifacts - path: reports/** - - + name: claude-nl-suite-artifacts + path: reports/** # ---------- Always stop Unity ---------- - name: Stop Unity From 1078039f0746af693893446c0e59377d134602ef Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 18:22:57 -0700 Subject: [PATCH 207/311] workflows(nl-suite): fix indentation on docker logs redaction line (YAML lint) --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 968df9d6..84dff773 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -146,7 +146,7 @@ jobs: echo "Unity container failed to start"; docker ps -a || true; exit 1 fi # Stream Unity logs with real-time redaction, capture PID - docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! deadline=$((SECONDS+420)); READY=0 try_connect_host() { P="$1" From 98e41ebc186d2d3d24181f9aa5859184db1fe8b6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 18:43:10 -0700 Subject: [PATCH 208/311] Add write to allowlist --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 84dff773..5ce856f8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -252,7 +252,7 @@ jobs: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md mcp_config: .claude/mcp.json - allowed_tools: "mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*),Bash(printf:*)" + allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*),Bash(printf:*)" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" timeout_minutes: "30" From 9139cfcece3b29eed59d0524feff474748728083 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 19:05:55 -0700 Subject: [PATCH 209/311] nl-suite: harden reporting discipline (fragment-only writes, forbid alt paths); workflow: clean stray junit-*updated*.xml --- .claude/prompts/nl-unity-suite-full.md | 16 ++++++++-------- .github/workflows/claude-nl-suite.yml | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index f54a1f16..32afc752 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -24,23 +24,23 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ## Output Requirements (match NL suite conventions) - JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL-T`. -- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`. +- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md` (CI synthesizes this from JUnit at the end; you do not need to write markdown mid-run). - Log allowed tools once as a single line: `AllowedTools: ...`. - For every edit: Read → Write (with precondition hash). On `{status:"stale_file"}`, retry once using a server-provided hash (`current_sha256` or `expected_sha256`) if present; otherwise perform a single re-read and retry. - Evidence windows only (±20–40 lines); cap unified diffs to 100 lines and note truncation. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. ### Reporting discipline (must-follow) -- At suite start, create a failing skeleton JUnit and Markdown via Write (do not read existing files into model context): - - JUnit: one suite `UnityMCP.NL-T`, testcase `NL-Suite.Bootstrap` failed with message `bootstrap`. - - Markdown: stub header and empty checklist. -- During the run, do not round-trip full report contents through the model. For each testcase, generate only the new testcase block (XML/MD) and append via a lightweight shell/python step. -- Batch writes to the report files (e.g., append every 3–5 tests) plus a final write at the end. Always maintain the terminal VERDICT line in each testcase. -- At suite end (or on fatal error/time budget), assemble any remaining fragments and flush once so CI never sees an empty `reports/`. +- CI pre-creates the report skeletons. Do NOT rewrite wrappers or `$JUNIT_OUT` during the run. +- Do NOT create alternate report files (e.g., `reports/junit-*-updated.xml`). +- For each testcase, produce exactly one XML fragment file under `reports/` named `nl_results.xml` containing a single `` with `` and final `VERDICT:` line. +- Never include ``/`` wrappers or any leading markers; the fragment must be a valid `` element only. +- Do not write markdown mid-run; CI will synthesize the final markdown from JUnit. +- Avoid shell redirection for state (e.g., `> /tmp/...`). Keep transient state in memory; if persistence is required, use Write to a file under `reports/`. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. -- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the baseline `precondition_sha256`; re-read to confirm the hash matches. +- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the current on-disk sha as `precondition_sha256` (use server-provided `current_sha256` on `stale_file`). Only re-read to confirm when validation requires it or a retry failed. - Never push commits from CI. - Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 5ce856f8..b8b4c273 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -211,6 +211,8 @@ jobs: set -eux rm -f reports/*.xml reports/*.md || true mkdir -p reports + # Remove any stray updated files from agent runs + rm -f reports/junit-*updated*.xml || true - name: Create report skeletons run: | From a26f0738aa531e80bdb47ed748faf8ecd9d5ce17 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 19:36:06 -0700 Subject: [PATCH 210/311] nl-suite: enforce end-of-suite single Write (no bash redirection); workflow: restrict allowed_tools to Write+MCP only --- .claude/prompts/nl-unity-suite-full.md | 8 +++++--- .github/workflows/claude-nl-suite.yml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 32afc752..98fd2f5d 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -33,10 +33,12 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ### Reporting discipline (must-follow) - CI pre-creates the report skeletons. Do NOT rewrite wrappers or `$JUNIT_OUT` during the run. - Do NOT create alternate report files (e.g., `reports/junit-*-updated.xml`). -- For each testcase, produce exactly one XML fragment file under `reports/` named `nl_results.xml` containing a single `` with `` and final `VERDICT:` line. -- Never include ``/`` wrappers or any leading markers; the fragment must be a valid `` element only. +- Prefer end-of-suite emission: buffer all `` fragments in memory during the run, then Write once at the end to `reports/nl_final_results.xml` containing multiple `` siblings (no wrappers). CI will assemble into `$JUNIT_OUT`. +- If you must emit per-test files instead, use `reports/nl_results.xml` with exactly one `` and a `` that ends with `VERDICT:`. +- Fragments must contain only `` (no ``/``, no leading markers). +- Do NOT use Bash redirection (`>`, `>>`) to write files. Use the Write tool only, and only to paths under `reports/*_results.xml`. - Do not write markdown mid-run; CI will synthesize the final markdown from JUnit. -- Avoid shell redirection for state (e.g., `> /tmp/...`). Keep transient state in memory; if persistence is required, use Write to a file under `reports/`. +- Keep transient state in memory; if persistence is required, use Write to files under `reports/`. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b8b4c273..7514dfaa 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -254,7 +254,7 @@ jobs: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md mcp_config: .claude/mcp.json - allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*),Bash(printf:*)" + allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" timeout_minutes: "30" From 995529a7cafbcd46adfd32161de587cc0212642b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 20:01:10 -0700 Subject: [PATCH 211/311] prompts(nl-full): end-of-suite results must be valid XML with single root and only children; no raw text outside CDATA --- .claude/prompts/nl-unity-suite-full.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 98fd2f5d..335da93c 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -33,9 +33,8 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ### Reporting discipline (must-follow) - CI pre-creates the report skeletons. Do NOT rewrite wrappers or `$JUNIT_OUT` during the run. - Do NOT create alternate report files (e.g., `reports/junit-*-updated.xml`). -- Prefer end-of-suite emission: buffer all `` fragments in memory during the run, then Write once at the end to `reports/nl_final_results.xml` containing multiple `` siblings (no wrappers). CI will assemble into `$JUNIT_OUT`. -- If you must emit per-test files instead, use `reports/nl_results.xml` with exactly one `` and a `` that ends with `VERDICT:`. -- Fragments must contain only `` (no ``/``, no leading markers). +- Preferred (fastest): buffer all results and perform a single end-of-suite Write to `reports/nl_final_results.xml` as a valid XML document with a single root element `` containing only `` children (no ``/``). All human-readable lines (e.g., PLAN, AllowedTools) must appear only inside `` within a ``, never as raw text outside XML. +- Alternative: per-test files `reports/nl_results.xml`, each a valid XML document whose root is a single `` with `` ending in `VERDICT:`. - Do NOT use Bash redirection (`>`, `>>`) to write files. Use the Write tool only, and only to paths under `reports/*_results.xml`. - Do not write markdown mid-run; CI will synthesize the final markdown from JUnit. - Keep transient state in memory; if persistence is required, use Write to files under `reports/`. From 9f14238b11f7a9d59b10213b391225822f1560b8 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 20:05:28 -0700 Subject: [PATCH 212/311] workflows(nl-suite): make Claude step non-fatal; tolerant normalizer extracts via regex on bad fragments --- .github/workflows/claude-nl-suite.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 7514dfaa..1d46f722 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -250,6 +250,7 @@ jobs: - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-full.md @@ -288,17 +289,33 @@ jobs: suite = root # Append any testcase fragments from reports/*_results.xml fragments = sorted(Path('reports').glob('*_results.xml')) + import re for frag in fragments: try: froot = ET.parse(frag).getroot() - # Allow fragment to be either or contain it - if localname(froot.tag) == 'testcase': + tag = localname(froot.tag) + if tag == 'testcase': suite.append(froot) else: for tc in froot.findall('.//testcase'): suite.append(tc) except Exception as fe: - print(f"Skip bad fragment {frag}: {fe}") + print(f"Parse error for fragment {frag}: {fe}; attempting regex extraction of blocks") + try: + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + matches = re.findall(r'', txt, flags=re.DOTALL) + extracted = 0 + for m in matches: + try: + tc = ET.fromstring(m) + suite.append(tc) + extracted += 1 + except Exception as _: + pass + if extracted == 0: + print(f"No valid blocks found in {frag}") + except Exception as fe2: + print(f"Failed regex fallback for {frag}: {fe2}") tree.write(out, encoding='utf-8', xml_declaration=True) except Exception as e: print("Normalization error:", e) From 9d2047cb20839044e9c65f8c1a3e444d2519f6ea Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 20:15:05 -0700 Subject: [PATCH 213/311] nl-suite: fix stale classname to UnityMCP.NL-T in mini fallback; prompt: require re-read after every revert; correct PLAN/PROGRESS to 15 --- .claude/prompts/nl-unity-suite-full.md | 8 ++++---- .github/workflows/claude-nl-suite-mini.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 335da93c..87d8aef2 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -41,7 +41,7 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. -- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the current on-disk sha as `precondition_sha256` (use server-provided `current_sha256` on `stale_file`). Only re-read to confirm when validation requires it or a retry failed. +- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the current on-disk sha as `precondition_sha256` (use server-provided `current_sha256` on `stale_file`), then re-read to confirm the revert before proceeding. - Never push commits from CI. - Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. @@ -60,9 +60,9 @@ Run tests exactly in this order: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. At suite start, emit a single line plan: -PLAN: NL-0,NL-1,NL-2,NL-3,NL-4,T-A,T-B,T-C,T-D,T-E,T-F,T-G,T-H,T-I,T-J (len=16 inc. bootstrap) +PLAN: NL-0,NL-1,NL-2,NL-3,NL-4,T-A,T-B,T-C,T-D,T-E,T-F,T-G,T-H,T-I,T-J (len=15) After each testcase, emit: -PROGRESS: /16 completed +PROGRESS: /15 completed ### NL-0. Sanity Reads (windowed) - Tail 120 lines; read 40 lines around `Update()` signature. @@ -138,7 +138,7 @@ For each test NL-0..NL-4, then T-A..T-J: 2) RUN using the guarded write pattern for every mutation. 3) VALIDATE with `mcp__unity__validate_script(level:"standard")` unless the step is read-only. 4) RE-READ evidence windows; write JUnit + Markdown entries. -5) REVERT: if the test mutated the file, restore the exact pre-test content via a guarded full-file replace using `pre_sha` as `precondition_sha256`; re-read and confirm the hash matches. +5) REVERT: if the test mutated the file, restore the exact pre-test content via a guarded full-file replace using `pre_sha` as `precondition_sha256`; always re-read and confirm the hash matches before continuing. 6) Append `VERDICT: PASS` or `VERDICT: FAIL` to `` for that testcase. 7) Continue to the next test regardless of outcome. diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml index 24291744..93bd09ee 100644 --- a/.github/workflows/claude-nl-suite-mini.yml +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -322,7 +322,7 @@ jobs: printf '%s\n' \ '' \ '' \ - ' ' \ + ' ' \ ' ' \ ' ' \ '' \ From 66c7ca9a604d104ddbc54a54b092fed349df8dc4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 20:24:09 -0700 Subject: [PATCH 214/311] nl-suite: fix fallback JUnit classname to UnityMCP.NL-T; prompt: forbid create_script and env/mkdir checks, enforce single baseline-byte revert flow and post-revert re-read; add corruption-handling guidance --- .claude/prompts/nl-unity-suite-full.md | 10 +++++++--- .github/workflows/claude-nl-suite.yml | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 87d8aef2..71f47248 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -19,6 +19,7 @@ When the tests say **replace_range** or **regex_replace**, call: - `mcp__unity__apply_text_edits` for precise text edits, including atomic multi-edit batches (multiple non-overlapping ranges applied together in one call). - `mcp__unity__script_apply_edits` for regex/anchor or structured method/class edits (pattern- or symbol-based changes). - `mcp__unity__validate_script` for validation (`level: "standard"`). +- Do not use `mcp__unity__create_script`; restoring is done via full-file text edits, not create. Edits within a batch are applied atomically; ranges must be non-overlapping. @@ -41,7 +42,7 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. -- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a guarded write using the current on-disk sha as `precondition_sha256` (use server-provided `current_sha256` on `stale_file`), then re-read to confirm the revert before proceeding. +- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a single full-file replace using the baseline bytes with `precondition_sha256` = current on-disk sha (use server-provided `current_sha256`/`expected_sha256` on `stale_file`), then re-read to confirm the revert before proceeding. - Never push commits from CI. - Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. @@ -123,7 +124,7 @@ PROGRESS: /15 completed - Maintain a per-test in-memory working buffer `buf` (text) and `pre_sha = sha256(read_bytes(uri))` (raw bytes; no normalization) at the start of each test. - After a successful write, update `buf` locally by applying the same edit and recompute `pre_sha` from the on-disk bytes only if needed; prefer avoiding a re-read when positions are stable. - If a write returns `stale_file`, prefer retrying once without reading using a server-provided hash (`data.current_sha256` or `data.expected_sha256`). Only if neither is present, perform a single re-read and retry; otherwise record failure and continue. -- Re-read only at well-defined points: (a) at the start of each test, (b) after a failed stale retry, or (c) when validation demands it. +- Re-read only at well-defined points: (a) at the start of each test, (b) after a failed stale retry, (c) when validation demands it, and (d) immediately after each revert to confirm baseline. - Always revert any mutations at the end of each test, then re-read to confirm clean state before the next test. - Never abort the suite on a single test failure; log the failure (including `{ status: ... }`) and proceed to the next test. @@ -138,7 +139,7 @@ For each test NL-0..NL-4, then T-A..T-J: 2) RUN using the guarded write pattern for every mutation. 3) VALIDATE with `mcp__unity__validate_script(level:"standard")` unless the step is read-only. 4) RE-READ evidence windows; write JUnit + Markdown entries. -5) REVERT: if the test mutated the file, restore the exact pre-test content via a guarded full-file replace using `pre_sha` as `precondition_sha256`; always re-read and confirm the hash matches before continuing. +5) REVERT: if the test mutated the file, restore the exact pre-test content via a single full-file replace using the baseline bytes; set `precondition_sha256` to the current on-disk sha (or server-provided hash on `stale_file`); always re-read and confirm the baseline hash before continuing. 6) Append `VERDICT: PASS` or `VERDICT: FAIL` to `` for that testcase. 7) Continue to the next test regardless of outcome. @@ -175,6 +176,9 @@ function guarded_write(uri, make_edit_from_text): ``` Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. +Corruption handling: +- If you detect corruption signatures (e.g., misplaced `using` directives inside class scope), skip incremental edits and perform the single baseline-byte full-file restore immediately (guarded write). Validate after restore, not before. + Revert guidance: - At test start, snapshot the exact original bytes (including any BOM). For revert, prefer a full-file replace back to that snapshot (single edit). If that’s not available, compute the minimal edit against current `buf` to restore exact content, then confirm hash matches the baseline. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 1d46f722..79875984 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -458,7 +458,7 @@ jobs: printf '%s\n' \ '' \ '' \ - ' ' \ + ' ' \ ' ' \ ' ' \ '' \ From b032f3321affaacfd4c8abae38872b4605dae1a9 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 27 Aug 2025 22:07:32 -0700 Subject: [PATCH 215/311] prompts(nl-full): after each write re-read raw bytes to refresh pre_sha; prefer script_apply_edits for anchors; avoid header/using changes --- .claude/prompts/nl-unity-suite-full.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 71f47248..e543087a 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -37,6 +37,7 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. - Preferred (fastest): buffer all results and perform a single end-of-suite Write to `reports/nl_final_results.xml` as a valid XML document with a single root element `` containing only `` children (no ``/``). All human-readable lines (e.g., PLAN, AllowedTools) must appear only inside `` within a ``, never as raw text outside XML. - Alternative: per-test files `reports/nl_results.xml`, each a valid XML document whose root is a single `` with `` ending in `VERDICT:`. - Do NOT use Bash redirection (`>`, `>>`) to write files. Use the Write tool only, and only to paths under `reports/*_results.xml`. +- Do not use Bash at all. Emit all report fragments via the Write tool only. - Do not write markdown mid-run; CI will synthesize the final markdown from JUnit. - Keep transient state in memory; if persistence is required, use Write to files under `reports/`. @@ -144,6 +145,10 @@ For each test NL-0..NL-4, then T-A..T-J: 7) Continue to the next test regardless of outcome. ### Guarded write pattern (must use for every edit) +Hash refresh and anchors (priority rules): +- CRITICAL: After every successful write, immediately re-read raw bytes from disk and set `pre_sha` to the on-disk hash before any further edits within the same test. +- Prefer `mcp__unity__script_apply_edits` for anchor work (e.g., above `Update`, end-of-class) to reduce offset drift; keep changes inside methods. +- Do not touch `using` directives or the file header. ```pseudo function guarded_write(uri, make_edit_from_text): # Precondition: buf (text) and pre_sha (sha256 over raw bytes) are current for this test From 9c07afca5abd7d44ac5ce01a30f833923e583f77 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 07:24:42 -0700 Subject: [PATCH 216/311] prompts(nl-full): canonicalize outputs to /; allow small fragment appends via Write or Bash(printf/echo); forbid wrappers and full-file round-trips --- .claude/prompts/nl-unity-suite-full.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index e543087a..a788b111 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -24,22 +24,19 @@ Edits within a batch are applied atomically; ranges must be non-overlapping. ## Output Requirements (match NL suite conventions) -- JUnit at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`. Suite name `UnityMCP.NL-T`. -- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md` (CI synthesizes this from JUnit at the end; you do not need to write markdown mid-run). +- JUnit at `$JUNIT_OUT` (canonical). Suite name `UnityMCP.NL-T`. +- Markdown at `$MD_OUT` (canonical). CI synthesizes at end; you may append small fragments but do not round‑trip full files. - Log allowed tools once as a single line: `AllowedTools: ...`. - For every edit: Read → Write (with precondition hash). On `{status:"stale_file"}`, retry once using a server-provided hash (`current_sha256` or `expected_sha256`) if present; otherwise perform a single re-read and retry. - Evidence windows only (±20–40 lines); cap unified diffs to 100 lines and note truncation. - End `` with `VERDICT: PASS` or `VERDICT: FAIL`. ### Reporting discipline (must-follow) -- CI pre-creates the report skeletons. Do NOT rewrite wrappers or `$JUNIT_OUT` during the run. -- Do NOT create alternate report files (e.g., `reports/junit-*-updated.xml`). -- Preferred (fastest): buffer all results and perform a single end-of-suite Write to `reports/nl_final_results.xml` as a valid XML document with a single root element `` containing only `` children (no ``/``). All human-readable lines (e.g., PLAN, AllowedTools) must appear only inside `` within a ``, never as raw text outside XML. -- Alternative: per-test files `reports/nl_results.xml`, each a valid XML document whose root is a single `` with `` ending in `VERDICT:`. -- Do NOT use Bash redirection (`>`, `>>`) to write files. Use the Write tool only, and only to paths under `reports/*_results.xml`. -- Do not use Bash at all. Emit all report fragments via the Write tool only. -- Do not write markdown mid-run; CI will synthesize the final markdown from JUnit. -- Keep transient state in memory; if persistence is required, use Write to files under `reports/`. +- CI pre-creates the report skeletons. Do NOT rewrite wrappers. +- Do NOT create alternate report files; always emit to `$JUNIT_OUT` and `$MD_OUT`. +- Append small fragments only (PLAN/PROGRESS; single `` blocks) via Write or Bash(printf/echo). No full‑file round‑trips and no wrappers. +- All human‑readable lines (PLAN, AllowedTools) must appear only inside `` within a ``. +- Keep transient state in memory. ## Safety & hygiene - Make edits in-place, then revert after validation so the workspace is clean. @@ -146,7 +143,7 @@ For each test NL-0..NL-4, then T-A..T-J: ### Guarded write pattern (must use for every edit) Hash refresh and anchors (priority rules): -- CRITICAL: After every successful write, immediately re-read raw bytes from disk and set `pre_sha` to the on-disk hash before any further edits within the same test. +- CRITICAL: After every successful write of a file, immediately re-read raw bytes from disk and set `pre_sha` to the on-disk hash before any further edits within the same test. - Prefer `mcp__unity__script_apply_edits` for anchor work (e.g., above `Update`, end-of-class) to reduce offset drift; keep changes inside methods. - Do not touch `using` directives or the file header. ```pseudo From a27dd380b0dac27d80a9656cadb07f9e6e48a236 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 07:46:12 -0700 Subject: [PATCH 217/311] prompts(nl-full): finalize markdown formatting for guarded write, execution order, specs, status --- .claude/prompts/nl-unity-suite-full.md | 318 +++++++++++-------------- 1 file changed, 135 insertions(+), 183 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index a788b111..80240c3f 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -1,189 +1,141 @@ -# Unity NL/T Editing Suite +# Unity NL/T Editing Suite — CI Agent Contract -You are running inside CI for the unity-mcp repository. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. +You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. + +**Print this once, verbatim, early in the run:** +AllowedTools: Write,Bash(printf:*),Bash(echo:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file, mcp__read__console + +--- ## Mission -1) Discover capabilities (primer/capabilities if available). -2) Choose target file: prefer `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs`; otherwise a simple C# under `TestProjects/UnityMCPTests/Assets/`. -3) Execute the full NL/T test list below using minimal, precise edits. Keep changes small and reversible. -4) Validate each edit via re-read and windows/diffs. -5) Report results in JUnit XML and Markdown. - -## Assumptions & Hints -- Include `ctx: {}` and `project_root: "TestProjects/UnityMCPTests"` for list/read/edit operations in CI. -- If the preferred file is missing, select a safe alternative C# script under `Assets/`. -- If compilation is unavailable, rely on structural checks and validation tools. - -## Tool mapping (use these APIs) -When the tests say **replace_range** or **regex_replace**, call: -- `mcp__unity__apply_text_edits` for precise text edits, including atomic multi-edit batches (multiple non-overlapping ranges applied together in one call). -- `mcp__unity__script_apply_edits` for regex/anchor or structured method/class edits (pattern- or symbol-based changes). -- `mcp__unity__validate_script` for validation (`level: "standard"`). -- Do not use `mcp__unity__create_script`; restoring is done via full-file text edits, not create. -Edits within a batch are applied atomically; ranges must be non-overlapping. - - -## Output Requirements (match NL suite conventions) -- JUnit at `$JUNIT_OUT` (canonical). Suite name `UnityMCP.NL-T`. -- Markdown at `$MD_OUT` (canonical). CI synthesizes at end; you may append small fragments but do not round‑trip full files. -- Log allowed tools once as a single line: `AllowedTools: ...`. -- For every edit: Read → Write (with precondition hash). On `{status:"stale_file"}`, retry once using a server-provided hash (`current_sha256` or `expected_sha256`) if present; otherwise perform a single re-read and retry. -- Evidence windows only (±20–40 lines); cap unified diffs to 100 lines and note truncation. -- End `` with `VERDICT: PASS` or `VERDICT: FAIL`. - -### Reporting discipline (must-follow) -- CI pre-creates the report skeletons. Do NOT rewrite wrappers. -- Do NOT create alternate report files; always emit to `$JUNIT_OUT` and `$MD_OUT`. -- Append small fragments only (PLAN/PROGRESS; single `` blocks) via Write or Bash(printf/echo). No full‑file round‑trips and no wrappers. -- All human‑readable lines (PLAN, AllowedTools) must appear only inside `` within a ``. -- Keep transient state in memory. - -## Safety & hygiene -- Make edits in-place, then revert after validation so the workspace is clean. -- At suite start, capture baseline `{ text, sha256 }` for the target file. After each test, revert to baseline via a single full-file replace using the baseline bytes with `precondition_sha256` = current on-disk sha (use server-provided `current_sha256`/`expected_sha256` on `stale_file`), then re-read to confirm the revert before proceeding. -- Never push commits from CI. -- Do not modify Unity start/stop/licensing; assume Unity is already running per workflow. - -## CI headless hints -- For `mcp__unity__list_resources`/`read_resource`, specify: - - `project_root`: `"TestProjects/UnityMCPTests"` - - `ctx`: `{}` -- Canonical URIs: - - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - - `Assets/Scripts/LongUnityScriptClaudeTest.cs` - -## Full NL/T Test List (imported) - -### Execution order (must follow; do not regex-filter) -Run tests exactly in this order: -NL-0, NL-1, NL-2, NL-3, NL-4, -T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. -At suite start, emit a single line plan: +1) Pick target file (prefer): + - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` +2) Execute **all** NL/T tests in order (listed below) using minimal, precise edits. +3) Validate each edit with `mcp__unity__validate_script(level:"standard")`. +4) **Report**: write a `` XML fragment per test to `reports/_results.xml`. Do **not** edit or read `$JUNIT_OUT`. +5) Revert file changes after each test; keep workspace clean. + +--- + +## Environment & Paths (CI) +- Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. +- **Canonical URIs only**: + - Primary: `unity://path/Assets/...` (never embed `project_root` into the URI) + - Relative (when supported): `Assets/...` +- CI prepares: + - `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created skeleton; do not modify directly) + - `$MD_OUT=reports/junit-nl-suite.md` (CI synthesizes from JUnit) + +--- + +## Tool Mapping +- **Anchors/regex/structured**: `mcp__unity__script_apply_edits` +- **Precise ranges / atomic multi‑edit batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) +- **Validation**: `mcp__unity__validate_script(level:"standard")` +- **Reporting**: `Write` small XML fragments to `reports/*_results.xml`. + Bash is allowed but not required; do not use Bash for diagnostics or env probing. + +> Never call: `mcp__unity__create_script`, “console/read_console”, or any tool not in AllowedTools. +> Never edit `using` directives or the header region. + +--- + +## Output Rules (JUnit fragments only) +- For each test, create **one** file: `reports/_results.xml` containing exactly a ` ... `. +- Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** `` of that testcase’s ``. +- Evidence windows only (±20–40 lines). If a unified diff is shown, cap at 100 lines and note truncation. +- **Do not** open/patch `$JUNIT_OUT` or `$MD_OUT`. CI will merge fragments and synthesize Markdown. + +**Example fragment (shape):** +```xml + + /15 completed - -### NL-0. Sanity Reads (windowed) -- Tail 120 lines; read 40 lines around `Update()` signature. - -### NL-1. Method replace/insert/delete -- Replace `HasTarget` body to `return currentTarget != null;` -- Insert `PrintSeries()` after `GetCurrentTarget` logging `"1,2,3"`. -- Verify windows, then delete `PrintSeries()`; confirm original hash. - -### NL-2. Anchor comment insertion -- Insert `// Build marker OK` immediately above `public void Update(...)` (ignore XML docs). - -### NL-3. End-of-class insertion -- Insert three lines `// Tail test A/B/C` before final class brace; preserve indentation and trailing newline. - -### NL-4. Compile trigger (record-only) -- Ensure no obvious syntax issues; record INFO. - -### T-A. Anchor insert (text path) -- After `GetCurrentTarget`, insert `private int __TempHelper(int a, int b) => a + b;` via `replace_range` at insertion point; verify; then delete via `regex_replace`. - -### T-B. Replace method body (minimal range) -- Change only inside `HasTarget` braces via a single `replace_range`; then revert. - -### T-C. Header/region preservation -- For `ApplyBlend`, modify interior lines only; keep signature/docs/regions unchanged. - -### T-D. End-of-class insertion (anchor) -- Find final class brace; insert helper before; then remove. - -### T-E. Temporary method lifecycle -- Insert helper (T-A), update via `apply_text_edits`, then delete via `regex_replace`. - -### T-F. Multi-edit atomic batch -- In a single `mcp__unity__apply_text_edits`/`mcp__unity__script_apply_edits` call, include two `replace_range` tweaks + one end-of-class comment insert using one `precondition_sha256` computed from the same snapshot. The server must apply all edits atomically or reject the entire batch (no partial application). - -### T-G. Path normalization -- Run the same edit with both URIs; second attempt should return `{ status: "no_change" }`. - -### T-H. Validation levels -- Use `validate_script` with `level: "standard"` after edits; only allow `basic` for transient steps. - -### T-I. Failure surfaces (expected) -- Too large payload → `{status:"too_large"}` -- Stale file (old hash) → `{status:"stale_file"}` -- Overlap → rejection -- Unbalanced braces → validation failure -- Using-directives guard → `{status:"using_guard"}` -- Parameter aliasing accepted; server echoes canonical keys. -- Auto-upgrade: prefer structured edits or return clear error. - -### T-J. Idempotency & no-op -- Re-run identical `replace_range` → `{ status: "no_change" }` and unchanged hash. -- Re-run delete of already-removed helper via `regex_replace` → no-op. - -### Implementation notes -- Capture pre/post windows; include pre/post hashes in logs. -- Maintain a per-test in-memory working buffer `buf` (text) and `pre_sha = sha256(read_bytes(uri))` (raw bytes; no normalization) at the start of each test. -- After a successful write, update `buf` locally by applying the same edit and recompute `pre_sha` from the on-disk bytes only if needed; prefer avoiding a re-read when positions are stable. -- If a write returns `stale_file`, prefer retrying once without reading using a server-provided hash (`data.current_sha256` or `data.expected_sha256`). Only if neither is present, perform a single re-read and retry; otherwise record failure and continue. -- Re-read only at well-defined points: (a) at the start of each test, (b) after a failed stale retry, (c) when validation demands it, and (d) immediately after each revert to confirm baseline. -- Always revert any mutations at the end of each test, then re-read to confirm clean state before the next test. -- Never abort the suite on a single test failure; log the failure (including `{ status: ... }`) and proceed to the next test. - -Logging (print these around each write for CI clarity): -- `pre_sha=` before write -- on stale: `stale: expected=<...> current=<...> retry_pre_sha=` -- after success: `post_sha=` - -### Test driver (must follow) -For each test NL-0..NL-4, then T-A..T-J: -1) READ → compute `pre_sha = sha256(read_bytes(uri))`. -2) RUN using the guarded write pattern for every mutation. -3) VALIDATE with `mcp__unity__validate_script(level:"standard")` unless the step is read-only. -4) RE-READ evidence windows; write JUnit + Markdown entries. -5) REVERT: if the test mutated the file, restore the exact pre-test content via a single full-file replace using the baseline bytes; set `precondition_sha256` to the current on-disk sha (or server-provided hash on `stale_file`); always re-read and confirm the baseline hash before continuing. -6) Append `VERDICT: PASS` or `VERDICT: FAIL` to `` for that testcase. -7) Continue to the next test regardless of outcome. - -### Guarded write pattern (must use for every edit) -Hash refresh and anchors (priority rules): -- CRITICAL: After every successful write of a file, immediately re-read raw bytes from disk and set `pre_sha` to the on-disk hash before any further edits within the same test. -- Prefer `mcp__unity__script_apply_edits` for anchor work (e.g., above `Update`, end-of-class) to reduce offset drift; keep changes inside methods. -- Do not touch `using` directives or the file header. -```pseudo -function guarded_write(uri, make_edit_from_text): - # Precondition: buf (text) and pre_sha (sha256 over raw bytes) are current for this test - edit = make_edit_from_text(buf) # compute ranges/anchors against in-memory buf - res = write(uri, edit, precondition_sha256=pre_sha) - if res.status == "ok": - buf = apply_local(edit, buf) # update buffer without re-read when possible - # Optionally refresh pre_sha by hashing on-disk bytes if subsequent ops require exact sync - # pre_sha = sha256(read_bytes(uri)) - elif res.status == "stale_file": - # Fast path: retry once using server-provided hash; avoid read if hash is present - next_sha = (res.data.current_sha256 or res.data.expected_sha256) if hasattr(res, 'data') else None - if next_sha: - edit2 = edit_or_recomputed(edit, buf) # often unchanged if anchors/ranges remain stable - res2 = write(uri, edit2, precondition_sha256=next_sha) - if res2.status == "ok": - buf = apply_local(edit2, buf) - else: - record_failure_and_continue() - else: - fresh_text = read(uri) - fresh_bytes = read_bytes(uri) - pre_sha = sha256(fresh_bytes) - edit2 = make_edit_from_text(fresh_text) - res2 = write(uri, edit2, precondition_sha256=pre_sha) - if res2.status == "ok": - buf = apply_local(edit2, fresh_text) - else: - record_failure_and_continue() # do not loop forever -``` -Notes: Prefer `mcp__unity__script_apply_edits` for anchor/regex operations; use `mcp__unity__apply_text_edits` only for precise `replace_range` steps. Always re‑read before each subsequent test so offsets are never computed against stale snapshots. - -Corruption handling: -- If you detect corruption signatures (e.g., misplaced `using` directives inside class scope), skip incremental edits and perform the single baseline-byte full-file restore immediately (guarded write). Validate after restore, not before. - -Revert guidance: -- At test start, snapshot the exact original bytes (including any BOM). For revert, prefer a full-file replace back to that snapshot (single edit). If that’s not available, compute the minimal edit against current `buf` to restore exact content, then confirm hash matches the baseline. +PROGRESS: 2/15 completed +pre_sha=<...> +... evidence windows ... +VERDICT: PASS +]]> + + +### Guarded Write Pattern (must follow) + +- Before any mutation in a test: set `buf = read_text(uri)` and `pre_sha = sha256(read_bytes(uri))`. +- Write using `precondition_sha256 = pre_sha`. +- On `{status:"stale_file"}`: + - Retry once using server hash (`data.current_sha256` or `data.expected_sha256`). + - If no hash provided, do one re-read then retry once. No loops. +- After every successful write: + - Immediately re-read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. +- Keep edits inside method bodies where possible. Use anchors for end-of-class/above-method insertions. +- Do not touch header/using regions. + +### Revert at test end + +- Restore exact pre-test bytes via a single full-file replace with `precondition_sha256` = current on-disk sha (or server-provided hash on stale), then re-read to confirm baseline hash. + +### Execution Order (fixed) + +- Run exactly in this order (15 tests total): + - NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J +- At NL‑0, include the PLAN line (len=15). +- After each testcase, include `PROGRESS: /15 completed`. + +### Test Specs (concise) + +- NL‑0. Sanity reads + - Tail ~120 lines; read ±40 lines around `Update()`. + +- NL‑1. Method replace/insert/delete + - Replace `HasTarget` body → `return currentTarget != null;` + - Insert `PrintSeries()` after `GetCurrentTarget` logging "1,2,3". + - Verify windows, then delete `PrintSeries()`; confirm original hash. + +- NL‑2. Anchor comment + - Insert `// Build marker OK` immediately above `public void Update(...)` (ignore XML docs). + +- NL‑3. End‑of‑class insertion + - Insert three lines `// Tail test A/B/C` before final class brace; preserve indentation + trailing newline. + +- NL‑4. Compile trigger (record‑only) + - Record INFO if no obvious syntax issues. + +- T‑A. Anchor insert (text path) + - After `GetCurrentTarget`, insert helper: + ```csharp + private int __TempHelper(int a, int b) => a + b; + ``` + - Minimal insertion; verify; then delete via `regex_replace`. + +- T‑B. Replace method body (minimal range) + - Change only inside `HasTarget` braces via a single `replace_range`; then revert. + +- T‑C. Header/region preservation + - For `ApplyBlend`, modify interior only; keep signature/docs/regions unchanged. + +- T‑D. End‑of‑class insertion (anchor) + - Insert helper before the final class brace; then remove. + +- T‑E. Temporary method lifecycle + - Insert helper (as in T‑A), update via `apply_text_edits`, then delete via `regex_replace`. + +- T‑F. Multi‑edit atomic batch + - In a single call, two small `replace_range` tweaks + one end‑of‑class comment, using one `precondition_sha256` from the same snapshot. Server must apply all or reject all. + +- T‑G. Path normalization + - Perform the same edit once with `unity://path/Assets/...` then with `Assets/...`. The second should yield `{status:"no_change"}`. + +- T‑H. Validation levels + - Use `validate_script(level:"standard")` after edits; `basic` only for transient checks. + +- T‑I. Failure surfaces (expected) + - Record INFO on `{status:"too_large"}`, `{status:"stale_file"}`, overlap rejection, validation failure (unbalanced braces), `{status:"using_guard"}`. No retries beyond the guarded pattern. + +- T‑J. Idempotency & no‑op + - Re‑run identical `replace_range` → `{status:"no_change"}` with same hash. + - Re‑run delete of already‑removed helper via `regex_replace` → no‑op. ### Status handling -- Treat expected safeguard statuses as non-fatal: `using_guard`, `unsupported`, and similar should record INFO in JUnit and continue. -- For idempotency cases (e.g., T-J), `{ status: "no_change" }` counts as PASS; for tests that require a real change, treat `{ status: "no_change" }` as SKIP/INFO and continue. + +- Treat safeguard statuses as non‑fatal; record within the testcase and proceed. +- Each testcase ends its `` with `VERDICT: PASS` or `VERDICT: FAIL`. \ No newline at end of file From e016171cf891432712ee721194800594b315a545 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 07:46:58 -0700 Subject: [PATCH 218/311] workflows(nl-suite, mini): header/lint fixes and constrained Bash append path; align allowed_tools --- .github/workflows/claude-nl-suite-mini.yml | 2 +- .github/workflows/claude-nl-suite.yml | 873 ++++++++++----------- 2 files changed, 412 insertions(+), 463 deletions(-) diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml index 93bd09ee..272e04d6 100644 --- a/.github/workflows/claude-nl-suite-mini.yml +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -221,7 +221,7 @@ jobs: use_node_cache: false prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md mcp_config: .claude/mcp.json - allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*)" + allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file, mcp__unity__read_console" disallowed_tools: "TodoWrite,Task" model: "claude-3-7-sonnet-latest" timeout_minutes: "30" diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 79875984..549922b9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,4 +1,4 @@ -name: Claude Full NL Test Suite (Unity live) +name: Claude NL/T Suite (Unity live) on: workflow_dispatch: {} @@ -17,441 +17,390 @@ env: UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home jobs: - nl-suite: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - JUNIT_OUT: reports/junit-nl-suite.xml - MD_OUT: reports/junit-nl-suite.md - - steps: - # ---------- Detect secrets ---------- - - name: Detect secrets (outputs) - id: detect - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - set -e - if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi - if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then - echo "unity_ok=true" >> "$GITHUB_OUTPUT" - else - echo "unity_ok=false" >> "$GITHUB_OUTPUT" - fi - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ---------- Python env for MCP server (uv) ---------- - - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Install MCP server - run: | - set -eux - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then - uv pip install -e UnityMcpBridge/UnityMcpServer~/src - elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then - uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt - elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then - uv pip install -e UnityMcpBridge/UnityMcpServer~/ - elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then - uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt - else - echo "No MCP Python deps found (skipping)" - fi - - # ---------- License prime on host (handles ULF or EBL) ---------- - - name: Prime Unity license on host (GameCI) - if: steps.detect.outputs.unity_ok == 'true' - uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - with: - projectPath: TestProjects/UnityMCPTests - testMode: EditMode - customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics - unityVersion: ${{ env.UNITY_VERSION }} - - # (Optional) Show where the license actually got written - - name: Inspect GameCI license caches (host) - if: steps.detect.outputs.unity_ok == 'true' - run: | - set -eux - find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true - - # ---------- Clean any stale MCP status from previous runs ---------- - - name: Clean old MCP status - run: | - set -eux - mkdir -p "$HOME/.unity-mcp" - rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true - - # ---------- Start headless Unity that stays up (bridge enabled) ---------- - - name: Start Unity (persistent bridge) - if: steps.detect.outputs.unity_ok == 'true' - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -eu - if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then - echo "Unity project not found; failing fast." - exit 1 - fi - mkdir -p "$HOME/.unity-mcp" - MANUAL_ARG=() - if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then - MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) - fi - EBL_ARGS=() - [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") - [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") - [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") - docker rm -f unity-mcp >/dev/null 2>&1 || true - docker run -d --name unity-mcp --network host \ - -e HOME=/root \ - -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ - -e UNITY_MCP_BIND_HOST=127.0.0.1 \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ - -v "$HOME/.unity-mcp:/root/.unity-mcp" \ - ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -stackTraceLogType Full \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - "${MANUAL_ARG[@]}" \ - "${EBL_ARGS[@]}" \ - -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect - - # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- - - name: Wait for Unity bridge (robust) - if: steps.detect.outputs.unity_ok == 'true' - run: | - set -euo pipefail - if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then - echo "Unity container failed to start"; docker ps -a || true; exit 1 - fi - # Stream Unity logs with real-time redaction, capture PID - docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! - deadline=$((SECONDS+420)); READY=0 - try_connect_host() { - P="$1" - timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true - if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi - return 1 - } - - # in-container probe will try IPv4 then IPv6 via nc or /dev/tcp - - while [ $SECONDS -lt $deadline ]; do - if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then - READY=1; echo "Bridge ready (log markers)"; break + nl-suite: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md + + steps: + # ---------- Secrets check ---------- + - name: Detect secrets (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -e + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ---------- Python env for MCP server (uv) ---------- + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install MCP server + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found (skipping)" fi - PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) - if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then - READY=1; echo "Bridge ready on port $PORT"; break + + # ---------- License prime on host (GameCI) ---------- + - name: Prime Unity license on host (GameCI) + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + unityVersion: ${{ env.UNITY_VERSION }} + + # (Optional) Inspect license caches + - name: Inspect GameCI license caches (host) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true + + # ---------- Clean old MCP status ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + + # ---------- Start headless Unity (persistent bridge) ---------- + - name: Start Unity (persistent bridge) + if: steps.detect.outputs.unity_ok == 'true' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; failing fast." + exit 1 fi - if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then - echo "Licensing error detected"; break + mkdir -p "$HOME/.unity-mcp" + MANUAL_ARG=() + if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) fi - sleep 2 - done - - kill $LOGPID || true - - if [ "$READY" != "1" ]; then - echo "Bridge not ready; diagnostics:" - echo "== status files =="; ls -la "$HOME/.unity-mcp" || true - echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done - echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' - echo "== tail of Unity log ==" - docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true - exit 1 - fi - - # ---------- Make MCP config available to the action ---------- - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json </dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${MANUAL_ARG[@]}" \ + "${EBL_ARGS[@]}" \ + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + + # ---------- Wait for Unity bridge ---------- + - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container failed to start"; docker ps -a || true; exit 1 + fi + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! + deadline=$((SECONDS+420)); READY=0 + try_connect_host() { + P="$1" + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true + if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi + return 1 + } + while [ $SECONDS -lt $deadline ]; do + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + READY=1; echo "Bridge ready (log markers)"; break + fi + PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) + if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then + READY=1; echo "Bridge ready on port $PORT"; break + fi + if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then + echo "Licensing error detected"; break + fi + sleep 2 + done + kill $LOGPID || true + if [ "$READY" != "1" ]; then + echo "Bridge not ready; diagnostics:" + echo "== status files =="; ls -la "$HOME/.unity-mcp" || true + echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done + echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' + echo "== tail of Unity log ==" + docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + exit 1 + fi + + # ---------- MCP client config ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json < "$JUNIT_OUT" <<'XML' - - - - Bootstrap placeholder; suite will append real tests. - - - XML - cat > "$MD_OUT" <<'MD' - # Unity NL/T Editing Suite Test Results - - ## Test Checklist - - [ ] NL-0 - - [ ] NL-1 - - [ ] NL-2 - - [ ] NL-3 - - [ ] NL-4 - - [ ] T-A - - [ ] T-B - - [ ] T-C - - [ ] T-D - - [ ] T-E - - [ ] T-F - - [ ] T-G - - [ ] T-H - - [ ] T-I - - [ ] T-J - MD - - # ---------- Run full NL suite once ---------- - - name: Run Claude NL suite (single pass) - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' - continue-on-error: true - with: - use_node_cache: false - prompt_file: .claude/prompts/nl-unity-suite-full.md - mcp_config: .claude/mcp.json - allowed_tools: "Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file" - disallowed_tools: "TodoWrite,Task" - model: "claude-3-7-sonnet-latest" - timeout_minutes: "30" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - - name: Normalize/assemble JUnit in-place (single file) - if: always() - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - import xml.etree.ElementTree as ET - import os - - def localname(tag: str) -> str: - return tag.rsplit('}', 1)[-1] if '}' in tag else tag - - src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) - out = src - out.parent.mkdir(parents=True, exist_ok=True) - - if src.exists(): - try: - tree = ET.parse(src) - root = tree.getroot() - rtag = localname(root.tag) - # Get node - if rtag == 'testsuites': - suite = root.find('./*') - else: - suite = root - # Append any testcase fragments from reports/*_results.xml - fragments = sorted(Path('reports').glob('*_results.xml')) - import re - for frag in fragments: - try: - froot = ET.parse(frag).getroot() - tag = localname(froot.tag) - if tag == 'testcase': - suite.append(froot) - else: - for tc in froot.findall('.//testcase'): - suite.append(tc) - except Exception as fe: - print(f"Parse error for fragment {frag}: {fe}; attempting regex extraction of blocks") - try: - txt = Path(frag).read_text(encoding='utf-8', errors='replace') - matches = re.findall(r'', txt, flags=re.DOTALL) - extracted = 0 - for m in matches: - try: - tc = ET.fromstring(m) - suite.append(tc) - extracted += 1 - except Exception as _: - pass - if extracted == 0: - print(f"No valid blocks found in {frag}") - except Exception as fe2: - print(f"Failed regex fallback for {frag}: {fe2}") - tree.write(out, encoding='utf-8', xml_declaration=True) - except Exception as e: - print("Normalization error:", e) - out.write_bytes(src.read_bytes()) - PY - - - name: Build markdown summary from JUnit - if: always() - shell: bash - run: | - python3 - <<'PY' - import xml.etree.ElementTree as ET - from pathlib import Path - import os - - def localname(tag: str) -> str: - return tag.rsplit('}', 1)[-1] if '}' in tag else tag - - src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) - md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) - - if not src.exists(): - md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') - else: - tree = ET.parse(src) - root = tree.getroot() - if localname(root.tag) == 'testsuites': - suite = next(iter(list(root)), None) - else: - suite = root - - cases = [] - if suite is not None: - cases = [tc for tc in suite.findall('.//testcase')] - total = len(cases) - failures = 0 - for tc in cases: - if tc.find('failure') is not None or tc.find('error') is not None: - failures += 1 - passed = total - failures - - lines = [] - lines.append('# Unity NL/T Editing Suite Test Results') - lines.append('') - lines.append(f'Totals: {passed} passed, {failures} failed, {total} total') - lines.append('') - - desired_order = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] - name_to_case = { (tc.get('name') or ''): tc for tc in cases } - - def status_for(prefix: str): - for name, tc in name_to_case.items(): - if name.startswith(prefix): - failed = (tc.find('failure') is not None) or (tc.find('error') is not None) - return not failed - return None - - lines.append('## Test Checklist') - for p in desired_order: - st = status_for(p) - if st is True: - lines.append(f'- [x] {p}') - elif st is False: - lines.append(f'- [ ] {p} (fail)') - else: - lines.append(f'- [ ] {p} (not run)') - lines.append('') - - # Brief failure details - for name, tc in name_to_case.items(): - fail = tc.find('failure') - err = tc.find('error') - node = fail if fail is not None else err - if node is None: - continue - msg = (node.get('message') or '').strip() - text = (node.text or '').strip() - lines.append(f'### {name}') - if msg: - lines.append(f'- Message: {msg}') - if text: - first = text.splitlines()[0][:500] - lines.append(f'- Detail: {first}') - lines.append('') - - md_out.write_text('\n'.join(lines), encoding='utf-8') - PY - - - name: "Debug: list report files" - if: always() - shell: bash - run: | - set -eux - ls -la reports || true - shopt -s nullglob - for f in reports/*.xml; do - echo "===== $f =====" - head -n 40 "$f" || true - done - - - # sanitize only the markdown (does not touch JUnit xml) - - name: Sanitize markdown (all shards) - if: always() - run: | - set -eu - python - <<'PY' - from pathlib import Path - rp=Path('reports') - rp.mkdir(parents=True, exist_ok=True) - for p in rp.glob('*.md'): - b=p.read_bytes().replace(b'\x00', b'') - s=b.decode('utf-8','replace').replace('\r\n','\n') - p.write_text(s, encoding='utf-8', newline='\n') - PY - - - name: NL/T details → Job Summary - if: always() - run: | - echo "## Unity NL/T Editing Suite — Full Coverage" >> $GITHUB_STEP_SUMMARY - python - <<'PY' >> $GITHUB_STEP_SUMMARY - from pathlib import Path - p = Path('reports/junit-nl-suite.md') if Path('reports/junit-nl-suite.md').exists() else Path('reports/claude-nl-tests.md') - if p.exists(): - text = p.read_bytes().decode('utf-8', 'replace') - MAX = 65000 - print(text[:MAX]) - if len(text) > MAX: - print("\n\n_…truncated in summary; full report is in artifacts._") - else: - print("_No markdown report found._") - PY - - - name: Fallback JUnit if missing - if: always() - run: | + JSON + + # ---------- Reports dir & skeletons ---------- + - name: Prepare reports + run: | + set -eux + rm -f reports/*.xml reports/*.md || true + mkdir -p reports + + - name: Create report skeletons + run: | + set -eu + cat > "$JUNIT_OUT" <<'XML' + + + + Bootstrap placeholder; suite will append real tests. + + + XML + printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" + + # ---------- Run suite ---------- + - name: Run Claude NL suite (single pass) + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + with: + use_node_cache: false + prompt_file: .claude/prompts/nl-unity-suite-full.md + mcp_config: .claude/mcp.json + allowed_tools: >- + Write, + Bash(printf:*),Bash(echo:*), + mcp__unity__manage_editor, + mcp__unity__list_resources, + mcp__unity__read_resource, + mcp__unity__apply_text_edits, + mcp__unity__script_apply_edits, + mcp__unity__validate_script, + mcp__unity__find_in_file + disallowed_tools: TodoWrite,Task + model: claude-3-7-sonnet-latest + timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # ---------- Merge testcase fragments into JUnit ---------- + - name: Normalize/assemble JUnit in-place (single file) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import re, os + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + out = src + out.parent.mkdir(parents=True, exist_ok=True) + if not src.exists(): + raise SystemExit(0) + + try: + tree = ET.parse(src) + root = tree.getroot() + rtag = localname(root.tag) + suite = root.find('./*') if rtag == 'testsuites' else root + if suite is None: + print("No suite element in JUnit skeleton") + raise SystemExit(0) + + # Append any testcase fragments from reports/*_results.xml + fragments = sorted(Path('reports').glob('*_results.xml')) + added = 0 + for frag in fragments: + try: + froot = ET.parse(frag).getroot() + if localname(froot.tag) == 'testcase': + suite.append(froot); added += 1 + else: + for tc in froot.findall('.//testcase'): + suite.append(tc); added += 1 + except Exception as fe: + # Regex fallback for raw strings + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + for m in re.findall(r'', txt, flags=re.DOTALL): + try: + tc = ET.fromstring(m); suite.append(tc); added += 1 + except Exception: + pass + if added: + tree.write(out, encoding='utf-8', xml_declaration=True) + print(f"Added {added} testcase fragments") + except Exception as e: + print("Normalization error:", e) + PY + + # ---------- Markdown summary from JUnit ---------- + - name: Build markdown summary from JUnit + if: always() + shell: bash + run: | + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + import os + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + if not src.exists(): + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8'); raise SystemExit(0) + tree = ET.parse(src); root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + cases = [] if suite is None else [tc for tc in suite.findall('.//testcase')] + total = len(cases) + failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) + passed = total - failures + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] + name_to_case = {(tc.get('name') or ''): tc for tc in cases} + def status_for(prefix: str): + for name, tc in name_to_case.items(): + if name.startswith(prefix): + return not ((tc.find('failure') is not None) or (tc.find('error') is not None)) + return None + lines = [] + lines += ['# Unity NL/T Editing Suite Test Results','',f'Totals: {passed} passed, {failures} failed, {total} total','', '## Test Checklist'] + for p in desired: + st = status_for(p) + lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) + lines.append('') + # brief failure details + for name, tc in name_to_case.items(): + node = tc.find('failure') or tc.find('error') + if node is None: continue + msg = (node.get('message') or '').strip() + text = (node.text or '').strip() + lines.append(f"### {name}") + if msg: lines.append(f"- Message: {msg}") + if text: lines.append(f"- Detail: {text.splitlines()[0][:500]}") + lines.append('') + md_out.write_text('\n'.join(lines), encoding='utf-8') + PY + + - name: "Debug: list report files" + if: always() + shell: bash + run: | + set -eux + ls -la reports || true + shopt -s nullglob + for f in reports/*.xml; do + echo "===== $f =====" + head -n 40 "$f" || true + done + + # ---------- Collect execution transcript (if present) ---------- + - name: Collect action execution transcript + if: always() + shell: bash + run: | + set -eux + if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then + cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json + elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then + cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json + fi + + - name: Sanitize markdown (normalize newlines) + if: always() + run: | + set -eu + python - <<'PY' + from pathlib import Path + rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) + for p in rp.glob('*.md'): + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY + + - name: NL/T details → Job Summary + if: always() + run: | + echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY + python - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + p = Path('reports/junit-nl-suite.md') + if p.exists(): + text = p.read_bytes().decode('utf-8', 'replace') + MAX = 65000 + print(text[:MAX]) + if len(text) > MAX: + print("\n\n_…truncated; full report in artifacts._") + else: + print("_No markdown report found._") + PY + + - name: Fallback JUnit if missing + if: always() + run: | set -eu mkdir -p reports if [ ! -f "$JUNIT_OUT" ]; then @@ -459,39 +408,39 @@ jobs: '' \ '' \ ' ' \ - ' ' \ + ' ' \ ' ' \ '' \ > "$JUNIT_OUT" fi - - - - name: Publish JUnit report - if: always() - uses: mikepenz/action-junit-report@v5 - with: - report_paths: '${{ env.JUNIT_OUT }}' - include_passed: true - detailed_summary: true - annotate_notice: true - require_tests: false - fail_on_parse_error: true - - - - name: Upload artifacts (lean) - if: always() - uses: actions/upload-artifact@v4 - with: - name: claude-nl-suite-artifacts - path: | - ${{ env.JUNIT_OUT }} - ${{ env.MD_OUT }} - retention-days: 7 - - - # ---------- Always stop Unity ---------- - - name: Stop Unity - if: always() - run: | - docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true - docker rm -f unity-mcp || true + + - name: Publish JUnit report + if: always() + uses: mikepenz/action-junit-report@v5 + with: + report_paths: '${{ env.JUNIT_OUT }}' + include_passed: true + detailed_summary: true + annotate_notice: true + require_tests: false + fail_on_parse_error: true + + - name: Upload artifacts (reports + fragments + transcript) + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-suite-artifacts + path: | + ${{ env.JUNIT_OUT }} + ${{ env.MD_OUT }} + reports/*_results.xml + reports/claude-execution-output.json + retention-days: 7 + + # ---------- Always stop Unity ---------- + - name: Stop Unity + if: always() + run: | + docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + docker rm -f unity-mcp || true + \ No newline at end of file From 5dad550df1d7d7ebf18f4dee14eb2ce17c2493af Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:14:47 -0700 Subject: [PATCH 219/311] prompts(nl-full): format Fast Restore, Guarded Write, Execution, Specs, Status as proper markdown lists and code fences --- .claude/prompts/nl-unity-suite-full.md | 155 +++++++++++-------------- 1 file changed, 66 insertions(+), 89 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 80240c3f..bf13ab7a 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -3,50 +3,53 @@ You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. **Print this once, verbatim, early in the run:** -AllowedTools: Write,Bash(printf:*),Bash(echo:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file, mcp__read__console +AllowedTools: Write,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console --- ## Mission 1) Pick target file (prefer): - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` -2) Execute **all** NL/T tests in order (listed below) using minimal, precise edits. +2) Execute **all** NL/T tests in order using minimal, precise edits. 3) Validate each edit with `mcp__unity__validate_script(level:"standard")`. -4) **Report**: write a `` XML fragment per test to `reports/_results.xml`. Do **not** edit or read `$JUNIT_OUT`. -5) Revert file changes after each test; keep workspace clean. +4) **Report**: write one `` XML fragment per test to `reports/_results.xml`. Do **not** read or edit `$JUNIT_OUT`. +5) **Restore** the file after each test using the OS‑level helper (fast), not a full‑file text write. --- ## Environment & Paths (CI) - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - **Canonical URIs only**: - - Primary: `unity://path/Assets/...` (never embed `project_root` into the URI) + - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) - Relative (when supported): `Assets/...` -- CI prepares: - - `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created skeleton; do not modify directly) - - `$MD_OUT=reports/junit-nl-suite.md` (CI synthesizes from JUnit) +- File paths for the helper script are workspace‑relative: + - `TestProjects/UnityMCPTests/Assets/...` + +CI provides: +- `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created; leave alone) +- `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit) +- Helper script: `scripts/nlt-revert.sh` (snapshot/restore) --- ## Tool Mapping - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` -- **Precise ranges / atomic multi‑edit batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) +- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) - **Validation**: `mcp__unity__validate_script(level:"standard")` -- **Reporting**: `Write` small XML fragments to `reports/*_results.xml`. - Bash is allowed but not required; do not use Bash for diagnostics or env probing. +- **Reporting**: `Write` small XML fragments to `reports/*_results.xml` +- **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` -> Never call: `mcp__unity__create_script`, “console/read_console”, or any tool not in AllowedTools. -> Never edit `using` directives or the header region. +> Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- ## Output Rules (JUnit fragments only) -- For each test, create **one** file: `reports/_results.xml` containing exactly a ` ... `. -- Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** `` of that testcase’s ``. -- Evidence windows only (±20–40 lines). If a unified diff is shown, cap at 100 lines and note truncation. -- **Do not** open/patch `$JUNIT_OUT` or `$MD_OUT`. CI will merge fragments and synthesize Markdown. +- For each test, create **one** file: `reports/_results.xml` containing exactly a single ` ... `. +- Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** ``. +- Evidence windows only (±20–40 lines). If showing a unified diff, cap at 100 lines and note truncation. +- **Never** open/patch `$JUNIT_OUT` or `$MD_OUT`; CI merges fragments and synthesizes Markdown. -**Example fragment (shape):** +**Example fragment** ```xml -### Guarded Write Pattern (must follow) -- Before any mutation in a test: set `buf = read_text(uri)` and `pre_sha = sha256(read_bytes(uri))`. -- Write using `precondition_sha256 = pre_sha`. -- On `{status:"stale_file"}`: - - Retry once using server hash (`data.current_sha256` or `data.expected_sha256`). - - If no hash provided, do one re-read then retry once. No loops. -- After every successful write: - - Immediately re-read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. -- Keep edits inside method bodies where possible. Use anchors for end-of-class/above-method insertions. -- Do not touch header/using regions. +### Fast Restore Strategy (OS‑level) -### Revert at test end +- Snapshot once at NL‑0, then restore after each test via the helper. +- Snapshot (once after confirming the target): + ```bash + scripts/nlt-revert.sh snapshot "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline" + ``` +- Log `snapshot_sha=...` printed by the script. +- Restore (after each mutating test): + ```bash + scripts/nlt-revert.sh restore "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline" + ``` +- Then `read_resource` to confirm and (optionally) `validate_script(level:"standard")`. +- If the helper fails: fall back once to a guarded full‑file restore using the baseline bytes; then continue. -- Restore exact pre-test bytes via a single full-file replace with `precondition_sha256` = current on-disk sha (or server-provided hash on stale), then re-read to confirm baseline hash. +### Guarded Write Pattern (for edits, not restores) + +- Before any mutation: `buf = read_text(uri)`; `pre_sha = sha256(read_bytes(uri))`. +- Write with `precondition_sha256 = pre_sha`. +- On `{status:"stale_file"}`: + - Retry once using the server hash (`data.current_sha256` or `data.expected_sha256`). + - If absent, one re‑read then a final retry. No loops. +- After success: immediately re‑read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. +- Prefer anchors (`script_apply_edits`) for end‑of‑class / above‑method insertions. Keep edits inside method bodies. Avoid header/using. ### Execution Order (fixed) -- Run exactly in this order (15 tests total): - - NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J -- At NL‑0, include the PLAN line (len=15). +- Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). +- NL‑0 must include the PLAN line (len=15). - After each testcase, include `PROGRESS: /15 completed`. ### Test Specs (concise) -- NL‑0. Sanity reads - - Tail ~120 lines; read ±40 lines around `Update()`. - -- NL‑1. Method replace/insert/delete - - Replace `HasTarget` body → `return currentTarget != null;` - - Insert `PrintSeries()` after `GetCurrentTarget` logging "1,2,3". - - Verify windows, then delete `PrintSeries()`; confirm original hash. - -- NL‑2. Anchor comment - - Insert `// Build marker OK` immediately above `public void Update(...)` (ignore XML docs). - -- NL‑3. End‑of‑class insertion - - Insert three lines `// Tail test A/B/C` before final class brace; preserve indentation + trailing newline. - -- NL‑4. Compile trigger (record‑only) - - Record INFO if no obvious syntax issues. - -- T‑A. Anchor insert (text path) - - After `GetCurrentTarget`, insert helper: - ```csharp - private int __TempHelper(int a, int b) => a + b; - ``` - - Minimal insertion; verify; then delete via `regex_replace`. - -- T‑B. Replace method body (minimal range) - - Change only inside `HasTarget` braces via a single `replace_range`; then revert. - -- T‑C. Header/region preservation - - For `ApplyBlend`, modify interior only; keep signature/docs/regions unchanged. - -- T‑D. End‑of‑class insertion (anchor) - - Insert helper before the final class brace; then remove. - -- T‑E. Temporary method lifecycle - - Insert helper (as in T‑A), update via `apply_text_edits`, then delete via `regex_replace`. - -- T‑F. Multi‑edit atomic batch - - In a single call, two small `replace_range` tweaks + one end‑of‑class comment, using one `precondition_sha256` from the same snapshot. Server must apply all or reject all. - -- T‑G. Path normalization - - Perform the same edit once with `unity://path/Assets/...` then with `Assets/...`. The second should yield `{status:"no_change"}`. - -- T‑H. Validation levels - - Use `validate_script(level:"standard")` after edits; `basic` only for transient checks. - -- T‑I. Failure surfaces (expected) - - Record INFO on `{status:"too_large"}`, `{status:"stale_file"}`, overlap rejection, validation failure (unbalanced braces), `{status:"using_guard"}`. No retries beyond the guarded pattern. - -- T‑J. Idempotency & no‑op - - Re‑run identical `replace_range` → `{status:"no_change"}` with same hash. - - Re‑run delete of already‑removed helper via `regex_replace` → no‑op. - -### Status handling - -- Treat safeguard statuses as non‑fatal; record within the testcase and proceed. -- Each testcase ends its `` with `VERDICT: PASS` or `VERDICT: FAIL`. \ No newline at end of file +- NL‑0. Sanity reads — Tail ~120; ±40 around `Update()`. Then snapshot via helper. +- NL‑1. Replace/insert/delete — `HasTarget → return currentTarget != null;`; insert `PrintSeries()` after `GetCurrentTarget` logging "1,2,3"; verify; delete `PrintSeries()`; restore. +- NL‑2. Anchor comment — Insert `// Build marker OK` above `public void Update(...)`; restore. +- NL‑3. End‑of‑class — Insert `// Tail test A/B/C` (3 lines) before final brace; restore. +- NL‑4. Compile trigger — Record INFO only. + +- T‑A. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore. +- T‑B. Replace body — Single `replace_range` inside `HasTarget`; restore. +- T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. +- T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. +- T‑E. Lifecycle — Insert → update → delete via regex; restore. +- T‑F. Atomic batch — One call: two small `replace_range` + one end‑of‑class comment; all‑or‑nothing; restore. +- T‑G. Path normalization — Same edit with `unity://path/Assets/...` then `Assets/...`; second returns `{status:"no_change"}`. +- T‑H. Validation — `standard` after edits; `basic` only for transient checks. +- T‑I. Failure surfaces — Record INFO on `{too_large}`, `{stale_file}`, overlap rejection, validation failure, `{using_guard}`. +- T‑J. Idempotency — Repeat `replace_range` → `{status:"no_change"}`; repeat delete → no‑op. + +### Status & Reporting + +- Safeguard statuses are non‑fatal; record and continue. +- End each testcase `` with `VERDICT: PASS` or `VERDICT: FAIL`. \ No newline at end of file From df97ab2ecc9f49a51bfcac8a8dc73b4a8095c0cd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:20:52 -0700 Subject: [PATCH 220/311] workflows(nl-suite): keep header tidy and append-path alignment with prompt --- .github/workflows/claude-nl-suite.yml | 153 ++++++++++++++------------ 1 file changed, 85 insertions(+), 68 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 549922b9..b16db525 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,22 +1,23 @@ -name: Claude NL/T Suite (Unity live) - -on: - workflow_dispatch: {} - -permissions: - contents: read - checks: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -env: - UNITY_VERSION: 2021.3.45f1 - UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 - UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home - -jobs: +name: Claude NL/T Suite (Unity live) + + on: + workflow_dispatch: {} + + permissions: + contents: read + checks: write + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + env: + UNITY_VERSION: 2021.3.45f1 + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 + UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home + + jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -199,12 +200,12 @@ jobs: } JSON - # ---------- Reports dir & skeletons ---------- - - name: Prepare reports + # ---------- Reports & helper ---------- + - name: Prepare reports and dirs run: | set -eux rm -f reports/*.xml reports/*.md || true - mkdir -p reports + mkdir -p reports reports/_snapshots scripts - name: Create report skeletons run: | @@ -219,6 +220,42 @@ jobs: XML printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" + - name: Write safe revert helper (scripts/nlt-revert.sh) + shell: bash + run: | + set -eux + cat > scripts/nlt-revert.sh <<'BASH' + #!/usr/bin/env bash + set -euo pipefail + sub="${1:-}"; target_rel="${2:-}"; snap="${3:-}" + WS="${GITHUB_WORKSPACE:-$PWD}" + ROOT="$WS/TestProjects/UnityMCPTests" + t_abs="$(realpath -m "$WS/$target_rel")" + s_abs="$(realpath -m "$WS/$snap")" + if [[ "$t_abs" != "$ROOT/Assets/"* ]]; then + echo "refuse: target outside allowed scope: $t_abs" >&2; exit 2 + fi + mkdir -p "$(dirname "$s_abs")" + case "$sub" in + snapshot) + cp -f "$t_abs" "$s_abs" + sha=$(sha256sum "$s_abs" | awk '{print $1}') + echo "snapshot_sha=$sha" + ;; + restore) + if [[ ! -f "$s_abs" ]]; then echo "snapshot missing: $s_abs" >&2; exit 3; fi + cp -f "$s_abs" "$t_abs" + touch "$t_abs" + sha=$(sha256sum "$t_abs" | awk '{print $1}') + echo "restored_sha=$sha" + ;; + *) + echo "usage: $0 snapshot|restore " >&2; exit 1 + ;; + esac + BASH + chmod +x scripts/nlt-revert.sh + # ---------- Run suite ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta @@ -230,14 +267,15 @@ jobs: mcp_config: .claude/mcp.json allowed_tools: >- Write, - Bash(printf:*),Bash(echo:*), + Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*), mcp__unity__manage_editor, mcp__unity__list_resources, mcp__unity__read_resource, mcp__unity__apply_text_edits, mcp__unity__script_apply_edits, mcp__unity__validate_script, - mcp__unity__find_in_file + mcp__unity__find_in_file, + mcp__unity__read_console disallowed_tools: TodoWrite,Task model: claude-3-7-sonnet-latest timeout_minutes: "30" @@ -252,49 +290,30 @@ jobs: from pathlib import Path import xml.etree.ElementTree as ET import re, os - - def localname(tag: str) -> str: - return tag.rsplit('}', 1)[-1] if '}' in tag else tag - + def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) - out = src - out.parent.mkdir(parents=True, exist_ok=True) - if not src.exists(): - raise SystemExit(0) - - try: - tree = ET.parse(src) - root = tree.getroot() - rtag = localname(root.tag) - suite = root.find('./*') if rtag == 'testsuites' else root - if suite is None: - print("No suite element in JUnit skeleton") - raise SystemExit(0) - - # Append any testcase fragments from reports/*_results.xml - fragments = sorted(Path('reports').glob('*_results.xml')) - added = 0 - for frag in fragments: - try: - froot = ET.parse(frag).getroot() - if localname(froot.tag) == 'testcase': - suite.append(froot); added += 1 - else: - for tc in froot.findall('.//testcase'): - suite.append(tc); added += 1 - except Exception as fe: - # Regex fallback for raw strings - txt = Path(frag).read_text(encoding='utf-8', errors='replace') - for m in re.findall(r'', txt, flags=re.DOTALL): - try: - tc = ET.fromstring(m); suite.append(tc); added += 1 - except Exception: - pass - if added: - tree.write(out, encoding='utf-8', xml_declaration=True) - print(f"Added {added} testcase fragments") - except Exception as e: - print("Normalization error:", e) + if not src.exists(): raise SystemExit(0) + tree = ET.parse(src); root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + if suite is None: raise SystemExit(0) + fragments = sorted(Path('reports').glob('*_results.xml')) + added = 0 + for frag in fragments: + try: + froot = ET.parse(frag).getroot() + if localname(froot.tag) == 'testcase': + suite.append(froot); added += 1 + else: + for tc in froot.findall('.//testcase'): + suite.append(tc); added += 1 + except Exception: + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + for m in re.findall(r'', txt, flags=re.DOTALL): + try: suite.append(ET.fromstring(m)); added += 1 + except Exception: pass + if added: + tree.write(src, encoding='utf-8', xml_declaration=True) + print(f"Added {added} testcase fragments") PY # ---------- Markdown summary from JUnit ---------- @@ -306,8 +325,7 @@ jobs: import xml.etree.ElementTree as ET from pathlib import Path import os - def localname(tag: str) -> str: - return tag.rsplit('}', 1)[-1] if '}' in tag else tag + def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) if not src.exists(): @@ -331,7 +349,6 @@ jobs: st = status_for(p) lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) lines.append('') - # brief failure details for name, tc in name_to_case.items(): node = tc.find('failure') or tc.find('error') if node is None: continue From 6a86db31ffed8aa108cdeceeed5216a9e3ffc4d5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:28:08 -0700 Subject: [PATCH 221/311] minor fix --- .claude/prompts/nl-unity-suite-full.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index bf13ab7a..f46d9b89 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -61,6 +61,10 @@ VERDICT: PASS ]]> +``` + +Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). + ### Fast Restore Strategy (OS‑level) From e0f8a72f420e3e2633fc22b0bb4f93992ffef666 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:35:18 -0700 Subject: [PATCH 222/311] workflows(nl-suite): fix indentation and dispatch; align allowed_tools and revert helper --- .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 b16db525..0d990a21 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,23 +1,22 @@ +name: Claude NL/T Full Suite (Unity live) -name: Claude NL/T Suite (Unity live) - - on: +on: workflow_dispatch: {} - permissions: +permissions: contents: read checks: write - concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - env: +env: UNITY_VERSION: 2021.3.45f1 UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home - jobs: +jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest From 72987b704903cb0527ba52ad606a05786efa3274 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:40:14 -0700 Subject: [PATCH 223/311] prompts(nl-full): switch to read_resource for buf/sha; re-read only when needed; convert 'Print this once' to heading; note snapshot helper creates parent dirs --- .claude/prompts/nl-unity-suite-full.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index f46d9b89..ed757397 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -2,7 +2,7 @@ You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. -**Print this once, verbatim, early in the run:** +### Print this once, verbatim, early in the run AllowedTools: Write,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console --- @@ -79,16 +79,19 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). scripts/nlt-revert.sh restore "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline" ``` - Then `read_resource` to confirm and (optionally) `validate_script(level:"standard")`. +- The helper creates parent directories for the snapshot path if missing. - If the helper fails: fall back once to a guarded full‑file restore using the baseline bytes; then continue. ### Guarded Write Pattern (for edits, not restores) -- Before any mutation: `buf = read_text(uri)`; `pre_sha = sha256(read_bytes(uri))`. +- Before any mutation: + - Call `mcp__unity__read_resource(uri, project_root, ctx)` and set `buf = res.text` and `pre_sha = res.sha256` (server‑computed over raw on‑disk bytes). - Write with `precondition_sha256 = pre_sha`. - On `{status:"stale_file"}`: - - Retry once using the server hash (`data.current_sha256` or `data.expected_sha256`). - - If absent, one re‑read then a final retry. No loops. -- After success: immediately re‑read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. + - Retry once using a server hash (`data.current_sha256` or `data.expected_sha256`) if present. + - Otherwise perform one `read_resource(...)` to refresh `pre_sha` and retry. No loops. +- After success: + - Prefer not to re‑read. Update `buf` locally; refresh `pre_sha = mcp__unity__read_resource(...).sha256` only when the next step requires exact on‑disk sync (validation, anchor recompute) or before leaving the test. - Prefer anchors (`script_apply_edits`) for end‑of‑class / above‑method insertions. Keep edits inside method bodies. Avoid header/using. ### Execution Order (fixed) From 2031fcd93facae6d187aa964e5e627d5810bfcfd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 09:57:37 -0700 Subject: [PATCH 224/311] workflows(nl-suite): normalize step removes bootstrap when real testcases present; recompute tests/failures --- .github/workflows/claude-nl-suite.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0d990a21..8d449253 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -311,8 +311,24 @@ jobs: try: suite.append(ET.fromstring(m)); added += 1 except Exception: pass if added: + # If we added real testcases, drop the bootstrap placeholder and recompute counts + removed_bootstrap = 0 + for tc in list(suite.findall('.//testcase')): + name = (tc.get('name') or '') + fail = tc.find('failure') + if name == 'NL-Suite.Bootstrap' and (added > 0): + suite.remove(tc) + removed_bootstrap += 1 + # Recompute suite attributes + testcases = suite.findall('.//testcase') + tests_cnt = len(testcases) + failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) + suite.set('tests', str(tests_cnt)) + suite.set('failures', str(failures_cnt)) + suite.set('errors', str(0)) + suite.set('skipped', str(0)) tree.write(src, encoding='utf-8', xml_declaration=True) - print(f"Added {added} testcase fragments") + print(f"Added {added} testcase fragments, removed bootstrap={removed_bootstrap}, tests={tests_cnt}, failures={failures_cnt}") PY # ---------- Markdown summary from JUnit ---------- From b8e461ab137e7d79ee521bbe192fc4f4e83b496f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 10:02:43 -0700 Subject: [PATCH 225/311] workflows(nl-suite): enrich Markdown summary by extracting per-test blocks (truncated) --- .github/workflows/claude-nl-suite.yml | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8d449253..660538ac 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -364,6 +364,35 @@ jobs: st = status_for(p) lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) lines.append('') + # Rich per-test details (pull from ) + lines.append('## Test Details') + # Sort by canonical order where possible + def order_key(n: str): + try: + if n.startswith('NL-') and n[3].isdigit(): + return (0, int(n.split('.')[0].split('-')[1])) + except Exception: + pass + if n.startswith('T-') and len(n)>2 and n[2].isalpha(): + return (1, ord(n[2])) + return (2, n) + for name in sorted(name_to_case.keys(), key=order_key): + tc = name_to_case[name] + so = tc.find('system-out') + text = '' if so is None or so.text is None else so.text + text = text.replace('\r\n','\n') + # Trim overly long outputs + MAX_CHARS = 2000 + if len(text) > MAX_CHARS: + text = text[:MAX_CHARS] + "\n…(truncated)" + lines.append(f"### {name}") + if text.strip(): + lines.append('```') + lines.append(text.strip()) + lines.append('```') + else: + lines.append('(no system-out)') + lines.append('') for name, tc in name_to_case.items(): node = tc.find('failure') or tc.find('error') if node is None: continue From f0b3bba183ee01398cd1ceb12d7357a504d9174c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 11:39:44 -0700 Subject: [PATCH 226/311] clarify prompt resilience instructions --- .claude/prompts/nl-unity-suite-full.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index ed757397..a4cc1488 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -42,6 +42,14 @@ CI provides: > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- +### Structured edit ops (required usage) +- For method insertion anchored after `GetCurrentTarget`: use `script_apply_edits` with `{"op":"anchor_insert", "afterMethodName":"GetCurrentTarget", "text": ""}` +- To delete the temporary helper (T‑A/T‑E): **do not** use `anchor_replace`. Prefer: + 1) `script_apply_edits` with `{"op":"regex_replace", "pattern":"(?s)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", "replacement":""}` + 2) If that returns `missing_field` or `bad_request`, fallback to `apply_text_edits` with a single `replace_range` computed from the method’s start/end offsets (found by scanning braces). +- If any write returns `missing_field`, `bad_request`, or `unsupported`: **write the testcase fragment anyway** with the error in ``, mark `VERDICT: FAIL`, then **restore** and proceed to the next test. +- Never call generic Bash like `mkdir`; the revert helper creates needed directories. Do not attempt directory creation; use only `scripts/nlt-revert.sh` for snapshot/restore. + ## Output Rules (JUnit fragments only) - For each test, create **one** file: `reports/_results.xml` containing exactly a single ` ... `. @@ -63,7 +71,7 @@ VERDICT: PASS ``` -Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). +Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests or later `` blocks). ### Fast Restore Strategy (OS‑level) From 0b908c1284bb26ddd0d1fa2d51f49ad254eb79a9 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 15:03:45 -0700 Subject: [PATCH 227/311] ci(nl-suite): revert prompt and workflow to known-good e0f8a72 for green run; remove extra MD details --- .claude/prompts/nl-unity-suite-full.md | 23 ++++--------- .github/workflows/claude-nl-suite.yml | 47 +------------------------- 2 files changed, 7 insertions(+), 63 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index a4cc1488..f46d9b89 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -2,7 +2,7 @@ You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. -### Print this once, verbatim, early in the run +**Print this once, verbatim, early in the run:** AllowedTools: Write,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console --- @@ -42,14 +42,6 @@ CI provides: > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- -### Structured edit ops (required usage) -- For method insertion anchored after `GetCurrentTarget`: use `script_apply_edits` with `{"op":"anchor_insert", "afterMethodName":"GetCurrentTarget", "text": ""}` -- To delete the temporary helper (T‑A/T‑E): **do not** use `anchor_replace`. Prefer: - 1) `script_apply_edits` with `{"op":"regex_replace", "pattern":"(?s)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", "replacement":""}` - 2) If that returns `missing_field` or `bad_request`, fallback to `apply_text_edits` with a single `replace_range` computed from the method’s start/end offsets (found by scanning braces). -- If any write returns `missing_field`, `bad_request`, or `unsupported`: **write the testcase fragment anyway** with the error in ``, mark `VERDICT: FAIL`, then **restore** and proceed to the next test. -- Never call generic Bash like `mkdir`; the revert helper creates needed directories. Do not attempt directory creation; use only `scripts/nlt-revert.sh` for snapshot/restore. - ## Output Rules (JUnit fragments only) - For each test, create **one** file: `reports/_results.xml` containing exactly a single ` ... `. @@ -71,7 +63,7 @@ VERDICT: PASS ``` -Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests or later `` blocks). +Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### Fast Restore Strategy (OS‑level) @@ -87,19 +79,16 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests or lat scripts/nlt-revert.sh restore "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline" ``` - Then `read_resource` to confirm and (optionally) `validate_script(level:"standard")`. -- The helper creates parent directories for the snapshot path if missing. - If the helper fails: fall back once to a guarded full‑file restore using the baseline bytes; then continue. ### Guarded Write Pattern (for edits, not restores) -- Before any mutation: - - Call `mcp__unity__read_resource(uri, project_root, ctx)` and set `buf = res.text` and `pre_sha = res.sha256` (server‑computed over raw on‑disk bytes). +- Before any mutation: `buf = read_text(uri)`; `pre_sha = sha256(read_bytes(uri))`. - Write with `precondition_sha256 = pre_sha`. - On `{status:"stale_file"}`: - - Retry once using a server hash (`data.current_sha256` or `data.expected_sha256`) if present. - - Otherwise perform one `read_resource(...)` to refresh `pre_sha` and retry. No loops. -- After success: - - Prefer not to re‑read. Update `buf` locally; refresh `pre_sha = mcp__unity__read_resource(...).sha256` only when the next step requires exact on‑disk sync (validation, anchor recompute) or before leaving the test. + - Retry once using the server hash (`data.current_sha256` or `data.expected_sha256`). + - If absent, one re‑read then a final retry. No loops. +- After success: immediately re‑read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. - Prefer anchors (`script_apply_edits`) for end‑of‑class / above‑method insertions. Keep edits inside method bodies. Avoid header/using. ### Execution Order (fixed) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 660538ac..0d990a21 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -311,24 +311,8 @@ jobs: try: suite.append(ET.fromstring(m)); added += 1 except Exception: pass if added: - # If we added real testcases, drop the bootstrap placeholder and recompute counts - removed_bootstrap = 0 - for tc in list(suite.findall('.//testcase')): - name = (tc.get('name') or '') - fail = tc.find('failure') - if name == 'NL-Suite.Bootstrap' and (added > 0): - suite.remove(tc) - removed_bootstrap += 1 - # Recompute suite attributes - testcases = suite.findall('.//testcase') - tests_cnt = len(testcases) - failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) - suite.set('tests', str(tests_cnt)) - suite.set('failures', str(failures_cnt)) - suite.set('errors', str(0)) - suite.set('skipped', str(0)) tree.write(src, encoding='utf-8', xml_declaration=True) - print(f"Added {added} testcase fragments, removed bootstrap={removed_bootstrap}, tests={tests_cnt}, failures={failures_cnt}") + print(f"Added {added} testcase fragments") PY # ---------- Markdown summary from JUnit ---------- @@ -364,35 +348,6 @@ jobs: st = status_for(p) lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) lines.append('') - # Rich per-test details (pull from ) - lines.append('## Test Details') - # Sort by canonical order where possible - def order_key(n: str): - try: - if n.startswith('NL-') and n[3].isdigit(): - return (0, int(n.split('.')[0].split('-')[1])) - except Exception: - pass - if n.startswith('T-') and len(n)>2 and n[2].isalpha(): - return (1, ord(n[2])) - return (2, n) - for name in sorted(name_to_case.keys(), key=order_key): - tc = name_to_case[name] - so = tc.find('system-out') - text = '' if so is None or so.text is None else so.text - text = text.replace('\r\n','\n') - # Trim overly long outputs - MAX_CHARS = 2000 - if len(text) > MAX_CHARS: - text = text[:MAX_CHARS] + "\n…(truncated)" - lines.append(f"### {name}") - if text.strip(): - lines.append('```') - lines.append(text.strip()) - lines.append('```') - else: - lines.append('(no system-out)') - lines.append('') for name, tc in name_to_case.items(): node = tc.find('failure') or tc.find('error') if node is None: continue From 36ed38dd3921dd1e7b3b6e7ff1aaf0bfb125674c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 15:48:19 -0700 Subject: [PATCH 228/311] =?UTF-8?q?ci(nl-suite):=20minimal=20fixes=20?= =?UTF-8?q?=E2=80=94=20no-mkdir=20guard=20in=20prompt;=20drop=20bootstrap?= =?UTF-8?q?=20and=20recompute=20JUnit=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/prompts/nl-unity-suite-full.md | 1 + .github/workflows/claude-nl-suite.yml | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index f46d9b89..0b8cb68f 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -38,6 +38,7 @@ CI provides: - **Validation**: `mcp__unity__validate_script(level:"standard")` - **Reporting**: `Write` small XML fragments to `reports/*_results.xml` - **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` + - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0d990a21..9167e385 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -311,8 +311,22 @@ jobs: try: suite.append(ET.fromstring(m)); added += 1 except Exception: pass if added: + # Drop bootstrap placeholder and recompute counts + removed_bootstrap = 0 + for tc in list(suite.findall('.//testcase')): + name = (tc.get('name') or '') + if name == 'NL-Suite.Bootstrap': + suite.remove(tc) + removed_bootstrap += 1 + testcases = suite.findall('.//testcase') + tests_cnt = len(testcases) + failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) + suite.set('tests', str(tests_cnt)) + suite.set('failures', str(failures_cnt)) + suite.set('errors', str(0)) + suite.set('skipped', str(0)) tree.write(src, encoding='utf-8', xml_declaration=True) - print(f"Added {added} testcase fragments") + print(f"Added {added} testcase fragments; removed bootstrap={removed_bootstrap}; tests={tests_cnt}; failures={failures_cnt}") PY # ---------- Markdown summary from JUnit ---------- From 503b45b2a0a2657ad2fcb3477eb19252b3aed2e1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:11:05 -0700 Subject: [PATCH 229/311] =?UTF-8?q?ci(nl-suite):=20richer=20JUnit=E2=86=92?= =?UTF-8?q?Markdown=20report=20(per-test=20system-out)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-nl-suite.yml | 69 ++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9167e385..038981fa 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -338,39 +338,84 @@ jobs: import xml.etree.ElementTree as ET from pathlib import Path import os - def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + if not src.exists(): - md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8'); raise SystemExit(0) - tree = ET.parse(src); root = tree.getroot() + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') + raise SystemExit(0) + + tree = ET.parse(src) + root = tree.getroot() suite = root.find('./*') if localname(root.tag) == 'testsuites' else root - cases = [] if suite is None else [tc for tc in suite.findall('.//testcase')] + cases = [] if suite is None else list(suite.findall('.//testcase')) + total = len(cases) failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) passed = total - failures + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] name_to_case = {(tc.get('name') or ''): tc for tc in cases} + def status_for(prefix: str): for name, tc in name_to_case.items(): if name.startswith(prefix): return not ((tc.find('failure') is not None) or (tc.find('error') is not None)) return None + lines = [] - lines += ['# Unity NL/T Editing Suite Test Results','',f'Totals: {passed} passed, {failures} failed, {total} total','', '## Test Checklist'] + lines += [ + '# Unity NL/T Editing Suite Test Results', + '', + f'Totals: {passed} passed, {failures} failed, {total} total', + '', + '## Test Checklist' + ] for p in desired: st = status_for(p) lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) lines.append('') - for name, tc in name_to_case.items(): - node = tc.find('failure') or tc.find('error') - if node is None: continue - msg = (node.get('message') or '').strip() - text = (node.text or '').strip() + + # Rich per-test system-out details + lines.append('## Test Details') + + def order_key(n: str): + try: + if n.startswith('NL-') and n[3].isdigit(): + return (0, int(n.split('.')[0].split('-')[1])) + except Exception: + pass + if n.startswith('T-') and len(n) > 2 and n[2].isalpha(): + return (1, ord(n[2])) + return (2, n) + + MAX_CHARS = 2000 + for name in sorted(name_to_case.keys(), key=order_key): + tc = name_to_case[name] lines.append(f"### {name}") - if msg: lines.append(f"- Message: {msg}") - if text: lines.append(f"- Detail: {text.splitlines()[0][:500]}") + so = tc.find('system-out') + text = '' if so is None or so.text is None else so.text.replace('\r\n','\n') + if text.strip(): + t = text.strip() + if len(t) > MAX_CHARS: + t = t[:MAX_CHARS] + "\n…(truncated)" + lines.append('```') + lines.append(t) + lines.append('```') + else: + lines.append('(no system-out)') + node = tc.find('failure') or tc.find('error') + if node is not None: + msg = (node.get('message') or '').strip() + body = (node.text or '').strip() + if msg: lines.append(f"- Message: {msg}") + if body: lines.append(f"- Detail: {body.splitlines()[0][:500]}") lines.append('') + md_out.write_text('\n'.join(lines), encoding='utf-8') PY From 74226d7c7a60e20871c0f3d9339d25364530a49c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:11:35 -0700 Subject: [PATCH 230/311] Small guard to incorret asset read call. --- .claude/prompts/nl-unity-suite-full.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 0b8cb68f..7a63181c 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -38,6 +38,8 @@ CI provides: - **Validation**: `mcp__unity__validate_script(level:"standard")` - **Reporting**: `Write` small XML fragments to `reports/*_results.xml` - **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` + - For `script_apply_edits`: use `name` + workspace‑relative `path` only (e.g., `name="LongUnityScriptClaudeTest"`, `path="Assets/Scripts"`). Do not pass `unity://...` URIs as `path`. + - For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI. - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. From 01f8408e7c6426850efc9d4ab43bb675d8f3685f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:35:38 -0700 Subject: [PATCH 231/311] =?UTF-8?q?ci(nl-suite):=20refine=20MD=20builder?= =?UTF-8?q?=20=E2=80=94=20unescape=20XML=20entities,=20safe=20code=20fence?= =?UTF-8?q?s,=20PASS/FAIL=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-nl-suite.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 038981fa..52cc78d8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -337,7 +337,7 @@ jobs: python3 - <<'PY' import xml.etree.ElementTree as ET from pathlib import Path - import os + import os, html def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag @@ -396,16 +396,24 @@ jobs: MAX_CHARS = 2000 for name in sorted(name_to_case.keys(), key=order_key): tc = name_to_case[name] - lines.append(f"### {name}") + status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" + lines.append(f"### {name} — {status_badge}") so = tc.find('system-out') text = '' if so is None or so.text is None else so.text.replace('\r\n','\n') + # Unescape XML entities so code reads naturally (e.g., => instead of =>) + if text: + text = html.unescape(text) if text.strip(): t = text.strip() if len(t) > MAX_CHARS: t = t[:MAX_CHARS] + "\n…(truncated)" - lines.append('```') + # Use a safer fence if content contains triple backticks + fence = '```' + if '```' in t: + fence = '````' + lines.append(fence) lines.append(t) - lines.append('```') + lines.append(fence) else: lines.append('(no system-out)') node = tc.find('failure') or tc.find('error') From 52facd6958ab769f50bf0047bdae50420934c6b3 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:36:34 -0700 Subject: [PATCH 232/311] Update UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../UnityMcpServer~/src/tools/resource_tools.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 2d4a47bf..a7f33e82 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -50,17 +50,23 @@ def _resolve_project_root(override: str | None) -> Path: cur = cur.parent # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings try: + import os as _os root = Path.cwd().resolve() max_depth = 3 - for path in root.rglob("*"): + for dirpath, dirnames, _ in _os.walk(root): + rel = Path(dirpath).resolve() try: - rel_depth = len(path.relative_to(root).parts) + depth = len(rel.relative_to(root).parts) except Exception: + # Unrelated mount/permission edge; skip deeper traversal + dirnames[:] = [] continue - if rel_depth > max_depth: + if depth > max_depth: + # Prune deeper traversal + dirnames[:] = [] continue - if path.is_dir() and (path / "Assets").exists() and (path / "ProjectSettings").exists(): - return path + if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): + return rel except Exception: pass # 6) Fallback: CWD From 978abaa24bd037678e103b1664e73333a7d09f7e Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:36:51 -0700 Subject: [PATCH 233/311] Update UnityMcpBridge/UnityMcpServer~/src/unity_connection.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../UnityMcpServer~/src/unity_connection.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 13713b36..27b90c1d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -46,9 +46,24 @@ def connect(self) -> bool: # Strict handshake: require FRAMING=1 try: 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 '' +- self.sock.settimeout(getattr(config, "handshake_timeout", 1.0)) +- greeting = self.sock.recv(256) + timeout = float(getattr(config, "handshake_timeout", 1.0)) + self.sock.settimeout(timeout) + buf = bytearray() + end = time.time() + timeout + while time.time() < end and len(buf) < 512: + try: + chunk = self.sock.recv(256) + if not chunk: + break + buf.extend(chunk) + if b"\n" in buf: + break + except socket.timeout: + break + text = bytes(buf).decode('ascii', errors='ignore').strip() + if 'FRAMING=1' in text: self.use_framing = True logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') From bc8439bc90063b08450444c86db8ce62df2d6dde Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:37:45 -0700 Subject: [PATCH 234/311] Update UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/tools/manage_script_edits.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 59c4d8f4..ce3df000 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -659,19 +659,39 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: options.setdefault("validate", "standard") options.setdefault("refresh", "immediate") - params: Dict[str, Any] = { - "action": "update", + import hashlib + # Compute the SHA of the current file contents for the precondition + old_lines = contents.splitlines(keepends=True) + end_line = len(old_lines) + 1 # 1-based exclusive end + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + + # Apply a whole-file text edit rather than the deprecated 'update' action + params = { + "action": "apply_text_edits", "name": name, "path": path, "namespace": namespace, "scriptType": script_type, - "encodedContents": base64.b64encode(new_contents.encode("utf-8")).decode("ascii"), - "contentsEncoded": True, + "edits": [ + { + "startLine": 1, + "startCol": 1, + "endLine": end_line, + "endCol": 1, + "newText": new_contents, + } + ], + "precondition_sha256": sha, + "options": options or {"validate": "standard", "refresh": "immediate"}, } - if options is not None: - params["options"] = options + write_resp = send_command_with_retry("manage_script", params) - return _with_norm(write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, normalized_for_echo, routing="text") + return _with_norm( + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, + normalized_for_echo, + routing="text", + ) From 1045882ccf4fdd01ac07548e216ccef38e14ecc1 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:38:37 -0700 Subject: [PATCH 235/311] Update .github/scripts/mark_skipped.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/scripts/mark_skipped.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index dc06a020..45ccbe61 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -70,21 +70,25 @@ def main(path: str) -> int: changed = False for ts in suites: for case in list(ts.findall("testcase")): - for node_name in ("failure", "error"): - node = case.find(node_name) - if node is None: - continue - msg = (node.get("message") or "") + "\n" + (node.text or "") + nodes = [n for n in list(case) if n.tag in ("failure", "error")] + if not nodes: + continue + # If any node matches skip patterns, convert the whole case to skipped. + first_match_text = None + to_skip = False + for n in nodes: + msg = (n.get("message") or "") + "\n" + (n.text or "") if should_skip(msg): - # Replace with - reason = "Marked skipped: environment/permission precondition not met" - case.remove(node) - skip = ET.SubElement(case, "skipped") - skip.set("message", reason) - skip.text = (node.text or "").strip() or reason - changed = True - break # only one conversion per case - + first_match_text = (n.text or "").strip() or first_match_text + to_skip = True + if to_skip: + for n in nodes: + case.remove(n) + reason = "Marked skipped: environment/permission precondition not met" + skip = ET.SubElement(case, "skipped") + skip.set("message", reason) + skip.text = first_match_text or reason + changed = True # Recompute tallies per testsuite tests, failures, errors, skipped = summarize_counts(ts) ts.set("tests", str(tests)) From 2e7239eb3ef62f4e06642663d215845ba79c6a7d Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:39:38 -0700 Subject: [PATCH 236/311] Update .github/scripts/mark_skipped.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/scripts/mark_skipped.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index 45ccbe61..bf26db01 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -105,5 +105,9 @@ def main(path: str) -> int: return 0 if __name__ == "__main__": - target = sys.argv[1] if len(sys.argv) > 1 else "reports/claude-nl-tests.xml" + target = ( + sys.argv[1] + if len(sys.argv) > 1 + else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml") + ) raise SystemExit(main(target)) From af0c805c0df5b07af1c4f22e16f7139e92b1fee1 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:39:55 -0700 Subject: [PATCH 237/311] Update .github/scripts/mark_skipped.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .github/scripts/mark_skipped.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index bf26db01..d2e7ca7b 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -38,7 +38,7 @@ def should_skip(msg: str) -> bool: return True return False -def summarize_counts(ts): +def summarize_counts(ts: ET.Element): tests = 0 failures = 0 errors = 0 From c52960b80bb9b6c5b5345d8b3d108d3f67b0bf1c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:40:11 -0700 Subject: [PATCH 238/311] =?UTF-8?q?server(manage=5Fscript):=20robust=20URI?= =?UTF-8?q?=20handling=20=E2=80=94=20percent-decode=20file://,=20normalize?= =?UTF-8?q?,=20strip=20host/leading=20slashes,=20return=20Assets-relative?= =?UTF-8?q?=20if=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/manage_script.py | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index ac19795d..6eae7261 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -3,21 +3,57 @@ from unity_connection import send_command_with_retry import base64 import os +from urllib.parse import urlparse, unquote def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str if uri.startswith("unity://path/"): - path = uri[len("unity://path/") :] + raw_path = uri[len("unity://path/") :] elif uri.startswith("file://"): - path = uri[len("file://") :] + parsed = urlparse(uri) + # Use parsed.path (percent-encoded) and decode it + raw_path = unquote(parsed.path or "") + # Handle cases like file://localhost/... + if not raw_path and uri.startswith("file://"): + raw_path = uri[len("file://") :] else: - path = uri - path = path.replace("\\", "/") - name = os.path.splitext(os.path.basename(path))[0] - directory = os.path.dirname(path) + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + if raw_path.startswith("//"): + # Strip possible leading '//' from malformed file URIs + raw_path = raw_path.lstrip("/") + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it + parts = [p for p in norm.split("/") if p not in ("", ".")] + try: + idx = parts.index("Assets") + assets_rel = "/".join(parts[idx:]) + except ValueError: + assets_rel = None + + effective_path = assets_rel if assets_rel else norm + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) return name, directory @mcp.tool() From 69aa4f3b53f831238894e2fb1f3cbfd81516f01c Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 16:41:18 -0700 Subject: [PATCH 239/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 7a63181c..abdff5fa 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -40,7 +40,7 @@ CI provides: - **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` - For `script_apply_edits`: use `name` + workspace‑relative `path` only (e.g., `name="LongUnityScriptClaudeTest"`, `path="Assets/Scripts"`). Do not pass `unity://...` URIs as `path`. - For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI. - - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. + - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. From 15dd4c1cb95d68f024673b0f43c98fcd454f266d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:45:12 -0700 Subject: [PATCH 240/311] tests(framing): reduce handshake poll window, nonblocking peek to avoid disconnect race; still enforce pre-handshake data drop --- tests/test_transport_framing.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 2008c4c1..4dd688dd 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -87,17 +87,31 @@ def _run(): ready.set() conn, _ = sock.accept() # 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: + try: + conn.setblocking(False) + deadline = time.time() + 0.15 # short, reduces race with legitimate clients + while time.time() < deadline: + r, _, _ = select.select([conn], [], [], 0.01) + if r: + try: + peek = conn.recv(1, socket.MSG_PEEK) + except BlockingIOError: + peek = b"" + except Exception: + peek = b"\x00" + if peek: + conn.close() + sock.close() + return + # No pre-handshake data observed; send greeting + conn.setblocking(True) + conn.sendall(b"MCP/0.1 FRAMING=1\n") + time.sleep(0.1) + finally: + try: conn.close() + finally: sock.close() - return - conn.sendall(b"MCP/0.1 FRAMING=1\n") - time.sleep(0.1) - conn.close() - sock.close() threading.Thread(target=_run, daemon=True).start() ready.wait() From 8b91d0b1ef68091ae56adab1001056c90dae17d3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:48:46 -0700 Subject: [PATCH 241/311] tests(manage_script): add _split_uri tests for unity://path, file:// URLs (decoded/Assets-relative), and plain paths --- tests/test_manage_script_uri.py | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_manage_script_uri.py diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py new file mode 100644 index 00000000..d25f8179 --- /dev/null +++ b/tests/test_manage_script_uri.py @@ -0,0 +1,111 @@ +import sys +from pathlib import Path + +import pytest + + +# Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) +ROOT = Path(__file__).resolve().parents[1] +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +SRC = next((p for p in candidates if p.exists()), None) +if SRC is None: + searched = "\n".join(str(p) for p in candidates) + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, + ) +sys.path.insert(0, str(SRC)) + + +# Import target module after path injection +import tools.manage_script as manage_script # type: ignore + + +class DummyMCP: + def __init__(self): + self.tools = {} + + def tool(self): + def _decorator(fn): + self.tools[fn.__name__] = fn + return fn + return _decorator + + +class DummyCtx: # FastMCP Context placeholder + pass + + +def _register_tools(): + mcp = DummyMCP() + manage_script.register_manage_script_tools(mcp) # populates mcp.tools + return mcp.tools + + +def test_split_uri_unity_path(monkeypatch): + tools = _register_tools() + captured = {} + + def fake_send(cmd, params): # capture params and return success + captured['cmd'] = cmd + captured['params'] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + fn = tools['apply_text_edits'] + uri = "unity://path/Assets/Scripts/MyScript.cs" + fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) + + assert captured['cmd'] == 'manage_script' + assert captured['params']['name'] == 'MyScript' + assert captured['params']['path'] == 'Assets/Scripts' + + +@pytest.mark.parametrize( + "uri, expected_name, expected_path", + [ + ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", "Foo Bar", "Assets/Scripts"), + ("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"), + ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"), + ("file:///tmp/Other.cs", "Other", "tmp"), # outside Assets → fall back to normalized dir + ], +) +def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): + tools = _register_tools() + captured = {} + + def fake_send(cmd, params): + captured['cmd'] = cmd + captured['params'] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + fn = tools['apply_text_edits'] + fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) + + assert captured['params']['name'] == expected_name + assert captured['params']['path'] == expected_path + + +def test_split_uri_plain_path(monkeypatch): + tools = _register_tools() + captured = {} + + def fake_send(cmd, params): + captured['params'] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + fn = tools['apply_text_edits'] + fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None) + + assert captured['params']['name'] == 'Thing' + assert captured['params']['path'] == 'Assets/Scripts' + + From 7469808301f50822af671910fb36826f4362d961 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:53:48 -0700 Subject: [PATCH 242/311] server+tests: fix handshake syntax error; robust file:// URI normalization in manage_script; add _split_uri tests; adjust stdout scan to ignore venv/site-packages --- .../UnityMcpServer~/src/tools/manage_script.py | 5 ++--- .../UnityMcpServer~/src/unity_connection.py | 2 -- tests/test_logging_stdout.py | 4 ++++ tests/test_manage_script_uri.py | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 6eae7261..3e782374 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -35,9 +35,8 @@ def _split_uri(uri: str) -> tuple[str, str]: # Percent-decode any residual encodings and normalize separators raw_path = unquote(raw_path).replace("\\", "/") - if raw_path.startswith("//"): - # Strip possible leading '//' from malformed file URIs - raw_path = raw_path.lstrip("/") + # Strip any leading slashes/host artifacts from file:// style URIs + raw_path = raw_path.lstrip("/") # Normalize path (collapse ../, ./) norm = os.path.normpath(raw_path).replace("\\", "/") diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 27b90c1d..c488b888 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -46,8 +46,6 @@ def connect(self) -> bool: # Strict handshake: require FRAMING=1 try: require_framing = getattr(config, "require_framing", True) -- self.sock.settimeout(getattr(config, "handshake_timeout", 1.0)) -- greeting = self.sock.recv(256) timeout = float(getattr(config, "handshake_timeout", 1.0)) self.sock.settimeout(timeout) buf = bytearray() diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 3b7f0c16..bd6d7dca 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -29,6 +29,10 @@ def test_no_print_statements_in_codebase(): offenders = [] syntax_errors = [] for py_file in SRC.rglob("*.py"): + # Skip virtual envs and third-party packages if they exist under SRC + parts = set(py_file.parts) + if ".venv" in parts or "site-packages" in parts: + continue try: text = py_file.read_text(encoding="utf-8", errors="strict") except UnicodeDecodeError: diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index d25f8179..4b816ac3 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -1,4 +1,5 @@ import sys +import types from pathlib import Path import pytest @@ -19,6 +20,19 @@ ) sys.path.insert(0, str(SRC)) +# Stub mcp.server.fastmcp to satisfy imports without full package +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") +class _Dummy: pass +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + # Import target module after path injection import tools.manage_script as manage_script # type: ignore From 480c66a0e1e27030a34f0f501d4c9cbaebdcf632 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:57:50 -0700 Subject: [PATCH 243/311] bridge(framing): accept zero-length frames (treat as empty keepalive) --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 1a979d57..a9bbb74d 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -557,10 +557,14 @@ private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(Ne { byte[] header = await ReadExactAsync(stream, 8, timeoutMs); ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen == 0UL || payloadLen > MaxFrameBytes) + if (payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } + if (payloadLen == 0UL) + { + return string.Empty; + } if (payloadLen > int.MaxValue) { throw new System.IO.IOException("Frame too large for buffer"); From 40ffc8f3b43b1647d1fe2ffd59e81d2bbdc3084e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 16:59:50 -0700 Subject: [PATCH 244/311] tests(logging): use errors='replace' on decode fallback to avoid silent drops --- tests/test_logging_stdout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index bd6d7dca..5b40fba3 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -36,8 +36,8 @@ def test_no_print_statements_in_codebase(): 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") + # Be tolerant of encoding edge cases in source tree without silently dropping bytes + text = py_file.read_text(encoding="utf-8", errors="replace") try: tree = ast.parse(text, filename=str(py_file)) except SyntaxError: From bd45ddd29abc1dc0373fa6a5031db67bdb0a4d6e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 17:09:44 -0700 Subject: [PATCH 245/311] resources(list): restrict to Assets/, resolve symlinks, enforce .cs; add traversal/outside-path tests --- .../src/tools/resource_tools.py | 14 ++++ tests/test_resources_api.py | 82 +++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index a7f33e82..35cb4aba 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -115,11 +115,25 @@ async def list_resources( base.relative_to(project) except ValueError: return {"success": False, "error": "Base path must be under project root"} + # Enforce listing only under Assets + try: + base.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Listing is restricted to Assets/"} matches: List[str] = [] for p in base.rglob("*"): if not p.is_file(): continue + # Resolve symlinks and ensure the real path stays under project/Assets + try: + rp = p.resolve() + rp.relative_to(project / "Assets") + except Exception: + continue + # Enforce .cs extension regardless of provided pattern + if p.suffix.lower() != ".cs": + continue if pattern and not fnmatch.fnmatch(p.name, pattern): continue rel = p.relative_to(project).as_posix() diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 62cc1ac1..8aef5f95 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,11 +1,81 @@ import pytest -@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 +import sys +from pathlib import Path +import pytest +import types + +# locate server src dynamically to avoid hardcoded layout assumptions +ROOT = Path(__file__).resolve().parents[1] +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +SRC = next((p for p in candidates if p.exists()), None) +if SRC is None: + searched = "\n".join(str(p) for p in candidates) + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, + ) +sys.path.insert(0, str(SRC)) + +from tools.resource_tools import register_resource_tools # type: ignore + +class DummyMCP: + def __init__(self): + self._tools = {} + def tool(self): + def deco(fn): + self._tools[fn.__name__] = fn + return fn + return deco + +@pytest.fixture() +def resource_tools(): + mcp = DummyMCP() + register_resource_tools(mcp) + return mcp._tools + + +def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, monkeypatch): + # Create fake project structure + proj = tmp_path + assets = proj / "Assets" / "Scripts" + assets.mkdir(parents=True) + (assets / "A.cs").write_text("// a", encoding="utf-8") + (assets / "B.txt").write_text("b", encoding="utf-8") + outside = tmp_path / "Outside.cs" + outside.write_text("// outside", encoding="utf-8") + # Symlink attempting to escape + sneaky_link = assets / "link_out" + try: + sneaky_link.symlink_to(outside) + except Exception: + # Some platforms may not allow symlinks in tests; ignore + pass + + list_resources = resource_tools["list_resources"] + # Only .cs under Assets should be listed + import asyncio + resp = asyncio.get_event_loop().run_until_complete( + list_resources(ctx=None, pattern="*.cs", under="Assets", limit=50, project_root=str(proj)) + ) + assert resp["success"] is True + uris = resp["data"]["uris"] + assert any(u.endswith("Assets/Scripts/A.cs") for u in uris) + assert not any(u.endswith("B.txt") for u in uris) + assert not any(u.endswith("Outside.cs") for u in uris) -@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 +def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): + proj = tmp_path + # under points outside Assets + list_resources = resource_tools["list_resources"] + import asyncio + resp = asyncio.get_event_loop().run_until_complete( + list_resources(ctx=None, pattern="*.cs", under="..", limit=10, project_root=str(proj)) + ) + assert resp["success"] is False + assert "Assets" in resp.get("error", "") or "under project root" in resp.get("error", "") From bf5255d96daf1faa6f7915d3a87128e634903870 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 17:11:25 -0700 Subject: [PATCH 246/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index abdff5fa..9c5c2a8a 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -86,13 +86,13 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### Guarded Write Pattern (for edits, not restores) -- Before any mutation: `buf = read_text(uri)`; `pre_sha = sha256(read_bytes(uri))`. -- Write with `precondition_sha256 = pre_sha`. +- Before any mutation: `res = mcp__unity__read_resource(uri)`; `pre_sha = sha256(res.bytes)`. +- Write with `precondition_sha256 = pre_sha` on `apply_text_edits`/`script_apply_edits`. - On `{status:"stale_file"}`: - - Retry once using the server hash (`data.current_sha256` or `data.expected_sha256`). - - If absent, one re‑read then a final retry. No loops. -- After success: immediately re‑read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. -- Prefer anchors (`script_apply_edits`) for end‑of‑class / above‑method insertions. Keep edits inside method bodies. Avoid header/using. + - Retry once using the server-provided hash (e.g., `data.current_sha256` or `data.expected_sha256`, per API schema). + - If absent, one re-read then a final retry. No loops. +- After success: immediately re-read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. +- Prefer anchors (`script_apply_edits`) for end-of-class / above-method insertions. Keep edits inside method bodies. Avoid header/using. ### Execution Order (fixed) From dc22d82635cd63d9e0b9e69f1a9ebd2db4877b21 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 17:11:48 -0700 Subject: [PATCH 247/311] Update UnityMcpBridge/UnityMcpServer~/src/unity_connection.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/UnityMcpServer~/src/unity_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index c488b888..e79a5f06 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -104,7 +104,7 @@ def _read_exact(self, sock: socket.socket, count: int) -> bytes: while len(data) < count: chunk = sock.recv(count - len(data)) if not chunk: - raise Exception("Connection closed before reading expected bytes") + raise ConnectionError("Connection closed before reading expected bytes") data.extend(chunk) return bytes(data) From d5e723aee0f5770d70d83f857b68b2eee1390a6c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 17:18:01 -0700 Subject: [PATCH 248/311] misc: framing keepalive (zero-length), regex preview consistency, resource.list hardening, URI parsing, legacy update routing, test cleanups --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 2 +- .../src/tools/manage_script.py | 49 ++++++++++++++++++- .../src/tools/manage_script_edits.py | 13 +++-- .../src/tools/resource_tools.py | 16 +++++- tests/test_manage_script_uri.py | 1 + tests/test_script_tools.py | 2 +- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index a9bbb74d..e1fc67b7 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -557,7 +557,7 @@ private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(Ne { byte[] header = await ReadExactAsync(stream, 8, timeoutMs); ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen > MaxFrameBytes) + if (payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 3e782374..1bb63832 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -151,9 +151,54 @@ def manage_script( Dictionary with results ('success', 'message', 'data'). """ try: - # Deprecate full-file update path entirely + # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) if action == 'update': - return {"success": False, "message": "Deprecated: use apply_text_edits or resources/read + small edits."} + try: + # 1) Read current contents to compute end range and precondition + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + }) + if not (isinstance(read_resp, dict) and read_resp.get("success")): + return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."} + data = read_resp.get("data", {}) + current = data.get("contents") + if not current and data.get("contentsEncoded"): + import base64 as _b64 + current = _b64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + if current is None: + return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} + + # 2) Compute whole-file range (1-based, end exclusive) and SHA + import hashlib as _hashlib + old_lines = current.splitlines(keepends=True) + end_line = len(old_lines) + 1 + sha = _hashlib.sha256(current.encode("utf-8")).hexdigest() + + # 3) Apply single whole-file text edit with provided 'contents' + edits = [{ + "startLine": 1, + "startCol": 1, + "endLine": end_line, + "endCol": 1, + "newText": contents or "", + }] + route_params = { + "action": "apply_text_edits", + "name": name, + "path": path, + "edits": edits, + "precondition_sha256": sha, + "options": {"refresh": "immediate", "validate": "standard"}, + } + routed = send_command_with_retry("manage_script", route_params) + if isinstance(routed, dict): + routed.setdefault("message", "Routed legacy update to apply_text_edits") + return routed + return {"success": False, "message": str(routed)} + except Exception as e: + return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"} # Prepare parameters for Unity params = { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index ce3df000..186c8315 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -384,8 +384,8 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str ) # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace"} - TEXT = {"prepend","append","replace_range","regex_replace","anchor_insert"} + STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"} + TEXT = {"prepend","append","replace_range","regex_replace"} ops_set = { (e.get("op") or "").lower() for e in edits or [] } all_struct = ops_set.issubset(STRUCT) all_text = ops_set.issubset(TEXT) @@ -434,7 +434,7 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str # 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"}] + struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT] try: current_text = contents def line_col_from_index(idx: int) -> Tuple[int, int]: @@ -625,14 +625,17 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: except Exception as e: return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") - # For regex_replace on large files, support preview/confirm - if "regex_replace" in text_ops and not (options or {}).get("confirm"): + # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. + # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. + if "regex_replace" in text_ops and (preview or 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) ..."] + if preview: + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} 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 _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 35cb4aba..b1fb30e1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -8,6 +8,7 @@ from typing import Dict, Any, List import re from pathlib import Path +from urllib.parse import urlparse, unquote import fnmatch import hashlib import os @@ -78,11 +79,24 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: if uri.startswith("unity://path/"): raw = uri[len("unity://path/"):] elif uri.startswith("file://"): - raw = uri[len("file://"):] + parsed = urlparse(uri) + raw = unquote(parsed.path or "") + # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. + try: + import os as _os + if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): + raw = raw[1:] + # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share + if _os.name == "nt" and parsed.netloc: + raw = f"//{parsed.netloc}{raw}" + except Exception: + pass elif uri.startswith("Assets/"): raw = uri if raw is None: return None + # Normalize separators early + raw = raw.replace("\\", "/") p = (project / raw).resolve() try: p.relative_to(project) diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 4b816ac3..0ffece77 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -5,6 +5,7 @@ import pytest + # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) ROOT = Path(__file__).resolve().parents[1] candidates = [ diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index 9b953a1a..352c849a 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -3,6 +3,7 @@ import importlib.util import types import pytest +import asyncio # add server src to path and load modules without triggering package imports ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -119,5 +120,4 @@ async def run(): assert captured["params"]["properties"] == {"hp": 100} assert resp["success"] is True - import asyncio asyncio.run(run()) From ef0301e211ec5b89659bd90e9778e6878657864f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 17:37:01 -0700 Subject: [PATCH 249/311] docs(tools): richer MCP tool descriptions; tests accept decorator kwargs; resource URI parsing hardened --- .../src/tools/manage_script.py | 37 ++++++++++++++++--- .../src/tools/resource_tools.py | 14 ++++++- tests/test_manage_script_uri.py | 2 +- tests/test_resources_api.py | 2 +- tests/test_script_tools.py | 2 +- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 1bb63832..4588878f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -55,7 +55,16 @@ def _split_uri(uri: str) -> tuple[str, str]: directory = os.path.dirname(effective_path) return name, directory - @mcp.tool() + @mcp.tool(description=( + "Apply small text edits to a C# script identified by URI.\n\n" + "Args:\n" + "- uri: unity://path/Assets/... or file://... or Assets/...\n" + "- edits: list of {startLine,startCol,endLine,endCol,newText}\n" + "- precondition_sha256: optional SHA of current file (whole-file)\n\n" + "Notes:\n" + "- Path is computed from the URI; it must resolve under Assets/.\n" + "- This tool is for precise ranges; for method/class ops use script_apply_edits.\n" + )) def apply_text_edits( ctx: Context, uri: str, @@ -75,7 +84,11 @@ def apply_text_edits( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool() + @mcp.tool(description=( + "Create a new C# script at the given project path.\n\n" + "Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n" + "Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n" + )) def create_script( ctx: Context, path: str, @@ -100,7 +113,11 @@ def create_script( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool() + @mcp.tool(description=( + "Delete a C# script by URI or Assets-relative path.\n\n" + "Args: uri (unity://path/... or file://... or Assets/...).\n" + "Rules: Target must resolve under Assets/.\n" + )) def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: """Delete a C# script by URI.""" name, directory = _split_uri(uri) @@ -108,7 +125,12 @@ def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool() + @mcp.tool(description=( + "Validate a C# script and return diagnostics.\n\n" + "Args: uri, level=('basic'|'standard').\n" + "- basic: quick syntax checks.\n" + "- standard: deeper checks (performance hints, common pitfalls).\n" + )) def validate_script( ctx: Context, uri: str, level: str = "basic" ) -> Dict[str, Any]: @@ -123,7 +145,12 @@ def validate_script( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool() + @mcp.tool(description=( + "Compatibility router for legacy script operations.\n\n" + "Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n" + "Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n" + "Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n" + )) def manage_script( ctx: Context, action: str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index b1fb30e1..c3a9bd69 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -108,7 +108,12 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: def register_resource_tools(mcp: FastMCP) -> None: """Registers list_resources and read_resource wrapper tools.""" - @mcp.tool() + @mcp.tool(description=( + "List project URIs (unity://path/...) under a folder (default: Assets).\n\n" + "Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n" + "Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n" + "Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n" + )) async def list_resources( ctx: Context | None = None, pattern: str | None = "*.cs", @@ -163,7 +168,12 @@ async def list_resources( except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool() + @mcp.tool(description=( + "Read a resource by unity://path/... URI with optional slicing.\n\n" + "Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n" + "Security: uri must resolve under Assets/.\n" + "Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n" + )) async def read_resource( uri: str, ctx: Context | None = None, diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 0ffece77..40b64584 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -43,7 +43,7 @@ class DummyMCP: def __init__(self): self.tools = {} - def tool(self): + def tool(self, *args, **kwargs): # ignore decorator kwargs like description def _decorator(fn): self.tools[fn.__name__] = fn return fn diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 8aef5f95..29082160 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -26,7 +26,7 @@ class DummyMCP: def __init__(self): self._tools = {} - def tool(self): + def tool(self, *args, **kwargs): # accept kwargs like description def deco(fn): self._tools[fn.__name__] = fn return fn diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index 352c849a..71550663 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -40,7 +40,7 @@ class DummyMCP: def __init__(self): self.tools = {} - def tool(self): + def tool(self, *args, **kwargs): # accept decorator kwargs like description def decorator(func): self.tools[func.__name__] = func return func From f264746f79d5544f03526d4973f4df4b7ef37c13 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 17:38:12 -0700 Subject: [PATCH 250/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 9c5c2a8a..c7fe41cb 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -91,9 +91,8 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - On `{status:"stale_file"}`: - Retry once using the server-provided hash (e.g., `data.current_sha256` or `data.expected_sha256`, per API schema). - If absent, one re-read then a final retry. No loops. -- After success: immediately re-read raw bytes and set `pre_sha = sha256(read_bytes(uri))` before any further edits in the same test. +- After success: immediately re-read via `res2 = mcp__unity__read_resource(uri)` and set `pre_sha = sha256(res2.bytes)` before any further edits in the same test. - Prefer anchors (`script_apply_edits`) for end-of-class / above-method insertions. Keep edits inside method bodies. Avoid header/using. - ### Execution Order (fixed) - Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). From 083363df263b7d32a221773ca3e429b330a723cd Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 17:38:45 -0700 Subject: [PATCH 251/311] Update UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index c3a9bd69..23f72ac3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -255,7 +255,10 @@ async def read_resource( 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}"} - + try: + p.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Read restricted to Assets/"} # Natural-language convenience: request like "last 120 lines", "first 200 lines", # "show 40 lines around MethodName", etc. if request: From 16c963c74b19f84141104fbf42336c142dd4b37e Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 28 Aug 2025 17:39:05 -0700 Subject: [PATCH 252/311] Update UnityMcpBridge/UnityMcpServer~/src/unity_connection.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/UnityMcpServer~/src/unity_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index e79a5f06..b86bc925 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -129,7 +129,7 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: raise chunks = [] - sock.settimeout(config.connection_timeout) # Use timeout from config + # Respect the socket's currently configured timeout try: while True: chunk = sock.recv(buffer_size) From b5d57080461351ea8d971d3cf41764a4fa55403e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 17:44:42 -0700 Subject: [PATCH 253/311] net+docs: hard-reject zero-length frames; TCP_NODELAY on connect; Assets detection case-insensitive; NL prompt statuses aligned --- .claude/prompts/nl-unity-suite-full.md | 4 ++-- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 2 +- .../UnityMcpServer~/src/tools/manage_script.py | 14 ++++++-------- .../UnityMcpServer~/src/unity_connection.py | 3 +++ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index c7fe41cb..b4d6bf37 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -113,10 +113,10 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. - T‑E. Lifecycle — Insert → update → delete via regex; restore. - T‑F. Atomic batch — One call: two small `replace_range` + one end‑of‑class comment; all‑or‑nothing; restore. -- T‑G. Path normalization — Same edit with `unity://path/Assets/...` then `Assets/...`; second returns `{status:"no_change"}`. +- T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. - T‑I. Failure surfaces — Record INFO on `{too_large}`, `{stale_file}`, overlap rejection, validation failure, `{using_guard}`. -- T‑J. Idempotency — Repeat `replace_range` → `{status:"no_change"}`; repeat delete → no‑op. +- T‑J. Idempotency — Repeat `replace_range` and then repeat delete; observe and record behavior. Regex/range operations are not strictly idempotent (no special status is emitted). ### Status & Reporting diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index e1fc67b7..5df17523 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -563,7 +563,7 @@ private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(Ne } if (payloadLen == 0UL) { - return string.Empty; + throw new System.IO.IOException("Zero-length frame is not allowed"); } if (payloadLen > int.MaxValue) { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 4588878f..3611e9f8 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -35,19 +35,17 @@ def _split_uri(uri: str) -> tuple[str, str]: # Percent-decode any residual encodings and normalize separators raw_path = unquote(raw_path).replace("\\", "/") - # Strip any leading slashes/host artifacts from file:// style URIs - raw_path = raw_path.lstrip("/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] # Normalize path (collapse ../, ./) norm = os.path.normpath(raw_path).replace("\\", "/") - # If an 'Assets' segment exists, compute path relative to it + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) parts = [p for p in norm.split("/") if p not in ("", ".")] - try: - idx = parts.index("Assets") - assets_rel = "/".join(parts[idx:]) - except ValueError: - assets_rel = None + idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index b86bc925..8c066138 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -41,6 +41,9 @@ def connect(self) -> bool: try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) + # Disable Nagle's algorithm to reduce small RPC latency + with contextlib.suppress(Exception): + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) logger.debug(f"Connected to Unity at {self.host}:{self.port}") # Strict handshake: require FRAMING=1 From e974b07f372d1f161ec3430aa9a2adc93e6da623 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 17:50:37 -0700 Subject: [PATCH 254/311] prompt(nl-suite): constrain Write destinations under reports/, forbid traversal --- .claude/prompts/nl-unity-suite-full.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index b4d6bf37..811360ca 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -51,6 +51,9 @@ CI provides: - Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** ``. - Evidence windows only (±20–40 lines). If showing a unified diff, cap at 100 lines and note truncation. - **Never** open/patch `$JUNIT_OUT` or `$MD_OUT`; CI merges fragments and synthesizes Markdown. + - Write destinations must match: `^reports/[A-Za-z0-9._-]+_results\.xml$` + - Snapshot files must live under `reports/_snapshots/` + - Reject absolute paths and any path containing `..` **Example fragment** ```xml From 2ce29ce9417415ae1bd8936bbc95d4509f62e285 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 18:52:18 -0700 Subject: [PATCH 255/311] prompt+net: harden Write path rules; use monotonic deadline and plain-text advisory for non-framed peers --- .claude/prompts/nl-unity-suite-full.md | 13 ++++++++++++- .../UnityMcpServer~/src/unity_connection.py | 10 ++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 811360ca..779dc785 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -54,6 +54,8 @@ CI provides: - Write destinations must match: `^reports/[A-Za-z0-9._-]+_results\.xml$` - Snapshot files must live under `reports/_snapshots/` - Reject absolute paths and any path containing `..` + - Reject control characters and line breaks in filenames; enforce UTF‑8 + - Cap basename length to ≤64 chars; cap any path segment to ≤100 and total path length to ≤255 **Example fragment** ```xml @@ -96,6 +98,11 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - If absent, one re-read then a final retry. No loops. - After success: immediately re-read via `res2 = mcp__unity__read_resource(uri)` and set `pre_sha = sha256(res2.bytes)` before any further edits in the same test. - Prefer anchors (`script_apply_edits`) for end-of-class / above-method insertions. Keep edits inside method bodies. Avoid header/using. + +**On non‑JSON/transport errors (timeout, EOF, connection closed):** +- Write `reports/_results.xml` with a `` that includes a `` or `` node capturing the error text. +- Run the OS restore via `scripts/nlt-revert.sh restore …`. +- Continue to the next test (do not abort). ### Execution Order (fixed) - Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). @@ -118,7 +125,11 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑F. Atomic batch — One call: two small `replace_range` + one end‑of‑class comment; all‑or‑nothing; restore. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. -- T‑I. Failure surfaces — Record INFO on `{too_large}`, `{stale_file}`, overlap rejection, validation failure, `{using_guard}`. +- T‑I. Failure surfaces (expected) — safe‑first order + - 1) Overlap: call `apply_text_edits` with two overlapping ranges on the same file; expect `{status:"overlap"}`. + - 2) Stale file: re‑use a deliberately old `precondition_sha256` on a small no‑op tweak; expect `{status:"stale_file"}` (then restore hash). + - 3) Using guard (optional, only within T‑I): you may touch the header (e.g., insert a newline above the first `using`) to elicit `{status:"using_guard"}`; restore immediately. + - 4) Too large (optional, last): if needed, send a payload just over the limit (small bounded overage). If a transport error/timeout occurs instead of JSON, still write the testcase fragment with an `` and proceed. - T‑J. Idempotency — Repeat `replace_range` and then repeat delete; observe and record behavior. Regex/range operations are not strictly idempotent (no special status is emitted). ### Status & Reporting diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 8c066138..69152f1f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -52,8 +52,8 @@ def connect(self) -> bool: timeout = float(getattr(config, "handshake_timeout", 1.0)) self.sock.settimeout(timeout) buf = bytearray() - end = time.time() + timeout - while time.time() < end and len(buf) < 512: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and len(buf) < 512: try: chunk = self.sock.recv(256) if not chunk: @@ -70,11 +70,9 @@ def connect(self) -> bool: logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') else: if require_framing: - # Best-effort advisory; peer may ignore if not framed-capable + # Best-effort plain-text advisory for legacy peers with contextlib.suppress(Exception): - msg = b'Unity MCP requires FRAMING=1' - header = struct.pack('>Q', len(msg)) - self.sock.sendall(header + msg) + self.sock.sendall(b'Unity MCP requires FRAMING=1\n') raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') else: self.use_framing = False From cc2704e4ec813ec7f66fdeff0c227fdf2c6a25e6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:20:24 -0700 Subject: [PATCH 256/311] unity_connection: restore recv timeout via try/finally; make global connection getter thread-safe with module lock and double-checked init --- .../UnityMcpServer~/src/unity_connection.py | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 69152f1f..0922f0c5 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -20,6 +20,9 @@ ) logger = logging.getLogger("mcp-for-unity-server") +# Module-level lock to guard global connection initialization +_connection_lock = threading.Lock() + @dataclass class UnityConnection: """Manages the socket connection to the Unity Editor.""" @@ -250,22 +253,19 @@ def read_status_file() -> dict | None: else: self.sock.sendall(payload) - # During retry bursts use a short receive timeout + # During retry bursts use a short receive timeout and ensure restoration + restore_timeout = None if attempt > 0 and last_short_timeout is None: - last_short_timeout = self.sock.gettimeout() + restore_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 + try: + response_data = self.receive_full_response(self.sock) + with contextlib.suppress(Exception): + logger.debug("recv %d bytes; mode=%s", len(response_data), mode) + finally: + if restore_timeout is not None: + self.sock.settimeout(restore_timeout) + last_short_timeout = None # Parse if command_type == 'ping': @@ -339,13 +339,17 @@ def get_unity_connection() -> UnityConnection: if _unity_connection is not None: return _unity_connection - logger.info("Creating new Unity connection") - _unity_connection = UnityConnection() - if not _unity_connection.connect(): - _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - logger.info("Connected to Unity on startup") - return _unity_connection + # Double-checked locking to avoid concurrent socket creation + with _connection_lock: + if _unity_connection is not None: + return _unity_connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + logger.info("Connected to Unity on startup") + return _unity_connection # ----------------------------- From 12eb81635335cbc27ab34db130cf6eba8d3fe8a6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:23:00 -0700 Subject: [PATCH 257/311] NL/T prompt: pin structured edit ops for T-D/T-E; add schema-error guarded write behavior; keep existing path/URI and revert rules --- .claude/prompts/nl-unity-suite-full.md | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 779dc785..eec082e6 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -42,6 +42,37 @@ CI provides: - For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI. - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. +### Structured edit ops (required usage) + +# Insert a helper RIGHT BEFORE the final class brace (NL‑3, T‑D) +1) Prefer `script_apply_edits` with a regex capture on the final closing brace: +```json +{"op":"regex_replace", + "pattern":"(?s)(\\n\\s*\\})\\s*$", + "replacement":"\\n // Tail test A\\n // Tail test B\\n // Tail test C\\1"} +``` + +2) If the server returns `missing_field` / `unsupported` / `bad_request`, FALL BACK to + `apply_text_edits`: + - Find the last `}` in the file (class closing brace) by scanning from end. + - Insert the three comment lines immediately before that index with one non‑overlapping range. + +# Insert after GetCurrentTarget (T‑A/T‑E) +- Use `script_apply_edits` with: +```json +{"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"private int __TempHelper(int a,int b)=>a+b;\\n"} +``` + +# Delete the temporary helper (T‑A/T‑E) +- Do NOT use `anchor_replace`. +- Use `script_apply_edits` with: +```json +{"op":"regex_replace", + "pattern":"(?s)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", + "replacement":""} +``` +- If rejected, fall back to `apply_text_edits` with a single `replace_range` spanning the method. + > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- @@ -103,6 +134,10 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - Write `reports/_results.xml` with a `` that includes a `` or `` node capturing the error text. - Run the OS restore via `scripts/nlt-revert.sh restore …`. - Continue to the next test (do not abort). + +**If any write returns `missing_field`, `bad_request`, or `unsupported`:** +- Write `reports/_results.xml` with a `` that includes a `` node capturing the server error, include evidence, and end with `VERDICT: FAIL`. +- Run `scripts/nlt-revert.sh restore ...` and continue to the next test. ### Execution Order (fixed) - Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). From c8cf82e5363c6016b861ce3b0050cef7a52ef243 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:31:48 -0700 Subject: [PATCH 258/311] unity_connection: add FRAMED_MAX; use ValueError for framed length violations; lower framed receive log to debug; serialize connect() with per-instance lock --- .../UnityMcpServer~/src/unity_connection.py | 110 ++++++++++-------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 0922f0c5..75d0f8bc 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -23,6 +23,9 @@ # Module-level lock to guard global connection initialization _connection_lock = threading.Lock() +# Maximum allowed framed payload size (64 MiB) +FRAMED_MAX = 64 * 1024 * 1024 + @dataclass class UnityConnection: """Manages the socket connection to the Unity Editor.""" @@ -36,62 +39,67 @@ def __post_init__(self): if self.port is None: self.port = PortDiscovery.discover_unity_port() self._io_lock = threading.Lock() + self._conn_lock = threading.Lock() def connect(self) -> bool: """Establish a connection to the Unity Editor.""" if self.sock: return True - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - # Disable Nagle's algorithm to reduce small RPC latency - with contextlib.suppress(Exception): - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - logger.debug(f"Connected to Unity at {self.host}:{self.port}") - - # Strict handshake: require FRAMING=1 + with self._conn_lock: + if self.sock: + return True try: - require_framing = getattr(config, "require_framing", True) - timeout = float(getattr(config, "handshake_timeout", 1.0)) - self.sock.settimeout(timeout) - buf = bytearray() - deadline = time.monotonic() + timeout - while time.monotonic() < deadline and len(buf) < 512: - try: - chunk = self.sock.recv(256) - if not chunk: - break - buf.extend(chunk) - if b"\n" in buf: + # Bounded connect to avoid indefinite blocking + connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) + self.sock = socket.create_connection((self.host, self.port), connect_timeout) + # Disable Nagle's algorithm to reduce small RPC latency + with contextlib.suppress(Exception): + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + logger.debug(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + require_framing = getattr(config, "require_framing", True) + timeout = float(getattr(config, "handshake_timeout", 1.0)) + self.sock.settimeout(timeout) + buf = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and len(buf) < 512: + try: + chunk = self.sock.recv(256) + if not chunk: + break + buf.extend(chunk) + if b"\n" in buf: + break + except socket.timeout: break - except socket.timeout: - break - text = bytes(buf).decode('ascii', errors='ignore').strip() + text = bytes(buf).decode('ascii', errors='ignore').strip() - if 'FRAMING=1' in text: - self.use_framing = True - logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') - else: - if require_framing: - # Best-effort plain-text advisory for legacy peers - with contextlib.suppress(Exception): - self.sock.sendall(b'Unity MCP requires FRAMING=1\n') - raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + if 'FRAMING=1' in text: + self.use_framing = True + logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') 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 - except Exception as e: - logger.error(f"Failed to connect to Unity: {str(e)}") - try: - if self.sock: - self.sock.close() - except Exception: - pass - self.sock = None - return False + if require_framing: + # Best-effort plain-text advisory for legacy peers + with contextlib.suppress(Exception): + self.sock.sendall(b'Unity MCP requires FRAMING=1\n') + 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 + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + try: + if self.sock: + self.sock.close() + except Exception: + pass + self.sock = None + return False def disconnect(self): """Close the connection to the Unity Editor.""" @@ -119,11 +127,11 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: 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}") + raise ValueError("Invalid framed length: 0") + if payload_len > FRAMED_MAX: + raise ValueError(f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) - logger.info(f"Received framed response ({len(payload)} bytes)") + logger.debug(f"Received framed response ({len(payload)} bytes)") return payload except socket.timeout as e: logger.warning("Socket timeout during framed receive") From 42f9f6d0cffbe8cf45b6e71e8af9a83213f07e21 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:36:59 -0700 Subject: [PATCH 259/311] ManageScript: use UTF8Encoding(without BOM) for atomic writes in ApplyTextEdits/EditScript to align with Create/Update and avoid BOM-related diffs/hash mismatches --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 82d81aca..1a7a4108 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -654,7 +654,7 @@ private static object ApplyTextEdits( // Atomic write and schedule refresh try { - var enc = System.Text.Encoding.UTF8; + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); string backup = fullPath + ".bak"; @@ -1223,7 +1223,7 @@ private static object EditScript( Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); // Atomic write with backup; schedule refresh - var enc = System.Text.Encoding.UTF8; + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); string backup = fullPath + ".bak"; From cadce7188b38667d29238f115ffcb394fe87879d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:39:00 -0700 Subject: [PATCH 260/311] NL/T prompt: make helper deletion regex multiline-safe ((?ms) so ^ anchors line starts) --- .claude/prompts/nl-unity-suite-full.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index eec082e6..9d0b85d3 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -68,7 +68,7 @@ CI provides: - Use `script_apply_edits` with: ```json {"op":"regex_replace", - "pattern":"(?s)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", + "pattern":"(?ms)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", "replacement":""} ``` - If rejected, fall back to `apply_text_edits` with a single `replace_range` spanning the method. From 80d2558eceecff461a28c5fa523997a6087b5fca Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:41:20 -0700 Subject: [PATCH 261/311] ManageScript: emit structured overlap status {status:"overlap"} for overlapping edit ranges in apply_text_edits and edit paths --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 1a7a4108..8f2c128b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -607,7 +607,7 @@ private static object ApplyTextEdits( for (int i = 1; i < spans.Count; i++) { if (spans[i].end > spans[i - 1].start) - return Response.Error("Edits overlap; split into separate calls or adjust ranges."); + return Response.Error("overlap", new { status = "overlap" }); } string working = original; @@ -1192,7 +1192,7 @@ private static object EditScript( if (!applySequentially) { if (HasOverlaps(replacements)) - return Response.Error("Edits overlap; split into separate calls or adjust targets."); + return Response.Error("overlap", new { status = "overlap" }); foreach (var r in replacements.OrderByDescending(r => r.start)) working = working.Remove(r.start, r.length).Insert(r.start, r.text); From ed9bbfdf485da0d8f1c5b9b09a6e6466aa57b992 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:42:13 -0700 Subject: [PATCH 262/311] =?UTF-8?q?NL/T=20prompt:=20clarify=20fallback=20v?= =?UTF-8?q?s=20failure=20=E2=80=94=20fallback=20only=20for=20unsupported/m?= =?UTF-8?q?issing=5Ffield;=20treat=20bad=5Frequest=20as=20failure;=20note?= =?UTF-8?q?=20unsupported=20after=20fallback=20as=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/prompts/nl-unity-suite-full.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 9d0b85d3..a4b03873 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -52,7 +52,7 @@ CI provides: "replacement":"\\n // Tail test A\\n // Tail test B\\n // Tail test C\\1"} ``` -2) If the server returns `missing_field` / `unsupported` / `bad_request`, FALL BACK to +2) If the server returns `unsupported` (op not available) or `missing_field` (op‑specific), FALL BACK to `apply_text_edits`: - Find the last `}` in the file (class closing brace) by scanning from end. - Insert the three comment lines immediately before that index with one non‑overlapping range. @@ -135,7 +135,7 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - Run the OS restore via `scripts/nlt-revert.sh restore …`. - Continue to the next test (do not abort). -**If any write returns `missing_field`, `bad_request`, or `unsupported`:** +**If any write returns `bad_request`, or `unsupported` after a fallback attempt:** - Write `reports/_results.xml` with a `` that includes a `` node capturing the server error, include evidence, and end with `VERDICT: FAIL`. - Run `scripts/nlt-revert.sh restore ...` and continue to the next test. ### Execution Order (fixed) From 89096829051ee0e952e9835f4184c853d0dce079 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 19:43:46 -0700 Subject: [PATCH 263/311] NL/T prompt: pin deterministic overlap probe (apply_text_edits two ranges from same snapshot); gate too_large behind RUN_TOO_LARGE env hint --- .claude/prompts/nl-unity-suite-full.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index a4b03873..66ff4c85 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -161,10 +161,10 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. - T‑I. Failure surfaces (expected) — safe‑first order - - 1) Overlap: call `apply_text_edits` with two overlapping ranges on the same file; expect `{status:"overlap"}`. + - 1) Overlap (deterministic): use `apply_text_edits` with two character ranges from the SAME fresh snapshot of the file. Within `HasTarget` body, e.g., pick r1=[start+2,start+10) and r2=[start+8,start+14). Send both in ONE call; expect `{status:"overlap"}`. Do not use regex/anchors for this probe. - 2) Stale file: re‑use a deliberately old `precondition_sha256` on a small no‑op tweak; expect `{status:"stale_file"}` (then restore hash). - 3) Using guard (optional, only within T‑I): you may touch the header (e.g., insert a newline above the first `using`) to elicit `{status:"using_guard"}`; restore immediately. - - 4) Too large (optional, last): if needed, send a payload just over the limit (small bounded overage). If a transport error/timeout occurs instead of JSON, still write the testcase fragment with an `` and proceed. + - 4) Too large (optional, last): only run if an env hint is present (e.g., `RUN_TOO_LARGE=1`). Keep overage small (+16–32 KB). If a transport error/timeout occurs instead of JSON, still write the testcase fragment with an `` and proceed. - T‑J. Idempotency — Repeat `replace_range` and then repeat delete; observe and record behavior. Regex/range operations are not strictly idempotent (no special status is emitted). ### Status & Reporting From 4e9e6ee19a303f9dd3c241a5579f3cda3a3251d1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:08:20 -0700 Subject: [PATCH 264/311] TB update --- .claude/prompts/nl-unity-suite-full.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 66ff4c85..92a6d061 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -73,6 +73,12 @@ CI provides: ``` - If rejected, fall back to `apply_text_edits` with a single `replace_range` spanning the method. +# T‑B (replace method body) +- Use `mcp__unity__apply_text_edits` with a single `replace_range` strictly inside the `HasTarget` braces. +- Compute start/end from a fresh `read_resource` at test start. Do not edit signature or header. +- On `{status:"stale_file"}` retry once with the server‑provided hash; if absent, re‑read once and retry. +- On `missing_field` or `bad_request`, write the testcase with ``, restore, and continue to next test. + > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- From 6696c409208569717ac2c3192a68fbb2c1365b44 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:13:46 -0700 Subject: [PATCH 265/311] =?UTF-8?q?NL/T=20prompt:=20harden=20Output=20Rule?= =?UTF-8?q?s=20=E2=80=94=20constrain=20Bash(printf|echo)=20to=20stdout-onl?= =?UTF-8?q?y;=20forbid=20redirection/here-docs/tee;=20only=20scripts/nlt-r?= =?UTF-8?q?evert.sh=20may=20mutate=20FS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/prompts/nl-unity-suite-full.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 92a6d061..6b42fec9 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -93,6 +93,7 @@ CI provides: - Reject absolute paths and any path containing `..` - Reject control characters and line breaks in filenames; enforce UTF‑8 - Cap basename length to ≤64 chars; cap any path segment to ≤100 and total path length to ≤255 + - Bash(printf|echo) must write to stdout only. Do not use shell redirection, here‑docs, or `tee` to create/modify files. The only allowed FS mutation is via `scripts/nlt-revert.sh`. **Example fragment** ```xml From effa020b6fc1a883c8f61c00115cb38d32515f93 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:25:19 -0700 Subject: [PATCH 266/311] =?UTF-8?q?Prompt:=20enumerate=20allowed=20script?= =?UTF-8?q?=5Fapply=5Fedits=20ops;=20add=20manage=5Feditor/read=5Fconsole?= =?UTF-8?q?=20guidance;=20fix=20T=E2=80=91F=20atomic=20batch=20to=20single?= =?UTF-8?q?=20script=5Fapply=5Fedits.=20ManageScript:=20regex=20timeout=20?= =?UTF-8?q?for=20diagnostics;=20symlink=20ancestor=20guard;=20complete=20a?= =?UTF-8?q?llowed-modes=20list.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/prompts/nl-unity-suite-full.md | 8 +++++++- UnityMcpBridge/Editor/Tools/ManageScript.cs | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 6b42fec9..b5747fb0 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -34,9 +34,12 @@ CI provides: ## Tool Mapping - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` + - Allowed ops: `anchor_insert`, `replace_range`, `regex_replace` (no overlapping ranges within a single call) - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) - **Validation**: `mcp__unity__validate_script(level:"standard")` - **Reporting**: `Write` small XML fragments to `reports/*_results.xml` +- **Editor state/flush**: `mcp__unity__manage_editor` (use sparingly; no project mutations) +- **Console readback**: `mcp__unity__read_console` (INFO capture only; do not assert in place of `validate_script`) - **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` - For `script_apply_edits`: use `name` + workspace‑relative `path` only (e.g., `name="LongUnityScriptClaudeTest"`, `path="Assets/Scripts"`). Do not pass `unity://...` URIs as `path`. - For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI. @@ -164,7 +167,10 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. - T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. - T‑E. Lifecycle — Insert → update → delete via regex; restore. -- T‑F. Atomic batch — One call: two small `replace_range` + one end‑of‑class comment; all‑or‑nothing; restore. +- T‑F. Atomic batch — One `mcp__unity__script_apply_edits` call containing: + - two `replace_range` ops (small interior edits), and + - one `anchor_insert` immediately before the final class brace, + all‑or‑nothing; restore. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. - T‑I. Failure surfaces (expected) — safe‑first order diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 8f2c128b..edc1144b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -220,7 +220,12 @@ public static object HandleCommand(JObject @params) bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); var diags = (diagsRaw ?? Array.Empty()).Select(s => { - var m = Regex.Match(s, @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$"); + var m = Regex.Match( + s, + @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", + RegexOptions.CultureInvariant | RegexOptions.Multiline, + TimeSpan.FromMilliseconds(250) + ); string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; string message = m.Success ? m.Groups[2].Value : s; int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; @@ -456,12 +461,16 @@ private static object ApplyTextEdits( { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); - // Refuse edits if the target is a symlink + // Refuse edits if the target or any ancestor is a symlink try { - var attrs = File.GetAttributes(fullPath); - if ((attrs & FileAttributes.ReparsePoint) != 0) - return Response.Error("Refusing to edit a symlinked script path."); + var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); + while (di != null && !string.Equals(di.FullName.Replace('\\','/'), Application.dataPath.Replace('\\','/'), StringComparison.OrdinalIgnoreCase)) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + di = di.Parent; + } } catch { @@ -1185,7 +1194,7 @@ private static object EditScript( } default: - return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert."); + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); } } From 305360b5c4f8dba897a6698478b21407f7097561 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:28:50 -0700 Subject: [PATCH 267/311] Fixes --- .claude/prompts/nl-unity-suite-full.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index b5747fb0..89e71c4e 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -170,7 +170,7 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑F. Atomic batch — One `mcp__unity__script_apply_edits` call containing: - two `replace_range` ops (small interior edits), and - one `anchor_insert` immediately before the final class brace, - all‑or‑nothing; restore. + all‑or‑nothing; restore. Compute all ranges from the same fresh read and sort ranges descending by start index. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. - T‑I. Failure surfaces (expected) — safe‑first order From 3937ead1ae6d95e5806a0771a672a073ce0be30d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:31:25 -0700 Subject: [PATCH 268/311] ManageScript: add rich overlap diagnostics (conflicts + hint) for both text range and structured batch paths --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index edc1144b..389b4e65 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -616,7 +616,10 @@ private static object ApplyTextEdits( for (int i = 1; i < spans.Count; i++) { if (spans[i].end > spans[i - 1].start) - return Response.Error("overlap", new { status = "overlap" }); + { + var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } } string working = original; @@ -1201,7 +1204,18 @@ private static object EditScript( if (!applySequentially) { if (HasOverlaps(replacements)) + { + var ordered = replacements.OrderByDescending(r => r.start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + if (ordered[i].start + ordered[i].length > ordered[i - 1].start) + { + var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Apply in descending order against the same precondition snapshot." }); + } + } return Response.Error("overlap", new { status = "overlap" }); + } foreach (var r in replacements.OrderByDescending(r => r.start)) working = working.Remove(r.start, r.length).Insert(r.start, r.text); From 3bd60bb2297a816e55f166e3689e6316e6325b8e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:34:16 -0700 Subject: [PATCH 269/311] ManageScript: return structured {status:"validation_failed"} diagnostics in create/update/edits and validate before commit --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 389b4e65..b3bef561 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -294,8 +294,7 @@ string namespaceName bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); - return Response.Error(errorMessage); + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -393,8 +392,7 @@ string contents bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); - return Response.Error(errorMessage); + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -1241,7 +1239,7 @@ private static object EditScript( } catch { /* ignore option parsing issues */ } if (!ValidateScriptSyntax(working, level, out var errors)) - return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); else if (errors != null && errors.Length > 0) Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); From 3f01e7fe89165ae1eb9a6a1afcc4081a64657d38 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 28 Aug 2025 20:36:05 -0700 Subject: [PATCH 270/311] ManageScript: echo canonical uri in responses (create/read/update/apply_text_edits/structured edits) to reinforce resource identity --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index b3bef561..3e329fbd 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -711,11 +711,9 @@ private static object ApplyTextEdits( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { - applied = spans.Count, - unchanged = 0, - sha256 = newSha, uri = $"unity://path/{relativePath}", - scheduledRefresh = !immediate + path = relativePath, + editsApplied = spans.Count } ); } @@ -1272,7 +1270,7 @@ private static object EditScript( var ok = Response.Success( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", - new { path = relativePath, editsApplied = appliedCount, scheduledRefresh = !immediate } + new { path = relativePath, uri = $"unity://path/{relativePath}", editsApplied = appliedCount, scheduledRefresh = !immediate } ); if (immediate) From 5e1acba5299ff7856540b13d94f9d76910b90dd1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 08:03:20 -0700 Subject: [PATCH 271/311] improve clarity of capabilities message --- .claude/prompts/nl-unity-suite-full.md | 10 ++++---- .../src/tools/manage_script.py | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 89e71c4e..a0c4dcb8 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -167,10 +167,12 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. - T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. - T‑E. Lifecycle — Insert → update → delete via regex; restore. -- T‑F. Atomic batch — One `mcp__unity__script_apply_edits` call containing: - - two `replace_range` ops (small interior edits), and - - one `anchor_insert` immediately before the final class brace, - all‑or‑nothing; restore. Compute all ranges from the same fresh read and sort ranges descending by start index. +- T‑F. Atomic batch — One `mcp__unity__apply_text_edits` call (text ranges only) + - Compute all three edits from the **same fresh read**: + 1) Two small interior `replace_range` tweaks. + 2) One **end‑of‑class insertion**: find the **index of the final `}`** for the class; create a zero‑width range `[idx, idx)` and set `replacement` to the 3‑line comment block. + - Send all three ranges in **one call**, sorted **descending by start index** to avoid offset drift. + - Expect all‑or‑nothing semantics; on `{status:"overlap"}` or `{status:"bad_request"}`, write the testcase fragment with ``, **restore**, and continue. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. - T‑H. Validation — `standard` after edits; `basic` only for transient checks. - T‑I. Failure surfaces (expected) — safe‑first order diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 3611e9f8..c4234a16 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -268,3 +268,27 @@ def manage_script( "success": False, "message": f"Python error managing script: {str(e)}", } + + @mcp.tool(description=( + "Get manage_script capabilities (supported ops, limits, and guards).\n\n" + "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" + )) + def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: + try: + # Keep in sync with server/Editor ManageScript implementation + ops = [ + "replace_class","delete_class","replace_method","delete_method", + "insert_method","anchor_insert","anchor_delete","anchor_replace" + ] + text_ops = ["replace_range","regex_replace","prepend","append"] + # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback + max_edit_payload_bytes = 256 * 1024 + guards = {"using_guard": True} + return {"success": True, "data": { + "ops": ops, + "text_ops": text_ops, + "max_edit_payload_bytes": max_edit_payload_bytes, + "guards": guards, + }} + except Exception as e: + return {"success": False, "error": f"capabilities error: {e}"} From 34865d64fe4cf98c32dd63f89d1ef0878c123981 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 08:28:16 -0700 Subject: [PATCH 272/311] Framing: allow zero-length frames on both ends (C# bridge, Python server). Prompt: harden T-F to single text-range apply_text_edits batch (descending order, one snapshot). URI: normalize file:// outside Assets by stripping leading slash. --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 3 ++- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py | 4 ++++ UnityMcpBridge/UnityMcpServer~/src/unity_connection.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 5df17523..c6ead2a4 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -563,7 +563,8 @@ private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(Ne } if (payloadLen == 0UL) { - throw new System.IO.IOException("Zero-length frame is not allowed"); + // Allow zero-length frames (e.g., heartbeats/empty responses) + return string.Empty; } if (payloadLen > int.MaxValue) { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index c4234a16..a31abdcb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -48,6 +48,10 @@ def _split_uri(uri: str) -> tuple[str, str]: assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm + # On POSIX absolute paths outside Assets, drop the leading '/' + # so callers get a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] name = os.path.splitext(os.path.basename(effective_path))[0] directory = os.path.dirname(effective_path) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 75d0f8bc..c7e78549 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -127,7 +127,8 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: header = self._read_exact(sock, 8) payload_len = struct.unpack('>Q', header)[0] if payload_len == 0: - raise ValueError("Invalid framed length: 0") + logger.debug("Received framed response (0 bytes)") + return b"" if payload_len > FRAMED_MAX: raise ValueError(f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) From 2234860eb0f8281175383f988641c77d2c2c0db2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 08:30:05 -0700 Subject: [PATCH 273/311] ManageScript: include new sha256 in success payload for apply_text_edits; harden TryResolveUnderAssets by rejecting symlinked ancestors up to Assets/. --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 3e329fbd..98e1b4b0 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -77,19 +77,25 @@ private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, return false; } - // Best-effort symlink guard: if directory is a reparse point/symlink, reject + // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject try { var di = new DirectoryInfo(full); - if (di.Exists) + while (di != null) { - var attrs = di.Attributes; - if ((attrs & FileAttributes.ReparsePoint) != 0) + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) { fullPathDir = null; relPathSafe = null; return false; } + var atAssets = string.Equals( + di.FullName.Replace('\\','/'), + assets, + StringComparison.OrdinalIgnoreCase + ); + if (atAssets) break; + di = di.Parent; } } catch { /* best effort; proceed */ } @@ -713,7 +719,8 @@ private static object ApplyTextEdits( { uri = $"unity://path/{relativePath}", path = relativePath, - editsApplied = spans.Count + editsApplied = spans.Count, + sha256 = newSha } ); } From f4c19e018c733423e4704bc0c2d37b2baa287cbf Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 08:47:24 -0700 Subject: [PATCH 274/311] remove claudetest dir --- ClaudeTests/longUnityScript-claudeTest.cs | 2039 --------------------- 1 file changed, 2039 deletions(-) delete mode 100644 ClaudeTests/longUnityScript-claudeTest.cs diff --git a/ClaudeTests/longUnityScript-claudeTest.cs b/ClaudeTests/longUnityScript-claudeTest.cs deleted file mode 100644 index c40b5371..00000000 --- a/ClaudeTests/longUnityScript-claudeTest.cs +++ /dev/null @@ -1,2039 +0,0 @@ -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 - -} - - From 95eb2a1a780bb6e61b65e2837fcd0c9590b93813 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 09:06:42 -0700 Subject: [PATCH 275/311] =?UTF-8?q?manage=5Fscript=5Fedits:=20normalize=20?= =?UTF-8?q?method-anchored=20anchor=5Finsert=20to=20insert=5Fmethod=20(map?= =?UTF-8?q?=20text->replacement);=20improves=20CI=20compatibility=20for=20?= =?UTF-8?q?T=E2=80=91A/T=E2=80=91E=20without=20changing=20Editor=20behavio?= =?UTF-8?q?r.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/manage_script_edits.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 186c8315..59cd7063 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -275,6 +275,19 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: if "newText" in e and "text" not in e: e["text"] = e.pop("newText") + # CI compatibility (T‑A/T‑E): + # Accept method-anchored anchor_insert and upgrade to insert_method + # Example incoming shape: + # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} + if ( + e.get("op") == "anchor_insert" + and not e.get("anchor") + and (e.get("afterMethodName") or e.get("beforeMethodName")) + ): + e["op"] = "insert_method" + if "replacement" not in e: + e["replacement"] = e.get("text", "") + # LSP-like range edit -> replace_range if "range" in e and isinstance(e["range"], dict): rng = e.pop("range") From 7696e3d19ee3ebf5593450d23dc8abd29f810f5d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 09:18:44 -0700 Subject: [PATCH 276/311] tighten testing protocol around mkdir --- .claude/prompts/nl-unity-suite-full.md | 1 + .github/workflows/claude-nl-suite.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index a0c4dcb8..6a7bc6ab 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -44,6 +44,7 @@ CI provides: - For `script_apply_edits`: use `name` + workspace‑relative `path` only (e.g., `name="LongUnityScriptClaudeTest"`, `path="Assets/Scripts"`). Do not pass `unity://...` URIs as `path`. - For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI. - Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore. + - If you believe a directory is missing, you are mistaken: the workflow pre-creates it and the snapshot helper creates it if needed. Do not attempt any Bash other than scripts/nlt-revert.sh:*. ### Structured edit ops (required usage) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 52cc78d8..2653a5e4 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -266,7 +266,7 @@ jobs: mcp_config: .claude/mcp.json allowed_tools: >- Write, - Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*), + Bash(scripts/nlt-revert.sh:*), mcp__unity__manage_editor, mcp__unity__list_resources, mcp__unity__read_resource, From ca429f48d2dca06b7639e793cf5a6f433da46ffc Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 09:29:16 -0700 Subject: [PATCH 277/311] manage_script: validate create_script inputs (Assets/.cs/name/no traversal); add Assets/ guard to delete_script; validate level+Assets in validate_script; make legacy manage_script optional params; harden legacy update routing with base64 reuse and payload size preflight. --- .../src/tools/manage_script.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index a31abdcb..43aced7c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -101,6 +101,16 @@ def create_script( """Create a new C# script at the given path.""" name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) + # Local validation to avoid round-trips on obviously bad input + norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/") + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} + if ".." in norm_path.split("/") or norm_path.startswith("/"): + return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} + if not name: + return {"success": False, "code": "bad_path", "message": "path must include a script file name."} + if not norm_path.lower().endswith(".cs"): + return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} params: Dict[str, Any] = { "action": "create", "name": name, @@ -123,6 +133,8 @@ def create_script( def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: """Delete a C# script by URI.""" name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} params = {"action": "delete", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @@ -138,6 +150,10 @@ def validate_script( ) -> Dict[str, Any]: """Validate a C# script and return diagnostics.""" name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + if level not in ("basic", "standard"): + return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} params = { "action": "validate", "name": name, @@ -158,9 +174,9 @@ def manage_script( action: str, name: str, path: str, - contents: str, - script_type: str, - namespace: str, + contents: str = "", + script_type: str | None = None, + namespace: str | None = None, ) -> Dict[str, Any]: """Compatibility router for legacy script operations. @@ -194,8 +210,7 @@ def manage_script( data = read_resp.get("data", {}) current = data.get("contents") if not current and data.get("contentsEncoded"): - import base64 as _b64 - current = _b64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") if current is None: return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} @@ -221,6 +236,14 @@ def manage_script( "precondition_sha256": sha, "options": {"refresh": "immediate", "validate": "standard"}, } + # Preflight size vs. default cap (256 KiB) to avoid opaque server errors + try: + import json as _json + payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8")) + if payload_bytes > 256 * 1024: + return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."} + except Exception: + pass routed = send_command_with_retry("manage_script", route_params) if isinstance(routed, dict): routed.setdefault("message", "Routed legacy update to apply_text_edits") From 28712e60a797e8c6bca3ab97f76720b20f4b94c8 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 13:02:41 -0700 Subject: [PATCH 278/311] Tighten prompt for testing --- .claude/prompts/nl-unity-suite-full.md | 2 ++ .github/workflows/claude-nl-suite.yml | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 6a7bc6ab..390f4609 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -152,9 +152,11 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### Execution Order (fixed) - Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). +- Always run: Bash(scripts/nlt-revert.sh:restore "" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline") BEFORE starting each test (NL-0..NL-4 and each T-*). Do not proceed if restore fails. - NL‑0 must include the PLAN line (len=15). - After each testcase, include `PROGRESS: /15 completed`. + ### Test Specs (concise) - NL‑0. Sanity reads — Tail ~120; ±40 around `Update()`. Then snapshot via helper. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2653a5e4..23bee7ce 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -254,7 +254,18 @@ jobs: esac BASH chmod +x scripts/nlt-revert.sh + + # ---------- Snapshot baseline (pre-agent) ---------- + - name: Snapshot baseline (pre-agent) + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + shell: bash + run: | + set -euo pipefail + TARGET="TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" + SNAP="reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline" + scripts/nlt-revert.sh snapshot "$TARGET" "$SNAP" + # ---------- Run suite ---------- - name: Run Claude NL suite (single pass) uses: anthropics/claude-code-base-action@beta From 19e39e74cb7cd610fcd7a118747a5b1de8c5ada6 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:06:32 -0700 Subject: [PATCH 279/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 390f4609..18575c67 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -52,9 +52,8 @@ CI provides: 1) Prefer `script_apply_edits` with a regex capture on the final closing brace: ```json {"op":"regex_replace", - "pattern":"(?s)(\\n\\s*\\})\\s*$", + "pattern":"(?s)(\\r?\\n\\s*\\})\\s*$", "replacement":"\\n // Tail test A\\n // Tail test B\\n // Tail test C\\1"} -``` 2) If the server returns `unsupported` (op not available) or `missing_field` (op‑specific), FALL BACK to `apply_text_edits`: From fa45ea753b8e0628599ebb2b94cfd0e198012d01 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:07:06 -0700 Subject: [PATCH 280/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 18575c67..97332777 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -88,7 +88,8 @@ CI provides: ## Output Rules (JUnit fragments only) - For each test, create **one** file: `reports/_results.xml` containing exactly a single ` ... `. -- Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** ``. + Put human-readable lines (PLAN/PROGRESS/evidence) **inside** ``. + - If content contains `]]>`, split CDATA: replace `]]>` with `]]]]>`. - Evidence windows only (±20–40 lines). If showing a unified diff, cap at 100 lines and note truncation. - **Never** open/patch `$JUNIT_OUT` or `$MD_OUT`; CI merges fragments and synthesizes Markdown. - Write destinations must match: `^reports/[A-Za-z0-9._-]+_results\.xml$` From 60ac88fcb456ed79e0b404385d65523ff91fcacc Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:08:23 -0700 Subject: [PATCH 281/311] Update UnityMcpBridge/Editor/Tools/ManageScript.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 33 +++++++-------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 98e1b4b0..6ae89f9b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1249,35 +1249,22 @@ private static object EditScript( Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); // Atomic write with backup; schedule refresh - var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - var tmp = fullPath + ".tmp"; - File.WriteAllText(tmp, working, enc); - string backup = fullPath + ".bak"; - try - { - File.Replace(tmp, fullPath, backup); - try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } - } - catch (PlatformNotSupportedException) - { - File.Copy(tmp, fullPath, true); - try { File.Delete(tmp); } catch { } - try { if (File.Exists(backup)) File.Delete(backup); } catch { } - } - catch (IOException) - { - File.Copy(tmp, fullPath, true); - try { File.Delete(tmp); } catch { } - try { if (File.Exists(backup)) File.Delete(backup); } catch { } - } - // Decide refresh behavior string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + // Compute and return the new file SHA for precondition chaining + var newSha = ComputeSha256(working); var ok = Response.Success( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", - new { path = relativePath, uri = $"unity://path/{relativePath}", editsApplied = appliedCount, scheduledRefresh = !immediate } + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = appliedCount, + scheduledRefresh = !immediate, + sha256 = newSha + } ); if (immediate) From 3572c4c807bd27ab9ca0bc1fe68e0084c8134921 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:10:39 -0700 Subject: [PATCH 282/311] manage_script_edits: honor ignore_case on anchor_insert and regex_replace in both direct and text-conversion paths (MULTILINE|IGNORECASE). --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 59cd7063..5e5b0e79 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -38,7 +38,7 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str anchor = edit.get("anchor", "") position = (edit.get("position") or "before").lower() insert_text = edit.get("text", "") - flags = re.MULTILINE + flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0) m = re.search(anchor, text, flags) if not m: if edit.get("allow_noop", True): @@ -464,7 +464,8 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if opx == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "before").lower() - m = _re.search(anchor, current_text, _re.MULTILINE) + flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + m = _re.search(anchor, current_text, flags) 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() @@ -598,7 +599,8 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: elif op == "regex_replace": pattern = e.get("pattern") or "" repl = text_field - m = _re.search(pattern, current_text, _re.MULTILINE) + flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + m = _re.search(pattern, current_text, flags) if not m: continue sl, sc = line_col_from_index(m.start()) From a3790e3dea233972824c4c6ebf8d9b8a84b99c77 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:13:48 -0700 Subject: [PATCH 283/311] remove extra file --- claude-chunk.md | 51 ------------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 claude-chunk.md diff --git a/claude-chunk.md b/claude-chunk.md deleted file mode 100644 index 964038c6..00000000 --- a/claude-chunk.md +++ /dev/null @@ -1,51 +0,0 @@ -### macOS: Claude CLI fails to start (dyld ICU library not loaded) - -- Symptoms - - MCP for Unity error: “Failed to start Claude CLI. dyld: Library not loaded: /usr/local/opt/icu4c/lib/libicui18n.71.dylib …” - - Running `claude` in Terminal fails with missing `libicui18n.xx.dylib`. - -- Cause - - Homebrew Node (or the `claude` binary) was linked against an ICU version that’s no longer installed; dyld can’t find that dylib. - -- Fix options (pick one) - - Reinstall Homebrew Node (relinks to current ICU), then reinstall CLI: - ```bash - brew update - brew reinstall node - npm uninstall -g @anthropic-ai/claude-code - npm install -g @anthropic-ai/claude-code - ``` - - Use NVM Node (avoids Homebrew ICU churn): - ```bash - nvm install --lts - nvm use --lts - npm install -g @anthropic-ai/claude-code - # MCP for Unity → Claude Code → Choose Claude Location → ~/.nvm/versions/node//bin/claude - ``` - - Use the native installer (puts claude in a stable path): - ```bash - # macOS/Linux - curl -fsSL https://claude.ai/install.sh | bash - # MCP for Unity → Claude Code → Choose Claude Location → /opt/homebrew/bin/claude or ~/.local/bin/claude - ``` - -- After fixing - - In MCP for Unity (Claude Code), click “Choose Claude Location” and select the working `claude` binary, then Register again. - -- More details - - See: Troubleshooting MCP for Unity and Claude Code - ---- - -### FAQ (Claude Code) - -- Q: Unity can’t find `claude` even though Terminal can. - - A: macOS apps launched from Finder/Hub don’t inherit your shell PATH. In the MCP for Unity window, click “Choose Claude Location” and select the absolute path (e.g., `/opt/homebrew/bin/claude` or `~/.nvm/versions/node//bin/claude`). - -- Q: I installed via NVM; where is `claude`? - - A: Typically `~/.nvm/versions/node//bin/claude`. Our UI also scans NVM versions and you can browse to it via “Choose Claude Location”. - -- Q: The Register button says “Claude Not Found”. - - A: Install the CLI or set the path. Click the orange “[HELP]” link in the MCP for Unity window for step‑by‑step install instructions, then choose the binary location. - - From 30e0e82baa6efa4a21c169426edbd4c7a25c774d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:17:36 -0700 Subject: [PATCH 284/311] workflow: use python3 for inline scripts and port detection on ubuntu-latest. --- .github/workflows/claude-nl-suite.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 23bee7ce..9812a557 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -157,7 +157,7 @@ jobs: if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then READY=1; echo "Bridge ready (log markers)"; break fi - PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) + PORT=$(python3 -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then READY=1; echo "Bridge ready on port $PORT"; break fi @@ -466,7 +466,7 @@ jobs: if: always() run: | set -eu - python - <<'PY' + python3 - <<'PY' from pathlib import Path rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) for p in rp.glob('*.md'): @@ -479,7 +479,7 @@ jobs: if: always() run: | echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY - python - <<'PY' >> $GITHUB_STEP_SUMMARY + python3 - <<'PY' >> $GITHUB_STEP_SUMMARY from pathlib import Path p = Path('reports/junit-nl-suite.md') if p.exists(): From 6f9faec10071af5786e27a734b6fc9b689f74a1a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:21:01 -0700 Subject: [PATCH 285/311] Tighten prompt + manage_script --- .claude/prompts/nl-unity-suite-full.md | 47 ++++++++++++++----- .../src/tools/manage_script.py | 17 ++++--- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 97332777..81e5f210 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -165,25 +165,46 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - NL‑3. End‑of‑class — Insert `// Tail test A/B/C` (3 lines) before final brace; restore. - NL‑4. Compile trigger — Record INFO only. -- T‑A. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore. -- T‑B. Replace body — Single `replace_range` inside `HasTarget`; restore. -- T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. -- T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. -- T‑E. Lifecycle — Insert → update → delete via regex; restore. -- T‑F. Atomic batch — One `mcp__unity__apply_text_edits` call (text ranges only) +### T‑A. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore. +### T‑B. Replace body — Single `replace_range` inside `HasTarget`; restore. +### T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. +### T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. +### T‑E. Lifecycle — Insert → update → delete via regex; restore. +### T‑F. Atomic batch — One `mcp__unity__apply_text_edits` call (text ranges only) - Compute all three edits from the **same fresh read**: 1) Two small interior `replace_range` tweaks. 2) One **end‑of‑class insertion**: find the **index of the final `}`** for the class; create a zero‑width range `[idx, idx)` and set `replacement` to the 3‑line comment block. - Send all three ranges in **one call**, sorted **descending by start index** to avoid offset drift. - Expect all‑or‑nothing semantics; on `{status:"overlap"}` or `{status:"bad_request"}`, write the testcase fragment with ``, **restore**, and continue. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. -- T‑H. Validation — `standard` after edits; `basic` only for transient checks. -- T‑I. Failure surfaces (expected) — safe‑first order - - 1) Overlap (deterministic): use `apply_text_edits` with two character ranges from the SAME fresh snapshot of the file. Within `HasTarget` body, e.g., pick r1=[start+2,start+10) and r2=[start+8,start+14). Send both in ONE call; expect `{status:"overlap"}`. Do not use regex/anchors for this probe. - - 2) Stale file: re‑use a deliberately old `precondition_sha256` on a small no‑op tweak; expect `{status:"stale_file"}` (then restore hash). - - 3) Using guard (optional, only within T‑I): you may touch the header (e.g., insert a newline above the first `using`) to elicit `{status:"using_guard"}`; restore immediately. - - 4) Too large (optional, last): only run if an env hint is present (e.g., `RUN_TOO_LARGE=1`). Keep overage small (+16–32 KB). If a transport error/timeout occurs instead of JSON, still write the testcase fragment with an `` and proceed. -- T‑J. Idempotency — Repeat `replace_range` and then repeat delete; observe and record behavior. Regex/range operations are not strictly idempotent (no special status is emitted). + +### T-H. Validation (standard) +- Restore baseline (helper call above). +- Perform a harmless interior tweak (or none), then MUST call: + mcp__unity__validate_script(level:"standard") +- Write the validator output to system-out; VERDICT: PASS if standard is clean, else include with the validator message and continue. + +### T-I. Failure surfaces (expected) +- Restore baseline. +- (1) OVERLAP: + * Fresh read of file; compute two interior ranges that overlap inside HasTarget. + * Single mcp__unity__apply_text_edits call with both ranges. + * Expect {status:"overlap"} → record as PASS; else FAIL. Restore. +- (2) STALE_FILE: + * Fresh read → pre_sha. + * Make a tiny legit edit with pre_sha; success. + * Attempt another edit reusing the OLD pre_sha. + * Expect {status:"stale_file"} → record as PASS; else FAIL. Re-read to refresh, restore. +- (3) USING_GUARD (optional): + * Attempt a 1-line insert above the first 'using'. + * Expect {status:"using_guard"} → record as PASS; else note 'not emitted'. Restore. + +### T-J. Idempotency +- Restore baseline. +- Repeat a replace_range twice (second call may be noop). Validate standard after each. +- Insert or ensure a tiny comment, then delete it twice (second delete may be noop). +- Restore and PASS unless an error/structural break occurred. + ### Status & Reporting diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 43aced7c..bcb2f606 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -25,11 +25,13 @@ def _split_uri(uri: str) -> tuple[str, str]: raw_path = uri[len("unity://path/") :] elif uri.startswith("file://"): parsed = urlparse(uri) - # Use parsed.path (percent-encoded) and decode it - raw_path = unquote(parsed.path or "") - # Handle cases like file://localhost/... - if not raw_path and uri.startswith("file://"): - raw_path = uri[len("file://") :] + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) else: raw_path = uri @@ -48,10 +50,7 @@ def _split_uri(uri: str) -> tuple[str, str]: assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm - # On POSIX absolute paths outside Assets, drop the leading '/' - # so callers get a clean relative-like directory (e.g., '/tmp' -> 'tmp'). - if effective_path.startswith("/"): - effective_path = effective_path[1:] + # Keep POSIX absolute paths as-is; allow guards to enforce Assets/ scope later. name = os.path.splitext(os.path.basename(effective_path))[0] directory = os.path.dirname(effective_path) From 45623b29ef21e84541636523b55104a0851f3f0a Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:22:07 -0700 Subject: [PATCH 286/311] Update UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/tools/manage_script_edits.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 5e5b0e79..31297c56 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -48,18 +48,22 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str text = text[:idx] + insert_text + text[idx:] elif op == "replace_range": start_line = int(edit.get("startLine", 1)) - end_line = int(edit.get("endLine", start_line)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) replacement = edit.get("text", "") lines = text.splitlines(keepends=True) - max_end = len(lines) + 1 - if start_line < 1 or end_line < start_line or end_line > max_end: + max_line = len(lines) + 1 # 1-based, exclusive end + if (start_line < 1 or end_line < start_line or end_line > max_line + or start_col < 1 or end_col < 1): raise RuntimeError("replace_range out of bounds") - a = start_line - 1 - b = min(end_line, len(lines)) - rep = replacement - if rep and not rep.endswith("\n"): - rep += "\n" - text = "".join(lines[:a]) + rep + "".join(lines[b:]) + def index_of(line: int, col: int) -> int: + if line <= len(lines): + return sum(len(l) for l in lines[: line - 1]) + (col - 1) + return sum(len(l) for l in lines) + a = index_of(start_line, start_col) + b = index_of(end_line, end_col) + text = text[:a] + replacement + text[b:] elif op == "regex_replace": pattern = edit.get("pattern", "") repl = edit.get("replacement", "") From 23259266ae662eb085c220272575b8a6a94364e3 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:22:42 -0700 Subject: [PATCH 287/311] Update UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 31297c56..e002c425 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -636,11 +636,16 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: "precondition_sha256": sha, "options": { "refresh": "immediate", - "validate": (options or {}).get("validate", "standard") + "validate": (options or {}).get("validate", "standard"), + "applyMode": "sequential" } } resp = send_command_with_retry("manage_script", params) - return _with_norm(resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, routing="text") + return _with_norm( + resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, + normalized_for_echo, + routing="text" + ) except Exception as e: return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") From b43436650f881a3f960371fdb3a0959e477d72a2 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:24:52 -0700 Subject: [PATCH 288/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 81e5f210..70005259 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -79,9 +79,9 @@ CI provides: # T‑B (replace method body) - Use `mcp__unity__apply_text_edits` with a single `replace_range` strictly inside the `HasTarget` braces. - Compute start/end from a fresh `read_resource` at test start. Do not edit signature or header. -- On `{status:"stale_file"}` retry once with the server‑provided hash; if absent, re‑read once and retry. -- On `missing_field` or `bad_request`, write the testcase with ``, restore, and continue to next test. - +- On `{status:"stale_file"}` retry once with the server-provided hash; if absent, re-read once and retry. +- On `bad_request`: write the testcase with ``, restore, and continue to next test. +- On `missing_field`: FALL BACK per above; if the fallback also returns `unsupported` or `bad_request`, then fail as above. > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. --- From 2fb1b96513f55ee34c131d9072b8ac5fe548fbc3 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:29:14 -0700 Subject: [PATCH 289/311] Update UnityMcpBridge/Editor/Tools/ManageScript.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 6ae89f9b..b4eda811 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1253,7 +1253,29 @@ private static object EditScript( string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); bool immediate = refreshMode == "immediate" || refreshMode == "sync"; - // Compute and return the new file SHA for precondition chaining + // Persist changes atomically (no BOM), then compute/return new file SHA + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + var backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + var newSha = ComputeSha256(working); var ok = Response.Success( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", @@ -1286,7 +1308,6 @@ private static object EditScript( ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } return ok; - } catch (Exception ex) { return Response.Error($"Edit failed: {ex.Message}"); From bf2b3b30d48c0ab44ffa24242d9261d7841ca536 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 29 Aug 2025 14:30:51 -0700 Subject: [PATCH 290/311] Update .claude/prompts/nl-unity-suite-full.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .claude/prompts/nl-unity-suite-full.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 70005259..f2eedb64 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -152,8 +152,8 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### Execution Order (fixed) - Run exactly: NL-0, NL-1, NL-2, NL-3, NL-4, T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J (15 total). -- Always run: Bash(scripts/nlt-revert.sh:restore "" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline") BEFORE starting each test (NL-0..NL-4 and each T-*). Do not proceed if restore fails. -- NL‑0 must include the PLAN line (len=15). +- Before NL-1..T-J: Bash(scripts/nlt-revert.sh:restore "" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline") IF the baseline exists; skip for NL-0. +- NL-0 must include the PLAN line (len=15). - After each testcase, include `PROGRESS: /15 completed`. From 42e115c87d2b8de61f4cbbb5603dd45a29eb673f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:27:31 -0700 Subject: [PATCH 291/311] manage_script: improve file:// UNC handling; preserve POSIX absolute semantics internally; keep test-expected slash stripping for non-Assets paths. --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index bcb2f606..0f68c8dd 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -50,7 +50,10 @@ def _split_uri(uri: str) -> tuple[str, str]: assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm - # Keep POSIX absolute paths as-is; allow guards to enforce Assets/ scope later. + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] name = os.path.splitext(os.path.basename(effective_path))[0] directory = os.path.dirname(effective_path) From 5f5be64206192517f09fb63c980db9efa297984f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:31:13 -0700 Subject: [PATCH 292/311] ManageScript.cs: add TimeSpan timeouts to all Regex uses (IsMatch/Match/new Regex) and keep CultureInvariant/Multiline options; reduces risk of catastrophic backtracking stalls. --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index b4eda811..95da33ed 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -147,7 +147,7 @@ public static object HandleCommand(JObject @params) return Response.Error("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) - if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) { return Response.Error( $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." @@ -531,7 +531,8 @@ private static object ApplyTextEdits( var mUsing = System.Text.RegularExpressions.Regex.Match( original, @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", - System.Text.RegularExpressions.RegexOptions.CultureInvariant + System.Text.RegularExpressions.RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(2) ); if (mUsing.Success) { @@ -1119,7 +1120,7 @@ private static object EditScript( try { - var rx = new Regex(anchor, RegexOptions.Multiline); + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); 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; @@ -1147,7 +1148,7 @@ private static object EditScript( if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); try { - var rx = new Regex(anchor, RegexOptions.Multiline); + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); int delAt = m.Index; @@ -1176,7 +1177,7 @@ private static object EditScript( if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); try { - var rx = new Regex(anchor, RegexOptions.Multiline); + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); int at = m.Index; @@ -1486,7 +1487,7 @@ private static bool TryComputeMethodSpan( rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; string slice = source.Substring(searchStart, searchEnd - searchStart); - var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); if (!headerMatch.Success) { why = $"method '{methodName}' header not found in class"; return false; @@ -2294,7 +2295,7 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge } // Check for magic numbers - var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])"); + var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); var matches = magicNumberPattern.Matches(contents); if (matches.Count > 5) { @@ -2302,7 +2303,7 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge } // Check for long methods (simple line count check) - var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{"); + var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); var methodMatches = methodPattern.Matches(contents); foreach (Match match in methodMatches) { From 1420640c1bb6fcab27d2bdbe67bf5508577eab06 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 14:38:44 -0700 Subject: [PATCH 293/311] workflow: ensure reports/ exists in markdown build step to avoid FileNotFoundError when writing MD_OUT. --- .github/workflows/claude-nl-suite.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9812a557..f58ad10d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -355,6 +355,8 @@ jobs: src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + # Ensure destination directory exists even if earlier prep steps were skipped + md_out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') From e2e26f7f85ed0d0c28b2006cee2b7bc65fbc78d0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 15:32:22 -0700 Subject: [PATCH 294/311] fix brace --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 95da33ed..1c86fdf7 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1309,6 +1309,7 @@ private static object EditScript( ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } return ok; + } catch (Exception ex) { return Response.Error($"Edit failed: {ex.Message}"); From f242fea70f8f40e4104fbdce0920565a811ba020 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 15:57:09 -0700 Subject: [PATCH 295/311] manage_script_edits: expand backrefs for regex_replace in preview->text conversion and translate to \g in local apply; keeps previews and actual edits consistent. --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index e002c425..eb1a3ab7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -67,11 +67,13 @@ def index_of(line: int, col: int) -> int: elif op == "regex_replace": pattern = edit.get("pattern", "") repl = edit.get("replacement", "") + # Translate $n backrefs (our input) to Python \g + repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) count = int(edit.get("count", 0)) # 0 = replace all flags = re.MULTILINE if edit.get("ignore_case"): flags |= re.IGNORECASE - text = re.sub(pattern, repl, text, count=count, flags=flags) + text = re.sub(pattern, repl_py, text, count=count, flags=flags) else: allowed = "anchor_insert, prepend, append, replace_range, regex_replace" raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") @@ -492,10 +494,14 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: m = _re.search(pattern, current_text, _re.MULTILINE) if not m: continue + # Expand $1, $2... in replacement using this match + def _expand_dollars(rep: str) -> str: + return _re.sub(r"\$(\d+)", lambda g: m.group(int(g.group(1))) or "", rep) + repl = _expand_dollars(text_field) 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():] + at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + current_text = current_text[:m.start()] + repl + current_text[m.end():] elif opx in ("prepend","append"): if opx == "prepend": sl, sc = 1, 1 From 81a85cde9b3621bf5d345a0dbe1a24b21f258186 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 16:13:52 -0700 Subject: [PATCH 296/311] anchor_insert: default to position=after, normalize surrounding newlines in Python conversion paths; C# path ensures trailing newline and skips duplicate insertion within class. --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 15 +++++++++++++++ .../src/tools/manage_script_edits.py | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 1c86fdf7..cd1c4711 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1125,6 +1125,21 @@ private static object EditScript( 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 (!norm.EndsWith("\n")) + { + norm += "\n"; + } + + // Duplicate guard: if identical snippet already exists within this class, skip insert + if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + { + string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); + if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + { + // Do not insert duplicate; treat as no-op + break; + } + } if (applySequentially) { working = working.Insert(insAt, norm); diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index eb1a3ab7..01cb52a8 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -469,15 +469,21 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 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() + position = (e.get("position") or "after").lower() flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) m = _re.search(anchor, current_text, flags) 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() + # Normalize insertion to avoid jammed methods + text_field_norm = text_field + if not text_field_norm.startswith("\n"): + text_field_norm = "\n" + text_field_norm + if not text_field_norm.endswith("\n"): + text_field_norm = text_field_norm + "\n" 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:] + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + current_text = current_text[:idx] + text_field_norm + current_text[idx:] elif opx == "replace_range": if all(k in e for k in ("startLine","startCol","endLine","endCol")): at_edits.append({ @@ -578,11 +584,16 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 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() + position = (e.get("position") or "after").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="text") idx = m.start() if position == "before" else m.end() + # Normalize insertion newlines + if text_field and not text_field.startswith("\n"): + text_field = "\n" + text_field + if text_field and not text_field.endswith("\n"): + text_field = text_field + "\n" sl, sc = line_col_from_index(idx) at_edits.append({ "startLine": sl, From de8e731c4caf932e69d96e5d0d14951db3306784 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 18:07:40 -0700 Subject: [PATCH 297/311] feat(mcp): add get_sha tool; apply_text_edits normalization+overlap preflight+strict; no-op evidence in C#; update NL suite prompt; add unit tests --- .claude/prompts/nl-unity-suite-full.md | 13 +- .github/workflows/claude-nl-suite.yml | 3 +- UnityMcpBridge/Editor/Tools/ManageScript.cs | 64 ++++++ .../src/tools/manage_script.py | 202 +++++++++++++++++- .../src/tools/manage_script_edits.py | 16 +- tests/test_edit_normalization_and_noop.py | 90 ++++++++ tests/test_edit_strict_and_warnings.py | 84 ++++++++ tests/test_get_sha.py | 74 +++++++ 8 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 tests/test_edit_normalization_and_noop.py create mode 100644 tests/test_edit_strict_and_warnings.py create mode 100644 tests/test_get_sha.py diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index f2eedb64..73654a1f 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -3,7 +3,7 @@ You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. **Print this once, verbatim, early in the run:** -AllowedTools: Write,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console +AllowedTools: Write,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha --- @@ -36,6 +36,7 @@ CI provides: - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` - Allowed ops: `anchor_insert`, `replace_range`, `regex_replace` (no overlapping ranges within a single call) - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) +- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body - **Validation**: `mcp__unity__validate_script(level:"standard")` - **Reporting**: `Write` small XML fragments to `reports/*_results.xml` - **Editor state/flush**: `mcp__unity__manage_editor` (use sparingly; no project mutations) @@ -84,6 +85,12 @@ CI provides: - On `missing_field`: FALL BACK per above; if the fallback also returns `unsupported` or `bad_request`, then fail as above. > Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. +Span formats for `apply_text_edits`: +- Prefer LSP ranges (0‑based): `{ "range": { "start": {"line": L, "character": C}, "end": {…} }, "newText": "…" }` +- Explicit fields are 1‑based: `{ "startLine": L1, "startCol": C1, "endLine": L2, "endCol": C2, "newText": "…" }` +- SDK preflights overlap after normalization; overlapping non‑zero spans → `{status:"overlap"}` with conflicts and no file mutation. +- Optional debug: pass `strict:true` to reject explicit 0‑based fields (else they are normalized and a warning is emitted). + --- ## Output Rules (JUnit fragments only) @@ -135,6 +142,7 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - Before any mutation: `res = mcp__unity__read_resource(uri)`; `pre_sha = sha256(res.bytes)`. - Write with `precondition_sha256 = pre_sha` on `apply_text_edits`/`script_apply_edits`. +- To compute `pre_sha` without reading file contents, you may instead call `mcp__unity__get_sha(uri).sha256`. - On `{status:"stale_file"}`: - Retry once using the server-provided hash (e.g., `data.current_sha256` or `data.expected_sha256`, per API schema). - If absent, one re-read then a final retry. No loops. @@ -188,8 +196,9 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). - Restore baseline. - (1) OVERLAP: * Fresh read of file; compute two interior ranges that overlap inside HasTarget. + * Prefer LSP ranges (0‑based) or explicit 1‑based fields; ensure both spans come from the same snapshot. * Single mcp__unity__apply_text_edits call with both ranges. - * Expect {status:"overlap"} → record as PASS; else FAIL. Restore. + * Expect `{status:"overlap"}` (SDK preflight) → record as PASS; else FAIL. Restore. - (2) STALE_FILE: * Fresh read → pre_sha. * Make a tiny legit edit with pre_sha; success. diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index f58ad10d..8fc8603e 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -285,7 +285,8 @@ jobs: mcp__unity__script_apply_edits, mcp__unity__validate_script, mcp__unity__find_in_file, - mcp__unity__read_console + mcp__unity__read_console, + mcp__unity__get_sha disallowed_tools: TodoWrite,Task model: claude-3-7-sonnet-latest timeout_minutes: "30" diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index cd1c4711..8ce4ab50 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -247,6 +247,34 @@ public static object HandleCommand(JObject @params) var structEdits = @params["edits"] as JArray; var options = @params["options"] as JObject; return EditScript(fullPath, relativePath, name, structEdits, options); + case "get_sha": + { + try + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + + string text = File.ReadAllText(fullPath); + string sha = ComputeSha256(text); + var fi = new FileInfo(fullPath); + long lengthBytes; + try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } + catch { lengthBytes = fi.Exists ? fi.Length : 0; } + var data = new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + sha256 = sha, + lengthBytes, + lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty + }; + return Response.Success($"SHA computed for '{relativePath}'.", data); + } + catch (Exception ex) + { + return Response.Error($"Failed to compute SHA: {ex.Message}"); + } + } default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." @@ -633,6 +661,24 @@ private static object ApplyTextEdits( working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); } + // No-op guard: if resulting text is identical, avoid writes and return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + string noChangeSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = 0, + no_op = true, + sha256 = noChangeSha, + evidence = new { reason = "identical_content" } + } + ); + } + if (!CheckBalancedDelimiters(working, out int line, out char expected)) { int startLine = Math.Max(1, line - 5); @@ -1241,6 +1287,24 @@ private static object EditScript( appliedCount = replacements.Count; } + // No-op guard for structured edits: if text unchanged, return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + var sameSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = 0, + no_op = true, + sha256 = sameSha, + evidence = new { reason = "identical_content" } + } + ); + } + // Validate result using override from options if provided; otherwise GUI strictness var level = GetValidationLevelFromGUI(); try diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 0f68c8dd..2d8ca79c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -74,19 +74,200 @@ def apply_text_edits( uri: str, edits: List[Dict[str, Any]], precondition_sha256: str | None = None, + strict: bool | None = None, ) -> Dict[str, Any]: """Apply small text edits to a C# script identified by URI.""" name, directory = _split_uri(uri) + + # Normalize common aliases/misuses for resilience: + # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} + # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} + # If normalization is required, read current contents to map indices -> 1-based line/col. + def _needs_normalization(arr: List[Dict[str, Any]]) -> bool: + for e in arr or []: + if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): + return True + return False + + normalized_edits: List[Dict[str, Any]] = [] + warnings: List[str] = [] + if _needs_normalization(edits): + # Read file to support index->line/col conversion when needed + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": directory, + }) + if not (isinstance(read_resp, dict) and read_resp.get("success")): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + except Exception: + contents = contents or "" + + # Helper to map 0-based character index to 1-based line/col + def line_col_from_index(idx: int) -> tuple[int, int]: + if idx <= 0: + return 1, 1 + # Count lines up to idx and position within line + nl_count = contents.count("\n", 0, idx) + line = nl_count + 1 + last_nl = contents.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + for e in edits or []: + e2 = dict(e) + # Map text->newText if needed + if "newText" not in e2 and "text" in e2: + e2["newText"] = e2.pop("text") + + if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: + # Guard: explicit fields must be 1-based. + zero_based = False + for k in ("startLine","startCol","endLine","endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} + # Normalize by clamping to 1 and warn + for k in ("startLine","startCol","endLine","endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + warnings.append("zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + continue + + rng = e2.get("range") + if isinstance(rng, dict): + # LSP style: 0-based + s = rng.get("start", {}) + t = rng.get("end", {}) + e2["startLine"] = int(s.get("line", 0)) + 1 + e2["startCol"] = int(s.get("character", 0)) + 1 + e2["endLine"] = int(t.get("line", 0)) + 1 + e2["endCol"] = int(t.get("character", 0)) + 1 + e2.pop("range", None) + normalized_edits.append(e2) + continue + if isinstance(rng, (list, tuple)) and len(rng) == 2: + try: + a = int(rng[0]) + b = int(rng[1]) + if b < a: + a, b = b, a + sl, sc = line_col_from_index(a) + el, ec = line_col_from_index(b) + e2["startLine"] = sl + e2["startCol"] = sc + e2["endLine"] = el + e2["endCol"] = ec + e2.pop("range", None) + normalized_edits.append(e2) + continue + except Exception: + pass + # Could not normalize this edit + return { + "success": False, + "code": "missing_field", + "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", + "data": {"expected": ["startLine","startCol","endLine","endCol","newText"], "got": e} + } + else: + # Even when edits appear already in explicit form, validate 1-based coordinates. + normalized_edits = [] + for e in edits or []: + e2 = dict(e) + has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol")) + if has_all: + zero_based = False + for k in ("startLine","startCol","endLine","endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} + for k in ("startLine","startCol","endLine","endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + if "zero_based_explicit_fields_normalized" not in warnings: + warnings.append("zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + + # Preflight: detect overlapping ranges among normalized line/col spans + def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]: + return ( + int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)), + ) + + def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: + return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) + + # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. + spans = [] + for e in normalized_edits or []: + try: + s = _pos_tuple(e, True) + t = _pos_tuple(e, False) + if s != t: + spans.append((s, t)) + except Exception: + # If coordinates missing or invalid, let the server validate later + pass + + if spans: + spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) + for i in range(1, len(spans_sorted)): + prev_end = spans_sorted[i-1][1] + curr_start = spans_sorted[i][0] + # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start + if not _le(prev_end, curr_start): + conflicts = [{ + "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, + "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, + "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, + "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, + }] + return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} + + # Note: Do not auto-compute precondition if missing; callers should supply it + # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and + # preserves existing call-count expectations in clients/tests. + params = { "action": "apply_text_edits", "name": name, "path": directory, - "edits": edits, + "edits": normalized_edits, "precondition_sha256": precondition_sha256, } params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + if isinstance(resp, dict): + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", normalized_edits) + if warnings: + data.setdefault("warnings", warnings) + return resp + return {"success": False, "message": str(resp)} @mcp.tool(description=( "Create a new C# script at the given project path.\n\n" @@ -313,11 +494,28 @@ def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback max_edit_payload_bytes = 256 * 1024 guards = {"using_guard": True} + extras = {"get_sha": True} return {"success": True, "data": { "ops": ops, "text_ops": text_ops, "max_edit_payload_bytes": max_edit_payload_bytes, "guards": guards, + "extras": extras, }} except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} + + @mcp.tool(description=( + "Get SHA256 and metadata for a Unity C# script without returning file contents.\n\n" + "Args: uri (unity://path/Assets/... or file://... or Assets/...).\n" + "Returns: {sha256, lengthBytes, lastModifiedUtc, uri, path}." + )) + def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: + """Return SHA256 and basic metadata for a script.""" + try: + name, directory = _split_uri(uri) + params = {"action": "get_sha", "name": name, "path": directory} + 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"get_sha error: {e}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 01cb52a8..547d7daf 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -624,6 +624,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: m = _re.search(pattern, current_text, flags) if not m: continue + # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + def _expand_dollars(rep: str) -> str: + return _re.sub(r"\$(\d+)", lambda g: m.group(int(g.group(1))) or "", rep) + repl_expanded = _expand_dollars(repl) sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) at_edits.append({ @@ -631,9 +635,9 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: "startCol": sc, "endLine": el, "endCol": ec, - "newText": repl + "newText": repl_expanded }) - current_text = current_text[:m.start()] + repl + current_text[m.end():] + current_text = current_text[:m.start()] + repl_expanded + current_text[m.end():] else: 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") @@ -686,6 +690,14 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: except Exception as e: return {"success": False, "message": f"Edit application failed: {e}"} + # Short-circuit no-op edits to avoid false "applied" reports downstream + if new_contents == contents: + return _with_norm({ + "success": True, + "message": "No-op: contents unchanged", + "data": {"no_op": True, "evidence": {"reason": "identical_content"}} + }, normalized_for_echo, routing="text") + if preview: # Produce a compact unified diff limited to small context import difflib diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py new file mode 100644 index 00000000..e985f277 --- /dev/null +++ b/tests/test_edit_normalization_and_noop.py @@ -0,0 +1,90 @@ +import sys +import pathlib +import importlib.util +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") +class _Dummy: pass +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + +def _load(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") + + +class DummyMCP: + def __init__(self): self.tools = {} + def tool(self, *args, **kwargs): + def deco(fn): self.tools[fn.__name__] = fn; return fn + return deco + +def setup_tools(): + mcp = DummyMCP() + manage_script.register_manage_script_tools(mcp) + return mcp.tools + + +def test_normalizes_lsp_and_index_ranges(monkeypatch): + tools = setup_tools() + apply = tools["apply_text_edits"] + calls = [] + + def fake_send(cmd, params): + calls.append(params) + return {"success": True} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + # LSP-style + edits = [{ + "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, + "newText": "// lsp\n" + }] + apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") + p = calls[-1] + e = p["edits"][0] + assert e["startLine"] == 11 and e["startCol"] == 3 + + # Index pair + calls.clear() + edits = [{"range": [0, 0], "text": "// idx\n"}] + # fake read to provide contents length + def fake_read(cmd, params): + if params.get("action") == "read": + return {"success": True, "data": {"contents": "hello\n"}} + return {"success": True} + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) + apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") + # last call is apply_text_edits + + +def test_noop_evidence_shape(monkeypatch): + tools = setup_tools() + apply = tools["apply_text_edits"] + # Route response from Unity indicating no-op + def fake_send(cmd, params): + return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":""}], precondition_sha256="x") + assert resp["success"] is True + assert resp.get("data", {}).get("no_op") is True + diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py new file mode 100644 index 00000000..1d35323f --- /dev/null +++ b/tests/test_edit_strict_and_warnings.py @@ -0,0 +1,84 @@ +import sys +import pathlib +import importlib.util +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") +class _Dummy: pass +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + + +def _load(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3") + + +class DummyMCP: + def __init__(self): self.tools = {} + def tool(self, *args, **kwargs): + def deco(fn): self.tools[fn.__name__] = fn; return fn + return deco + + +def setup_tools(): + mcp = DummyMCP() + manage_script.register_manage_script_tools(mcp) + return mcp.tools + + +def test_explicit_zero_based_normalized_warning(monkeypatch): + tools = setup_tools() + apply_edits = tools["apply_text_edits"] + + def fake_send(cmd, params): + # Simulate Unity path returning minimal success + return {"success": True} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + # Explicit fields given as 0-based (invalid); SDK should normalize and warn + edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] + resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha") + + assert resp["success"] is True + data = resp.get("data", {}) + assert "normalizedEdits" in data + assert any(w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", [])) + ne = data["normalizedEdits"][0] + assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1 + + +def test_strict_zero_based_error(monkeypatch): + tools = setup_tools() + apply_edits = tools["apply_text_edits"] + + def fake_send(cmd, params): + return {"success": True} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] + resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True) + assert resp["success"] is False + assert resp.get("code") == "zero_based_explicit_fields" + + diff --git a/tests/test_get_sha.py b/tests/test_get_sha.py new file mode 100644 index 00000000..cb58ce29 --- /dev/null +++ b/tests/test_get_sha.py @@ -0,0 +1,74 @@ +import sys +import pathlib +import importlib.util +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp to satisfy imports without full dependency +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") + +class _Dummy: + pass + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + + +def _load_module(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod") + + +class DummyMCP: + def __init__(self): + self.tools = {} + + def tool(self, *args, **kwargs): + def deco(fn): + self.tools[fn.__name__] = fn + return fn + return deco + + +def setup_tools(): + mcp = DummyMCP() + manage_script.register_manage_script_tools(mcp) + return mcp.tools + + +def test_get_sha_param_shape_and_routing(monkeypatch): + tools = setup_tools() + get_sha = tools["get_sha"] + + captured = {} + + def fake_send(cmd, params): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}} + + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + resp = get_sha(None, uri="unity://path/Assets/Scripts/A.cs") + assert captured["cmd"] == "manage_script" + assert captured["params"]["action"] == "get_sha" + assert captured["params"]["name"] == "A" + assert captured["params"]["path"].endswith("Assets/Scripts") + assert resp["success"] is True + From a86423ca8f2c70e07e8b95a9873f6314fdf2a724 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 19:25:41 -0700 Subject: [PATCH 298/311] feat(frames): accept zero-length heartbeat frames in client; add heartbeat test --- .../UnityMcpServer~/src/unity_connection.py | 22 +++++----- tests/test_transport_framing.py | 42 +++++++++++++++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index c7e78549..a5b071af 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -124,16 +124,18 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: """Receive a complete response from Unity, handling chunked data.""" if self.use_framing: try: - header = self._read_exact(sock, 8) - payload_len = struct.unpack('>Q', header)[0] - if payload_len == 0: - logger.debug("Received framed response (0 bytes)") - return b"" - if payload_len > FRAMED_MAX: - raise ValueError(f"Invalid framed length: {payload_len}") - payload = self._read_exact(sock, payload_len) - logger.debug(f"Received framed response ({len(payload)} bytes)") - return payload + while True: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0: + # Heartbeat/no-op frame: consume and continue waiting for a data frame + logger.debug("Received heartbeat frame (length=0)") + continue + if payload_len > FRAMED_MAX: + raise ValueError(f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.debug(f"Received framed response ({len(payload)} bytes)") + return payload except socket.timeout as e: logger.warning("Socket timeout during framed receive") raise TimeoutError("Timeout receiving Unity response") from e diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 4dd688dd..b50ea42f 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -157,9 +157,45 @@ def test_unframed_data_disconnect(): sock.close() -@pytest.mark.skip(reason="TODO: zero-length payload should raise error") -def test_zero_length_payload_error(): - pass +def test_zero_length_payload_heartbeat(): + # Server that sends handshake and a zero-length heartbeat frame followed by a pong payload + import socket, struct, threading, time + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + ready = threading.Event() + + def _run(): + ready.set() + conn, _ = sock.accept() + try: + conn.sendall(b"MCP/0.1 FRAMING=1\n") + time.sleep(0.02) + # Heartbeat frame (length=0) + conn.sendall(struct.pack(">Q", 0)) + time.sleep(0.02) + # Real payload frame + payload = b'{"type":"pong"}' + conn.sendall(struct.pack(">Q", len(payload)) + payload) + time.sleep(0.02) + finally: + try: conn.close() + except Exception: pass + sock.close() + + threading.Thread(target=_run, daemon=True).start() + ready.wait() + + conn = UnityConnection(host="127.0.0.1", port=port) + try: + assert conn.connect() is True + # Receive should skip heartbeat and return the pong payload + resp = conn.receive_full_response(conn.sock) + assert resp == b'{"type":"pong"}' + finally: + conn.disconnect() @pytest.mark.skip(reason="TODO: oversized payload should disconnect") From 81026facda33f75b69217c11a86d808023a48d2c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 19:42:09 -0700 Subject: [PATCH 299/311] feat(edits): guard destructive regex_replace with structural preflight; add robust tests; prompt uses delete_method for temp helper --- .claude/prompts/nl-unity-suite-full.md | 19 ++- .../UnityMcpServer~/src/server_version.txt | 2 +- .../src/tools/manage_script_edits.py | 62 ++++++- tests/test_regex_delete_guard.py | 151 ++++++++++++++++++ 4 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 tests/test_regex_delete_guard.py diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 73654a1f..d808b0c5 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -68,14 +68,9 @@ CI provides: ``` # Delete the temporary helper (T‑A/T‑E) -- Do NOT use `anchor_replace`. -- Use `script_apply_edits` with: -```json -{"op":"regex_replace", - "pattern":"(?ms)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", - "replacement":""} -``` -- If rejected, fall back to `apply_text_edits` with a single `replace_range` spanning the method. +- Prefer structured delete: + - Use `script_apply_edits` with `{ "op":"delete_method", "className":"LongUnityScriptClaudeTest", "methodName":"PrintSeries" }` (or `__TempHelper` for T‑A). +- If structured delete is unavailable, fall back to `apply_text_edits` with a single `replace_range` spanning the exact method block (bounds computed from a fresh read); avoid whole‑file regex deletes. # T‑B (replace method body) - Use `mcp__unity__apply_text_edits` with a single `replace_range` strictly inside the `HasTarget` braces. @@ -204,6 +199,14 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). * Make a tiny legit edit with pre_sha; success. * Attempt another edit reusing the OLD pre_sha. * Expect {status:"stale_file"} → record as PASS; else FAIL. Re-read to refresh, restore. + +### Per‑test error handling and recovery +- For each test (NL‑0..T‑J), use a try/finally pattern: + - try: run the test steps; always write `reports/_results.xml` with PASS/FAIL/ERROR + - finally: run Bash(scripts/nlt-revert.sh:restore …baseline) to restore the target file +- On any transport/JSON/tool exception: + - catch and write a `` fragment with an `` node (include the message), then proceed to the next test. +- After NL‑4 completes, proceed directly to T‑A regardless of any earlier validator warnings (do not abort the run). - (3) USING_GUARD (optional): * Attempt a 1-line insert above the first 'using'. * Expect {status:"using_guard"} → record as PASS; else note 'not emitted'. Restore. diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index b5021469..fd2a0186 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.0.2 +3.1.0 diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 547d7daf..68795cfb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -91,6 +91,57 @@ def _extract_code_after(keyword: str, request: str) -> str: if idx >= 0: return request[idx + len(keyword):].strip() return "" +def _is_structurally_balanced(text: str) -> bool: + """Lightweight delimiter balance check for braces/paren/brackets. + Not a full parser; used to preflight destructive regex deletes. + """ + brace = paren = bracket = 0 + in_str = in_chr = False + esc = False + i = 0 + n = len(text) + while i < n: + c = text[i] + nxt = text[i+1] if i+1 < n else '' + if in_str: + if not esc and c == '"': + in_str = False + esc = (not esc and c == '\\') + i += 1 + continue + if in_chr: + if not esc and c == "'": + in_chr = False + esc = (not esc and c == '\\') + i += 1 + continue + # comments + if c == '/' and nxt == '/': + # skip to EOL + i = text.find('\n', i) + if i == -1: + break + i += 1 + continue + if c == '/' and nxt == '*': + j = text.find('*/', i+2) + i = (j + 2) if j != -1 else n + continue + if c == '"': + in_str = True; esc = False; i += 1; continue + if c == "'": + in_chr = True; esc = False; i += 1; continue + if c == '{': brace += 1 + elif c == '}': brace -= 1 + elif c == '(': paren += 1 + elif c == ')': paren -= 1 + elif c == '[': bracket += 1 + elif c == ']': bracket -= 1 + if brace < 0 or paren < 0 or bracket < 0: + return False + i += 1 + return brace == 0 and paren == 0 and bracket == 0 + def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: @@ -628,6 +679,15 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: def _expand_dollars(rep: str) -> str: return _re.sub(r"\$(\d+)", lambda g: m.group(int(g.group(1))) or "", rep) repl_expanded = _expand_dollars(repl) + # Preview structural balance after replacement; refuse destructive deletes + preview = current_text[:m.start()] + repl_expanded + current_text[m.end():] + if not _is_structurally_balanced(preview): + return _with_norm({ + "success": False, + "code": "validation_failed", + "message": "regex_replace would unbalance braces/parentheses; prefer delete_method", + "data": {"status": "validation_failed", "normalizedEdits": normalized_for_echo, "hint": "Use script_apply_edits delete_method for method removal"} + }, normalized_for_echo, routing="text") sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) at_edits.append({ @@ -637,7 +697,7 @@ def _expand_dollars(rep: str) -> str: "endCol": ec, "newText": repl_expanded }) - current_text = current_text[:m.start()] + repl_expanded + current_text[m.end():] + current_text = preview else: 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") diff --git a/tests/test_regex_delete_guard.py b/tests/test_regex_delete_guard.py new file mode 100644 index 00000000..c5bba26a --- /dev/null +++ b/tests/test_regex_delete_guard.py @@ -0,0 +1,151 @@ +import sys +import pytest +import pathlib +import importlib.util +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") +class _D: pass +fastmcp_pkg.FastMCP = _D +fastmcp_pkg.Context = _D +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + + +def _load(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +manage_script_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod_guard") + + +class DummyMCP: + def __init__(self): self.tools = {} + def tool(self, *args, **kwargs): + def deco(fn): self.tools[fn.__name__] = fn; return fn + return deco + + +def setup_tools(): + mcp = DummyMCP() + manage_script_edits.register_manage_script_edits_tools(mcp) + return mcp.tools + + +def test_regex_delete_structural_guard(monkeypatch): + tools = setup_tools() + apply = tools["script_apply_edits"] + + # Craft a minimal C# snippet with a method; a bad regex that deletes only the header and '{' + # will unbalance braces and should be rejected by preflight. + bad_pattern = r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{" + contents = ( + "using UnityEngine;\n\n" + "public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n" + "private void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n" + "}\n" + ) + + def fake_send(cmd, params): + # Only the initial read should be invoked; provide contents + if cmd == "manage_script" and params.get("action") == "read": + return {"success": True, "data": {"contents": contents}} + # If preflight failed as intended, no write should be attempted; return a marker if called + return {"success": True, "message": "SHOULD_NOT_WRITE"} + + monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send) + + resp = apply( + ctx=None, + name="LongUnityScriptClaudeTest", + path="Assets/Scripts", + edits=[{"op": "regex_replace", "pattern": bad_pattern, "replacement": ""}], + options={"validate": "standard"}, + ) + + assert isinstance(resp, dict) + assert resp.get("success") is False + assert resp.get("code") == "validation_failed" + data = resp.get("data", {}) + assert data.get("status") == "validation_failed" + # Helpful hint to prefer structured delete + assert "delete_method" in (data.get("hint") or "") + + +# Parameterized robustness cases +BRACE_CONTENT = ( + "using UnityEngine;\n\n" + "public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n" + "private void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n" + "}\n" +) + +ATTR_CONTENT = ( + "using UnityEngine;\n\n" + "public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n" + "[ContextMenu(\"PS\")]\nprivate void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n" + "}\n" +) + +EXPR_CONTENT = ( + "using UnityEngine;\n\n" + "public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n" + "private void PrintSeries() => Debug.Log(\"1\");\n" + "}\n" +) + + +@pytest.mark.parametrize( + "contents,pattern,repl,expect_success", + [ + # Unbalanced deletes (should fail with validation_failed) + (BRACE_CONTENT, r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{", "", False), + # Remove method closing brace only (leaves class closing brace) -> unbalanced + (BRACE_CONTENT, r"\n\}\n(?=\s*\})", "\n", False), + (ATTR_CONTENT, r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{", "", False), + # Expression-bodied: remove only '(' in header -> paren mismatch + (EXPR_CONTENT, r"(?m)private\s+void\s+PrintSeries\s*\(", "", False), + # Safe changes (should succeed) + (BRACE_CONTENT, r"(?m)^\s*Debug\.Log\(.*?\);\s*$", "", True), + (EXPR_CONTENT, r"Debug\.Log\(\"1\"\)", "Debug.Log(\"2\")", True), + ], +) +def test_regex_delete_variants(monkeypatch, contents, pattern, repl, expect_success): + tools = setup_tools() + apply = tools["script_apply_edits"] + + def fake_send(cmd, params): + if cmd == "manage_script" and params.get("action") == "read": + return {"success": True, "data": {"contents": contents}} + return {"success": True, "message": "WRITE"} + + monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send) + + resp = apply( + ctx=None, + name="LongUnityScriptClaudeTest", + path="Assets/Scripts", + edits=[{"op": "regex_replace", "pattern": pattern, "replacement": repl}], + options={"validate": "standard"}, + ) + + if expect_success: + assert isinstance(resp, dict) and resp.get("success") is True + else: + assert isinstance(resp, dict) and resp.get("success") is False and resp.get("code") == "validation_failed" + + From 9726054a50ec896b56aab60f5f6ea1938a71320f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 19:47:57 -0700 Subject: [PATCH 300/311] feat(frames): bound heartbeat loop with timeout/threshold; align zero-length response with C#; update test --- UnityMcpBridge/UnityMcpServer~/src/config.py | 3 +++ UnityMcpBridge/UnityMcpServer~/src/unity_connection.py | 8 ++++++++ tests/test_transport_framing.py | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index 5df28b8a..3023f119 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -17,6 +17,9 @@ class ServerConfig: # Connection settings connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts buffer_size: int = 16 * 1024 * 1024 # 16MB buffer + # Framed receive behavior + framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only + max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up # Logging settings log_level: str = "INFO" diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index a5b071af..f41b7a25 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -124,12 +124,20 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: """Receive a complete response from Unity, handling chunked data.""" if self.use_framing: try: + # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive + heartbeat_count = 0 + deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) while True: header = self._read_exact(sock, 8) payload_len = struct.unpack('>Q', header)[0] if payload_len == 0: # Heartbeat/no-op frame: consume and continue waiting for a data frame logger.debug("Received heartbeat frame (length=0)") + heartbeat_count += 1 + if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: + # Treat as empty successful response to match C# server behavior + logger.debug("Heartbeat threshold reached; returning empty response") + return b"" continue if payload_len > FRAMED_MAX: raise ValueError(f"Invalid framed length: {payload_len}") diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index b50ea42f..42f93701 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -191,9 +191,9 @@ def _run(): conn = UnityConnection(host="127.0.0.1", port=port) try: assert conn.connect() is True - # Receive should skip heartbeat and return the pong payload + # Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen) resp = conn.receive_full_response(conn.sock) - assert resp == b'{"type":"pong"}' + assert resp in (b'{"type":"pong"}', b"") finally: conn.disconnect() From afd1be82466516dd3f3883b12d97769beac1d18a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 20:31:12 -0700 Subject: [PATCH 301/311] SDK hardening: atomic multi-span text edits; stop forcing sequential for structured ops; forward options on apply_text_edits; add validate=relaxed support and scoped checks; update NL/T prompt; add tests for options forwarding, relaxed mode, and atomic batches --- .claude/prompts/nl-unity-suite-full.md | 6 ++ UnityMcpBridge/Editor/Tools/ManageScript.cs | 53 ++++++++++++++++-- .../src/tools/manage_script.py | 3 + .../src/tools/manage_script_edits.py | 55 +++++++++---------- tests/test_edit_normalization_and_noop.py | 26 +++++++++ tests/test_script_tools.py | 16 ++++++ 6 files changed, 125 insertions(+), 34 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index d808b0c5..4c7e13be 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -36,8 +36,13 @@ CI provides: - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` - Allowed ops: `anchor_insert`, `replace_range`, `regex_replace` (no overlapping ranges within a single call) - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) + - Multi‑span batches are computed from the same fresh read and sent atomically by default. + - Prefer `options.applyMode:"atomic"` when passing options for multiple spans; for single‑span, sequential is fine. - **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body - **Validation**: `mcp__unity__validate_script(level:"standard")` + - For edits, you may pass `options.validate`: + - `standard` (default): full‑file delimiter balance checks. + - `relaxed`: scoped checks for interior, non‑structural text edits; do not use for header/signature/brace‑touching changes. - **Reporting**: `Write` small XML fragments to `reports/*_results.xml` - **Editor state/flush**: `mcp__unity__manage_editor` (use sparingly; no project mutations) - **Console readback**: `mcp__unity__read_console` (INFO capture only; do not assert in place of `validate_script`) @@ -85,6 +90,7 @@ Span formats for `apply_text_edits`: - Explicit fields are 1‑based: `{ "startLine": L1, "startCol": C1, "endLine": L2, "endCol": C2, "newText": "…" }` - SDK preflights overlap after normalization; overlapping non‑zero spans → `{status:"overlap"}` with conflicts and no file mutation. - Optional debug: pass `strict:true` to reject explicit 0‑based fields (else they are normalized and a warning is emitted). +- Apply mode guidance: router defaults to atomic for multi‑span; you can explicitly set `options.applyMode` if needed. --- diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 8ce4ab50..79f9ab75 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -204,9 +204,10 @@ public static object HandleCommand(JObject @params) { var textEdits = @params["edits"] as JArray; string precondition = @params["precondition_sha256"]?.ToString(); - // Respect optional refresh options for immediate compile + // Respect optional options string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); - return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt); + string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); } case "validate": { @@ -489,7 +490,8 @@ private static object ApplyTextEdits( string name, JArray edits, string preconditionSha256, - string refreshModeFromCaller = null) + string refreshModeFromCaller = null, + string validateMode = null) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); @@ -656,9 +658,19 @@ private static object ApplyTextEdits( } string working = original; + bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); foreach (var sp in spans) { - working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + if (relaxed) + { + // Scoped balance check: validate just around the changed region to avoid false positives + if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, sp.start + (sp.text?.Length ?? 0) + 500))) + { + return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + } + } + working = next; } // No-op guard: if resulting text is identical, avoid writes and return explicit no-op @@ -679,7 +691,7 @@ private static object ApplyTextEdits( ); } - if (!CheckBalancedDelimiters(working, out int line, out char expected)) + if (!relaxed && !CheckBalancedDelimiters(working, out int line, out char expected)) { int startLine = Math.Max(1, line - 5); int endLine = line + 5; @@ -890,6 +902,37 @@ private static bool CheckBalancedDelimiters(string text, out int line, out char return true; } + // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context + private static bool CheckScopedBalance(string text, int start, int end) + { + start = Math.Max(0, Math.Min(text.Length, start)); + end = Math.Max(start, Math.Min(text.Length, end)); + int brace = 0, paren = 0, bracket = 0; + bool inStr = false, inChr = false, esc = false; + for (int i = start; i < end; i++) + { + char c = text[i]; + char n = (i + 1 < end) ? text[i + 1] : '\0'; + if (inStr) + { + if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; + } + if (inChr) + { + if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; + } + if (c == '"') { inStr = true; esc = false; continue; } + if (c == '\'') { inChr = true; esc = false; continue; } + if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } + if (c == '{') brace++; else if (c == '}') brace--; + else if (c == '(') paren++; else if (c == ')') paren--; + else if (c == '[') bracket++; else if (c == ']') bracket--; + if (brace < 0 || paren < 0 || bracket < 0) return false; + } + return brace >= -1 && paren >= -1 && bracket >= -1; // tolerate context from outside region + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 2d8ca79c..bd6cfb83 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -75,6 +75,7 @@ def apply_text_edits( edits: List[Dict[str, Any]], precondition_sha256: str | None = None, strict: bool | None = None, + options: Dict[str, Any] | None = None, ) -> Dict[str, Any]: """Apply small text edits to a C# script identified by URI.""" name, directory = _split_uri(uri) @@ -259,6 +260,8 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: "edits": normalized_edits, "precondition_sha256": precondition_sha256, } + if options: + params["options"] = options params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict): diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 68795cfb..7719a321 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -464,9 +464,7 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str # 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") + # Do not force sequential; allow server default (atomic) unless caller requests otherwise opts2.setdefault("refresh", "immediate") params_struct: Dict[str, Any] = { "action": "edit", @@ -506,10 +504,10 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str 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] try: - current_text = contents + base_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) + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 return line, col @@ -522,7 +520,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _re.search(anchor, current_text, flags) + m = _re.search(anchor, base_text, flags) 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() @@ -534,7 +532,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: text_field_norm = text_field_norm + "\n" sl, sc = line_col_from_index(idx) at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) - current_text = current_text[:idx] + text_field_norm + current_text[idx:] + # do not mutate base_text when building atomic spans elif opx == "replace_range": if all(k in e for k in ("startLine","startCol","endLine","endCol")): at_edits.append({ @@ -548,7 +546,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 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) + m = _re.search(pattern, base_text, _re.MULTILINE) if not m: continue # Expand $1, $2... in replacement using this match @@ -558,24 +556,24 @@ def _expand_dollars(rep: str) -> str: 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():] + # do not mutate base_text when building atomic spans 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 + # prepend can be applied atomically without local mutation else: # Insert at true EOF position (handles both \n and \r\n correctly) - eof_idx = len(current_text) + eof_idx = len(base_text) sl, sc = line_col_from_index(eof_idx) - new_text = ("\n" if not current_text.endswith("\n") else "") + text_field + new_text = ("\n" if not base_text.endswith("\n") else "") + text_field at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) - current_text = current_text + new_text + # do not mutate base_text when building atomic spans 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() + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() if at_edits: params_text: Dict[str, Any] = { "action": "apply_text_edits", @@ -585,7 +583,7 @@ def _expand_dollars(rep: str) -> str: "scriptType": script_type, "edits": at_edits, "precondition_sha256": sha, - "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} + "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} } resp_text = send_command_with_retry("manage_script", params_text) if not (isinstance(resp_text, dict) and resp_text.get("success")): @@ -595,7 +593,7 @@ def _expand_dollars(rep: str) -> str: if struct_edits: opts2 = dict(options or {}) - opts2.setdefault("applyMode", "sequential") + # Let server decide; do not force sequential opts2.setdefault("refresh", "immediate") params_struct: Dict[str, Any] = { "action": "edit", @@ -619,11 +617,11 @@ def _expand_dollars(rep: str) -> str: if not text_ops.issubset(structured_kinds): # Convert to apply_text_edits payload try: - current_text = contents + base_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) + # 1-based line/col against base buffer + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 return line, col @@ -636,7 +634,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if op == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - m = _re.search(anchor, current_text, _re.MULTILINE) + m = _re.search(anchor, base_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="text") idx = m.start() if position == "before" else m.end() @@ -653,8 +651,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: "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:] + # Do not mutate base buffer when building an atomic batch elif op == "replace_range": # Directly forward if already in line/col form if "startLine" in e: @@ -672,7 +669,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: pattern = e.get("pattern") or "" repl = text_field flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _re.search(pattern, current_text, flags) + m = _re.search(pattern, base_text, flags) if not m: continue # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) @@ -680,7 +677,7 @@ def _expand_dollars(rep: str) -> str: return _re.sub(r"\$(\d+)", lambda g: m.group(int(g.group(1))) or "", rep) repl_expanded = _expand_dollars(repl) # Preview structural balance after replacement; refuse destructive deletes - preview = current_text[:m.start()] + repl_expanded + current_text[m.end():] + preview = base_text[:m.start()] + repl_expanded + base_text[m.end():] if not _is_structurally_balanced(preview): return _with_norm({ "success": False, @@ -697,7 +694,7 @@ def _expand_dollars(rep: str) -> str: "endCol": ec, "newText": repl_expanded }) - current_text = preview + # Do not mutate base buffer when building an atomic batch else: 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") @@ -706,7 +703,7 @@ def _expand_dollars(rep: str) -> str: # Send to Unity with precondition SHA to enforce guards and immediate refresh import hashlib - sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() params: Dict[str, Any] = { "action": "apply_text_edits", "name": name, @@ -718,7 +715,7 @@ def _expand_dollars(rep: str) -> str: "options": { "refresh": "immediate", "validate": (options or {}).get("validate", "standard"), - "applyMode": "sequential" + "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) } } resp = send_command_with_retry("manage_script", params) diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py index e985f277..ab97e5e2 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -28,6 +28,7 @@ def _load(path: pathlib.Path, name: str): return mod manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") +manage_script_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") class DummyMCP: @@ -88,3 +89,28 @@ def fake_send(cmd, params): assert resp["success"] is True assert resp.get("data", {}).get("no_op") is True + +def test_atomic_multi_span_and_relaxed(monkeypatch): + tools_text = setup_tools() + apply_text = tools_text["apply_text_edits"] + tools_struct = DummyMCP(); manage_script_edits.register_manage_script_edits_tools(tools_struct) + # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through + sent = {} + def fake_send(cmd, params): + if params.get("action") == "read": + return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} + sent.setdefault("calls", []).append(params) + return {"success": True} + monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + edits = [ + {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, + {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"} + ] + resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) + assert resp["success"] is True + # Last manage_script call should include options with applyMode atomic and validate relaxed + last = sent["calls"][-1] + assert last.get("options", {}).get("applyMode") == "atomic" + assert last.get("options", {}).get("validate") == "relaxed" + diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index 71550663..93822c83 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -94,6 +94,22 @@ def fake_send(cmd, params): assert calls[1]["precondition_sha256"] == resp1["sha256"] assert resp2["sha256"] == "hash2" + +def test_apply_text_edits_forwards_options(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + captured = {} + + def fake_send(cmd, params): + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} + apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":"x"}], options=opts) + assert captured["params"].get("options") == opts + def test_manage_asset_prefab_modify_request(monkeypatch): tools = setup_manage_asset() manage_asset = tools["manage_asset"] From cd07ba142a759d98bd4efdd2ead5e5650bd63b94 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 20:54:29 -0700 Subject: [PATCH 302/311] Router: default applyMode=atomic for multi-span apply_text_edits; add tests --- .../src/tools/manage_script.py | 11 ++++++++-- tests/test_script_tools.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index bd6cfb83..06dcab3c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -253,15 +253,22 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and # preserves existing call-count expectations in clients/tests. + # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance + opts: Dict[str, Any] = dict(options or {}) + try: + if len(normalized_edits) > 1 and "applyMode" not in opts: + opts["applyMode"] = "atomic" + except Exception: + pass + params = { "action": "apply_text_edits", "name": name, "path": directory, "edits": normalized_edits, "precondition_sha256": precondition_sha256, + "options": opts, } - if options: - params["options"] = options params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict): diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index 93822c83..c7cadd35 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -110,6 +110,26 @@ def fake_send(cmd, params): apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":"x"}], options=opts) assert captured["params"].get("options") == opts + +def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + captured = {} + + def fake_send(cmd, params): + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edits = [ + {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, + {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}, + ] + apply_edits(None, "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x") + opts = captured["params"].get("options", {}) + assert opts.get("applyMode") == "atomic" + def test_manage_asset_prefab_modify_request(monkeypatch): tools = setup_manage_asset() manage_asset = tools["manage_asset"] From 55d4913b294ca647e400dd41cf58f4218e3f4dd2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 20:57:19 -0700 Subject: [PATCH 303/311] CI prompt: pass options.validate=relaxed for T-B/C; options.applyMode=atomic for T-F; emphasize always writing testcase and restoring on errors --- .claude/prompts/nl-unity-suite-full.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md index 4c7e13be..1b46127a 100644 --- a/.claude/prompts/nl-unity-suite-full.md +++ b/.claude/prompts/nl-unity-suite-full.md @@ -176,7 +176,9 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### T‑A. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore. ### T‑B. Replace body — Single `replace_range` inside `HasTarget`; restore. +- Options: pass {"validate":"relaxed"} for interior one-line edits. ### T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. +- Options: pass {"validate":"relaxed"} for interior one-line edits. ### T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore. ### T‑E. Lifecycle — Insert → update → delete via regex; restore. ### T‑F. Atomic batch — One `mcp__unity__apply_text_edits` call (text ranges only) @@ -185,6 +187,7 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). 2) One **end‑of‑class insertion**: find the **index of the final `}`** for the class; create a zero‑width range `[idx, idx)` and set `replacement` to the 3‑line comment block. - Send all three ranges in **one call**, sorted **descending by start index** to avoid offset drift. - Expect all‑or‑nothing semantics; on `{status:"overlap"}` or `{status:"bad_request"}`, write the testcase fragment with ``, **restore**, and continue. + - Options: pass {"applyMode":"atomic"} to enforce all‑or‑nothing. - T‑G. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file. ### T-H. Validation (standard) @@ -208,6 +211,7 @@ Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests). ### Per‑test error handling and recovery - For each test (NL‑0..T‑J), use a try/finally pattern: + - Always write a testcase fragment and perform restore in finally, even when tools return error payloads. - try: run the test steps; always write `reports/_results.xml` with PASS/FAIL/ERROR - finally: run Bash(scripts/nlt-revert.sh:restore …baseline) to restore the target file - On any transport/JSON/tool exception: From 1281ada0a420723c17595248a1209243bcfcbc5f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 29 Aug 2025 21:08:04 -0700 Subject: [PATCH 304/311] Validation & DX: add validate=syntax (scoped), standardize evidence windows; early regex compile with hints; debug_preview for apply_text_edits --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 49 +++++++++++-------- .../src/tools/manage_script.py | 19 +++++++ .../src/tools/manage_script_edits.py | 35 +++++++++---- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 79f9ab75..6d7b4abb 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -659,6 +659,7 @@ private static object ApplyTextEdits( string working = original; bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); + bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); foreach (var sp in spans) { string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); @@ -696,32 +697,38 @@ private static object ApplyTextEdits( int startLine = Math.Max(1, line - 5); int endLine = line + 5; string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; - return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString() }); + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); } #if USE_ROSLYN - var tree = CSharpSyntaxTree.ParseText(working); - var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) - .Select(d => new { - line = d.Location.GetLineSpan().StartLinePosition.Line + 1, - col = d.Location.GetLineSpan().StartLinePosition.Character + 1, - code = d.Id, - message = d.GetMessage() - }).ToArray(); - if (diagnostics.Length > 0) - { - return Response.Error("syntax_error", new { status = "syntax_error", diagnostics }); - } + if (!syntaxOnly) + { + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + int firstLine = diagnostics[0].line; + int startLineRos = Math.Max(1, firstLine - 5); + int endLineRos = firstLine + 5; + return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); + } - // Optional formatting - try - { - var root = tree.GetRoot(); - var workspace = new AdhocWorkspace(); - root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); - working = root.ToFullString(); + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } } - catch { } #endif string newSha = ComputeSha256(working); diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 06dcab3c..62649730 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -260,6 +260,25 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: opts["applyMode"] = "atomic" except Exception: pass + # Support optional debug preview for span-by-span simulation without write + if opts.get("debug_preview"): + try: + import difflib + # Apply locally to preview final result + lines = [] + # Build an indexable original from a read if we normalized from read; otherwise skip + prev = "" + # We cannot guarantee file contents here without a read; return normalized spans only + return { + "success": True, + "message": "Preview only (no write)", + "data": { + "normalizedEdits": normalized_edits, + "preview": True + } + } + except Exception as e: + return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} params = { "action": "apply_text_edits", diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 7719a321..33b824de 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -520,7 +520,11 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _re.search(anchor, base_text, flags) + try: + regex_obj = _re.compile(anchor, flags) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") + m = regex_obj.search(base_text) 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() @@ -546,7 +550,11 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 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, base_text, _re.MULTILINE) + try: + regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") + m = regex_obj.search(base_text) if not m: continue # Expand $1, $2... in replacement using this match @@ -634,7 +642,12 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if op == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - m = _re.search(anchor, base_text, _re.MULTILINE) + # Early regex compile with helpful errors + try: + regex_obj = _re.compile(anchor, _re.MULTILINE) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") + m = regex_obj.search(base_text) if not m: 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() @@ -669,7 +682,12 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: pattern = e.get("pattern") or "" repl = text_field flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _re.search(pattern, base_text, flags) + # Early compile for clearer error messages + try: + regex_obj = _re.compile(pattern, flags) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") + m = regex_obj.search(base_text) if not m: continue # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) @@ -679,12 +697,9 @@ def _expand_dollars(rep: str) -> str: # Preview structural balance after replacement; refuse destructive deletes preview = base_text[:m.start()] + repl_expanded + base_text[m.end():] if not _is_structurally_balanced(preview): - return _with_norm({ - "success": False, - "code": "validation_failed", - "message": "regex_replace would unbalance braces/parentheses; prefer delete_method", - "data": {"status": "validation_failed", "normalizedEdits": normalized_for_echo, "hint": "Use script_apply_edits delete_method for method removal"} - }, normalized_for_echo, routing="text") + return _with_norm(_err("validation_failed", "regex_replace would unbalance braces/parentheses; prefer delete_method", + normalized=normalized_for_echo, routing="text", + extra={"status": "validation_failed", "hint": "Use script_apply_edits delete_method for method removal"}), normalized_for_echo, routing="text") sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) at_edits.append({ From bbb8618cf84bac4bed4ad6223b514c4e74bc6aac Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 30 Aug 2025 08:31:22 -0700 Subject: [PATCH 305/311] Update UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 6af6ac13..640f6a88 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1136,7 +1136,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC catch { } // 1) Start from existing, only fill gaps (prefer trusted resolver) - string uvPath = FindUvPath(); + string uvPath = ServerInstaller.FindUvPath(); // Optionally trust existingCommand if it looks like uv/uv.exe try { @@ -1148,7 +1148,6 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ExtractDirectoryArg(existingArgs); bool serverValid = !string.IsNullOrEmpty(serverSrc) && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); From 48deedd6638c867d28bf7af18b0e9a3bd9535388 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:12:24 -0700 Subject: [PATCH 306/311] NL/T suite-driven edits: LongUnityScriptClaudeTest, bridge helpers, server_version; prepare framing tests --- .../Scripts/LongUnityScriptClaudeTest.cs | 4 +- .../Editor/Helpers/PackageDetector.cs | 32 +- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 187 ++++++------ UnityMcpBridge/Editor/Tools/ManageScript.cs | 2 +- .../Editor/Windows/MCPForUnityEditorWindow.cs | 283 +----------------- .../UnityMcpServer~/src/server_version.txt | 2 +- test_unity_socket_framing.py | 8 +- 7 files changed, 129 insertions(+), 389 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs index c40b5371..27fb9348 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs @@ -36,7 +36,7 @@ private Transform FindBestTarget() { if (reachOrigin == null) return null; // Dummy: prefer previously seen target within distance - if (currentTarget && Vector3.Distance(reachOrigin.position, currentTarget.position) <= maxReachDistance) + if (currentTarget != null && Vector3.Distance(reachOrigin.position, currentTarget.position) <= maxReachDistance) return currentTarget; return null; } @@ -60,7 +60,7 @@ private void LateUpdate() } // NL tests sometimes add comments above Update() as an anchor - public void Update() + private void Update() { if (reachOrigin == null) return; var best = FindBestTarget(); diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs index cb350d16..821bd017 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -27,15 +27,31 @@ static PackageDetector() { EditorApplication.delayCall += () => { - try + // Offload heavy I/O to a background thread, marshal back to main thread for Unity APIs + System.Threading.Tasks.Task.Run(() => { - ServerInstaller.EnsureServerInstalled(); - EditorPrefs.SetBool(key, true); - } - catch (System.Exception ex) - { - Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message); - } + string error = null; + System.Exception capturedEx = null; + try + { + ServerInstaller.EnsureServerInstalled(); + } + catch (System.Exception ex) + { + error = ex.Message; + capturedEx = ex; + } + + EditorApplication.delayCall += () => + { + try { EditorPrefs.SetBool(key, true); } catch { } + if (!string.IsNullOrEmpty(error)) + { + Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); + // Alternatively: Debug.LogException(capturedEx); + } + }; + }); }; } } diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index c6ead2a4..f90b2235 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -344,7 +344,7 @@ public static void Stop() // Mark as stopping early to avoid accept logging during disposal isRunning = false; // Mark heartbeat one last time before stopping - WriteHeartbeat(false); + WriteHeartbeat(false, "stopped"); listener?.Stop(); listener = null; EditorApplication.update -= ProcessCommands; @@ -431,7 +431,6 @@ private static async Task HandleClientAsync(TcpClient client) return; // abort this client } - byte[] buffer = new byte[8192]; while (isRunning) { try @@ -562,10 +561,7 @@ private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(Ne throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } if (payloadLen == 0UL) - { - // Allow zero-length frames (e.g., heartbeats/empty responses) - return string.Empty; - } + throw new System.IO.IOException("Zero-length frames are not allowed"); if (payloadLen > int.MaxValue) { throw new System.IO.IOException("Frame too large for buffer"); @@ -606,118 +602,115 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value) private static void ProcessCommands() { - List processedIds = new(); + // Heartbeat without holding the queue lock + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + WriteHeartbeat(false); + nextHeartbeatAt = now + 0.5f; + } + + // Snapshot under lock, then process outside to reduce contention + List<(string id, string text, TaskCompletionSource tcs)> work; lock (lockObj) { - // Periodic heartbeat while editor is idle/processing - double now = EditorApplication.timeSinceStartup; - if (now >= nextHeartbeatAt) - { - WriteHeartbeat(false); - nextHeartbeatAt = now + 0.5f; - } + work = commandQueue + .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) + .ToList(); + } - foreach ( - KeyValuePair< - string, - (string commandJson, TaskCompletionSource tcs) - > kvp in commandQueue.ToList() - ) - { - string id = kvp.Key; - string commandText = kvp.Value.commandJson; - TaskCompletionSource tcs = kvp.Value.tcs; + foreach (var item in work) + { + string id = item.id; + string commandText = item.text; + TaskCompletionSource tcs = item.tcs; - try + try + { + // Special case handling + if (string.IsNullOrEmpty(commandText)) { - // Special case handling - if (string.IsNullOrEmpty(commandText)) + var emptyResponse = new { - var emptyResponse = new - { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - processedIds.Add(id); - continue; - } - - // Trim the command text to remove any whitespace - commandText = commandText.Trim(); + status = "error", + error = "Empty command received", + }; + tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); + // Remove quickly under lock + lock (lockObj) { commandQueue.Remove(id); } + continue; + } - // Non-JSON direct commands handling (like ping) - if (commandText == "ping") - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - processedIds.Add(id); - continue; - } + // Trim the command text to remove any whitespace + commandText = commandText.Trim(); - // Check if the command is valid JSON before attempting to deserialize - if (!IsValidJson(commandText)) - { - var invalidJsonResponse = new - { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - processedIds.Add(id); - continue; - } - - // Normal JSON command processing - Command command = JsonConvert.DeserializeObject(commandText); - - if (command == null) - { - var nullCommandResponse = new - { - status = "error", - error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object", - }; - tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); - } - else + // Non-JSON direct commands handling (like ping) + if (commandText == "ping") + { + var pingResponse = new { - string responseJson = ExecuteCommand(command); - tcs.SetResult(responseJson); - } + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; } - catch (Exception ex) - { - Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - var response = new + // Check if the command is valid JSON before attempting to deserialize + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new { status = "error", - error = ex.Message, - commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText, }; - string responseJson = JsonConvert.SerializeObject(response); - tcs.SetResult(responseJson); + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; } - processedIds.Add(id); - } + // Normal JSON command processing + Command command = JsonConvert.DeserializeObject(commandText); - foreach (string id in processedIds) + if (command == null) + { + var nullCommandResponse = new + { + status = "error", + error = "Command deserialized to null", + details = "The command was valid JSON but could not be deserialized to a Command object", + }; + tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); + } + else + { + string responseJson = ExecuteCommand(command); + tcs.SetResult(responseJson); + } + } + catch (Exception ex) { - commandQueue.Remove(id); + Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + + var response = new + { + status = "error", + error = ex.Message, + commandType = "Unknown (error during processing)", + receivedText = commandText?.Length > 50 + ? commandText[..50] + "..." + : commandText, + }; + string responseJson = JsonConvert.SerializeObject(response); + tcs.SetResult(responseJson); } + + // Remove quickly under lock + lock (lockObj) { commandQueue.Remove(id); } } } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 6d7b4abb..0337f74f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -482,7 +482,7 @@ string contents /// /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. /// - private const int MaxEditPayloadBytes = 15 * 1024; + private const int MaxEditPayloadBytes = 64 * 1024; private static object ApplyTextEdits( string fullPath, diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 640f6a88..c1dacf50 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -722,11 +722,9 @@ private static bool PathsEqual(string a, string b) { string na = System.IO.Path.GetFullPath(a.Trim()); string nb = System.IO.Path.GetFullPath(b.Trim()); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows) - || System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) - { + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - } + // Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed return string.Equals(na, nb, StringComparison.Ordinal); } catch { return false; } @@ -1855,283 +1853,12 @@ private void UnregisterWithClaudeCode() private string FindUvPath() { - string uvPath = null; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - uvPath = FindWindowsUvPath(); - } - else - { - // macOS/Linux paths - string[] possiblePaths = { - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/usr/local/bin/uv", - "/opt/homebrew/bin/uv", - "/usr/bin/uv" - }; - - foreach (string path in possiblePaths) - { - if (File.Exists(path) && IsValidUvInstallation(path)) - { - uvPath = path; - break; - } - } - - // If not found in common locations, try to find via which command - if (uvPath == null) - { - try - { - var psi = new ProcessStartInfo - { - FileName = "which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (!string.IsNullOrEmpty(output) && File.Exists(output) && IsValidUvInstallation(output)) - { - uvPath = output; - } - } - catch - { - // Ignore errors - } - } - } - - // If no specific path found, fall back to using 'uv' from PATH - if (uvPath == null) - { - // Test if 'uv' is available in PATH by trying to run it - string uvCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.exe" : "uv"; - if (IsValidUvInstallation(uvCommand)) - { - uvPath = uvCommand; - } - } - - if (uvPath == null) - { - UnityEngine.Debug.LogError("UV package manager not found! Please install UV first:\n" + - "• macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n" + - "• Windows: pip install uv\n" + - "• Or visit: https://docs.astral.sh/uv/getting-started/installation"); - return null; - } - - return uvPath; + try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } } - private bool IsValidUvInstallation(string uvPath) - { - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - process.WaitForExit(5000); // 5 second timeout - - if (process.ExitCode == 0) - { - string output = process.StandardOutput.ReadToEnd().Trim(); - // Basic validation - just check if it responds with version info - // UV typically outputs "uv 0.x.x" format - if (output.StartsWith("uv ") && output.Contains(".")) - { - return true; - } - } - - return false; - } - catch - { - return false; - } - } + // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() - private string FindWindowsUvPath() - { - string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - // Dynamic Python version detection - check what's actually installed - List pythonVersions = new List(); - - // Add common versions but also scan for any Python* directories - string[] commonVersions = { "Python313", "Python312", "Python311", "Python310", "Python39", "Python38", "Python37" }; - pythonVersions.AddRange(commonVersions); - - // Scan for additional Python installations - string[] pythonBasePaths = { - Path.Combine(appData, "Python"), - Path.Combine(localAppData, "Programs", "Python"), - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Python", - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) + "\\Python" - }; - - foreach (string basePath in pythonBasePaths) - { - if (Directory.Exists(basePath)) - { - try - { - foreach (string dir in Directory.GetDirectories(basePath, "Python*")) - { - string versionName = Path.GetFileName(dir); - if (!pythonVersions.Contains(versionName)) - { - pythonVersions.Add(versionName); - } - } - } - catch - { - // Ignore directory access errors - } - } - } - - // Check Python installations for UV - foreach (string version in pythonVersions) - { - string[] pythonPaths = { - Path.Combine(appData, "Python", version, "Scripts", "uv.exe"), - Path.Combine(localAppData, "Programs", "Python", version, "Scripts", "uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python", version, "Scripts", "uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Python", version, "Scripts", "uv.exe") - }; - - foreach (string uvPath in pythonPaths) - { - if (File.Exists(uvPath) && IsValidUvInstallation(uvPath)) - { - return uvPath; - } - } - } - - // Check package manager installations - string[] packageManagerPaths = { - // Chocolatey - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "chocolatey", "lib", "uv", "tools", "uv.exe"), - Path.Combine("C:", "ProgramData", "chocolatey", "lib", "uv", "tools", "uv.exe"), - - // Scoop - Path.Combine(userProfile, "scoop", "apps", "uv", "current", "uv.exe"), - Path.Combine(userProfile, "scoop", "shims", "uv.exe"), - - // Winget/msstore - Path.Combine(localAppData, "Microsoft", "WinGet", "Packages", "astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe", "uv.exe"), - - // Common standalone installations - Path.Combine(localAppData, "uv", "uv.exe"), - Path.Combine(appData, "uv", "uv.exe"), - Path.Combine(userProfile, ".local", "bin", "uv.exe"), - Path.Combine(userProfile, "bin", "uv.exe"), - - // Cargo/Rust installations - Path.Combine(userProfile, ".cargo", "bin", "uv.exe"), - - // Manual installations in common locations - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "uv", "uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "uv", "uv.exe") - }; - - foreach (string uvPath in packageManagerPaths) - { - if (File.Exists(uvPath) && IsValidUvInstallation(uvPath)) - { - return uvPath; - } - } - - // Try to find uv via where command (Windows equivalent of which) - // Use where.exe explicitly to avoid PowerShell alias conflicts - try - { - var psi = new ProcessStartInfo - { - FileName = "where.exe", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - string[] lines = output.Split('\n'); - foreach (string line in lines) - { - string cleanPath = line.Trim(); - if (File.Exists(cleanPath) && IsValidUvInstallation(cleanPath)) - { - return cleanPath; - } - } - } - } - catch - { - // If where.exe fails, try PowerShell's Get-Command as fallback - try - { - var psi = new ProcessStartInfo - { - FileName = "powershell.exe", - Arguments = "-Command \"(Get-Command uv -ErrorAction SilentlyContinue).Source\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - if (IsValidUvInstallation(output)) - { - return output; - } - } - } - catch - { - // Ignore PowerShell errors too - } - } - - return null; // Will fallback to using 'uv' from PATH - } + // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead // Removed unused FindClaudeCommand diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index fd2a0186..94ff29cc 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.1.0 +3.1.1 diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index 7495ccb3..7c0cb93f 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -8,6 +8,7 @@ except (IndexError, ValueError): SIZE_MB = 5 # e.g., 5 or 10 FILL = "R" +MAX_FRAME = 64 * 1024 * 1024 def recv_exact(sock, n): buf = bytearray(n) @@ -45,6 +46,9 @@ def recv_legacy_json(sock, timeout=60): return data def main(): + # Cap filler to stay within framing limit (reserve small overhead for JSON) + safe_max = max(1, MAX_FRAME - 4096) + filler_len = min(SIZE_MB * 1024 * 1024, safe_max) body = { "type": "read_console", "params": { @@ -53,7 +57,7 @@ def main(): "count": 1000, "format": "detailed", "includeStacktrace": True, - "filterText": FILL * (SIZE_MB * 1024 * 1024) + "filterText": FILL * filler_len } } body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") @@ -77,7 +81,7 @@ def main(): s.sendall(header + body_bytes) resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] print(f"Response framed length: {resp_len}") - MAX_RESP = 128 * 1024 * 1024 + MAX_RESP = MAX_FRAME if resp_len <= 0 or resp_len > MAX_RESP: raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") resp = recv_exact(s, resp_len) From a5af8ba1172736fd7859ea306aec8617a99d188e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:15:33 -0700 Subject: [PATCH 307/311] Fix duplicate macConfigPath field in McpClient to resolve CS0102 --- UnityMcpBridge/Editor/Models/McpClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index 7d57a86f..a32f7f59 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -6,7 +6,6 @@ public class McpClient public string windowsConfigPath; public string macConfigPath; public string linuxConfigPath; - public string macConfigPath; // optional macOS-specific config path public McpTypes mcpType; public string configStatus; public McpStatus status = McpStatus.NotConfigured; From 62182bc50be95c76dc42fad4565a2a48401baccf Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:28:08 -0700 Subject: [PATCH 308/311] Editor threading: run EnsureServerInstalled on main thread; marshal EditorPrefs/DeleteKey + logging via delayCall --- .../Editor/Helpers/PackageDetector.cs | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs index 821bd017..d39685c2 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -25,33 +25,33 @@ static PackageDetector() if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) { + // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. EditorApplication.delayCall += () => { - // Offload heavy I/O to a background thread, marshal back to main thread for Unity APIs - System.Threading.Tasks.Task.Run(() => + string error = null; + System.Exception capturedEx = null; + try { - string error = null; - System.Exception capturedEx = null; - try - { - ServerInstaller.EnsureServerInstalled(); - } - catch (System.Exception ex) - { - error = ex.Message; - capturedEx = ex; - } + // Ensure any UnityEditor API usage inside runs on the main thread + ServerInstaller.EnsureServerInstalled(); + } + catch (System.Exception ex) + { + error = ex.Message; + capturedEx = ex; + } + + // Unity APIs must stay on main thread + try { EditorPrefs.SetBool(key, true); } catch { } + // Ensure prefs cleanup happens on main thread + try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } + try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } - EditorApplication.delayCall += () => - { - try { EditorPrefs.SetBool(key, true); } catch { } - if (!string.IsNullOrEmpty(error)) - { - Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); - // Alternatively: Debug.LogException(capturedEx); - } - }; - }); + if (!string.IsNullOrEmpty(error)) + { + Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); + // Alternatively: Debug.LogException(capturedEx); + } }; } } From b9d4590bc061e16be67c2f6f04fd7a320400ccdc Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:45:32 -0700 Subject: [PATCH 309/311] Docs(apply_text_edits): strengthen guidance on 1-based positions, verify-before-edit, and recommend anchors/structured edits --- .../src/tools/manage_script.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 62649730..9aad1249 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -61,13 +61,24 @@ def _split_uri(uri: str) -> tuple[str, str]: @mcp.tool(description=( "Apply small text edits to a C# script identified by URI.\n\n" + "⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" + "Common mistakes:\n" + "- Assuming what's on a line without checking\n" + "- Using wrong line numbers (they're 1-indexed)\n" + "- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n" + "RECOMMENDED WORKFLOW:\n" + "1) First call resources/read with start_line/line_count to verify exact content\n" + "2) Count columns carefully (or use find_in_file to locate patterns)\n" + "3) Apply your edit with precise coordinates\n" + "4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" "Args:\n" "- uri: unity://path/Assets/... or file://... or Assets/...\n" - "- edits: list of {startLine,startCol,endLine,endCol,newText}\n" - "- precondition_sha256: optional SHA of current file (whole-file)\n\n" + "- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n" + "- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n" "Notes:\n" - "- Path is computed from the URI; it must resolve under Assets/.\n" - "- This tool is for precise ranges; for method/class ops use script_apply_edits.\n" + "- Path must resolve under Assets/\n" + "- For method/class operations, use script_apply_edits (safer, structured edits)\n" + "- For pattern-based replacements, consider anchor operations in script_apply_edits\n" )) def apply_text_edits( ctx: Context, From cc5b3c42d531955d258bf5237948c507530209b6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:47:05 -0700 Subject: [PATCH 310/311] Docs(script_apply_edits): add safety guidance (anchors, method ops, validators) and recommended practices --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 33b824de..fc50be33 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -233,7 +233,13 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(description=( - "Apply targeted edits to an existing C# script (no full-file overwrite).\n\n" + "Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" + "Best practices:\n" + "- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" + "- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n" + "- Avoid whole-file regex deletes; validators will guard unbalanced braces\n" + "- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n" + "- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\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" From 435f15524eb92ca63a79e00adb8a7d681d2b634c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 30 Aug 2025 09:48:14 -0700 Subject: [PATCH 311/311] Framed VerifyBridgePing in editor window; docs hardening for apply_text_edits and script_apply_edits --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 116 ++++++++++++++++-- UnityMcpBridge/UnityMcpServer~/src/uv.lock | 2 +- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index c1dacf50..f9235fdb 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -757,22 +757,112 @@ private static bool IsClaudeConfigured() private static bool VerifyBridgePing(int port) { + // Use strict framed protocol to match bridge (FRAMING=1) + const int ConnectTimeoutMs = 1000; + const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout + try { - using TcpClient c = new TcpClient(); - var task = c.ConnectAsync(IPAddress.Loopback, port); - if (!task.Wait(500)) return false; - using NetworkStream s = c.GetStream(); - byte[] ping = Encoding.UTF8.GetBytes("ping"); - s.Write(ping, 0, ping.Length); - s.ReadTimeout = 1000; - byte[] buf = new byte[256]; - int n = s.Read(buf, 0, buf.Length); - if (n <= 0) return false; - string resp = Encoding.UTF8.GetString(buf, 0, n); - return resp.Contains("pong", StringComparison.OrdinalIgnoreCase); + using TcpClient client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (!connectTask.Wait(ConnectTimeoutMs)) return false; + + using NetworkStream stream = client.GetStream(); + try { client.NoDelay = true; } catch { } + + // 1) Read handshake line (ASCII, newline-terminated) + string handshake = ReadLineAscii(stream, 2000); + if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) + { + UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); + return false; + } + + // 2) Send framed "ping" + byte[] payload = Encoding.UTF8.GetBytes("ping"); + WriteFrame(stream, payload, FrameTimeoutMs); + + // 3) Read framed response and check for pong + string response = ReadFrameUtf8(stream, FrameTimeoutMs); + bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; + if (!ok) + { + UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); + } + return ok; } - catch { return false; } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); + return false; + } + } + + // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts + private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); + byte[] header = new byte[8]; + ulong len = (ulong)payload.LongLength; + header[0] = (byte)(len >> 56); + header[1] = (byte)(len >> 48); + header[2] = (byte)(len >> 40); + header[3] = (byte)(len >> 32); + header[4] = (byte)(len >> 24); + header[5] = (byte)(len >> 16); + header[6] = (byte)(len >> 8); + header[7] = (byte)(len); + + stream.WriteTimeout = timeoutMs; + stream.Write(header, 0, header.Length); + stream.Write(payload, 0, payload.Length); + } + + private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) + { + byte[] header = ReadExact(stream, 8, timeoutMs); + ulong len = ((ulong)header[0] << 56) + | ((ulong)header[1] << 48) + | ((ulong)header[2] << 40) + | ((ulong)header[3] << 32) + | ((ulong)header[4] << 24) + | ((ulong)header[5] << 16) + | ((ulong)header[6] << 8) + | header[7]; + if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); + if (len > int.MaxValue) throw new IOException("Frame too large"); + byte[] payload = ReadExact(stream, (int)len, timeoutMs); + return Encoding.UTF8.GetString(payload); + } + + private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) + { + byte[] buffer = new byte[count]; + int offset = 0; + stream.ReadTimeout = timeoutMs; + while (offset < count) + { + int read = stream.Read(buffer, offset, count - offset); + if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); + offset += read; + } + return buffer; + } + + private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) + { + stream.ReadTimeout = timeoutMs; + using var ms = new MemoryStream(); + byte[] one = new byte[1]; + while (ms.Length < maxLen) + { + int n = stream.Read(one, 0, 1); + if (n <= 0) break; + if (one[0] == (byte)'\n') break; + ms.WriteByte(one[0]); + } + return Encoding.ASCII.GetString(ms.ToArray()); } private void DrawClientConfigurationCompact(McpClient mcpClient) diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index 9c59867d..87a4deb9 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -162,7 +162,7 @@ cli = [ [[package]] name = "mcpforunityserver" -version = "3.0.0" +version = "3.0.2" source = { editable = "." } dependencies = [ { name = "httpx" },