From 111fdd19f519dbefc328cf3e93993406ef234557 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 09:32:19 -0700 Subject: [PATCH 01/15] Unity MCP: reliable auto-reload via Unity-side sentinel flip; remove Python writes - Add MCP/Flip Reload Sentinel editor menu and flip package sentinel synchronously - Trigger sentinel flip after Create/Update/ApplyTextEdits (sync) in ManageScript - Instrument flip path with Debug.Log for traceability - Remove Python reload_sentinel writes; tools now execute Unity menu instead - Harden reload_sentinel path resolution to project/package - ExecuteMenuItem runs synchronously for deterministic results - Verified MCP edits trigger compile/reload without focus; no Python permission errors --- TestProjects/UnityMCPTests/Assets/Editor.meta | 8 + UnityMcpBridge/Editor/Sentinel.meta | 8 + .../Editor/Sentinel/FlipReloadSentinelMenu.cs | 47 + .../Sentinel/FlipReloadSentinelMenu.cs.meta | 2 + .../Editor/Sentinel/__McpReloadSentinel.cs | 10 + .../Sentinel/__McpReloadSentinel.cs.meta | 2 + .../Editor/Tools/ExecuteMenuItem.cs | 46 +- UnityMcpBridge/Editor/Tools/ManageScript.cs | 55 + .../Editor/Tools/ManageScript.cs.backup | 2649 +++++++++++++++++ .../Editor/Tools/ManageScript.cs.backup.meta | 7 + .../UnityMcpServer~/src/reload_sentinel.py | 113 + .../src/tools/manage_script.py | 4 + .../src/tools/manage_script_edits.py | 26 + UnityMcpBridge/UnityMcpServer~/src/uv.lock | 2 +- 14 files changed, 2947 insertions(+), 32 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Editor.meta create mode 100644 UnityMcpBridge/Editor/Sentinel.meta create mode 100644 UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs create mode 100644 UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta create mode 100644 UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs create mode 100644 UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta create mode 100644 UnityMcpBridge/Editor/Tools/ManageScript.cs.backup create mode 100644 UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta create mode 100644 UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py diff --git a/TestProjects/UnityMCPTests/Assets/Editor.meta b/TestProjects/UnityMCPTests/Assets/Editor.meta new file mode 100644 index 000000000..79828f3ad --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 46421b2ea84fe4b1a903e2483cff3958 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Sentinel.meta b/UnityMcpBridge/Editor/Sentinel.meta new file mode 100644 index 000000000..5ea1a62d6 --- /dev/null +++ b/UnityMcpBridge/Editor/Sentinel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0199a62554c6d4b06a1c870dd8bc8379 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs new file mode 100644 index 000000000..f4b59f6d7 --- /dev/null +++ b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs @@ -0,0 +1,47 @@ +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; + +namespace MCPForUnity.Editor.Sentinel +{ + internal static class FlipReloadSentinelMenu + { + private const string PackageSentinelPath = "Packages/com.coplaydev.unity-mcp/Editor/Sentinel/__McpReloadSentinel.cs"; + + [MenuItem("MCP/Flip Reload Sentinel")] + private static void Flip() + { + try + { + string path = PackageSentinelPath; + if (!File.Exists(path)) + { + EditorUtility.DisplayDialog("Flip Sentinel", $"Sentinel not found at '{path}'.", "OK"); + return; + } + + string src = File.ReadAllText(path); + var m = Regex.Match(src, @"(const\s+int\s+Tick\s*=\s*)(\d+)(\s*;)" ); + if (m.Success) + { + string next = (m.Groups[2].Value == "1") ? "2" : "1"; + string newSrc = src.Substring(0, m.Groups[2].Index) + next + src.Substring(m.Groups[2].Index + m.Groups[2].Length); + File.WriteAllText(path, newSrc); + } + else + { + File.AppendAllText(path, "\n// MCP touch\n"); + } + + AssetDatabase.ImportAsset(path); + AssetDatabase.Refresh(); + } + catch (System.Exception ex) + { + UnityEngine.Debug.LogError($"Flip Reload Sentinel failed: {ex.Message}"); + } + } + } +} + + diff --git a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta new file mode 100644 index 000000000..d89f952f7 --- /dev/null +++ b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37d7e7fc4860947b9ac2745ab011c486 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs new file mode 100644 index 000000000..0ae485f2c --- /dev/null +++ b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs @@ -0,0 +1,10 @@ +#if UNITY_EDITOR +namespace MCP.Reload +{ + // Toggling this constant (1 <-> 2) changes IL and guarantees an assembly bump. + internal static class __McpReloadSentinel + { + internal const int Tick = 2; + } +} +#endif diff --git a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta new file mode 100644 index 000000000..996439e74 --- /dev/null +++ b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f5349fdb167948ac88fc9c99e250f9b \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs index e51d773e7..cfbf4ba9c 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs @@ -68,6 +68,8 @@ private static object ExecuteItem(JObject @params) { // Try both naming conventions: snake_case and camelCase string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + // Optional future param retained for API compatibility; not used in synchronous mode + // int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject() ?? 2000)); // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). @@ -94,42 +96,24 @@ private static object ExecuteItem(JObject @params) try { - // Attempt to execute the menu item on the main thread using delayCall for safety. - EditorApplication.delayCall += () => + // Execute synchronously. This code runs on the Editor main thread in our bridge path. + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (executed) { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - // Log potential failure inside the delayed call. - if (!executed) - { - Debug.LogError( - $"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent." - ); - } - } - catch (Exception delayEx) - { - Debug.LogError( - $"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}" - ); - } - }; - - // Report attempt immediately, as execution is delayed. - return Response.Success( - $"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors." + return Response.Success( + $"Executed menu item: '{menuPath}'", + new { executed = true, menuPath } + ); + } + return Response.Error( + $"Failed to execute menu item (not found or disabled): '{menuPath}'", + new { executed = false, menuPath } ); } catch (Exception e) { - // Catch errors during setup phase. - Debug.LogError( - $"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}" - ); - return Response.Error( - $"Error setting up execution for menu item '{menuPath}': {e.Message}" - ); + Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}"); + return Response.Error($"Error executing menu item '{menuPath}': {e.Message}"); } } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0337f74f3..ef63e91a6 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -361,6 +361,9 @@ string namespaceName // Schedule heavy work AFTER replying ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + // Also flip the reload sentinel from a background thread so Unity detects an on-disk IL change without requiring focus + Debug.Log("MCP: FlipSentinelInBackground() call [Create] for '{relativePath}'"); + ManageScriptRefreshHelpers.FlipSentinelInBackground(); return ok; } catch (Exception e) @@ -470,6 +473,8 @@ string contents // Schedule a debounced import/compile on next editor tick to avoid stalling the reply ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + Debug.Log("MCP: FlipSentinelInBackground() call [Update] for '{relativePath}'"); + ManageScriptRefreshHelpers.FlipSentinelInBackground(); return ok; } @@ -779,6 +784,27 @@ private static object ApplyTextEdits( ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } + // Trigger sentinel flip synchronously so domain reload happens deterministically + try + { + Debug.Log("MCP: Executing menu MCP/Flip Reload Sentinel (sync)"); + bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); + if (!executed) + { + Debug.LogWarning("MCP: Sentinel menu not found/disabled; falling back to AssetDatabase.Refresh()"); + AssetDatabase.Refresh(); + } + else + { + Debug.Log("MCP: Sentinel flip executed synchronously"); + } + } + catch (System.Exception e) + { + Debug.LogError("MCP: Exception flipping sentinel (sync): " + e.Message); + AssetDatabase.Refresh(); + } + return Response.Success( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new @@ -2620,5 +2646,34 @@ public static void ScheduleScriptRefresh(string relPath) { RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); } + + // Flip the MCP reload sentinel on the next editor tick to ensure Unity detects + // an on-disk IL change even if the Editor window is not focused. + public static void FlipSentinelInBackground() + { + Debug.Log("MCP: FlipSentinelInBackground() scheduled"); + EditorApplication.delayCall += () => + { + try + { + Debug.Log("MCP: Executing menu MCP/Flip Reload Sentinel"); + bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); + if (!executed) + { + Debug.LogWarning("MCP: Menu execution failed; falling back to AssetDatabase.Refresh()"); + AssetDatabase.Refresh(); + } + else + { + Debug.Log("MCP: Sentinel flip menu executed successfully"); + } + } + catch (System.Exception e) + { + Debug.LogError("MCP: Exception in FlipSentinelInBackground: " + e.Message + " — falling back to AssetDatabase.Refresh()"); + AssetDatabase.Refresh(); + } + }; + } } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup new file mode 100644 index 000000000..3cea706c8 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup @@ -0,0 +1,2649 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.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 +using UnityEditor.Compilation; +#endif + + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles CRUD operations for C# scripts within the Unity project. + /// + /// ROSLYN INSTALLATION GUIDE: + /// To enable advanced syntax validation with Roslyn compiler services: + /// + /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: + /// - Open Package Manager in Unity + /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity + /// + /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: + /// + /// 3. Alternative: Manual DLL installation: + /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies + /// - Place in Assets/Plugins/ folder + /// - Ensure .NET compatibility settings are correct + /// + /// 4. Define USE_ROSLYN symbol: + /// - Go to Player Settings > Scripting Define Symbols + /// - Add "USE_ROSLYN" to enable Roslyn-based validation + /// + /// 5. Restart Unity after installation + /// + /// Note: Without Roslyn, the system falls back to basic structural validation. + /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. + /// + 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('\\', '/'); + + // 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) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // 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); + while (di != null) + { + 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 */ } + + 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. + /// + public static object HandleCommand(JObject @params) + { + // Extract parameters + string action = @params["action"]?.ToString().ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode script contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation + string namespaceName = @params["namespace"]?.ToString(); // For organizing code + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + 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_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) + { + return Response.Error( + $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) + { + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + } + + // Construct file paths + string scriptFileName = $"{name}.cs"; + string fullPath = Path.Combine(fullPathDir, scriptFileName); + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateScript( + fullPath, + relativePath, + name, + contents, + scriptType, + namespaceName + ); + case "read": + Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); + case "update": + 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": + { + var textEdits = @params["edits"] as JArray; + string precondition = @params["precondition_sha256"]?.ToString(); + // Respect optional options + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => 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[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + 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; + 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": + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + 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)." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateScript( + string fullPath, + string relativePath, + string name, + string contents, + string scriptType, + string namespaceName + ) + { + // Check if script already exists + if (File.Exists(fullPath)) + { + return Response.Error( + $"Script already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block creation + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // 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 + { + File.Move(tmp, fullPath); + } + catch (IOException) + { + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } + + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( + $"Script '{name}.cs' created successfully at '{relativePath}'.", + new { uri, scheduledRefresh = true } + ); + + // Schedule heavy work AFTER replying + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + // Also flip the reload sentinel from a background thread so Unity detects an on-disk IL change without requiring focus + ManageScriptRefreshHelpers.FlipSentinelInBackground(); + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + } + } + + private static object ReadScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // 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 + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + } + } + + private static object UpdateScript( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return Response.Error( + $"Script not found at '{relativePath}'. Use 'create' action to add a new script." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block update + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // 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); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (IOException) + { + 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 { uri, path = relativePath, scheduledRefresh = true } + ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + ManageScriptRefreshHelpers.FlipSentinelInBackground(); + + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + } + } + + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private const int MaxEditPayloadBytes = 64 * 1024; + + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits, + string preconditionSha256, + string refreshModeFromCaller = null, + string validateMode = null) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target or any ancestor is a symlink + try + { + 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 + { + // If checking attributes fails, proceed without the symlink guard + } + 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}"); } + + // Require precondition to avoid drift on large files + string currentSha = ComputeSha256(original); + 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)>(); + long totalBytes = 0; + 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)); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + 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, + TimeSpan.FromSeconds(2) + ); + 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("using_guard", new { status = "using_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(); + + // 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"] = replacementText + }; + 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" }); + } + + // 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) + { + 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; + 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); + 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 + 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 (!relaxed && !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(), evidenceWindow = new { startLine, endLine } }); + } + +#if USE_ROSLYN + 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(); + } + catch { } + } +#endif + + string newSha = ComputeSha256(working); + + // Atomic write and schedule refresh + try + { + 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 { } + } + + // 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 + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = spans.Count, + sha256 = newSha + } + ); + } + 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 == '\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; + } + + 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; + } + + // 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)) + { + return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + } + + try + { + // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) + bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); + if (deleted) + { + AssetDatabase.Refresh(); + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } + ); + } + else + { + // Fallback or error if MoveAssetToTrash fails + return Response.Error( + $"Failed to move script '{relativePath}' to trash. It might be locked or in use." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + } + } + + /// + /// 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}'."); + // 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."); + + 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)>(); + 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) + { + 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}"); + + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + 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}"); + + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + 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)) + { + 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}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + 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)) + { + 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}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + 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); + // 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."); + + 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"); + 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"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + 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, 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; + 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); + 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, TimeSpan.FromSeconds(2)); + 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, TimeSpan.FromSeconds(2)); + 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, anchor_insert, anchor_delete, anchor_replace."); + } + } + + 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); + 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 + { + 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("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)); + + // Atomic write with backup; schedule refresh + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + // 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}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = appliedCount, + scheduledRefresh = !immediate, + sha256 = newSha + } + ); + + if (immediate) + { + // Force on main thread + EditorApplication.delayCall += () => + { + 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); + // 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+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + 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 + // 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++) + { + 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; + } + + // 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 + 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. + /// + private static string GenerateDefaultScriptContent( + string name, + string scriptType, + string namespaceName + ) + { + string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; + string classDeclaration; + string body = + "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; + + string baseClass = ""; + if (!string.IsNullOrEmpty(scriptType)) + { + if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) + baseClass = " : MonoBehaviour"; + else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) + { + baseClass = " : ScriptableObject"; + body = ""; // ScriptableObjects don't usually need Start/Update + } + else if ( + scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) + || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) + ) + { + usingStatements += "using UnityEditor;\n"; + if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) + baseClass = " : Editor"; + else + baseClass = " : EditorWindow"; + body = ""; // Editor scripts have different structures + } + // Add more types as needed + } + + classDeclaration = $"public class {name}{baseClass}"; + + string fullContent = $"{usingStatements}\n"; + bool useNamespace = !string.IsNullOrEmpty(namespaceName); + + if (useNamespace) + { + fullContent += $"namespace {namespaceName}\n{{\n"; + // Indent class and body if using namespace + classDeclaration = " " + classDeclaration; + body = string.Join("\n", body.Split('\n').Select(line => " " + line)); + } + + fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; + + if (useNamespace) + { + fullContent += "\n}"; // Close namespace + } + + return fullContent.Trim() + "\n"; // Ensure a trailing newline + } + + /// + /// Gets the validation level from the GUI settings + /// + private static ValidationLevel GetValidationLevelFromGUI() + { + string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); + return savedLevel.ToLower() switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => ValidationLevel.Standard // Default fallback + }; + } + + /// + /// Validates C# script syntax using multiple validation layers. + /// + private static bool ValidateScriptSyntax(string contents) + { + return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); + } + + /// + /// Advanced syntax validation with detailed diagnostics and configurable strictness. + /// + private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) + { + var errorList = new System.Collections.Generic.List(); + errors = null; + + if (string.IsNullOrEmpty(contents)) + { + return true; // Empty content is valid + } + + // Basic structural validation + if (!ValidateBasicStructure(contents, errorList)) + { + errors = errorList.ToArray(); + return false; + } + +#if USE_ROSLYN + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) + { + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } + } +#endif + + // Unity-specific validation + if (level >= ValidationLevel.Standard) + { + ValidateScriptSyntaxUnity(contents, errorList); + } + + // Semantic analysis for common issues + if (level >= ValidationLevel.Comprehensive) + { + ValidateSemanticRules(contents, errorList); + } + +#if USE_ROSLYN + // Full semantic compilation validation for Strict level + if (level == ValidationLevel.Strict) + { + if (!ValidateScriptSemantics(contents, errorList)) + { + errors = errorList.ToArray(); + return false; // Strict level fails on any semantic errors + } + } +#endif + + errors = errorList.ToArray(); + return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); + } + + /// + /// Validation strictness levels + /// + private enum ValidationLevel + { + Basic, // Only syntax errors + Standard, // Syntax + Unity best practices + Comprehensive, // All checks + semantic analysis + Strict // Treat all issues as errors + } + + /// + /// Validates basic code structure (braces, quotes, comments) + /// + private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors) + { + bool isValid = true; + int braceBalance = 0; + int parenBalance = 0; + int bracketBalance = 0; + bool inStringLiteral = false; + bool inCharLiteral = false; + bool inSingleLineComment = false; + bool inMultiLineComment = false; + bool escaped = false; + + for (int i = 0; i < contents.Length; i++) + { + char c = contents[i]; + char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; + + // Handle escape sequences + if (escaped) + { + escaped = false; + continue; + } + + if (c == '\\' && (inStringLiteral || inCharLiteral)) + { + escaped = true; + continue; + } + + // Handle comments + if (!inStringLiteral && !inCharLiteral) + { + if (c == '/' && next == '/' && !inMultiLineComment) + { + inSingleLineComment = true; + continue; + } + if (c == '/' && next == '*' && !inSingleLineComment) + { + inMultiLineComment = true; + i++; // Skip next character + continue; + } + if (c == '*' && next == '/' && inMultiLineComment) + { + inMultiLineComment = false; + i++; // Skip next character + continue; + } + } + + if (c == '\n') + { + inSingleLineComment = false; + continue; + } + + if (inSingleLineComment || inMultiLineComment) + continue; + + // Handle string and character literals + if (c == '"' && !inCharLiteral) + { + inStringLiteral = !inStringLiteral; + continue; + } + if (c == '\'' && !inStringLiteral) + { + inCharLiteral = !inCharLiteral; + continue; + } + + if (inStringLiteral || inCharLiteral) + continue; + + // Count brackets and braces + switch (c) + { + case '{': braceBalance++; break; + case '}': braceBalance--; break; + case '(': parenBalance++; break; + case ')': parenBalance--; break; + case '[': bracketBalance++; break; + case ']': bracketBalance--; break; + } + + // Check for negative balances (closing without opening) + if (braceBalance < 0) + { + errors.Add("ERROR: Unmatched closing brace '}'"); + isValid = false; + } + if (parenBalance < 0) + { + errors.Add("ERROR: Unmatched closing parenthesis ')'"); + isValid = false; + } + if (bracketBalance < 0) + { + errors.Add("ERROR: Unmatched closing bracket ']'"); + isValid = false; + } + } + + // Check final balances + if (braceBalance != 0) + { + errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); + isValid = false; + } + if (parenBalance != 0) + { + errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); + isValid = false; + } + if (bracketBalance != 0) + { + errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); + isValid = false; + } + if (inStringLiteral) + { + errors.Add("ERROR: Unterminated string literal"); + isValid = false; + } + if (inCharLiteral) + { + errors.Add("ERROR: Unterminated character literal"); + isValid = false; + } + if (inMultiLineComment) + { + errors.Add("WARNING: Unterminated multi-line comment"); + } + + return isValid; + } + +#if USE_ROSLYN + /// + /// Cached compilation references for performance + /// + private static System.Collections.Generic.List _cachedReferences = null; + private static DateTime _cacheTime = DateTime.MinValue; + private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); + + /// + /// Validates syntax using Roslyn compiler services + /// + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + var diagnostics = syntaxTree.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + string severity = diagnostic.Severity.ToString().ToUpper(); + string message = $"{severity}: {diagnostic.GetMessage()}"; + + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + } + + // Include warnings in comprehensive mode + if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now + { + var location = diagnostic.Location.GetLineSpan(); + if (location.IsValid) + { + message += $" (Line {location.StartLinePosition.Line + 1})"; + } + errors.Add(message); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors + /// + private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List errors) + { + try + { + // Get compilation references with caching + var references = GetCompilationReferences(); + if (references == null || references.Count == 0) + { + errors.Add("WARNING: Could not load compilation references for semantic validation"); + return true; // Don't fail if we can't get references + } + + // Create syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + + // Create compilation with full context + var compilation = CSharpCompilation.Create( + "TempValidation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Get semantic diagnostics - this catches all the issues you mentioned! + var diagnostics = compilation.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + // Include diagnostic ID for better error identification + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + else if (diagnostic.Severity == DiagnosticSeverity.Warning) + { + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Gets compilation references with caching for performance + /// + private static System.Collections.Generic.List GetCompilationReferences() + { + // Check cache validity + if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) + { + return _cachedReferences; + } + + try + { + var references = new System.Collections.Generic.List(); + + // Core .NET assemblies + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib + references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq + references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections + + // Unity assemblies + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); + } + +#if UNITY_EDITOR + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); + } + + // Get Unity project assemblies + try + { + var assemblies = CompilationPipeline.GetAssemblies(); + foreach (var assembly in assemblies) + { + if (File.Exists(assembly.outputPath)) + { + references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); + } +#endif + + // Cache the results + _cachedReferences = references; + _cacheTime = DateTime.Now; + + return references; + } + catch (Exception ex) + { + Debug.LogError($"Failed to get compilation references: {ex.Message}"); + return new System.Collections.Generic.List(); + } + } +#else + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + // Fallback when Roslyn is not available + return true; + } +#endif + + /// + /// Validates Unity-specific coding rules and best practices + /// //TODO: Naive Unity Checks and not really yield any results, need to be improved + /// + private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List errors) + { + // Check for common Unity anti-patterns + if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) + { + errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); + } + + if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) + { + errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); + } + + // Check for proper MonoBehaviour usage + if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); + } + + // Check for SerializeField usage + if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); + } + + // Check for proper coroutine usage + if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) + { + errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); + } + + // Check for Update without FixedUpdate for physics + if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) + { + errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); + } + + // Check for missing null checks on Unity objects + if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) + { + errors.Add("WARNING: Consider null checking GetComponent results"); + } + + // Check for proper event function signatures + if (contents.Contains("void Start(") && !contents.Contains("void Start()")) + { + errors.Add("WARNING: Start() should not have parameters"); + } + + if (contents.Contains("void Update(") && !contents.Contains("void Update()")) + { + errors.Add("WARNING: Update() should not have parameters"); + } + + // Check for inefficient string operations + if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) + { + errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); + } + } + + /// + /// Validates semantic rules and common coding issues + /// + private static void ValidateSemanticRules(string contents, System.Collections.Generic.List errors) + { + // Check for potential memory leaks + if (contents.Contains("new ") && contents.Contains("Update()")) + { + errors.Add("WARNING: Creating objects in Update() may cause memory issues"); + } + + // Check for magic numbers + var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var matches = magicNumberPattern.Matches(contents); + if (matches.Count > 5) + { + errors.Add("WARNING: Consider using named constants instead of magic numbers"); + } + + // Check for long methods (simple line count check) + 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) + { + int startIndex = match.Index; + int braceCount = 0; + int lineCount = 0; + bool inMethod = false; + + for (int i = startIndex; i < contents.Length; i++) + { + if (contents[i] == '{') + { + braceCount++; + inMethod = true; + } + else if (contents[i] == '}') + { + braceCount--; + if (braceCount == 0 && inMethod) + break; + } + else if (contents[i] == '\n' && inMethod) + { + lineCount++; + } + } + + if (lineCount > 50) + { + errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); + break; // Only report once + } + } + + // Check for proper exception handling + if (contents.Contains("catch") && contents.Contains("catch()")) + { + errors.Add("WARNING: Empty catch blocks should be avoided"); + } + + // Check for proper async/await usage + if (contents.Contains("async ") && !contents.Contains("await")) + { + errors.Add("WARNING: Async method should contain await or return Task"); + } + + // Check for hardcoded tags and layers + if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) + { + errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); + } + } + + //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) + /// + /// Public method to validate script syntax with configurable validation level + /// Returns detailed validation results including errors and warnings + /// + // public static object ValidateScript(JObject @params) + // { + // string contents = @params["contents"]?.ToString(); + // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; + + // if (string.IsNullOrEmpty(contents)) + // { + // return Response.Error("Contents parameter is required for validation."); + // } + + // // Parse validation level + // ValidationLevel level = ValidationLevel.Standard; + // switch (validationLevel.ToLower()) + // { + // case "basic": level = ValidationLevel.Basic; break; + // case "standard": level = ValidationLevel.Standard; break; + // case "comprehensive": level = ValidationLevel.Comprehensive; break; + // case "strict": level = ValidationLevel.Strict; break; + // default: + // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // } + + // // Perform validation + // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); + + // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; + // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; + + // var result = new + // { + // isValid = isValid, + // validationLevel = validationLevel, + // errorCount = errors.Length, + // warningCount = warnings.Length, + // errors = errors, + // warnings = warnings, + // summary = isValid + // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") + // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" + // }; + + // if (isValid) + // { + // return Response.Success("Script validation completed successfully.", result); + // } + // else + // { + // return Response.Error("Script validation failed.", result); + // } + // } + } +} + +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + 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); + _lastRequest = DateTime.UtcNow; + + // 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) + { + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) + { + _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(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + } +} + +static class ManageScriptRefreshHelpers +{ + public static void ScheduleScriptRefresh(string relPath) + { + RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); + } + + // Flip the MCP reload sentinel on the next editor tick to ensure Unity detects + // an on-disk IL change even if the Editor window is not focused. + public static void FlipSentinelInBackground() + { + EditorApplication.delayCall += () => + { + try + { + bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); + if (!executed) + { + // Fallback: at least refresh assets so changes are noticed + AssetDatabase.Refresh(); + } + } + catch + { + AssetDatabase.Refresh(); + } + }; + } +} + diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta new file mode 100644 index 000000000..119accca2 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 970f503aa9de343889f9beb85a84af88 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py new file mode 100644 index 000000000..42385ad5b --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py @@ -0,0 +1,113 @@ +# reload_sentinel.py +import os, io, re + + +def _resolve_project_root(hint: str | None) -> str: + """Best-effort absolute project root resolution. + Prefers env UNITY_PROJECT_ROOT/MCP_UNITY_PROJECT_ROOT; falls back to asking Unity; + last resort: current working directory. + """ + # 1) Environment overrides + env = (os.environ.get("UNITY_PROJECT_ROOT") or os.environ.get("MCP_UNITY_PROJECT_ROOT") or "").strip() + if not env: + env = (hint or "").strip() + if env: + pr = env if os.path.isabs(env) else os.path.abspath(env) + return pr + + # 2) Ask Unity via bridge (best effort) + try: + from unity_connection import send_command_with_retry # type: ignore + resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) + if isinstance(resp, dict) and resp.get("success"): + data = resp.get("data") or {} + pr = (data.get("projectRoot") or data.get("path") or "").strip() + if pr: + return pr if os.path.isabs(pr) else os.path.abspath(pr) + except Exception: + pass + + # 3) Fallback + return os.getcwd() + + +def _project_package_sentinel(project_root_abs: str) -> str: + # Packages/com.coplaydev.unity-mcp/Editor/Sentinel/__McpReloadSentinel.cs + return os.path.join( + project_root_abs, + "Packages", + "com.coplaydev.unity-mcp", + "Editor", + "Sentinel", + "__McpReloadSentinel.cs", + ) + + +def _project_assets_sentinel(project_root_abs: str) -> str: + # Assets/Editor/__McpReloadSentinel.cs (project-local copy) + return os.path.join(project_root_abs, "Assets", "Editor", "__McpReloadSentinel.cs") + + +def flip_reload_sentinel(project_root: str, + rel_path: str = "Assets/Editor/__McpReloadSentinel.cs") -> None: + """ + Atomically toggle a constant to force an Editor assembly IL change. + This produces a real on-disk edit that Unity's watcher will see, + causing compile + domain reload even when unfocused. + Resolves the sentinel path INSIDE the Unity project (Packages or Assets), + avoiding process working-directory issues. + """ + # Prefer an explicit override for the exact file to touch + override_path = (os.environ.get("MCP_UNITY_SENTINEL_PATH") or "").strip() + project_root_abs = _resolve_project_root(project_root) + + candidate_paths: list[str] = [] + + if override_path: + path = override_path if os.path.isabs(override_path) else os.path.join(project_root_abs, override_path) + candidate_paths.append(os.path.abspath(path)) + else: + # 1) Project package sentinel + candidate_paths.append(os.path.abspath(_project_package_sentinel(project_root_abs))) + # 2) Project assets-level sentinel (historical default) + candidate_paths.append(os.path.abspath(_project_assets_sentinel(project_root_abs))) + # 3) If caller passed a rel_path, resolve it under the project + if rel_path: + rp = rel_path if os.path.isabs(rel_path) else os.path.join(project_root_abs, rel_path) + candidate_paths.append(os.path.abspath(rp)) + + # Choose the first existing file among candidates; otherwise, create under package path + path = next((p for p in candidate_paths if os.path.exists(p)), candidate_paths[0]) + + # Ensure parent directory exists inside the project + os.makedirs(os.path.dirname(path), exist_ok=True) + + if not os.path.exists(path): + seed = ( + "#if UNITY_EDITOR\n" + "namespace MCP.Reload\n" + "{\n" + " internal static class __McpReloadSentinel\n" + " {\n" + " internal const int Tick = 1;\n" + " }\n" + "}\n" + "#endif\n" + ) + with io.open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(seed) + + with io.open(path, "r", encoding="utf-8") as f: + src = f.read() + + m = re.search(r"(const\s+int\s+Tick\s*=\s*)(\d+)(\s*;)", src) + if m: + nxt = "2" if m.group(2) == "1" else "1" + new_src = src[:m.start(2)] + nxt + src[m.end(2):] + else: + new_src = src + "\n// MCP touch\n" + + tmp = path + ".tmp" + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write(new_src) + os.replace(tmp, path) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 9aad12493..112fc63b9 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -306,6 +306,10 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: data.setdefault("normalizedEdits", normalized_edits) if warnings: data.setdefault("warnings", warnings) + if resp.get("success"): + # Unity-side ManageScript handles sentinel flip after successful writes. + # Avoid Python-side file writes that can fail due to working dir/permissions. + return resp return resp return {"success": False, "message": str(resp)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index fc50be330..836fb0293 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -2,6 +2,7 @@ from typing import Dict, Any, List, Tuple import base64 import re +import os from unity_connection import send_command_with_retry @@ -482,6 +483,11 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str "options": opts2, } resp_struct = send_command_with_retry("manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + try: + send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + except Exception: + pass 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 @@ -602,6 +608,11 @@ def _expand_dollars(rep: str) -> str: 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") + # Successful text write; flip sentinel via Unity menu to force reload + try: + send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + except Exception: + pass except Exception as e: return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") @@ -619,6 +630,11 @@ def _expand_dollars(rep: str) -> str: "options": opts2 } resp_struct = send_command_with_retry("manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + try: + send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + except Exception: + pass 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") @@ -740,6 +756,11 @@ def _expand_dollars(rep: str) -> str: } } resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + try: + send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + except Exception: + pass return _with_norm( resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, @@ -820,6 +841,11 @@ def _expand_dollars(rep: str) -> str: } write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict) and write_resp.get("success"): + try: + send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + except Exception: + pass return _with_norm( write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index 87a4deb97..f5cac0f5f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -162,7 +162,7 @@ cli = [ [[package]] name = "mcpforunityserver" -version = "3.0.2" +version = "3.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 528a37b8b2bf842e824f68a98bbf6dda05a06703 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 11:45:46 -0700 Subject: [PATCH 02/15] Getting double flips --- .../UnityMCPTests/Assets/Scripts/Hello.cs | 12 ++ .../Editor/Sentinel/FlipReloadSentinelMenu.cs | 13 +- .../Editor/Tools/ExecuteMenuItem.cs | 5 + UnityMcpBridge/Editor/Tools/ManageScript.cs | 63 +-------- .../UnityMcpServer~/src/reload_sentinel.py | 121 ++---------------- .../src/tools/manage_script.py | 34 ++++- .../src/tools/manage_script_edits.py | 49 ++++++- 7 files changed, 112 insertions(+), 185 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs index ab9964319..c68ea9de4 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs @@ -9,3 +9,15 @@ void Start() Debug.Log("Hello World"); } } +// edit test passes +// reload validation +// python flip should run now +// execute menu hit? +// server reinstall flip check +// async flip check +// async-only path verification +// single-flip verification run +// background reload check +// background reload check 2 +// single reload only check +// guarded flip test diff --git a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs index f4b59f6d7..f0607330b 100644 --- a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs +++ b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.RegularExpressions; using UnityEditor; +using UnityEngine; namespace MCPForUnity.Editor.Sentinel { @@ -13,10 +14,11 @@ private static void Flip() { try { + Debug.Log("[FlipReloadSentinelMenu] Executing menu MCP/Flip Reload Sentinel"); string path = PackageSentinelPath; if (!File.Exists(path)) { - EditorUtility.DisplayDialog("Flip Sentinel", $"Sentinel not found at '{path}'.", "OK"); + Debug.LogWarning($"[FlipReloadSentinelMenu] Sentinel not found at '{path}'."); return; } @@ -33,12 +35,15 @@ private static void Flip() File.AppendAllText(path, "\n// MCP touch\n"); } - AssetDatabase.ImportAsset(path); - AssetDatabase.Refresh(); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + Debug.Log("[FlipReloadSentinelMenu] Sentinel flip menu executed successfully"); } catch (System.Exception ex) { - UnityEngine.Debug.LogError($"Flip Reload Sentinel failed: {ex.Message}"); + Debug.LogError($"[FlipReloadSentinelMenu] Flip failed: {ex.Message}"); } } } diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs index cfbf4ba9c..5adb476d4 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs @@ -96,15 +96,20 @@ private static object ExecuteItem(JObject @params) try { + // Trace incoming execute requests + Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'"); + // Execute synchronously. This code runs on the Editor main thread in our bridge path. bool executed = EditorApplication.ExecuteMenuItem(menuPath); if (executed) { + Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'"); return Response.Success( $"Executed menu item: '{menuPath}'", new { executed = true, menuPath } ); } + Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'"); return Response.Error( $"Failed to execute menu item (not found or disabled): '{menuPath}'", new { executed = false, menuPath } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index ef63e91a6..e70aeda7f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -361,9 +361,7 @@ string namespaceName // Schedule heavy work AFTER replying ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - // Also flip the reload sentinel from a background thread so Unity detects an on-disk IL change without requiring focus - Debug.Log("MCP: FlipSentinelInBackground() call [Create] for '{relativePath}'"); - ManageScriptRefreshHelpers.FlipSentinelInBackground(); + return ok; } catch (Exception e) @@ -473,8 +471,6 @@ string contents // Schedule a debounced import/compile on next editor tick to avoid stalling the reply ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - Debug.Log("MCP: FlipSentinelInBackground() call [Update] for '{relativePath}'"); - ManageScriptRefreshHelpers.FlipSentinelInBackground(); return ok; } @@ -655,7 +651,7 @@ private static object ApplyTextEdits( spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) { - if (spans[i].end > spans[i - 1].start) + if (spans[i].start + (spans[i].end - spans[i].start) > spans[i - 1].start) { 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." }); @@ -774,9 +770,6 @@ private static object ApplyTextEdits( relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate ); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif }; } else @@ -784,27 +777,6 @@ private static object ApplyTextEdits( ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } - // Trigger sentinel flip synchronously so domain reload happens deterministically - try - { - Debug.Log("MCP: Executing menu MCP/Flip Reload Sentinel (sync)"); - bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); - if (!executed) - { - Debug.LogWarning("MCP: Sentinel menu not found/disabled; falling back to AssetDatabase.Refresh()"); - AssetDatabase.Refresh(); - } - else - { - Debug.Log("MCP: Sentinel flip executed synchronously"); - } - } - catch (System.Exception e) - { - Debug.LogError("MCP: Exception flipping sentinel (sync): " + e.Message); - AssetDatabase.Refresh(); - } - return Response.Success( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new @@ -1352,7 +1324,7 @@ private static object EditScript( 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", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } return Response.Error("overlap", new { status = "overlap" }); @@ -2646,34 +2618,5 @@ public static void ScheduleScriptRefresh(string relPath) { RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); } - - // Flip the MCP reload sentinel on the next editor tick to ensure Unity detects - // an on-disk IL change even if the Editor window is not focused. - public static void FlipSentinelInBackground() - { - Debug.Log("MCP: FlipSentinelInBackground() scheduled"); - EditorApplication.delayCall += () => - { - try - { - Debug.Log("MCP: Executing menu MCP/Flip Reload Sentinel"); - bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); - if (!executed) - { - Debug.LogWarning("MCP: Menu execution failed; falling back to AssetDatabase.Refresh()"); - AssetDatabase.Refresh(); - } - else - { - Debug.Log("MCP: Sentinel flip menu executed successfully"); - } - } - catch (System.Exception e) - { - Debug.LogError("MCP: Exception in FlipSentinelInBackground: " + e.Message + " — falling back to AssetDatabase.Refresh()"); - AssetDatabase.Refresh(); - } - }; - } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py index 42385ad5b..e224844b5 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py +++ b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py @@ -1,113 +1,8 @@ -# reload_sentinel.py -import os, io, re - - -def _resolve_project_root(hint: str | None) -> str: - """Best-effort absolute project root resolution. - Prefers env UNITY_PROJECT_ROOT/MCP_UNITY_PROJECT_ROOT; falls back to asking Unity; - last resort: current working directory. - """ - # 1) Environment overrides - env = (os.environ.get("UNITY_PROJECT_ROOT") or os.environ.get("MCP_UNITY_PROJECT_ROOT") or "").strip() - if not env: - env = (hint or "").strip() - if env: - pr = env if os.path.isabs(env) else os.path.abspath(env) - return pr - - # 2) Ask Unity via bridge (best effort) - try: - from unity_connection import send_command_with_retry # type: ignore - resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) - if isinstance(resp, dict) and resp.get("success"): - data = resp.get("data") or {} - pr = (data.get("projectRoot") or data.get("path") or "").strip() - if pr: - return pr if os.path.isabs(pr) else os.path.abspath(pr) - except Exception: - pass - - # 3) Fallback - return os.getcwd() - - -def _project_package_sentinel(project_root_abs: str) -> str: - # Packages/com.coplaydev.unity-mcp/Editor/Sentinel/__McpReloadSentinel.cs - return os.path.join( - project_root_abs, - "Packages", - "com.coplaydev.unity-mcp", - "Editor", - "Sentinel", - "__McpReloadSentinel.cs", - ) - - -def _project_assets_sentinel(project_root_abs: str) -> str: - # Assets/Editor/__McpReloadSentinel.cs (project-local copy) - return os.path.join(project_root_abs, "Assets", "Editor", "__McpReloadSentinel.cs") - - -def flip_reload_sentinel(project_root: str, - rel_path: str = "Assets/Editor/__McpReloadSentinel.cs") -> None: - """ - Atomically toggle a constant to force an Editor assembly IL change. - This produces a real on-disk edit that Unity's watcher will see, - causing compile + domain reload even when unfocused. - Resolves the sentinel path INSIDE the Unity project (Packages or Assets), - avoiding process working-directory issues. - """ - # Prefer an explicit override for the exact file to touch - override_path = (os.environ.get("MCP_UNITY_SENTINEL_PATH") or "").strip() - project_root_abs = _resolve_project_root(project_root) - - candidate_paths: list[str] = [] - - if override_path: - path = override_path if os.path.isabs(override_path) else os.path.join(project_root_abs, override_path) - candidate_paths.append(os.path.abspath(path)) - else: - # 1) Project package sentinel - candidate_paths.append(os.path.abspath(_project_package_sentinel(project_root_abs))) - # 2) Project assets-level sentinel (historical default) - candidate_paths.append(os.path.abspath(_project_assets_sentinel(project_root_abs))) - # 3) If caller passed a rel_path, resolve it under the project - if rel_path: - rp = rel_path if os.path.isabs(rel_path) else os.path.join(project_root_abs, rel_path) - candidate_paths.append(os.path.abspath(rp)) - - # Choose the first existing file among candidates; otherwise, create under package path - path = next((p for p in candidate_paths if os.path.exists(p)), candidate_paths[0]) - - # Ensure parent directory exists inside the project - os.makedirs(os.path.dirname(path), exist_ok=True) - - if not os.path.exists(path): - seed = ( - "#if UNITY_EDITOR\n" - "namespace MCP.Reload\n" - "{\n" - " internal static class __McpReloadSentinel\n" - " {\n" - " internal const int Tick = 1;\n" - " }\n" - "}\n" - "#endif\n" - ) - with io.open(path, "w", encoding="utf-8", newline="\n") as f: - f.write(seed) - - with io.open(path, "r", encoding="utf-8") as f: - src = f.read() - - m = re.search(r"(const\s+int\s+Tick\s*=\s*)(\d+)(\s*;)", src) - if m: - nxt = "2" if m.group(2) == "1" else "1" - new_src = src[:m.start(2)] + nxt + src[m.end(2):] - else: - new_src = src + "\n// MCP touch\n" - - tmp = path + ".tmp" - with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: - f.write(new_src) - os.replace(tmp, path) +""" +Deprecated: Sentinel flipping is handled inside Unity via the MCP menu +'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim. +All functions are no-ops to prevent accidental external writes. +""" + +def flip_reload_sentinel(*args, **kwargs) -> str: + return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 112fc63b9..b8e780208 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -307,8 +307,38 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: if warnings: data.setdefault("warnings", warnings) if resp.get("success"): - # Unity-side ManageScript handles sentinel flip after successful writes. - # Avoid Python-side file writes that can fail due to working dir/permissions. + # Ensure reload via Unity menu without blocking this response + try: + import threading, time, json, glob, os + def _latest_status() -> dict | None: + try: + files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if not files: + return None + with open(files[0], "r") as f: + return json.loads(f.read()) + except Exception: + return None + + def _flip_async(): + try: + # Small delay so write flushes; prefer early flip to avoid editor-focus second reload + time.sleep(0.1) + st = _latest_status() + if st and st.get("reloading"): + return # skip if a reload already started + # Best‑effort, single-shot; avoid retries during reload window + send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + threading.Thread(target=_flip_async, daemon=True).start() + except Exception: + pass return resp return resp return {"success": False, "message": str(resp)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 836fb0293..a48af0f60 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -81,6 +81,43 @@ def index_of(line: int, col: int) -> int: return text +def _trigger_sentinel_async() -> None: + """Fire the Unity menu flip on a short-lived background thread. + + This avoids blocking the current request or getting stuck during domain reloads + (socket reconnects) when the Editor recompiles. + """ + try: + import threading, time + + def _flip(): + try: + import json, glob, os + # Small delay so write flushes; prefer early flip to avoid editor-focus second reload + time.sleep(0.1) + try: + files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if files: + with open(files[0], "r") as f: + st = json.loads(f.read()) + if st.get("reloading"): + return + except Exception: + pass + # Best‑effort, single-shot; avoid retries during reload window + send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + + threading.Thread(target=_flip, daemon=True).start() + except Exception: + pass + def _infer_class_name(script_name: str) -> str: # Default to script name as class name (common Unity pattern) return (script_name or "").strip() @@ -485,7 +522,7 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str resp_struct = send_command_with_retry("manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): try: - send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + _trigger_sentinel_async() except Exception: pass return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") @@ -608,9 +645,9 @@ def _expand_dollars(rep: str) -> str: 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") - # Successful text write; flip sentinel via Unity menu to force reload + # Successful text write; flip sentinel asynchronously to avoid blocking try: - send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + _trigger_sentinel_async() except Exception: pass except Exception as e: @@ -632,7 +669,7 @@ def _expand_dollars(rep: str) -> str: resp_struct = send_command_with_retry("manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): try: - send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + _trigger_sentinel_async() except Exception: pass return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") @@ -758,7 +795,7 @@ def _expand_dollars(rep: str) -> str: resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): try: - send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + _trigger_sentinel_async() except Exception: pass return _with_norm( @@ -843,7 +880,7 @@ def _expand_dollars(rep: str) -> str: write_resp = send_command_with_retry("manage_script", params) if isinstance(write_resp, dict) and write_resp.get("success"): try: - send_command_with_retry("execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}) + _trigger_sentinel_async() except Exception: pass return _with_norm( From bdc44174ffb62d453b897cb34ac844dd33be4262 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 13:09:07 -0700 Subject: [PATCH 03/15] Fix double reload and ensure accurate batch edits; immediate structured reloads --- .../UnityMCPTests/Assets/Scripts/Hello.cs | 22 +++---- .../Assets/Scripts/Hello.cs.meta | 11 +--- UnityMcpBridge/Editor/Tools/ManageScript.cs | 31 +++++----- .../src/tools/manage_script.py | 10 ++- .../src/tools/manage_script_edits.py | 62 ++++++++++--------- 5 files changed, 61 insertions(+), 75 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs index c68ea9de4..9bab3e3c0 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs @@ -1,23 +1,15 @@ -using System.Collections; -using System.Collections.Generic; using UnityEngine; +using System.Collections; public class Hello : MonoBehaviour { + + // Use this for initialization void Start() { Debug.Log("Hello World"); } -} -// edit test passes -// reload validation -// python flip should run now -// execute menu hit? -// server reinstall flip check -// async flip check -// async-only path verification -// single-flip verification run -// background reload check -// background reload check 2 -// single reload only check -// guarded flip test + + + +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta index 6b1e12682..b01fea08a 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta @@ -1,11 +1,2 @@ fileFormatVersion: 2 -guid: bebdf68a6876b425494ee770d20f70ef -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: +guid: bebdf68a6876b425494ee770d20f70ef \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index e70aeda7f..65605ab0b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -764,16 +764,18 @@ private static object ApplyTextEdits( string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); if (immediate) { - EditorApplication.delayCall += () => - { - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); - }; + Debug.Log($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif } else { + Debug.Log($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } @@ -1419,17 +1421,14 @@ private static object EditScript( if (immediate) { - // Force on main thread - EditorApplication.delayCall += () => - { - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); + // Perform synchronous import/compile immediately to ensure reload even when Editor is unfocused + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); #if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif - }; } else { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index b8e780208..d4e9ad43b 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -306,8 +306,8 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: data.setdefault("normalizedEdits", normalized_edits) if warnings: data.setdefault("warnings", warnings) - if resp.get("success"): - # Ensure reload via Unity menu without blocking this response + if resp.get("success") and (options or {}).get("force_sentinel_reload"): + # Optional: flip sentinel via menu if explicitly requested try: import threading, time, json, glob, os def _latest_status() -> dict | None: @@ -322,12 +322,10 @@ def _latest_status() -> dict | None: def _flip_async(): try: - # Small delay so write flushes; prefer early flip to avoid editor-focus second reload time.sleep(0.1) st = _latest_status() if st and st.get("reloading"): - return # skip if a reload already started - # Best‑effort, single-shot; avoid retries during reload window + return send_command_with_retry( "execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}, @@ -491,7 +489,7 @@ def manage_script( "path": path, "edits": edits, "precondition_sha256": sha, - "options": {"refresh": "immediate", "validate": "standard"}, + "options": {"refresh": "debounced", "validate": "standard"}, } # Preflight size vs. default cap (256 KiB) to avoid opaque server errors try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index a48af0f60..14c79e12d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -508,7 +508,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 {}) - # Do not force sequential; allow server default (atomic) unless caller requests otherwise + # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused opts2.setdefault("refresh", "immediate") params_struct: Dict[str, Any] = { "action": "edit", @@ -521,10 +521,12 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str } resp_struct = send_command_with_retry("manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): - try: - _trigger_sentinel_async() - except Exception: - pass + # Optional: flip sentinel only if explicitly requested + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass 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 @@ -640,23 +642,24 @@ def _expand_dollars(rep: str) -> str: "scriptType": script_type, "edits": at_edits, "precondition_sha256": sha, - "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} + "options": {"refresh": (options or {}).get("refresh", "debounced"), "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")): return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") - # Successful text write; flip sentinel asynchronously to avoid blocking - try: - _trigger_sentinel_async() - except Exception: - pass + # Successful text write; flip sentinel only if explicitly requested + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass 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 {}) - # Let server decide; do not force sequential - opts2.setdefault("refresh", "immediate") + # Prefer debounced background refresh unless explicitly overridden + opts2.setdefault("refresh", "debounced") params_struct: Dict[str, Any] = { "action": "edit", "name": name, @@ -668,10 +671,11 @@ def _expand_dollars(rep: str) -> str: } resp_struct = send_command_with_retry("manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): - try: - _trigger_sentinel_async() - except Exception: - pass + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass 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") @@ -787,17 +791,18 @@ def _expand_dollars(rep: str) -> str: "edits": at_edits, "precondition_sha256": sha, "options": { - "refresh": "immediate", + "refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) } } resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): - try: - _trigger_sentinel_async() - except Exception: - pass + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm( resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, @@ -849,7 +854,7 @@ def _expand_dollars(rep: str) -> str: # Default refresh/validate for natural usage on text path as well options = dict(options or {}) options.setdefault("validate", "standard") - options.setdefault("refresh", "immediate") + options.setdefault("refresh", "debounced") import hashlib # Compute the SHA of the current file contents for the precondition @@ -874,15 +879,16 @@ def _expand_dollars(rep: str) -> str: } ], "precondition_sha256": sha, - "options": options or {"validate": "standard", "refresh": "immediate"}, + "options": options or {"validate": "standard", "refresh": "debounced"}, } write_resp = send_command_with_retry("manage_script", params) if isinstance(write_resp, dict) and write_resp.get("success"): - try: - _trigger_sentinel_async() - except Exception: - pass + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm( write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, From 44a5fd38cedebf6b1a3e29fec5a3af373326be1c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 13:19:12 -0700 Subject: [PATCH 04/15] Remove MCP/Flip Reload Sentinel menu; rely on synchronous import/compile for reloads --- .../Editor/Sentinel/FlipReloadSentinelMenu.cs | 52 ------------------- .../Sentinel/FlipReloadSentinelMenu.cs.meta | 2 - 2 files changed, 54 deletions(-) delete mode 100644 UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs delete mode 100644 UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta diff --git a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs deleted file mode 100644 index f0607330b..000000000 --- a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Sentinel -{ - internal static class FlipReloadSentinelMenu - { - private const string PackageSentinelPath = "Packages/com.coplaydev.unity-mcp/Editor/Sentinel/__McpReloadSentinel.cs"; - - [MenuItem("MCP/Flip Reload Sentinel")] - private static void Flip() - { - try - { - Debug.Log("[FlipReloadSentinelMenu] Executing menu MCP/Flip Reload Sentinel"); - string path = PackageSentinelPath; - if (!File.Exists(path)) - { - Debug.LogWarning($"[FlipReloadSentinelMenu] Sentinel not found at '{path}'."); - return; - } - - string src = File.ReadAllText(path); - var m = Regex.Match(src, @"(const\s+int\s+Tick\s*=\s*)(\d+)(\s*;)" ); - if (m.Success) - { - string next = (m.Groups[2].Value == "1") ? "2" : "1"; - string newSrc = src.Substring(0, m.Groups[2].Index) + next + src.Substring(m.Groups[2].Index + m.Groups[2].Length); - File.WriteAllText(path, newSrc); - } - else - { - File.AppendAllText(path, "\n// MCP touch\n"); - } - - AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif - Debug.Log("[FlipReloadSentinelMenu] Sentinel flip menu executed successfully"); - } - catch (System.Exception ex) - { - Debug.LogError($"[FlipReloadSentinelMenu] Flip failed: {ex.Message}"); - } - } - } -} - - diff --git a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta b/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta deleted file mode 100644 index d89f952f7..000000000 --- a/UnityMcpBridge/Editor/Sentinel/FlipReloadSentinelMenu.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 37d7e7fc4860947b9ac2745ab011c486 \ No newline at end of file From 903c804656b667dbacacff9dd36876de398955a3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 14:02:17 -0700 Subject: [PATCH 05/15] Route bridge/editor logs through McpLog and gate behind debug; create path now reloads synchronously --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 44 ++++++++++++++----- UnityMcpBridge/Editor/Tools/ManageScript.cs | 14 ++++-- .../Editor/Windows/MCPForUnityEditorWindow.cs | 4 +- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index f90b2235e..1a175847c 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -48,7 +48,7 @@ private static void LogBreadcrumb(string stage) { if (IsDebugEnabled()) { - Debug.Log($"MCP-FOR-UNITY: [{stage}]"); + McpLog.Info($"[{stage}]", always: false); } } @@ -230,7 +230,10 @@ public static void Start() // Don't restart if already running on a working port if (isRunning && listener != null) { - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); + if (IsDebugEnabled()) + { + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); + } return; } @@ -348,7 +351,7 @@ public static void Stop() listener?.Stop(); listener = null; EditorApplication.update -= ProcessCommands; - Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); + if (IsDebugEnabled()) Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); } catch (Exception ex) { @@ -389,7 +392,7 @@ private static async Task ListenerLoop() { if (isRunning) { - Debug.LogError($"Listener error: {ex.Message}"); + if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); } } } @@ -403,8 +406,11 @@ private static async Task HandleClientAsync(TcpClient client) // Framed I/O only; legacy mode removed try { - var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; - Debug.Log($"UNITY-MCP: Client connected {ep}"); + if (IsDebugEnabled()) + { + 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 @@ -423,11 +429,11 @@ private static async Task HandleClientAsync(TcpClient client) #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif - Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { - Debug.LogWarning($"UNITY-MCP: Handshake failed: {ex.Message}"); + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); return; // abort this client } @@ -440,8 +446,11 @@ private static async Task HandleClientAsync(TcpClient client) try { - var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; - Debug.Log($"UNITY-MCP: recv framed: {preview}"); + if (IsDebugEnabled()) + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); + } } catch { } string commandId = Guid.NewGuid().ToString(); @@ -470,7 +479,20 @@ private static async Task HandleClientAsync(TcpClient client) } catch (Exception ex) { - Debug.LogError($"Client handler error: {ex.Message}"); + // Treat common disconnects/timeouts as benign; only surface hard errors + string msg = ex.Message ?? string.Empty; + bool isBenign = + msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 + || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 + || ex is System.IO.IOException; + if (isBenign) + { + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); + } + else + { + MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); + } break; } } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 65605ab0b..e88535c5b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -356,12 +356,18 @@ string namespaceName var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { uri, scheduledRefresh = true } + new { uri, scheduledRefresh = false } ); - // Schedule heavy work AFTER replying - ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - + // Perform synchronous import/compile to ensure immediate reload + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + return ok; } catch (Exception e) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f9235fdbf..84113f7d2 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1771,7 +1771,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) { if (debugLogsEnabled) { - UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); } mcpClient.SetStatus(McpStatus.Configured); } @@ -1971,7 +1971,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) if (debugLogsEnabled) { - UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); } if (!File.Exists(configPath)) From d2729cda80a0fe7bb589ce922c2d041717299126 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 20:55:16 -0700 Subject: [PATCH 06/15] chore: ignore backup artifacts; remove stray ManageScript.cs.backup files --- .gitignore | 4 + .../Editor/Tools/ManageScript.cs.backup | 2649 ----------------- .../Editor/Tools/ManageScript.cs.backup.meta | 7 - 3 files changed, 4 insertions(+), 2656 deletions(-) delete mode 100644 UnityMcpBridge/Editor/Tools/ManageScript.cs.backup delete mode 100644 UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta diff --git a/.gitignore b/.gitignore index 0e2cbb036..be9fc7e30 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ CONTRIBUTING.md.meta .DS_Store* # Unity test project lock files TestProjects/UnityMCPTests/Packages/packages-lock.json + +# Backup artifacts +*.backup +*.backup.meta diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup deleted file mode 100644 index 3cea706c8..000000000 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup +++ /dev/null @@ -1,2649 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Collections.Generic; -using System.Text.RegularExpressions; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.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 -using UnityEditor.Compilation; -#endif - - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles CRUD operations for C# scripts within the Unity project. - /// - /// ROSLYN INSTALLATION GUIDE: - /// To enable advanced syntax validation with Roslyn compiler services: - /// - /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: - /// - Open Package Manager in Unity - /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity - /// - /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: - /// - /// 3. Alternative: Manual DLL installation: - /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies - /// - Place in Assets/Plugins/ folder - /// - Ensure .NET compatibility settings are correct - /// - /// 4. Define USE_ROSLYN symbol: - /// - Go to Player Settings > Scripting Define Symbols - /// - Add "USE_ROSLYN" to enable Roslyn-based validation - /// - /// 5. Restart Unity after installation - /// - /// Note: Without Roslyn, the system falls back to basic structural validation. - /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. - /// - 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('\\', '/'); - - // 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) - || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); - if (!underAssets) - { - fullPathDir = null; - relPathSafe = null; - return false; - } - - // 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); - while (di != null) - { - 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 */ } - - 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. - /// - public static object HandleCommand(JObject @params) - { - // Extract parameters - string action = @params["action"]?.ToString().ToLower(); - string name = @params["name"]?.ToString(); - string path = @params["path"]?.ToString(); // Relative to Assets/ - string contents = null; - - // Check if we have base64 encoded contents - bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; - if (contentsEncoded && @params["encodedContents"] != null) - { - try - { - contents = DecodeBase64(@params["encodedContents"].ToString()); - } - catch (Exception e) - { - return Response.Error($"Failed to decode script contents: {e.Message}"); - } - } - else - { - contents = @params["contents"]?.ToString(); - } - - string scriptType = @params["scriptType"]?.ToString(); // For templates/validation - string namespaceName = @params["namespace"]?.ToString(); // For organizing code - - // Validate required parameters - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - if (string.IsNullOrEmpty(name)) - { - 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_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) - { - return Response.Error( - $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." - ); - } - - // Resolve and harden target directory under Assets/ - if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) - { - return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); - } - - // Construct file paths - string scriptFileName = $"{name}.cs"; - string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); - - // Ensure the target directory exists for create/update - if (action == "create" || action == "update") - { - try - { - Directory.CreateDirectory(fullPathDir); - } - catch (Exception e) - { - return Response.Error( - $"Could not create directory '{fullPathDir}': {e.Message}" - ); - } - } - - // Route to specific action handlers - switch (action) - { - case "create": - return CreateScript( - fullPath, - relativePath, - name, - contents, - scriptType, - namespaceName - ); - case "read": - Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); - return ReadScript(fullPath, relativePath); - case "update": - 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": - { - var textEdits = @params["edits"] as JArray; - string precondition = @params["precondition_sha256"]?.ToString(); - // Respect optional options - string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); - string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); - return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); - } - case "validate": - { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; - var chosen = level switch - { - "basic" => ValidationLevel.Basic, - "standard" => ValidationLevel.Standard, - "strict" => ValidationLevel.Strict, - "comprehensive" => ValidationLevel.Comprehensive, - _ => 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[] diagsRaw); - var diags = (diagsRaw ?? Array.Empty()).Select(s => - { - 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; - 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": - Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); - 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)." - ); - } - } - - /// - /// Decode base64 string to normal text - /// - private static string DecodeBase64(string encoded) - { - byte[] data = Convert.FromBase64String(encoded); - return System.Text.Encoding.UTF8.GetString(data); - } - - /// - /// Encode text to base64 string - /// - private static string EncodeBase64(string text) - { - byte[] data = System.Text.Encoding.UTF8.GetBytes(text); - return Convert.ToBase64String(data); - } - - private static object CreateScript( - string fullPath, - string relativePath, - string name, - string contents, - string scriptType, - string namespaceName - ) - { - // Check if script already exists - if (File.Exists(fullPath)) - { - return Response.Error( - $"Script already exists at '{relativePath}'. Use 'update' action to modify." - ); - } - - // Generate default content if none provided - if (string.IsNullOrEmpty(contents)) - { - contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); - } - - // Validate syntax with detailed error reporting using GUI setting - ValidationLevel validationLevel = GetValidationLevelFromGUI(); - bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); - if (!isValid) - { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); - } - else if (validationErrors != null && validationErrors.Length > 0) - { - // Log warnings but don't block creation - Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); - } - - try - { - // 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 - { - File.Move(tmp, fullPath); - } - catch (IOException) - { - File.Copy(tmp, fullPath, overwrite: true); - try { File.Delete(tmp); } catch { } - } - - var uri = $"unity://path/{relativePath}"; - var ok = Response.Success( - $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { uri, scheduledRefresh = true } - ); - - // Schedule heavy work AFTER replying - ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - // Also flip the reload sentinel from a background thread so Unity detects an on-disk IL change without requiring focus - ManageScriptRefreshHelpers.FlipSentinelInBackground(); - return ok; - } - catch (Exception e) - { - return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); - } - } - - private static object ReadScript(string fullPath, string relativePath) - { - if (!File.Exists(fullPath)) - { - return Response.Error($"Script not found at '{relativePath}'."); - } - - try - { - string contents = File.ReadAllText(fullPath); - - // 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 - encodedContents = isLarge ? EncodeBase64(contents) : null, - contentsEncoded = isLarge, - }; - - return Response.Success( - $"Script '{Path.GetFileName(relativePath)}' read successfully.", - responseData - ); - } - catch (Exception e) - { - return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); - } - } - - private static object UpdateScript( - string fullPath, - string relativePath, - string name, - string contents - ) - { - if (!File.Exists(fullPath)) - { - return Response.Error( - $"Script not found at '{relativePath}'. Use 'create' action to add a new script." - ); - } - if (string.IsNullOrEmpty(contents)) - { - return Response.Error("Content is required for the 'update' action."); - } - - // Validate syntax with detailed error reporting using GUI setting - ValidationLevel validationLevel = GetValidationLevelFromGUI(); - bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); - if (!isValid) - { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); - } - else if (validationErrors != null && validationErrors.Length > 0) - { - // Log warnings but don't block update - Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); - } - - try - { - // 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); - - string backupPath = fullPath + ".bak"; - try - { - File.Replace(tempPath, fullPath, backupPath); - try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } - } - catch (PlatformNotSupportedException) - { - File.Copy(tempPath, fullPath, true); - try { File.Delete(tempPath); } catch { } - try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } - } - catch (IOException) - { - 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 { uri, path = relativePath, scheduledRefresh = true } - ); - - // Schedule a debounced import/compile on next editor tick to avoid stalling the reply - ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - ManageScriptRefreshHelpers.FlipSentinelInBackground(); - - return ok; - } - catch (Exception e) - { - return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); - } - } - - /// - /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. - /// - private const int MaxEditPayloadBytes = 64 * 1024; - - private static object ApplyTextEdits( - string fullPath, - string relativePath, - string name, - JArray edits, - string preconditionSha256, - string refreshModeFromCaller = null, - string validateMode = null) - { - if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); - // Refuse edits if the target or any ancestor is a symlink - try - { - 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 - { - // If checking attributes fails, proceed without the symlink guard - } - 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}"); } - - // Require precondition to avoid drift on large files - string currentSha = ComputeSha256(original); - 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)>(); - long totalBytes = 0; - 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)); - checked - { - totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); - } - } - catch (Exception ex) - { - return Response.Error($"Invalid edit payload: {ex.Message}"); - } - } - - // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption - 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, - TimeSpan.FromSeconds(2) - ); - 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("using_guard", new { status = "using_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(); - - // 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"] = replacementText - }; - 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" }); - } - - // 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) - { - 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; - 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); - 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 - 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 (!relaxed && !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(), evidenceWindow = new { startLine, endLine } }); - } - -#if USE_ROSLYN - 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(); - } - catch { } - } -#endif - - string newSha = ComputeSha256(working); - - // Atomic write and schedule refresh - try - { - 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 { } - } - - // 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 - { - uri = $"unity://path/{relativePath}", - path = relativePath, - editsApplied = spans.Count, - sha256 = newSha - } - ); - } - 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 == '\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; - } - - 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; - } - - // 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)) - { - return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); - } - - try - { - // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) - bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); - if (deleted) - { - AssetDatabase.Refresh(); - return Response.Success( - $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", - new { deleted = true } - ); - } - else - { - // Fallback or error if MoveAssetToTrash fails - return Response.Error( - $"Failed to move script '{relativePath}' to trash. It might be locked or in use." - ); - } - } - catch (Exception e) - { - return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); - } - } - - /// - /// 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}'."); - // 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."); - - 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)>(); - 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) - { - 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}"); - - if (applySequentially) - { - working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); - appliedCount++; - } - else - { - 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}"); - - if (applySequentially) - { - working = working.Remove(s, l); - appliedCount++; - } - else - { - 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)) - { - 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}"); - } - - if (applySequentially) - { - working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); - appliedCount++; - } - else - { - 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)) - { - 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}"); - } - - if (applySequentially) - { - working = working.Remove(mStart, mLen); - appliedCount++; - } - else - { - 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); - // 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."); - - 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"); - 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"); - if (applySequentially) - { - working = working.Insert(insAt, text); - appliedCount++; - } - else - { - replacements.Add((insAt, 0, text)); - } - } - 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, 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; - 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); - 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, TimeSpan.FromSeconds(2)); - 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, TimeSpan.FromSeconds(2)); - 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, anchor_insert, anchor_delete, anchor_replace."); - } - } - - 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); - 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 - { - 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("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)); - - // Atomic write with backup; schedule refresh - // Decide refresh behavior - string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); - bool immediate = refreshMode == "immediate" || refreshMode == "sync"; - - // 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}'.", - new - { - path = relativePath, - uri = $"unity://path/{relativePath}", - editsApplied = appliedCount, - scheduledRefresh = !immediate, - sha256 = newSha - } - ); - - if (immediate) - { - // Force on main thread - EditorApplication.delayCall += () => - { - 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); - // 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+)*" + - rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; - - string slice = source.Substring(searchStart, searchEnd - searchStart); - var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); - 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 - // 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++) - { - 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; - } - - // 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 - 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. - /// - private static string GenerateDefaultScriptContent( - string name, - string scriptType, - string namespaceName - ) - { - string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; - string classDeclaration; - string body = - "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; - - string baseClass = ""; - if (!string.IsNullOrEmpty(scriptType)) - { - if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) - baseClass = " : MonoBehaviour"; - else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) - { - baseClass = " : ScriptableObject"; - body = ""; // ScriptableObjects don't usually need Start/Update - } - else if ( - scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) - || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) - ) - { - usingStatements += "using UnityEditor;\n"; - if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) - baseClass = " : Editor"; - else - baseClass = " : EditorWindow"; - body = ""; // Editor scripts have different structures - } - // Add more types as needed - } - - classDeclaration = $"public class {name}{baseClass}"; - - string fullContent = $"{usingStatements}\n"; - bool useNamespace = !string.IsNullOrEmpty(namespaceName); - - if (useNamespace) - { - fullContent += $"namespace {namespaceName}\n{{\n"; - // Indent class and body if using namespace - classDeclaration = " " + classDeclaration; - body = string.Join("\n", body.Split('\n').Select(line => " " + line)); - } - - fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; - - if (useNamespace) - { - fullContent += "\n}"; // Close namespace - } - - return fullContent.Trim() + "\n"; // Ensure a trailing newline - } - - /// - /// Gets the validation level from the GUI settings - /// - private static ValidationLevel GetValidationLevelFromGUI() - { - string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); - return savedLevel.ToLower() switch - { - "basic" => ValidationLevel.Basic, - "standard" => ValidationLevel.Standard, - "comprehensive" => ValidationLevel.Comprehensive, - "strict" => ValidationLevel.Strict, - _ => ValidationLevel.Standard // Default fallback - }; - } - - /// - /// Validates C# script syntax using multiple validation layers. - /// - private static bool ValidateScriptSyntax(string contents) - { - return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); - } - - /// - /// Advanced syntax validation with detailed diagnostics and configurable strictness. - /// - private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) - { - var errorList = new System.Collections.Generic.List(); - errors = null; - - if (string.IsNullOrEmpty(contents)) - { - return true; // Empty content is valid - } - - // Basic structural validation - if (!ValidateBasicStructure(contents, errorList)) - { - errors = errorList.ToArray(); - return false; - } - -#if USE_ROSLYN - // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors - if (level >= ValidationLevel.Standard) - { - if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) - { - errors = errorList.ToArray(); - return false; - } - } -#endif - - // Unity-specific validation - if (level >= ValidationLevel.Standard) - { - ValidateScriptSyntaxUnity(contents, errorList); - } - - // Semantic analysis for common issues - if (level >= ValidationLevel.Comprehensive) - { - ValidateSemanticRules(contents, errorList); - } - -#if USE_ROSLYN - // Full semantic compilation validation for Strict level - if (level == ValidationLevel.Strict) - { - if (!ValidateScriptSemantics(contents, errorList)) - { - errors = errorList.ToArray(); - return false; // Strict level fails on any semantic errors - } - } -#endif - - errors = errorList.ToArray(); - return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); - } - - /// - /// Validation strictness levels - /// - private enum ValidationLevel - { - Basic, // Only syntax errors - Standard, // Syntax + Unity best practices - Comprehensive, // All checks + semantic analysis - Strict // Treat all issues as errors - } - - /// - /// Validates basic code structure (braces, quotes, comments) - /// - private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors) - { - bool isValid = true; - int braceBalance = 0; - int parenBalance = 0; - int bracketBalance = 0; - bool inStringLiteral = false; - bool inCharLiteral = false; - bool inSingleLineComment = false; - bool inMultiLineComment = false; - bool escaped = false; - - for (int i = 0; i < contents.Length; i++) - { - char c = contents[i]; - char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; - - // Handle escape sequences - if (escaped) - { - escaped = false; - continue; - } - - if (c == '\\' && (inStringLiteral || inCharLiteral)) - { - escaped = true; - continue; - } - - // Handle comments - if (!inStringLiteral && !inCharLiteral) - { - if (c == '/' && next == '/' && !inMultiLineComment) - { - inSingleLineComment = true; - continue; - } - if (c == '/' && next == '*' && !inSingleLineComment) - { - inMultiLineComment = true; - i++; // Skip next character - continue; - } - if (c == '*' && next == '/' && inMultiLineComment) - { - inMultiLineComment = false; - i++; // Skip next character - continue; - } - } - - if (c == '\n') - { - inSingleLineComment = false; - continue; - } - - if (inSingleLineComment || inMultiLineComment) - continue; - - // Handle string and character literals - if (c == '"' && !inCharLiteral) - { - inStringLiteral = !inStringLiteral; - continue; - } - if (c == '\'' && !inStringLiteral) - { - inCharLiteral = !inCharLiteral; - continue; - } - - if (inStringLiteral || inCharLiteral) - continue; - - // Count brackets and braces - switch (c) - { - case '{': braceBalance++; break; - case '}': braceBalance--; break; - case '(': parenBalance++; break; - case ')': parenBalance--; break; - case '[': bracketBalance++; break; - case ']': bracketBalance--; break; - } - - // Check for negative balances (closing without opening) - if (braceBalance < 0) - { - errors.Add("ERROR: Unmatched closing brace '}'"); - isValid = false; - } - if (parenBalance < 0) - { - errors.Add("ERROR: Unmatched closing parenthesis ')'"); - isValid = false; - } - if (bracketBalance < 0) - { - errors.Add("ERROR: Unmatched closing bracket ']'"); - isValid = false; - } - } - - // Check final balances - if (braceBalance != 0) - { - errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); - isValid = false; - } - if (parenBalance != 0) - { - errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); - isValid = false; - } - if (bracketBalance != 0) - { - errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); - isValid = false; - } - if (inStringLiteral) - { - errors.Add("ERROR: Unterminated string literal"); - isValid = false; - } - if (inCharLiteral) - { - errors.Add("ERROR: Unterminated character literal"); - isValid = false; - } - if (inMultiLineComment) - { - errors.Add("WARNING: Unterminated multi-line comment"); - } - - return isValid; - } - -#if USE_ROSLYN - /// - /// Cached compilation references for performance - /// - private static System.Collections.Generic.List _cachedReferences = null; - private static DateTime _cacheTime = DateTime.MinValue; - private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); - - /// - /// Validates syntax using Roslyn compiler services - /// - private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) - { - try - { - var syntaxTree = CSharpSyntaxTree.ParseText(contents); - var diagnostics = syntaxTree.GetDiagnostics(); - - bool hasErrors = false; - foreach (var diagnostic in diagnostics) - { - string severity = diagnostic.Severity.ToString().ToUpper(); - string message = $"{severity}: {diagnostic.GetMessage()}"; - - if (diagnostic.Severity == DiagnosticSeverity.Error) - { - hasErrors = true; - } - - // Include warnings in comprehensive mode - if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now - { - var location = diagnostic.Location.GetLineSpan(); - if (location.IsValid) - { - message += $" (Line {location.StartLinePosition.Line + 1})"; - } - errors.Add(message); - } - } - - return !hasErrors; - } - catch (Exception ex) - { - errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); - return false; - } - } - - /// - /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors - /// - private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List errors) - { - try - { - // Get compilation references with caching - var references = GetCompilationReferences(); - if (references == null || references.Count == 0) - { - errors.Add("WARNING: Could not load compilation references for semantic validation"); - return true; // Don't fail if we can't get references - } - - // Create syntax tree - var syntaxTree = CSharpSyntaxTree.ParseText(contents); - - // Create compilation with full context - var compilation = CSharpCompilation.Create( - "TempValidation", - new[] { syntaxTree }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - ); - - // Get semantic diagnostics - this catches all the issues you mentioned! - var diagnostics = compilation.GetDiagnostics(); - - bool hasErrors = false; - foreach (var diagnostic in diagnostics) - { - if (diagnostic.Severity == DiagnosticSeverity.Error) - { - hasErrors = true; - var location = diagnostic.Location.GetLineSpan(); - string locationInfo = location.IsValid ? - $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; - - // Include diagnostic ID for better error identification - string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; - errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); - } - else if (diagnostic.Severity == DiagnosticSeverity.Warning) - { - var location = diagnostic.Location.GetLineSpan(); - string locationInfo = location.IsValid ? - $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; - - string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; - errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); - } - } - - return !hasErrors; - } - catch (Exception ex) - { - errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); - return false; - } - } - - /// - /// Gets compilation references with caching for performance - /// - private static System.Collections.Generic.List GetCompilationReferences() - { - // Check cache validity - if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) - { - return _cachedReferences; - } - - try - { - var references = new System.Collections.Generic.List(); - - // Core .NET assemblies - references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib - references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq - references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections - - // Unity assemblies - try - { - references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine - } - catch (Exception ex) - { - Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); - } - -#if UNITY_EDITOR - try - { - references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor - } - catch (Exception ex) - { - Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); - } - - // Get Unity project assemblies - try - { - var assemblies = CompilationPipeline.GetAssemblies(); - foreach (var assembly in assemblies) - { - if (File.Exists(assembly.outputPath)) - { - references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); - } - } - } - catch (Exception ex) - { - Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); - } -#endif - - // Cache the results - _cachedReferences = references; - _cacheTime = DateTime.Now; - - return references; - } - catch (Exception ex) - { - Debug.LogError($"Failed to get compilation references: {ex.Message}"); - return new System.Collections.Generic.List(); - } - } -#else - private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) - { - // Fallback when Roslyn is not available - return true; - } -#endif - - /// - /// Validates Unity-specific coding rules and best practices - /// //TODO: Naive Unity Checks and not really yield any results, need to be improved - /// - private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List errors) - { - // Check for common Unity anti-patterns - if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) - { - errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); - } - - if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) - { - errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); - } - - // Check for proper MonoBehaviour usage - if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) - { - errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); - } - - // Check for SerializeField usage - if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) - { - errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); - } - - // Check for proper coroutine usage - if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) - { - errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); - } - - // Check for Update without FixedUpdate for physics - if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) - { - errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); - } - - // Check for missing null checks on Unity objects - if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) - { - errors.Add("WARNING: Consider null checking GetComponent results"); - } - - // Check for proper event function signatures - if (contents.Contains("void Start(") && !contents.Contains("void Start()")) - { - errors.Add("WARNING: Start() should not have parameters"); - } - - if (contents.Contains("void Update(") && !contents.Contains("void Update()")) - { - errors.Add("WARNING: Update() should not have parameters"); - } - - // Check for inefficient string operations - if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) - { - errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); - } - } - - /// - /// Validates semantic rules and common coding issues - /// - private static void ValidateSemanticRules(string contents, System.Collections.Generic.List errors) - { - // Check for potential memory leaks - if (contents.Contains("new ") && contents.Contains("Update()")) - { - errors.Add("WARNING: Creating objects in Update() may cause memory issues"); - } - - // Check for magic numbers - var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); - var matches = magicNumberPattern.Matches(contents); - if (matches.Count > 5) - { - errors.Add("WARNING: Consider using named constants instead of magic numbers"); - } - - // Check for long methods (simple line count check) - 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) - { - int startIndex = match.Index; - int braceCount = 0; - int lineCount = 0; - bool inMethod = false; - - for (int i = startIndex; i < contents.Length; i++) - { - if (contents[i] == '{') - { - braceCount++; - inMethod = true; - } - else if (contents[i] == '}') - { - braceCount--; - if (braceCount == 0 && inMethod) - break; - } - else if (contents[i] == '\n' && inMethod) - { - lineCount++; - } - } - - if (lineCount > 50) - { - errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); - break; // Only report once - } - } - - // Check for proper exception handling - if (contents.Contains("catch") && contents.Contains("catch()")) - { - errors.Add("WARNING: Empty catch blocks should be avoided"); - } - - // Check for proper async/await usage - if (contents.Contains("async ") && !contents.Contains("await")) - { - errors.Add("WARNING: Async method should contain await or return Task"); - } - - // Check for hardcoded tags and layers - if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) - { - errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); - } - } - - //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) - /// - /// Public method to validate script syntax with configurable validation level - /// Returns detailed validation results including errors and warnings - /// - // public static object ValidateScript(JObject @params) - // { - // string contents = @params["contents"]?.ToString(); - // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; - - // if (string.IsNullOrEmpty(contents)) - // { - // return Response.Error("Contents parameter is required for validation."); - // } - - // // Parse validation level - // ValidationLevel level = ValidationLevel.Standard; - // switch (validationLevel.ToLower()) - // { - // case "basic": level = ValidationLevel.Basic; break; - // case "standard": level = ValidationLevel.Standard; break; - // case "comprehensive": level = ValidationLevel.Comprehensive; break; - // case "strict": level = ValidationLevel.Strict; break; - // default: - // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); - // } - - // // Perform validation - // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); - - // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; - // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; - - // var result = new - // { - // isValid = isValid, - // validationLevel = validationLevel, - // errorCount = errors.Length, - // warningCount = warnings.Length, - // errors = errors, - // warnings = warnings, - // summary = isValid - // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") - // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" - // }; - - // if (isValid) - // { - // return Response.Success("Script validation completed successfully.", result); - // } - // else - // { - // return Response.Error("Script validation failed.", result); - // } - // } - } -} - -// Debounced refresh/compile scheduler to coalesce bursts of edits -static class RefreshDebounce -{ - private static int _pending; - 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); - _lastRequest = DateTime.UtcNow; - - // 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) - { - // Only proceed once the debounce window has fully elapsed. - ready = (DateTime.UtcNow - _lastRequest) >= window; - if (ready) - { - _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(); -#endif - // Fallback if needed: - // AssetDatabase.Refresh(); - } - } -} - -static class ManageScriptRefreshHelpers -{ - public static void ScheduleScriptRefresh(string relPath) - { - RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); - } - - // Flip the MCP reload sentinel on the next editor tick to ensure Unity detects - // an on-disk IL change even if the Editor window is not focused. - public static void FlipSentinelInBackground() - { - EditorApplication.delayCall += () => - { - try - { - bool executed = EditorApplication.ExecuteMenuItem("MCP/Flip Reload Sentinel"); - if (!executed) - { - // Fallback: at least refresh assets so changes are noticed - AssetDatabase.Refresh(); - } - } - catch - { - AssetDatabase.Refresh(); - } - }; - } -} - diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta b/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta deleted file mode 100644 index 119accca2..000000000 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs.backup.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 970f503aa9de343889f9beb85a84af88 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From 4d04aaa6a6c9e82e6ca4dbedf0ff85967bbf6f4c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 20:56:34 -0700 Subject: [PATCH 07/15] fix span logic --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index e88535c5b..072eab959 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -657,7 +657,7 @@ private static object ApplyTextEdits( spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) { - if (spans[i].start + (spans[i].end - spans[i].start) > spans[i - 1].start) + if (spans[i].end > spans[i - 1].start) { 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." }); @@ -770,7 +770,7 @@ private static object ApplyTextEdits( string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); if (immediate) { - Debug.Log($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); AssetDatabase.ImportAsset( relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate @@ -781,7 +781,7 @@ private static object ApplyTextEdits( } else { - Debug.Log($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); + McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } From 157060942e32143ebea8b578a07ea503a0956243 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 21:04:14 -0700 Subject: [PATCH 08/15] fix: honor UNITY_MCP_STATUS_DIR for sentinel status file lookup (fallback to ~/.unity-mcp) --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 14c79e12d..01b0c8416 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -96,7 +96,12 @@ def _flip(): # Small delay so write flushes; prefer early flip to avoid editor-focus second reload time.sleep(0.1) try: - files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + status_dir = os.environ.get("UNITY_MCP_STATUS_DIR") or os.path.join(os.path.expanduser("~"), ".unity-mcp") + files = sorted( + glob.glob(os.path.join(status_dir, "unity-mcp-status-*.json")), + key=os.path.getmtime, + reverse=True, + ) if files: with open(files[0], "r") as f: st = json.loads(f.read()) From 79aba0dacbf9404b27c199327fd08baf4b26494e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 21:16:31 -0700 Subject: [PATCH 09/15] test: add sentinel test honoring UNITY_MCP_STATUS_DIR; chore: ManageScript overlap check simplification and log consistency --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 2 +- tests/test_manage_script_edits_sentinel.py | 69 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/test_manage_script_edits_sentinel.py diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 072eab959..44c775698 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -657,7 +657,7 @@ private static object ApplyTextEdits( spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) { - if (spans[i].end > spans[i - 1].start) + if (spans[i].end > spans[i - 1].start) { 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." }); diff --git a/tests/test_manage_script_edits_sentinel.py b/tests/test_manage_script_edits_sentinel.py new file mode 100644 index 000000000..5788523ff --- /dev/null +++ b/tests/test_manage_script_edits_sentinel.py @@ -0,0 +1,69 @@ +import sys +import pathlib +import importlib.util +import types +import time +import json + +import pytest + + +# Add server src to import path +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + + +# Stub mcp.server.fastmcp so manage_script_edits can import without the 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) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +manage_script_edits = load_module(SRC / "tools" / "manage_script_edits.py", "manage_script_edits") + + +def test_trigger_sentinel_honors_status_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): + calls = [] + + def fake_send(cmd, params, *args, **kwargs): + calls.append((cmd, params)) + return {"success": True} + + monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send) + monkeypatch.setenv("UNITY_MCP_STATUS_DIR", str(tmp_path)) + + # Case 1: Latest status says reloading => should not send command + (tmp_path / "unity-mcp-status-1.json").write_text(json.dumps({"reloading": True})) + manage_script_edits._trigger_sentinel_async() + time.sleep(0.3) + assert len(calls) == 0 + + # Case 2: Newer status says not reloading => should send command once + time.sleep(0.1) + (tmp_path / "unity-mcp-status-2.json").write_text(json.dumps({"reloading": False})) + manage_script_edits._trigger_sentinel_async() + time.sleep(0.3) + assert len(calls) == 1 + + From ebd6bc1b4223b823fd507d8abd6c573f0d36b381 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 21:26:35 -0700 Subject: [PATCH 10/15] Harden environment path, remove extraneous flip menu test --- .../src/tools/manage_script_edits.py | 7 +- tests/test_manage_script_edits_sentinel.py | 69 ------------------- 2 files changed, 1 insertion(+), 75 deletions(-) delete mode 100644 tests/test_manage_script_edits_sentinel.py diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 01b0c8416..14c79e12d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -96,12 +96,7 @@ def _flip(): # Small delay so write flushes; prefer early flip to avoid editor-focus second reload time.sleep(0.1) try: - status_dir = os.environ.get("UNITY_MCP_STATUS_DIR") or os.path.join(os.path.expanduser("~"), ".unity-mcp") - files = sorted( - glob.glob(os.path.join(status_dir, "unity-mcp-status-*.json")), - key=os.path.getmtime, - reverse=True, - ) + files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) if files: with open(files[0], "r") as f: st = json.loads(f.read()) diff --git a/tests/test_manage_script_edits_sentinel.py b/tests/test_manage_script_edits_sentinel.py deleted file mode 100644 index 5788523ff..000000000 --- a/tests/test_manage_script_edits_sentinel.py +++ /dev/null @@ -1,69 +0,0 @@ -import sys -import pathlib -import importlib.util -import types -import time -import json - -import pytest - - -# Add server src to import path -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -# Stub mcp.server.fastmcp so manage_script_edits can import without the 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) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -manage_script_edits = load_module(SRC / "tools" / "manage_script_edits.py", "manage_script_edits") - - -def test_trigger_sentinel_honors_status_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): - calls = [] - - def fake_send(cmd, params, *args, **kwargs): - calls.append((cmd, params)) - return {"success": True} - - monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send) - monkeypatch.setenv("UNITY_MCP_STATUS_DIR", str(tmp_path)) - - # Case 1: Latest status says reloading => should not send command - (tmp_path / "unity-mcp-status-1.json").write_text(json.dumps({"reloading": True})) - manage_script_edits._trigger_sentinel_async() - time.sleep(0.3) - assert len(calls) == 0 - - # Case 2: Newer status says not reloading => should send command once - time.sleep(0.1) - (tmp_path / "unity-mcp-status-2.json").write_text(json.dumps({"reloading": False})) - manage_script_edits._trigger_sentinel_async() - time.sleep(0.3) - assert len(calls) == 1 - - From 9a8b1f0e8666a666372ac0fa88f27e20474eb273 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 21:35:15 -0700 Subject: [PATCH 11/15] refactor: centralize import/compile via ManageScriptRefreshHelpers.ImportAndRequestCompile; replace duplicated sequences --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 28 +++++++++------------ 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 44c775698..c397a3a2f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -359,14 +359,7 @@ string namespaceName new { uri, scheduledRefresh = false } ); - // Perform synchronous import/compile to ensure immediate reload - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); return ok; } @@ -1427,14 +1420,7 @@ private static object EditScript( if (immediate) { - // Perform synchronous import/compile immediately to ensure reload even when Editor is unfocused - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); } else { @@ -2623,5 +2609,15 @@ public static void ScheduleScriptRefresh(string relPath) { RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); } + + public static void ImportAndRequestCompile(string relPath, bool synchronous = true) + { + var opts = ImportAssetOptions.ForceUpdate; + if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; + AssetDatabase.ImportAsset(relPath, opts); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } } From e433c0a039d10460162c3307a97b807e2ad39404 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 1 Sep 2025 21:42:18 -0700 Subject: [PATCH 12/15] feat: add scheduledRefresh flag; standardize logs; gate info and DRY immediate import/compile --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index c397a3a2f..7ec6afe90 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -193,10 +193,10 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": - Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); return ReadScript(fullPath, relativePath); case "update": - Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + McpLog.Warn("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); @@ -785,7 +785,8 @@ private static object ApplyTextEdits( uri = $"unity://path/{relativePath}", path = relativePath, editsApplied = spans.Count, - sha256 = newSha + sha256 = newSha, + scheduledRefresh = !immediate } ); } @@ -1420,6 +1421,7 @@ private static object EditScript( if (immediate) { + McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); } else From 0c0ab92940c2bae70f5779901f7e60757a93f2aa Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 2 Sep 2025 09:24:10 -0700 Subject: [PATCH 13/15] chore: remove execute_menu_item sentinel flip from manage_script_edits; rely on import/compile --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 14c79e12d..7c08d73fa 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -104,13 +104,7 @@ def _flip(): return except Exception: pass - # Best‑effort, single-shot; avoid retries during reload window - send_command_with_retry( - "execute_menu_item", - {"menuPath": "MCP/Flip Reload Sentinel"}, - max_retries=0, - retry_ms=0, - ) + # Removed best‑effort menu flip; rely on import/compile triggers instead except Exception: pass From eea6f7011ed9da910f9d3b98e7110fbbbd32fac5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 2 Sep 2025 09:28:03 -0700 Subject: [PATCH 14/15] chore: remove unused MCP reload sentinel mechanism --- UnityMcpBridge/Editor/Sentinel.meta | 8 -------- UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs | 10 ---------- .../Editor/Sentinel/__McpReloadSentinel.cs.meta | 2 -- 3 files changed, 20 deletions(-) delete mode 100644 UnityMcpBridge/Editor/Sentinel.meta delete mode 100644 UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs delete mode 100644 UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta diff --git a/UnityMcpBridge/Editor/Sentinel.meta b/UnityMcpBridge/Editor/Sentinel.meta deleted file mode 100644 index 5ea1a62d6..000000000 --- a/UnityMcpBridge/Editor/Sentinel.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 0199a62554c6d4b06a1c870dd8bc8379 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs deleted file mode 100644 index 0ae485f2c..000000000 --- a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs +++ /dev/null @@ -1,10 +0,0 @@ -#if UNITY_EDITOR -namespace MCP.Reload -{ - // Toggling this constant (1 <-> 2) changes IL and guarantees an assembly bump. - internal static class __McpReloadSentinel - { - internal const int Tick = 2; - } -} -#endif diff --git a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta b/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta deleted file mode 100644 index 996439e74..000000000 --- a/UnityMcpBridge/Editor/Sentinel/__McpReloadSentinel.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7f5349fdb167948ac88fc9c99e250f9b \ No newline at end of file From 092e7f80703c00421d4f13b69be6768587a72a2a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 2 Sep 2025 09:34:56 -0700 Subject: [PATCH 15/15] fix: honor ignore_case for anchor_insert in text conversion path --- .../UnityMcpServer~/src/tools/manage_script_edits.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 7c08d73fa..4ed65deab 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -104,7 +104,7 @@ def _flip(): return except Exception: pass - # Removed best‑effort menu flip; rely on import/compile triggers instead + except Exception: pass @@ -699,9 +699,10 @@ 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() - # Early regex compile with helpful errors + # Early regex compile with helpful errors, honoring ignore_case try: - regex_obj = _re.compile(anchor, _re.MULTILINE) + flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + 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="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") m = regex_obj.search(base_text)