From 07932eabb26603da99cbd198cdbfb38efddd0a02 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 20:23:56 -0400 Subject: [PATCH 01/30] refactor: migrate command routing to use CommandRegistry lookup instead of switch statement --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 22 +------------------ .../Editor/Tools/CommandRegistry.cs | 2 ++ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index cd861658..326f921e 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -1040,27 +1040,7 @@ private static string ExecuteCommand(Command command) // Use JObject for parameters as the new handlers likely expect this JObject paramsObject = command.@params ?? new JObject(); - - // Route command based on the new tool structure from the refactor plan - object result = command.type switch - { - // Maps the command type (tool name) to the corresponding handler's static HandleCommand method - // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters - "manage_script" => ManageScript.HandleCommand(paramsObject), - // Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag) - "manage_scene" => HandleManageScene(paramsObject) - ?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"), - "manage_editor" => ManageEditor.HandleCommand(paramsObject), - "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), - "manage_asset" => ManageAsset.HandleCommand(paramsObject), - "manage_shader" => ManageShader.HandleCommand(paramsObject), - "read_console" => ReadConsole.HandleCommand(paramsObject), - "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject), - "manage_prefabs" => ManagePrefabs.HandleCommand(paramsObject), - _ => throw new ArgumentException( - $"Unknown or unsupported command type: {command.type}" - ), - }; + object result = CommandRegistry.GetHandler(command.type)(paramsObject); // Standard success response format var response = new { status = "success", result }; diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index c53642c0..afc14448 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools.MenuItems; +using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor.Tools { @@ -22,6 +23,7 @@ public static class CommandRegistry { "read_console", ReadConsole.HandleCommand }, { "manage_menu_item", ManageMenuItem.HandleCommand }, { "manage_shader", ManageShader.HandleCommand}, + { "manage_prefabs", ManagePrefabs.HandleCommand}, }; /// From d7dd3092a6c7f117409364c2ce753a18d7c98631 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 20:24:07 -0400 Subject: [PATCH 02/30] style: improve code formatting and indentation consistency --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 588 ++++++++++---------- 1 file changed, 295 insertions(+), 293 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0ed65afa..0231c858 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -90,7 +90,7 @@ private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, return false; } var atAssets = string.Equals( - di.FullName.Replace('\\','/'), + di.FullName.Replace('\\', '/'), assets, StringComparison.OrdinalIgnoreCase ); @@ -115,7 +115,7 @@ public static object HandleCommand(JObject @params) { return Response.Error("invalid_params", "Parameters cannot be null."); } - + // Extract parameters string action = @params["action"]?.ToString()?.ToLower(); string name = @params["name"]?.ToString(); @@ -207,81 +207,81 @@ public static object HandleCommand(JObject @params) 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 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": { - 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(); + 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}"); } - var result = new { diagnostics = diags }; - return ok ? Response.Success("Validation completed.", result) - : Response.Error("Validation failed.", result); - } + 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 + try { - 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}"); + 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)." @@ -505,7 +505,7 @@ private static object ApplyTextEdits( try { var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); - while (di != null && !string.Equals(di.FullName.Replace('\\','/'), Application.dataPath.Replace('\\','/'), StringComparison.OrdinalIgnoreCase)) + 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."); @@ -640,7 +640,7 @@ private static object ApplyTextEdits( }; structEdits.Add(op); // Reuse structured path - return EditScript(fullPath, relativePath, name, structEdits, new JObject{ ["refresh"] = "immediate", ["validate"] = "standard" }); + return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" }); } } } @@ -656,7 +656,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." }); @@ -942,8 +942,10 @@ private static bool CheckScopedBalance(string text, int start, int end) 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--; + if (c == '{') brace++; + else if (c == '}') brace--; + else if (c == '(') paren++; + else if (c == ')') paren--; else if (c == '[') bracket++; else if (c == ']') bracket--; // Allow temporary negative balance - will check tolerance at end } @@ -1035,291 +1037,291 @@ private static object EditScript( switch (mode) { case "replace_class": - { - string className = op.Value("className"); - string ns = op.Value("namespace"); - string replacement = ExtractReplacement(op); + { + 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 (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 (!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 (!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))); + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } + break; } - 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'."); + { + 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 (!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)); + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } + break; } - 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}"); - } + 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))); + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } + break; } - 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"); + { + 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 (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 (!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; - } + 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}"); + } - 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); + working = working.Remove(mStart, mLen); appliedCount++; } else { - replacements.Add((insAt, 0, text)); + replacements.Add((mStart, mLen, string.Empty)); } + break; } - else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) - return Response.Error($"insert_method failed: {whyIns}"); - else + + case "insert_method": { - string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); - if (applySequentially) + 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") { - working = working.Insert(insAt, text); - appliedCount++; + 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 { - replacements.Add((insAt, 0, text)); + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } } + break; } - 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"; - } + 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'."); - // Duplicate guard: if identical snippet already exists within this class, skip insert - if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + try { - string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); - if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + 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")) { - // Do not insert duplicate; treat as no-op - break; + 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)); } } - if (applySequentially) - { - working = working.Insert(insAt, norm); - appliedCount++; - } - else + catch (Exception ex) { - replacements.Add((insAt, 0, norm)); + return Response.Error($"anchor_insert failed: {ex.Message}"); } + break; } - 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) + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try { - working = working.Remove(delAt, delLen); - appliedCount++; + 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)); + } } - else + catch (Exception ex) { - replacements.Add((delAt, delLen, string.Empty)); + return Response.Error($"anchor_delete failed: {ex.Message}"); } + break; } - 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) + 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 { - working = working.Remove(at, len).Insert(at, norm); - appliedCount++; + 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)); + } } - else + catch (Exception ex) { - replacements.Add((at, len, norm)); + return Response.Error($"anchor_replace failed: {ex.Message}"); } + break; } - 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."); @@ -1703,7 +1705,7 @@ private static bool TryComputeMethodSpan( } // Tolerate generic constraints between params and body: multiple 'where T : ...' - for (;;) + for (; ; ) { // Skip whitespace/comments before checking for 'where' for (; i < searchEnd; i++) From b3338e791ede737def9781d992068008300d1667 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 20:31:51 -0400 Subject: [PATCH 03/30] refactor: clean up imports and type hints across tool modules --- .../UnityMcpServer~/src/tools/manage_asset.py | 6 +-- .../src/tools/manage_editor.py | 4 +- .../src/tools/manage_gameobject.py | 6 +-- .../UnityMcpServer~/src/tools/manage_scene.py | 4 +- .../src/tools/manage_script_edits.py | 1 - .../src/tools/manage_shader.py | 7 +-- .../UnityMcpServer~/src/tools/read_console.py | 4 +- .../src/tools/resource_tools.py | 53 ++++++++++--------- 8 files changed, 37 insertions(+), 48 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 49a8aeee..4aeb56b4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -4,11 +4,7 @@ import asyncio # Added: Import asyncio for running sync code in async from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context -# from ..unity_connection import get_unity_connection # Original line that caused error from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper -from config import config -import time - from telemetry_decorator import telemetry_tool def register_manage_asset_tools(mcp: FastMCP): @@ -17,7 +13,7 @@ def register_manage_asset_tools(mcp: FastMCP): @mcp.tool() @telemetry_tool("manage_asset") async def manage_asset( - ctx: Any, + ctx: Context, action: str, path: str, asset_type: str = None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index f5508a4e..d8e9365e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,8 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context -import time from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config +from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool from telemetry import is_telemetry_enabled, record_tool_usage diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index a2ffe0ea..3cc639e3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,8 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time +from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool @@ -12,7 +10,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): @mcp.tool() @telemetry_tool("manage_gameobject") def manage_gameobject( - ctx: Any, + ctx: Context, action: str, target: str = None, # GameObject identifier by name or path search_method: str = None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 9435f039..346cab1c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,8 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time +from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 3d66da00..88e941fd 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -2,7 +2,6 @@ from typing import Dict, Any, List, Tuple, Optional import base64 import re -import os from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index abf1d702..21a61c30 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,9 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time -import os +from unity_connection import send_command_with_retry import base64 from telemetry_decorator import telemetry_tool @@ -14,7 +11,7 @@ def register_manage_shader_tools(mcp: FastMCP): @mcp.tool() @telemetry_tool("manage_shader") def manage_shader( - ctx: Any, + ctx: Context, action: str, name: str, path: str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 9b398451..dcfe0c08 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -2,10 +2,8 @@ Defines the read_console tool for accessing Unity Editor console messages. """ from typing import List, Dict, Any -import time from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, send_command_with_retry -from config import config +from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool def register_read_console_tools(mcp: FastMCP): diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 5024fd4a..14999eb4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -4,7 +4,7 @@ safe path logic (re-implemented here to avoid importing server.py). """ -from typing import Dict, Any, List, Optional +from typing import Any import re from pathlib import Path from urllib.parse import urlparse, unquote @@ -17,7 +17,7 @@ from unity_connection import send_command_with_retry -def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]: +def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: """Safely coerce various inputs (str/float/etc.) to an int. Returns default on failure; clamps to minimum when provided. """ @@ -41,6 +41,7 @@ def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int except Exception: return default + def _resolve_project_root(override: str | None) -> Path: # 1) Explicit override if override: @@ -52,14 +53,17 @@ def _resolve_project_root(override: str | None) -> Path: if env: env_path = Path(env).expanduser() # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir - pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() + pr = (Path.cwd( + ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() if (pr / "Assets").exists(): return pr # 3) Ask Unity via manage_editor.get_project_root try: - resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) + resp = send_command_with_retry( + "manage_editor", {"action": "get_project_root"}) if isinstance(resp, dict) and resp.get("success"): - pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve() + pr = Path(resp.get("data", {}).get( + "projectRoot", "")).expanduser().resolve() if pr and (pr / "Assets").exists(): return pr except Exception: @@ -140,12 +144,12 @@ def register_resource_tools(mcp: FastMCP) -> None: )) @telemetry_tool("list_resources") async def list_resources( - ctx: Optional[Context] = None, - pattern: Optional[str] = "*.cs", + ctx: Context | None = None, + pattern: str | None = "*.cs", under: str = "Assets", limit: Any = 200, - project_root: Optional[str] = None, - ) -> Dict[str, Any]: + project_root: str | None = None, + ) -> dict[str, Any]: """ Lists project URIs (unity://path/...) under a folder (default: Assets). - pattern: glob like *.cs or *.shader (None to list all files) @@ -165,7 +169,7 @@ async def list_resources( except ValueError: return {"success": False, "error": "Listing is restricted to Assets/"} - matches: List[str] = [] + matches: list[str] = [] limit_int = _coerce_int(limit, default=200, minimum=1) for p in base.rglob("*"): if not p.is_file(): @@ -203,14 +207,14 @@ async def list_resources( @telemetry_tool("read_resource") async def read_resource( uri: str, - ctx: Optional[Context] = None, + ctx: Context | None = None, start_line: Any = None, line_count: Any = None, head_bytes: Any = None, tail_lines: Any = None, - project_root: Optional[str] = None, - request: Optional[str] = None, - ) -> Dict[str, Any]: + project_root: str | None = None, + request: str | None = None, + ) -> dict[str, Any]: """ Reads a resource by unity://path/... URI with optional slicing. One of line window (start_line/line_count) or head_bytes can be used to limit size. @@ -300,14 +304,16 @@ async def read_resource( m = re.search(r"first\s+(\d+)\s*bytes", req) if m: head_bytes = int(m.group(1)) - m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + m = re.search( + r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) if m: window = int(m.group(1)) method = m.group(2) # naive search for method header to get a line number text_all = p.read_text(encoding="utf-8") lines_all = text_all.splitlines() - pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + pat = re.compile( + rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) hit_line = None for i, line in enumerate(lines_all, start=1): if pat.search(line): @@ -329,7 +335,8 @@ async def read_resource( full_sha = hashlib.sha256(full_bytes).hexdigest() # Selection only when explicitly requested via windowing args or request text hints - selection_requested = bool(head_bytes or tail_lines or (start_line is not None and line_count is not None) or request) + selection_requested = bool(head_bytes or tail_lines or ( + start_line is not None and line_count is not None) or request) if selection_requested: # Mutually exclusive windowing options precedence: # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text @@ -354,16 +361,16 @@ async def read_resource( except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool() + @mcp.tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") @telemetry_tool("find_in_file") async def find_in_file( uri: str, pattern: str, - ctx: Optional[Context] = None, - ignore_case: Optional[bool] = True, - project_root: Optional[str] = None, + ctx: Context | None = None, + ignore_case: bool | None = True, + project_root: str | None = None, max_results: Any = 200, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Searches a file with a regex pattern and returns line numbers and excerpts. - uri: unity://path/Assets/... or file path form supported by read_resource @@ -404,5 +411,3 @@ async def find_in_file( return {"success": True, "data": {"matches": results, "count": len(results)}} except Exception as e: return {"success": False, "error": str(e)} - - From a967d67630aac50862b3e27584a314ccc5482b69 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 20:35:05 -0400 Subject: [PATCH 04/30] Revert "feat: Implement Asset Store Compliance for Unity MCP Bridge" This reverts commit 2fca7fc3dab4136f0e9b64cff49b62113c828cef. --- .../PR_DESCRIPTION.md | 84 ---- .../TEST_EXECUTION_REPORT.md | 314 --------------- .../AssetStoreComplianceTests.Editor.asmdef | 24 -- ...setStoreComplianceTests.Editor.asmdef.meta | 7 - .../Dependencies/DependencyManagerTests.cs | 196 --------- .../Dependencies/DependencyModelsTests.cs | 334 --------------- .../Dependencies/PlatformDetectorTests.cs | 187 --------- .../Tests/EditMode/EdgeCasesTests.cs | 367 ----------------- .../InstallationOrchestratorTests.cs | 325 --------------- .../AssetStoreComplianceIntegrationTests.cs | 310 -------------- .../EditMode/Mocks/MockPlatformDetector.cs | 107 ----- .../Tests/EditMode/PerformanceTests.cs | 325 --------------- .../Tests/EditMode/Setup/SetupWizardTests.cs | 268 ------------ .../Tests/EditMode/TestRunner.cs | 380 ------------------ .../ava-asset-store-compliance/run_tests.py | 270 ------------- 15 files changed, 3498 deletions(-) delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/PR_DESCRIPTION.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/TEST_EXECUTION_REPORT.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyManagerTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyModelsTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/PlatformDetectorTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/EdgeCasesTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Installation/InstallationOrchestratorTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Integration/AssetStoreComplianceIntegrationTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Mocks/MockPlatformDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/PerformanceTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Setup/SetupWizardTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/TestRunner.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/run_tests.py diff --git a/ava-worktrees/feature/ava-asset-store-compliance/PR_DESCRIPTION.md b/ava-worktrees/feature/ava-asset-store-compliance/PR_DESCRIPTION.md deleted file mode 100644 index ae4b0e3d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/PR_DESCRIPTION.md +++ /dev/null @@ -1,84 +0,0 @@ -## Unity MCP Bridge: Asset Store Compliance Implementation 🚀 - -### 📋 Summary -This pull request introduces a comprehensive Asset Store compliance solution for the Unity MCP Bridge, removing bundled dependencies and implementing a user-guided installation process. The implementation ensures a clean, flexible, and user-friendly approach to dependency management. - -### 🔍 Key Changes - -#### 1. Dependency Management Architecture -- Removed bundled Python and UV dependencies -- Implemented cross-platform dependency detection system -- Created platform-specific installation guidance -- Developed comprehensive error handling and recovery mechanisms - -#### 2. Setup Wizard System -- Introduced 5-step progressive setup wizard -- Implemented persistent state management -- Added manual and automatic setup trigger options -- Provided clear, actionable guidance for users - -#### 3. Asset Store Compliance Features -- No bundled external dependencies -- User-guided installation approach -- Clean package structure -- Fallback modes for incomplete installations -- Comprehensive documentation - -### 🧪 Testing Overview -- **Total Test Methods**: 110 -- **Test Coverage**: 98% -- **Test Categories**: - - Dependency Detection - - Setup Wizard - - Installation Orchestrator - - Integration Tests - - Edge Cases - - Performance Tests - -### 🌐 Cross-Platform Support -- Windows compatibility -- macOS compatibility -- Linux compatibility -- Intelligent path resolution -- Version validation (Python 3.10+) - -### 🚦 Deployment Considerations -- Minimal Unity startup impact (< 200ms) -- No automatic external downloads -- Manual dependency installation -- Clear user communication - -### 📦 Package Structure -- Modular design -- SOLID principles implementation -- Extensible architecture -- Performance-optimized components - -### 🔒 Security & Compliance -- No automatic downloads -- Manual dependency verification -- Platform-specific security checks -- Comprehensive error handling - -### 🎯 Next Steps -1. Comprehensive cross-platform testing -2. User acceptance validation -3. Performance optimization -4. Asset Store submission preparation - -### 🤝 Contribution -This implementation addresses long-standing Asset Store compliance challenges while maintaining the core functionality of the Unity MCP Bridge. - -### 📝 Test Execution -- Comprehensive test suite available -- Multiple test execution methods -- Detailed coverage reporting -- Performance benchmarking included - -### ✅ Quality Assurance -- 110 test methods -- 98% test coverage -- Rigorous error handling -- Cross-platform compatibility verified - -**Deployment Readiness**: ✅ PRODUCTION READY \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/TEST_EXECUTION_REPORT.md b/ava-worktrees/feature/ava-asset-store-compliance/TEST_EXECUTION_REPORT.md deleted file mode 100644 index 303a6e1d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/TEST_EXECUTION_REPORT.md +++ /dev/null @@ -1,314 +0,0 @@ -# Unity MCP Bridge - Asset Store Compliance Test Suite - -## 🎯 Test Execution Report - -**Date**: September 23, 2025 -**Branch**: `feature/ava-asset-store-compliance` -**Worktree**: `/home/jpb/dev/tingz/unity-mcp/ava-worktrees/feature/ava-asset-store-compliance` - ---- - -## 📊 Test Suite Overview - -### Test Statistics -- **Total Test Files**: 10 -- **Total Test Methods**: 110 -- **Total Lines of Test Code**: 2,799 -- **Average Tests per File**: 11.0 -- **Test Coverage**: 98% - -### Test Categories - -| Category | Test Files | Test Methods | Lines of Code | Coverage | -|----------|------------|--------------|---------------|----------| -| **Dependency Detection** | 3 | 45 | 717 | 100% | -| **Setup Wizard** | 1 | 13 | 268 | 100% | -| **Installation Orchestrator** | 1 | 12 | 325 | 100% | -| **Integration Tests** | 1 | 11 | 310 | 100% | -| **Edge Cases** | 1 | 17 | 367 | 95% | -| **Performance Tests** | 1 | 12 | 325 | 90% | -| **Mock Infrastructure** | 1 | 0 | 107 | N/A | -| **Test Runner** | 1 | 0 | 380 | N/A | - ---- - -## 🧪 Detailed Test Coverage - -### 1. Dependency Detection Tests (`45 tests`) - -#### DependencyManagerTests.cs (15 tests) -- ✅ Platform detector retrieval and validation -- ✅ Comprehensive dependency checking -- ✅ Individual dependency availability checks -- ✅ Installation recommendations generation -- ✅ System readiness validation -- ✅ Error handling and graceful degradation -- ✅ Diagnostic information generation -- ✅ MCP server startup validation -- ✅ Python environment repair functionality - -#### PlatformDetectorTests.cs (10 tests) -- ✅ Cross-platform detector functionality (Windows, macOS, Linux) -- ✅ Platform-specific dependency detection -- ✅ Installation URL generation -- ✅ Mock detector implementation validation -- ✅ Platform compatibility verification - -#### DependencyModelsTests.cs (20 tests) -- ✅ DependencyStatus model validation -- ✅ DependencyCheckResult functionality -- ✅ SetupState management and persistence -- ✅ State transition logic -- ✅ Summary generation algorithms -- ✅ Missing dependency identification -- ✅ Version-aware setup completion - -### 2. Setup Wizard Tests (`13 tests`) - -#### SetupWizardTests.cs (13 tests) -- ✅ Setup state persistence and loading -- ✅ Auto-trigger logic validation -- ✅ Setup completion and dismissal handling -- ✅ State reset functionality -- ✅ Corrupted data recovery -- ✅ Menu item accessibility -- ✅ Batch mode handling -- ✅ Error handling in save/load operations -- ✅ State transition workflows - -### 3. Installation Orchestrator Tests (`12 tests`) - -#### InstallationOrchestratorTests.cs (12 tests) -- ✅ Asset Store compliance validation (no automatic downloads) -- ✅ Installation progress tracking -- ✅ Event handling and notifications -- ✅ Concurrent installation management -- ✅ Cancellation handling -- ✅ Error recovery mechanisms -- ✅ Python/UV installation compliance (manual only) -- ✅ MCP Server installation (allowed) -- ✅ Multiple dependency processing - -### 4. Integration Tests (`11 tests`) - -#### AssetStoreComplianceIntegrationTests.cs (11 tests) -- ✅ End-to-end setup workflow validation -- ✅ Fresh install scenario testing -- ✅ Dependency check integration -- ✅ Setup completion persistence -- ✅ Asset Store compliance verification -- ✅ Cross-platform compatibility -- ✅ User experience flow validation -- ✅ Error handling integration -- ✅ Menu integration testing -- ✅ Performance considerations -- ✅ State management across sessions - -### 5. Edge Cases Tests (`17 tests`) - -#### EdgeCasesTests.cs (17 tests) -- ✅ Corrupted EditorPrefs handling -- ✅ Null and empty value handling -- ✅ Extreme value testing -- ✅ Concurrent access scenarios -- ✅ Memory management under stress -- ✅ Invalid dependency name handling -- ✅ Rapid operation cancellation -- ✅ Data corruption recovery -- ✅ Platform detector edge cases - -### 6. Performance Tests (`12 tests`) - -#### PerformanceTests.cs (12 tests) -- ✅ Dependency check performance (< 1000ms) -- ✅ System ready check optimization (< 1000ms) -- ✅ Platform detector retrieval speed (< 100ms) -- ✅ Setup state operations (< 100ms) -- ✅ Repeated operation caching -- ✅ Large dataset handling (1000+ dependencies) -- ✅ Concurrent access performance -- ✅ Memory usage validation (< 10MB increase) -- ✅ Unity startup impact (< 200ms) - ---- - -## 🏪 Asset Store Compliance Verification - -### ✅ Compliance Requirements Met - -1. **No Bundled Dependencies** - - ❌ No Python interpreter included - - ❌ No UV package manager included - - ❌ No large binary dependencies - - ✅ Clean package structure verified - -2. **User-Guided Installation** - - ✅ Manual installation guidance provided - - ✅ Platform-specific instructions generated - - ✅ Clear dependency requirements communicated - - ✅ Fallback modes for missing dependencies - -3. **Asset Store Package Structure** - - ✅ Package.json compliance verified - - ✅ Dependency requirements documented - - ✅ No automatic external downloads - - ✅ Clean separation of concerns - -4. **Installation Orchestrator Compliance** - - ✅ Python installation always fails (manual required) - - ✅ UV installation always fails (manual required) - - ✅ MCP Server installation allowed (source code only) - - ✅ Progress tracking without automatic downloads - ---- - -## 🚀 Test Execution Instructions - -### Running Tests in Unity - -1. **Open Unity Project** - ```bash - # Navigate to test project - cd /home/jpb/dev/tingz/unity-mcp/TestProjects/UnityMCPTests - ``` - -2. **Import Test Package** - - Copy test files to `Assets/Tests/AssetStoreCompliance/` - - Ensure assembly definition references are correct - -3. **Run Tests via Menu** - - `Window > MCP for Unity > Run All Asset Store Compliance Tests` - - `Window > MCP for Unity > Run Dependency Tests` - - `Window > MCP for Unity > Run Setup Wizard Tests` - - `Window > MCP for Unity > Run Installation Tests` - - `Window > MCP for Unity > Run Integration Tests` - - `Window > MCP for Unity > Run Performance Tests` - - `Window > MCP for Unity > Run Edge Case Tests` - -4. **Generate Coverage Report** - - `Window > MCP for Unity > Generate Test Coverage Report` - -### Running Tests via Unity Test Runner - -1. Open `Window > General > Test Runner` -2. Select `EditMode` tab -3. Run `AssetStoreComplianceTests.EditMode` assembly -4. View detailed results in Test Runner window - -### Command Line Testing - -```bash -# Run validation script -cd /home/jpb/dev/tingz/unity-mcp/ava-worktrees/feature/ava-asset-store-compliance -python3 run_tests.py -``` - ---- - -## 📈 Performance Benchmarks - -### Startup Impact -- **Platform Detector Retrieval**: < 100ms ✅ -- **Setup State Loading**: < 100ms ✅ -- **Total Unity Startup Impact**: < 200ms ✅ - -### Runtime Performance -- **Dependency Check**: < 1000ms ✅ -- **System Ready Check**: < 1000ms ✅ -- **State Persistence**: < 100ms ✅ - -### Memory Usage -- **Base Memory Footprint**: Minimal ✅ -- **100 Operations Memory Increase**: < 10MB ✅ -- **Concurrent Access**: No memory leaks ✅ - ---- - -## 🔧 Mock Infrastructure - -### MockPlatformDetector -- **Purpose**: Isolated testing of platform-specific functionality -- **Features**: Configurable dependency availability simulation -- **Usage**: Unit tests requiring controlled dependency states - -### Test Utilities -- **TestRunner**: Comprehensive test execution and reporting -- **Performance Measurement**: Automated benchmarking -- **Coverage Analysis**: Detailed coverage reporting - ---- - -## ✅ Quality Assurance Checklist - -### Code Quality -- ✅ All tests follow NUnit conventions -- ✅ Comprehensive error handling -- ✅ Clear test descriptions and assertions -- ✅ Proper setup/teardown procedures -- ✅ Mock implementations for external dependencies - -### Test Coverage -- ✅ Unit tests for all public methods -- ✅ Integration tests for workflows -- ✅ Edge case and error scenario coverage -- ✅ Performance validation -- ✅ Asset Store compliance verification - -### Documentation -- ✅ Test purpose clearly documented -- ✅ Expected behaviors specified -- ✅ Error conditions tested -- ✅ Performance expectations defined - ---- - -## 🎯 Test Results Summary - -| Validation Category | Status | Details | -|---------------------|--------|---------| -| **Test Structure** | ✅ PASS | All required directories and files present | -| **Test Content** | ✅ PASS | 110 tests, 2,799 lines of comprehensive test code | -| **Asset Store Compliance** | ✅ PASS | No bundled dependencies, manual installation only | -| **Performance** | ✅ PASS | All operations within acceptable thresholds | -| **Error Handling** | ✅ PASS | Graceful degradation and recovery verified | -| **Cross-Platform** | ✅ PASS | Windows, macOS, Linux compatibility tested | - ---- - -## 🚀 Deployment Readiness - -### Pre-Deployment Checklist -- ✅ All tests passing -- ✅ Performance benchmarks met -- ✅ Asset Store compliance verified -- ✅ Cross-platform compatibility confirmed -- ✅ Error handling comprehensive -- ✅ Documentation complete - -### Recommended Next Steps -1. **Manual Testing**: Validate on target platforms -2. **User Acceptance Testing**: Test with real user scenarios -3. **Performance Validation**: Verify in production-like environments -4. **Asset Store Submission**: Package meets all requirements - ---- - -## 📞 Support and Maintenance - -### Test Maintenance -- Tests are designed to be maintainable and extensible -- Mock infrastructure supports easy scenario simulation -- Performance tests provide regression detection -- Coverage reports identify gaps - -### Future Enhancements -- Additional platform detector implementations -- Enhanced performance monitoring -- Extended edge case coverage -- Automated CI/CD integration - ---- - -**Test Suite Status**: ✅ **READY FOR PRODUCTION** - -The comprehensive test suite successfully validates all aspects of the Unity MCP Bridge Asset Store compliance implementation, ensuring reliable functionality across platforms while maintaining strict Asset Store compliance requirements. \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef deleted file mode 100644 index d23fb694..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "AssetStoreComplianceTests.EditMode", - "rootNamespace": "MCPForUnity.Tests", - "references": [ - "MCPForUnity.Editor", - "UnityEngine.TestRunner", - "UnityEditor.TestRunner" - ], - "includePlatforms": [ - "Editor" - ], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": true, - "precompiledReferences": [ - "nunit.framework.dll" - ], - "autoReferenced": false, - "defineConstraints": [ - "UNITY_INCLUDE_TESTS" - ], - "versionDefines": [], - "noEngineReferences": false -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef.meta b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef.meta deleted file mode 100644 index 843a3262..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/AssetStoreComplianceTests.Editor.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 12345678901234567890123456789012 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyManagerTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyManagerTests.cs deleted file mode 100644 index c69a7766..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyManagerTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Dependencies.PlatformDetectors; -using MCPForUnity.Tests.Mocks; - -namespace MCPForUnity.Tests.Dependencies -{ - [TestFixture] - public class DependencyManagerTests - { - private MockPlatformDetector _mockDetector; - - [SetUp] - public void SetUp() - { - _mockDetector = new MockPlatformDetector(); - } - - [Test] - public void GetCurrentPlatformDetector_ReturnsValidDetector() - { - // Act - var detector = DependencyManager.GetCurrentPlatformDetector(); - - // Assert - Assert.IsNotNull(detector, "Platform detector should not be null"); - Assert.IsTrue(detector.CanDetect, "Platform detector should be able to detect on current platform"); - Assert.IsNotEmpty(detector.PlatformName, "Platform name should not be empty"); - } - - [Test] - public void CheckAllDependencies_ReturnsValidResult() - { - // Act - var result = DependencyManager.CheckAllDependencies(); - - // Assert - Assert.IsNotNull(result, "Dependency check result should not be null"); - Assert.IsNotNull(result.Dependencies, "Dependencies list should not be null"); - Assert.GreaterOrEqual(result.Dependencies.Count, 3, "Should check at least Python, UV, and MCP Server"); - Assert.IsNotNull(result.Summary, "Summary should not be null"); - Assert.IsNotEmpty(result.RecommendedActions, "Should have recommended actions"); - } - - [Test] - public void CheckAllDependencies_IncludesRequiredDependencies() - { - // Act - var result = DependencyManager.CheckAllDependencies(); - - // Assert - var dependencyNames = result.Dependencies.Select(d => d.Name).ToList(); - Assert.Contains("Python", dependencyNames, "Should check Python dependency"); - Assert.Contains("UV Package Manager", dependencyNames, "Should check UV dependency"); - Assert.Contains("MCP Server", dependencyNames, "Should check MCP Server dependency"); - } - - [Test] - public void IsSystemReady_ReturnsFalse_WhenDependenciesMissing() - { - // This test assumes some dependencies might be missing in test environment - // Act - var isReady = DependencyManager.IsSystemReady(); - - // Assert - Assert.IsNotNull(isReady, "IsSystemReady should return a boolean value"); - // Note: We can't assert true/false here as it depends on the test environment - } - - [Test] - public void GetMissingDependenciesSummary_ReturnsValidString() - { - // Act - var summary = DependencyManager.GetMissingDependenciesSummary(); - - // Assert - Assert.IsNotNull(summary, "Missing dependencies summary should not be null"); - Assert.IsNotEmpty(summary, "Missing dependencies summary should not be empty"); - } - - [Test] - public void IsDependencyAvailable_Python_ReturnsBoolean() - { - // Act - var isAvailable = DependencyManager.IsDependencyAvailable("python"); - - // Assert - Assert.IsNotNull(isAvailable, "Python availability check should return a boolean"); - } - - [Test] - public void IsDependencyAvailable_UV_ReturnsBoolean() - { - // Act - var isAvailable = DependencyManager.IsDependencyAvailable("uv"); - - // Assert - Assert.IsNotNull(isAvailable, "UV availability check should return a boolean"); - } - - [Test] - public void IsDependencyAvailable_MCPServer_ReturnsBoolean() - { - // Act - var isAvailable = DependencyManager.IsDependencyAvailable("mcpserver"); - - // Assert - Assert.IsNotNull(isAvailable, "MCP Server availability check should return a boolean"); - } - - [Test] - public void IsDependencyAvailable_UnknownDependency_ReturnsFalse() - { - // Act - var isAvailable = DependencyManager.IsDependencyAvailable("unknown-dependency"); - - // Assert - Assert.IsFalse(isAvailable, "Unknown dependency should return false"); - } - - [Test] - public void GetInstallationRecommendations_ReturnsValidString() - { - // Act - var recommendations = DependencyManager.GetInstallationRecommendations(); - - // Assert - Assert.IsNotNull(recommendations, "Installation recommendations should not be null"); - Assert.IsNotEmpty(recommendations, "Installation recommendations should not be empty"); - } - - [Test] - public void GetInstallationUrls_ReturnsValidUrls() - { - // Act - var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); - - // Assert - Assert.IsNotNull(pythonUrl, "Python URL should not be null"); - Assert.IsNotNull(uvUrl, "UV URL should not be null"); - Assert.IsTrue(pythonUrl.StartsWith("http"), "Python URL should be a valid URL"); - Assert.IsTrue(uvUrl.StartsWith("http"), "UV URL should be a valid URL"); - } - - [Test] - public void GetDependencyDiagnostics_ReturnsDetailedInfo() - { - // Act - var diagnostics = DependencyManager.GetDependencyDiagnostics(); - - // Assert - Assert.IsNotNull(diagnostics, "Diagnostics should not be null"); - Assert.IsNotEmpty(diagnostics, "Diagnostics should not be empty"); - Assert.IsTrue(diagnostics.Contains("Platform:"), "Diagnostics should include platform info"); - Assert.IsTrue(diagnostics.Contains("System Ready:"), "Diagnostics should include system ready status"); - } - - [Test] - public void CheckAllDependencies_HandlesExceptions_Gracefully() - { - // This test verifies that the dependency manager handles exceptions gracefully - // We can't easily force an exception without mocking, but we can verify the result structure - - // Act - var result = DependencyManager.CheckAllDependencies(); - - // Assert - Assert.IsNotNull(result, "Result should not be null even if errors occur"); - Assert.IsNotNull(result.Summary, "Summary should be provided even if errors occur"); - } - - [Test] - public void ValidateMCPServerStartup_ReturnsBoolean() - { - // Act - var isValid = DependencyManager.ValidateMCPServerStartup(); - - // Assert - Assert.IsNotNull(isValid, "MCP Server startup validation should return a boolean"); - } - - [Test] - public void RepairPythonEnvironment_ReturnsBoolean() - { - // Act - var repairResult = DependencyManager.RepairPythonEnvironment(); - - // Assert - Assert.IsNotNull(repairResult, "Python environment repair should return a boolean"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyModelsTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyModelsTests.cs deleted file mode 100644 index 636c8a87..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/DependencyModelsTests.cs +++ /dev/null @@ -1,334 +0,0 @@ -using System; -using System.Linq; -using NUnit.Framework; -using MCPForUnity.Editor.Dependencies.Models; - -namespace MCPForUnity.Tests.Dependencies -{ - [TestFixture] - public class DependencyModelsTests - { - [Test] - public void DependencyStatus_DefaultConstructor_SetsCorrectDefaults() - { - // Act - var status = new DependencyStatus(); - - // Assert - Assert.IsNull(status.Name, "Name should be null by default"); - Assert.IsFalse(status.IsAvailable, "IsAvailable should be false by default"); - Assert.IsFalse(status.IsRequired, "IsRequired should be false by default"); - Assert.IsNull(status.Version, "Version should be null by default"); - Assert.IsNull(status.Path, "Path should be null by default"); - Assert.IsNull(status.Details, "Details should be null by default"); - Assert.IsNull(status.ErrorMessage, "ErrorMessage should be null by default"); - } - - [Test] - public void DependencyStatus_ParameterizedConstructor_SetsCorrectValues() - { - // Arrange - var name = "Test Dependency"; - var isAvailable = true; - var isRequired = true; - var version = "1.0.0"; - var path = "/test/path"; - var details = "Test details"; - - // Act - var status = new DependencyStatus - { - Name = name, - IsAvailable = isAvailable, - IsRequired = isRequired, - Version = version, - Path = path, - Details = details - }; - - // Assert - Assert.AreEqual(name, status.Name, "Name should be set correctly"); - Assert.AreEqual(isAvailable, status.IsAvailable, "IsAvailable should be set correctly"); - Assert.AreEqual(isRequired, status.IsRequired, "IsRequired should be set correctly"); - Assert.AreEqual(version, status.Version, "Version should be set correctly"); - Assert.AreEqual(path, status.Path, "Path should be set correctly"); - Assert.AreEqual(details, status.Details, "Details should be set correctly"); - } - - [Test] - public void DependencyCheckResult_DefaultConstructor_InitializesCollections() - { - // Act - var result = new DependencyCheckResult(); - - // Assert - Assert.IsNotNull(result.Dependencies, "Dependencies should be initialized"); - Assert.IsNotNull(result.RecommendedActions, "RecommendedActions should be initialized"); - Assert.AreEqual(0, result.Dependencies.Count, "Dependencies should be empty initially"); - Assert.AreEqual(0, result.RecommendedActions.Count, "RecommendedActions should be empty initially"); - Assert.IsFalse(result.IsSystemReady, "IsSystemReady should be false by default"); - Assert.IsTrue(result.CheckedAt <= DateTime.UtcNow, "CheckedAt should be set to current time or earlier"); - } - - [Test] - public void DependencyCheckResult_AllRequiredAvailable_ReturnsCorrectValue() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Required1", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Required2", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Optional1", IsRequired = false, IsAvailable = false }); - - // Act & Assert - Assert.IsTrue(result.AllRequiredAvailable, "AllRequiredAvailable should be true when all required dependencies are available"); - } - - [Test] - public void DependencyCheckResult_AllRequiredAvailable_ReturnsFalse_WhenRequiredMissing() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Required1", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Required2", IsRequired = true, IsAvailable = false }); - - // Act & Assert - Assert.IsFalse(result.AllRequiredAvailable, "AllRequiredAvailable should be false when required dependencies are missing"); - } - - [Test] - public void DependencyCheckResult_HasMissingOptional_ReturnsCorrectValue() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Required1", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Optional1", IsRequired = false, IsAvailable = false }); - - // Act & Assert - Assert.IsTrue(result.HasMissingOptional, "HasMissingOptional should be true when optional dependencies are missing"); - } - - [Test] - public void DependencyCheckResult_GetMissingDependencies_ReturnsCorrectList() - { - // Arrange - var result = new DependencyCheckResult(); - var available = new DependencyStatus { Name = "Available", IsAvailable = true }; - var missing1 = new DependencyStatus { Name = "Missing1", IsAvailable = false }; - var missing2 = new DependencyStatus { Name = "Missing2", IsAvailable = false }; - - result.Dependencies.Add(available); - result.Dependencies.Add(missing1); - result.Dependencies.Add(missing2); - - // Act - var missing = result.GetMissingDependencies(); - - // Assert - Assert.AreEqual(2, missing.Count, "Should return 2 missing dependencies"); - Assert.IsTrue(missing.Any(d => d.Name == "Missing1"), "Should include Missing1"); - Assert.IsTrue(missing.Any(d => d.Name == "Missing2"), "Should include Missing2"); - Assert.IsFalse(missing.Any(d => d.Name == "Available"), "Should not include available dependency"); - } - - [Test] - public void DependencyCheckResult_GetMissingRequired_ReturnsCorrectList() - { - // Arrange - var result = new DependencyCheckResult(); - var availableRequired = new DependencyStatus { Name = "AvailableRequired", IsRequired = true, IsAvailable = true }; - var missingRequired = new DependencyStatus { Name = "MissingRequired", IsRequired = true, IsAvailable = false }; - var missingOptional = new DependencyStatus { Name = "MissingOptional", IsRequired = false, IsAvailable = false }; - - result.Dependencies.Add(availableRequired); - result.Dependencies.Add(missingRequired); - result.Dependencies.Add(missingOptional); - - // Act - var missingRequired_result = result.GetMissingRequired(); - - // Assert - Assert.AreEqual(1, missingRequired_result.Count, "Should return 1 missing required dependency"); - Assert.AreEqual("MissingRequired", missingRequired_result[0].Name, "Should return the missing required dependency"); - } - - [Test] - public void DependencyCheckResult_GenerateSummary_AllAvailable() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Dep1", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Dep2", IsRequired = false, IsAvailable = true }); - - // Act - result.GenerateSummary(); - - // Assert - Assert.IsTrue(result.IsSystemReady, "System should be ready when all dependencies are available"); - Assert.IsTrue(result.Summary.Contains("All dependencies are available"), "Summary should indicate all dependencies are available"); - } - - [Test] - public void DependencyCheckResult_GenerateSummary_MissingOptional() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Required", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Optional", IsRequired = false, IsAvailable = false }); - - // Act - result.GenerateSummary(); - - // Assert - Assert.IsTrue(result.IsSystemReady, "System should be ready when only optional dependencies are missing"); - Assert.IsTrue(result.Summary.Contains("System is ready"), "Summary should indicate system is ready"); - Assert.IsTrue(result.Summary.Contains("optional"), "Summary should mention optional dependencies"); - } - - [Test] - public void DependencyCheckResult_GenerateSummary_MissingRequired() - { - // Arrange - var result = new DependencyCheckResult(); - result.Dependencies.Add(new DependencyStatus { Name = "Required1", IsRequired = true, IsAvailable = true }); - result.Dependencies.Add(new DependencyStatus { Name = "Required2", IsRequired = true, IsAvailable = false }); - - // Act - result.GenerateSummary(); - - // Assert - Assert.IsFalse(result.IsSystemReady, "System should not be ready when required dependencies are missing"); - Assert.IsTrue(result.Summary.Contains("System is not ready"), "Summary should indicate system is not ready"); - Assert.IsTrue(result.Summary.Contains("required"), "Summary should mention required dependencies"); - } - - [Test] - public void SetupState_DefaultConstructor_SetsCorrectDefaults() - { - // Act - var state = new SetupState(); - - // Assert - Assert.IsFalse(state.HasCompletedSetup, "HasCompletedSetup should be false by default"); - Assert.IsFalse(state.HasDismissedSetup, "HasDismissedSetup should be false by default"); - Assert.IsFalse(state.ShowSetupOnReload, "ShowSetupOnReload should be false by default"); - Assert.AreEqual("automatic", state.PreferredInstallMode, "PreferredInstallMode should be 'automatic' by default"); - Assert.AreEqual(0, state.SetupAttempts, "SetupAttempts should be 0 by default"); - } - - [Test] - public void SetupState_ShouldShowSetup_ReturnsFalse_WhenDismissed() - { - // Arrange - var state = new SetupState(); - state.HasDismissedSetup = true; - - // Act & Assert - Assert.IsFalse(state.ShouldShowSetup("1.0.0"), "Should not show setup when dismissed"); - } - - [Test] - public void SetupState_ShouldShowSetup_ReturnsTrue_WhenNotCompleted() - { - // Arrange - var state = new SetupState(); - state.HasCompletedSetup = false; - - // Act & Assert - Assert.IsTrue(state.ShouldShowSetup("1.0.0"), "Should show setup when not completed"); - } - - [Test] - public void SetupState_ShouldShowSetup_ReturnsTrue_WhenVersionChanged() - { - // Arrange - var state = new SetupState(); - state.HasCompletedSetup = true; - state.SetupVersion = "1.0.0"; - - // Act & Assert - Assert.IsTrue(state.ShouldShowSetup("2.0.0"), "Should show setup when version changed"); - } - - [Test] - public void SetupState_ShouldShowSetup_ReturnsFalse_WhenCompletedSameVersion() - { - // Arrange - var state = new SetupState(); - state.HasCompletedSetup = true; - state.SetupVersion = "1.0.0"; - - // Act & Assert - Assert.IsFalse(state.ShouldShowSetup("1.0.0"), "Should not show setup when completed for same version"); - } - - [Test] - public void SetupState_MarkSetupCompleted_SetsCorrectValues() - { - // Arrange - var state = new SetupState(); - var version = "1.0.0"; - - // Act - state.MarkSetupCompleted(version); - - // Assert - Assert.IsTrue(state.HasCompletedSetup, "HasCompletedSetup should be true"); - Assert.AreEqual(version, state.SetupVersion, "SetupVersion should be set"); - Assert.IsFalse(state.ShowSetupOnReload, "ShowSetupOnReload should be false"); - Assert.IsNull(state.LastSetupError, "LastSetupError should be null"); - } - - [Test] - public void SetupState_MarkSetupDismissed_SetsCorrectValues() - { - // Arrange - var state = new SetupState(); - - // Act - state.MarkSetupDismissed(); - - // Assert - Assert.IsTrue(state.HasDismissedSetup, "HasDismissedSetup should be true"); - Assert.IsFalse(state.ShowSetupOnReload, "ShowSetupOnReload should be false"); - } - - [Test] - public void SetupState_RecordSetupAttempt_IncrementsCounter() - { - // Arrange - var state = new SetupState(); - var error = "Test error"; - - // Act - state.RecordSetupAttempt(error); - - // Assert - Assert.AreEqual(1, state.SetupAttempts, "SetupAttempts should be incremented"); - Assert.AreEqual(error, state.LastSetupError, "LastSetupError should be set"); - } - - [Test] - public void SetupState_Reset_ClearsAllValues() - { - // Arrange - var state = new SetupState(); - state.HasCompletedSetup = true; - state.HasDismissedSetup = true; - state.ShowSetupOnReload = true; - state.SetupAttempts = 5; - state.LastSetupError = "Error"; - state.LastDependencyCheck = "2023-01-01"; - - // Act - state.Reset(); - - // Assert - Assert.IsFalse(state.HasCompletedSetup, "HasCompletedSetup should be reset"); - Assert.IsFalse(state.HasDismissedSetup, "HasDismissedSetup should be reset"); - Assert.IsFalse(state.ShowSetupOnReload, "ShowSetupOnReload should be reset"); - Assert.AreEqual(0, state.SetupAttempts, "SetupAttempts should be reset"); - Assert.IsNull(state.LastSetupError, "LastSetupError should be reset"); - Assert.IsNull(state.LastDependencyCheck, "LastDependencyCheck should be reset"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/PlatformDetectorTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/PlatformDetectorTests.cs deleted file mode 100644 index 651a0746..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Dependencies/PlatformDetectorTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using NUnit.Framework; -using MCPForUnity.Editor.Dependencies.PlatformDetectors; -using MCPForUnity.Tests.Mocks; - -namespace MCPForUnity.Tests.Dependencies -{ - [TestFixture] - public class PlatformDetectorTests - { - [Test] - public void WindowsPlatformDetector_CanDetect_OnWindows() - { - // Arrange - var detector = new WindowsPlatformDetector(); - - // Act & Assert - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - { - Assert.IsTrue(detector.CanDetect, "Windows detector should detect on Windows platform"); - Assert.AreEqual("Windows", detector.PlatformName, "Platform name should be Windows"); - } - else - { - Assert.IsFalse(detector.CanDetect, "Windows detector should not detect on non-Windows platform"); - } - } - - [Test] - public void MacOSPlatformDetector_CanDetect_OnMacOS() - { - // Arrange - var detector = new MacOSPlatformDetector(); - - // Act & Assert - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) - { - Assert.IsTrue(detector.CanDetect, "macOS detector should detect on macOS platform"); - Assert.AreEqual("macOS", detector.PlatformName, "Platform name should be macOS"); - } - else - { - Assert.IsFalse(detector.CanDetect, "macOS detector should not detect on non-macOS platform"); - } - } - - [Test] - public void LinuxPlatformDetector_CanDetect_OnLinux() - { - // Arrange - var detector = new LinuxPlatformDetector(); - - // Act & Assert - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux)) - { - Assert.IsTrue(detector.CanDetect, "Linux detector should detect on Linux platform"); - Assert.AreEqual("Linux", detector.PlatformName, "Platform name should be Linux"); - } - else - { - Assert.IsFalse(detector.CanDetect, "Linux detector should not detect on non-Linux platform"); - } - } - - [Test] - public void PlatformDetector_DetectPython_ReturnsValidStatus() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var pythonStatus = detector.DetectPython(); - - // Assert - Assert.IsNotNull(pythonStatus, "Python status should not be null"); - Assert.AreEqual("Python", pythonStatus.Name, "Dependency name should be Python"); - Assert.IsTrue(pythonStatus.IsRequired, "Python should be marked as required"); - } - - [Test] - public void PlatformDetector_DetectUV_ReturnsValidStatus() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var uvStatus = detector.DetectUV(); - - // Assert - Assert.IsNotNull(uvStatus, "UV status should not be null"); - Assert.AreEqual("UV Package Manager", uvStatus.Name, "Dependency name should be UV Package Manager"); - Assert.IsTrue(uvStatus.IsRequired, "UV should be marked as required"); - } - - [Test] - public void PlatformDetector_DetectMCPServer_ReturnsValidStatus() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var serverStatus = detector.DetectMCPServer(); - - // Assert - Assert.IsNotNull(serverStatus, "MCP Server status should not be null"); - Assert.AreEqual("MCP Server", serverStatus.Name, "Dependency name should be MCP Server"); - Assert.IsFalse(serverStatus.IsRequired, "MCP Server should not be marked as required (auto-installable)"); - } - - [Test] - public void PlatformDetector_GetInstallationRecommendations_ReturnsValidString() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var recommendations = detector.GetInstallationRecommendations(); - - // Assert - Assert.IsNotNull(recommendations, "Installation recommendations should not be null"); - Assert.IsNotEmpty(recommendations, "Installation recommendations should not be empty"); - } - - [Test] - public void PlatformDetector_GetPythonInstallUrl_ReturnsValidUrl() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var url = detector.GetPythonInstallUrl(); - - // Assert - Assert.IsNotNull(url, "Python install URL should not be null"); - Assert.IsTrue(url.StartsWith("http"), "Python install URL should be a valid URL"); - } - - [Test] - public void PlatformDetector_GetUVInstallUrl_ReturnsValidUrl() - { - // Arrange - var detector = GetCurrentPlatformDetector(); - - // Act - var url = detector.GetUVInstallUrl(); - - // Assert - Assert.IsNotNull(url, "UV install URL should not be null"); - Assert.IsTrue(url.StartsWith("http"), "UV install URL should be a valid URL"); - } - - [Test] - public void MockPlatformDetector_WorksCorrectly() - { - // Arrange - var mockDetector = new MockPlatformDetector(); - mockDetector.SetPythonAvailable(true, "3.11.0", "/usr/bin/python3"); - mockDetector.SetUVAvailable(false); - mockDetector.SetMCPServerAvailable(true); - - // Act - var pythonStatus = mockDetector.DetectPython(); - var uvStatus = mockDetector.DetectUV(); - var serverStatus = mockDetector.DetectMCPServer(); - - // Assert - Assert.IsTrue(pythonStatus.IsAvailable, "Mock Python should be available"); - Assert.AreEqual("3.11.0", pythonStatus.Version, "Mock Python version should match"); - Assert.AreEqual("/usr/bin/python3", pythonStatus.Path, "Mock Python path should match"); - - Assert.IsFalse(uvStatus.IsAvailable, "Mock UV should not be available"); - Assert.IsTrue(serverStatus.IsAvailable, "Mock MCP Server should be available"); - } - - private IPlatformDetector GetCurrentPlatformDetector() - { - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - return new WindowsPlatformDetector(); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) - return new MacOSPlatformDetector(); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux)) - return new LinuxPlatformDetector(); - - throw new PlatformNotSupportedException("Current platform not supported for testing"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/EdgeCasesTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/EdgeCasesTests.cs deleted file mode 100644 index 3287b529..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/EdgeCasesTests.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System; -using System.Collections.Generic; -using NUnit.Framework; -using UnityEditor; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Installation; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Tests.Mocks; - -namespace MCPForUnity.Tests -{ - [TestFixture] - public class EdgeCasesTests - { - private string _originalSetupState; - private const string SETUP_STATE_KEY = "MCPForUnity.SetupState"; - - [SetUp] - public void SetUp() - { - _originalSetupState = EditorPrefs.GetString(SETUP_STATE_KEY, ""); - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - - [TearDown] - public void TearDown() - { - if (!string.IsNullOrEmpty(_originalSetupState)) - { - EditorPrefs.SetString(SETUP_STATE_KEY, _originalSetupState); - } - else - { - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - } - - [Test] - public void DependencyManager_NullPlatformDetector_HandlesGracefully() - { - // This test verifies behavior when no platform detector is available - // (though this shouldn't happen in practice) - - // We can't easily mock this without changing the DependencyManager, - // but we can verify it handles the current platform correctly - Assert.DoesNotThrow(() => DependencyManager.GetCurrentPlatformDetector(), - "Should handle platform detection gracefully"); - } - - [Test] - public void DependencyManager_CorruptedDependencyData_HandlesGracefully() - { - // Test handling of corrupted or unexpected dependency data - - var result = DependencyManager.CheckAllDependencies(); - - // Even with potential corruption, should return valid result structure - Assert.IsNotNull(result, "Should return valid result even with potential data issues"); - Assert.IsNotNull(result.Dependencies, "Dependencies list should not be null"); - Assert.IsNotNull(result.Summary, "Summary should not be null"); - Assert.IsNotNull(result.RecommendedActions, "Recommended actions should not be null"); - } - - [Test] - public void SetupWizard_CorruptedEditorPrefs_CreatesDefaultState() - { - // Test handling of corrupted EditorPrefs data - - // Set invalid JSON - EditorPrefs.SetString(SETUP_STATE_KEY, "{ invalid json data }"); - - // Should create default state without throwing - var state = SetupWizard.GetSetupState(); - - Assert.IsNotNull(state, "Should create default state for corrupted data"); - Assert.IsFalse(state.HasCompletedSetup, "Default state should not be completed"); - Assert.IsFalse(state.HasDismissedSetup, "Default state should not be dismissed"); - } - - [Test] - public void SetupWizard_EmptyEditorPrefs_CreatesDefaultState() - { - // Test handling of empty EditorPrefs - - EditorPrefs.SetString(SETUP_STATE_KEY, ""); - - var state = SetupWizard.GetSetupState(); - - Assert.IsNotNull(state, "Should create default state for empty data"); - Assert.IsFalse(state.HasCompletedSetup, "Default state should not be completed"); - } - - [Test] - public void SetupWizard_VeryLongVersionString_HandlesCorrectly() - { - // Test handling of unusually long version strings - - var longVersion = new string('1', 1000) + ".0.0"; - var state = SetupWizard.GetSetupState(); - - Assert.DoesNotThrow(() => state.ShouldShowSetup(longVersion), - "Should handle long version strings"); - - Assert.DoesNotThrow(() => state.MarkSetupCompleted(longVersion), - "Should handle long version strings in completion"); - } - - [Test] - public void SetupWizard_NullVersionString_HandlesCorrectly() - { - // Test handling of null version strings - - var state = SetupWizard.GetSetupState(); - - Assert.DoesNotThrow(() => state.ShouldShowSetup(null), - "Should handle null version strings"); - - Assert.DoesNotThrow(() => state.MarkSetupCompleted(null), - "Should handle null version strings in completion"); - } - - [Test] - public void InstallationOrchestrator_NullDependenciesList_HandlesGracefully() - { - // Test handling of null dependencies list - - var orchestrator = new InstallationOrchestrator(); - - Assert.DoesNotThrow(() => orchestrator.StartInstallation(null), - "Should handle null dependencies list gracefully"); - } - - [Test] - public void InstallationOrchestrator_EmptyDependenciesList_CompletesSuccessfully() - { - // Test handling of empty dependencies list - - var orchestrator = new InstallationOrchestrator(); - var emptyList = new List(); - - bool completed = false; - bool success = false; - - orchestrator.OnInstallationComplete += (s, m) => { completed = true; success = s; }; - - orchestrator.StartInstallation(emptyList); - - // Wait briefly - System.Threading.Thread.Sleep(200); - - Assert.IsTrue(completed, "Empty installation should complete"); - Assert.IsTrue(success, "Empty installation should succeed"); - } - - [Test] - public void InstallationOrchestrator_DependencyWithNullName_HandlesGracefully() - { - // Test handling of dependency with null name - - var orchestrator = new InstallationOrchestrator(); - var dependencies = new List - { - new DependencyStatus { Name = null, IsRequired = true, IsAvailable = false } - }; - - bool completed = false; - - orchestrator.OnInstallationComplete += (s, m) => completed = true; - - Assert.DoesNotThrow(() => orchestrator.StartInstallation(dependencies), - "Should handle dependency with null name"); - - // Wait briefly - System.Threading.Thread.Sleep(1000); - - Assert.IsTrue(completed, "Installation should complete even with null dependency name"); - } - - [Test] - public void DependencyCheckResult_NullDependenciesList_HandlesGracefully() - { - // Test handling of null dependencies in result - - var result = new DependencyCheckResult(); - result.Dependencies = null; - - Assert.DoesNotThrow(() => result.GenerateSummary(), - "Should handle null dependencies list in summary generation"); - - Assert.DoesNotThrow(() => result.GetMissingDependencies(), - "Should handle null dependencies list in missing dependencies"); - - Assert.DoesNotThrow(() => result.GetMissingRequired(), - "Should handle null dependencies list in missing required"); - } - - [Test] - public void DependencyStatus_ExtremeValues_HandlesCorrectly() - { - // Test handling of extreme values in dependency status - - var status = new DependencyStatus(); - - // Test very long strings - var longString = new string('x', 10000); - - Assert.DoesNotThrow(() => status.Name = longString, - "Should handle very long name"); - - Assert.DoesNotThrow(() => status.Version = longString, - "Should handle very long version"); - - Assert.DoesNotThrow(() => status.Path = longString, - "Should handle very long path"); - - Assert.DoesNotThrow(() => status.Details = longString, - "Should handle very long details"); - - Assert.DoesNotThrow(() => status.ErrorMessage = longString, - "Should handle very long error message"); - } - - [Test] - public void SetupState_ExtremeAttemptCounts_HandlesCorrectly() - { - // Test handling of extreme attempt counts - - var state = new SetupState(); - - // Test very high attempt count - state.SetupAttempts = int.MaxValue; - - Assert.DoesNotThrow(() => state.RecordSetupAttempt(), - "Should handle overflow in setup attempts gracefully"); - } - - [Test] - public void DependencyManager_ConcurrentAccess_HandlesCorrectly() - { - // Test concurrent access to dependency manager - - var tasks = new List(); - var exceptions = new List(); - - for (int i = 0; i < 10; i++) - { - tasks.Add(System.Threading.Tasks.Task.Run(() => - { - try - { - DependencyManager.CheckAllDependencies(); - DependencyManager.IsSystemReady(); - DependencyManager.GetMissingDependenciesSummary(); - } - catch (Exception ex) - { - lock (exceptions) - { - exceptions.Add(ex); - } - } - })); - } - - System.Threading.Tasks.Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(10)); - - Assert.AreEqual(0, exceptions.Count, - $"Concurrent access should not cause exceptions. Exceptions: {string.Join(", ", exceptions)}"); - } - - [Test] - public void SetupWizard_ConcurrentStateAccess_HandlesCorrectly() - { - // Test concurrent access to setup wizard state - - var tasks = new List(); - var exceptions = new List(); - - for (int i = 0; i < 10; i++) - { - tasks.Add(System.Threading.Tasks.Task.Run(() => - { - try - { - var state = SetupWizard.GetSetupState(); - state.RecordSetupAttempt(); - SetupWizard.SaveSetupState(); - } - catch (Exception ex) - { - lock (exceptions) - { - exceptions.Add(ex); - } - } - })); - } - - System.Threading.Tasks.Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(10)); - - Assert.AreEqual(0, exceptions.Count, - $"Concurrent state access should not cause exceptions. Exceptions: {string.Join(", ", exceptions)}"); - } - - [Test] - public void MockPlatformDetector_EdgeCases_HandlesCorrectly() - { - // Test edge cases with mock platform detector - - var mock = new MockPlatformDetector(); - - // Test with null/empty values - mock.SetPythonAvailable(true, null, "", null); - mock.SetUVAvailable(false, "", null, ""); - mock.SetMCPServerAvailable(true, null, ""); - - Assert.DoesNotThrow(() => mock.DetectPython(), - "Mock should handle null/empty values"); - - Assert.DoesNotThrow(() => mock.DetectUV(), - "Mock should handle null/empty values"); - - Assert.DoesNotThrow(() => mock.DetectMCPServer(), - "Mock should handle null/empty values"); - } - - [Test] - public void InstallationOrchestrator_RapidCancellation_HandlesCorrectly() - { - // Test rapid cancellation of installation - - var orchestrator = new InstallationOrchestrator(); - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false } - }; - - // Start and immediately cancel - orchestrator.StartInstallation(dependencies); - orchestrator.CancelInstallation(); - - // Should handle rapid cancellation gracefully - Assert.IsFalse(orchestrator.IsInstalling, "Should not be installing after cancellation"); - } - - [Test] - public void DependencyManager_InvalidDependencyNames_HandlesCorrectly() - { - // Test handling of invalid dependency names - - var invalidNames = new[] { null, "", " ", "invalid-name", "PYTHON", "python123" }; - - foreach (var name in invalidNames) - { - Assert.DoesNotThrow(() => DependencyManager.IsDependencyAvailable(name), - $"Should handle invalid dependency name: '{name}'"); - - var result = DependencyManager.IsDependencyAvailable(name); - if (name != "python" && name != "uv" && name != "mcpserver" && name != "mcp-server") - { - Assert.IsFalse(result, $"Invalid dependency name '{name}' should return false"); - } - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Installation/InstallationOrchestratorTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Installation/InstallationOrchestratorTests.cs deleted file mode 100644 index c543d3b8..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Installation/InstallationOrchestratorTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using NUnit.Framework; -using MCPForUnity.Editor.Installation; -using MCPForUnity.Editor.Dependencies.Models; - -namespace MCPForUnity.Tests.Installation -{ - [TestFixture] - public class InstallationOrchestratorTests - { - private InstallationOrchestrator _orchestrator; - private List _progressUpdates; - private bool? _lastInstallationResult; - private string _lastInstallationMessage; - - [SetUp] - public void SetUp() - { - _orchestrator = new InstallationOrchestrator(); - _progressUpdates = new List(); - _lastInstallationResult = null; - _lastInstallationMessage = null; - - // Subscribe to events - _orchestrator.OnProgressUpdate += OnProgressUpdate; - _orchestrator.OnInstallationComplete += OnInstallationComplete; - } - - [TearDown] - public void TearDown() - { - // Unsubscribe from events - _orchestrator.OnProgressUpdate -= OnProgressUpdate; - _orchestrator.OnInstallationComplete -= OnInstallationComplete; - } - - private void OnProgressUpdate(string message) - { - _progressUpdates.Add(message); - } - - private void OnInstallationComplete(bool success, string message) - { - _lastInstallationResult = success; - _lastInstallationMessage = message; - } - - [Test] - public void InstallationOrchestrator_DefaultState() - { - // Assert - Assert.IsFalse(_orchestrator.IsInstalling, "Should not be installing by default"); - } - - [Test] - public void StartInstallation_EmptyList_CompletesSuccessfully() - { - // Arrange - var emptyDependencies = new List(); - - // Act - _orchestrator.StartInstallation(emptyDependencies); - - // Wait a bit for async operation - System.Threading.Thread.Sleep(100); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsTrue(_lastInstallationResult.Value, "Empty installation should succeed"); - Assert.IsNotNull(_lastInstallationMessage, "Should have completion message"); - } - - [Test] - public void StartInstallation_PythonDependency_FailsAsExpected() - { - // Arrange - var dependencies = new List - { - new DependencyStatus - { - Name = "Python", - IsRequired = true, - IsAvailable = false - } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(2000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsFalse(_lastInstallationResult.Value, "Python installation should fail (Asset Store compliance)"); - Assert.IsTrue(_progressUpdates.Count > 0, "Should have progress updates"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("Python")), "Should mention Python in progress"); - } - - [Test] - public void StartInstallation_UVDependency_FailsAsExpected() - { - // Arrange - var dependencies = new List - { - new DependencyStatus - { - Name = "UV Package Manager", - IsRequired = true, - IsAvailable = false - } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(2000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsFalse(_lastInstallationResult.Value, "UV installation should fail (Asset Store compliance)"); - Assert.IsTrue(_progressUpdates.Count > 0, "Should have progress updates"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("UV")), "Should mention UV in progress"); - } - - [Test] - public void StartInstallation_MCPServerDependency_AttemptsInstallation() - { - // Arrange - var dependencies = new List - { - new DependencyStatus - { - Name = "MCP Server", - IsRequired = false, - IsAvailable = false - } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(3000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - // Result depends on whether ServerInstaller.EnsureServerInstalled() succeeds - Assert.IsTrue(_progressUpdates.Count > 0, "Should have progress updates"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("MCP Server")), "Should mention MCP Server in progress"); - } - - [Test] - public void StartInstallation_MultipleDependencies_ProcessesAll() - { - // Arrange - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false }, - new DependencyStatus { Name = "UV Package Manager", IsRequired = true, IsAvailable = false }, - new DependencyStatus { Name = "MCP Server", IsRequired = false, IsAvailable = false } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(5000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsFalse(_lastInstallationResult.Value, "Should fail due to Python/UV compliance restrictions"); - - // Check that all dependencies were processed - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("Python")), "Should process Python"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("UV")), "Should process UV"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("MCP Server")), "Should process MCP Server"); - } - - [Test] - public void StartInstallation_UnknownDependency_HandlesGracefully() - { - // Arrange - var dependencies = new List - { - new DependencyStatus - { - Name = "Unknown Dependency", - IsRequired = true, - IsAvailable = false - } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(2000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsFalse(_lastInstallationResult.Value, "Unknown dependency installation should fail"); - Assert.IsTrue(_progressUpdates.Count > 0, "Should have progress updates"); - } - - [Test] - public void StartInstallation_AlreadyInstalling_IgnoresSecondCall() - { - // Arrange - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - Assert.IsTrue(_orchestrator.IsInstalling, "Should be installing after first call"); - - var initialProgressCount = _progressUpdates.Count; - _orchestrator.StartInstallation(dependencies); // Second call should be ignored - - // Assert - // The second call should be ignored, so progress count shouldn't change significantly - System.Threading.Thread.Sleep(100); - var progressCountAfterSecondCall = _progressUpdates.Count; - - // We expect minimal change in progress updates from the second call - Assert.IsTrue(progressCountAfterSecondCall - initialProgressCount <= 1, - "Second installation call should be ignored or have minimal impact"); - } - - [Test] - public void CancelInstallation_StopsInstallation() - { - // Arrange - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - Assert.IsTrue(_orchestrator.IsInstalling, "Should be installing"); - - _orchestrator.CancelInstallation(); - - // Wait a bit - System.Threading.Thread.Sleep(100); - - // Assert - Assert.IsFalse(_orchestrator.IsInstalling, "Should not be installing after cancellation"); - Assert.IsTrue(_lastInstallationResult.HasValue, "Should have completion result"); - Assert.IsFalse(_lastInstallationResult.Value, "Cancelled installation should be marked as failed"); - Assert.IsTrue(_lastInstallationMessage.Contains("cancelled"), "Message should indicate cancellation"); - } - - [Test] - public void CancelInstallation_WhenNotInstalling_DoesNothing() - { - // Act - _orchestrator.CancelInstallation(); - - // Assert - Assert.IsFalse(_orchestrator.IsInstalling, "Should not be installing"); - Assert.IsFalse(_lastInstallationResult.HasValue, "Should not have completion result"); - } - - [Test] - public void InstallationOrchestrator_EventHandling() - { - // Test that events are properly fired - var progressUpdateReceived = false; - var installationCompleteReceived = false; - - var testOrchestrator = new InstallationOrchestrator(); - testOrchestrator.OnProgressUpdate += (message) => progressUpdateReceived = true; - testOrchestrator.OnInstallationComplete += (success, message) => installationCompleteReceived = true; - - // Act - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false } - }; - testOrchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(2000); - - // Assert - Assert.IsTrue(progressUpdateReceived, "Progress update event should be fired"); - Assert.IsTrue(installationCompleteReceived, "Installation complete event should be fired"); - } - - [Test] - public void InstallationOrchestrator_AssetStoreCompliance() - { - // This test verifies Asset Store compliance by ensuring that - // Python and UV installations always fail (no automatic downloads) - - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false }, - new DependencyStatus { Name = "UV Package Manager", IsRequired = true, IsAvailable = false } - }; - - // Act - _orchestrator.StartInstallation(dependencies); - - // Wait for async operation - System.Threading.Thread.Sleep(3000); - - // Assert - Assert.IsTrue(_lastInstallationResult.HasValue, "Installation should complete"); - Assert.IsFalse(_lastInstallationResult.Value, "Installation should fail for Asset Store compliance"); - - // Verify that the failure messages indicate manual installation is required - Assert.IsTrue(_lastInstallationMessage.Contains("Failed"), "Should indicate failure"); - Assert.IsTrue(_progressUpdates.Exists(p => p.Contains("manual")), - "Should indicate manual installation is required"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Integration/AssetStoreComplianceIntegrationTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Integration/AssetStoreComplianceIntegrationTests.cs deleted file mode 100644 index 406ceef6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Integration/AssetStoreComplianceIntegrationTests.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using UnityEditor; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Installation; -using MCPForUnity.Editor.Dependencies.Models; - -namespace MCPForUnity.Tests.Integration -{ - [TestFixture] - public class AssetStoreComplianceIntegrationTests - { - private string _originalSetupState; - private const string SETUP_STATE_KEY = "MCPForUnity.SetupState"; - - [SetUp] - public void SetUp() - { - // Save original setup state - _originalSetupState = EditorPrefs.GetString(SETUP_STATE_KEY, ""); - - // Clear setup state for testing - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - - [TearDown] - public void TearDown() - { - // Restore original setup state - if (!string.IsNullOrEmpty(_originalSetupState)) - { - EditorPrefs.SetString(SETUP_STATE_KEY, _originalSetupState); - } - else - { - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - } - - [Test] - public void EndToEndWorkflow_FreshInstall_ShowsSetupWizard() - { - // This test simulates a fresh install scenario - - // Arrange - Fresh state - var setupState = SetupWizard.GetSetupState(); - Assert.IsFalse(setupState.HasCompletedSetup, "Should start with fresh state"); - - // Act - Check if setup should be shown - var shouldShow = setupState.ShouldShowSetup("3.4.0"); - - // Assert - Assert.IsTrue(shouldShow, "Setup wizard should be shown on fresh install"); - } - - [Test] - public void EndToEndWorkflow_DependencyCheck_Integration() - { - // This test verifies the integration between dependency checking and setup wizard - - // Act - var dependencyResult = DependencyManager.CheckAllDependencies(); - - // Assert - Assert.IsNotNull(dependencyResult, "Dependency check should return result"); - Assert.IsNotNull(dependencyResult.Dependencies, "Should have dependencies list"); - Assert.GreaterOrEqual(dependencyResult.Dependencies.Count, 3, "Should check core dependencies"); - - // Verify core dependencies are checked - var dependencyNames = dependencyResult.Dependencies.Select(d => d.Name).ToList(); - Assert.Contains("Python", dependencyNames, "Should check Python"); - Assert.Contains("UV Package Manager", dependencyNames, "Should check UV"); - Assert.Contains("MCP Server", dependencyNames, "Should check MCP Server"); - } - - [Test] - public void EndToEndWorkflow_SetupCompletion_PersistsState() - { - // This test verifies the complete setup workflow - - // Arrange - var initialState = SetupWizard.GetSetupState(); - Assert.IsFalse(initialState.HasCompletedSetup, "Should start incomplete"); - - // Act - Complete setup - SetupWizard.MarkSetupCompleted(); - SetupWizard.SaveSetupState(); - - // Simulate Unity restart by clearing cached state - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - var newState = SetupWizard.GetSetupState(); - - // Assert - Assert.IsTrue(newState.HasCompletedSetup, "Setup completion should persist"); - Assert.IsFalse(newState.ShouldShowSetup("3.4.0"), "Should not show setup after completion"); - } - - [Test] - public void AssetStoreCompliance_NoBundledDependencies() - { - // This test verifies Asset Store compliance by ensuring no bundled dependencies - - // Check that the installation orchestrator doesn't automatically install - // Python or UV (Asset Store compliance requirement) - - var orchestrator = new InstallationOrchestrator(); - var dependencies = new List - { - new DependencyStatus { Name = "Python", IsRequired = true, IsAvailable = false }, - new DependencyStatus { Name = "UV Package Manager", IsRequired = true, IsAvailable = false } - }; - - bool installationCompleted = false; - bool installationSucceeded = false; - string installationMessage = ""; - - orchestrator.OnInstallationComplete += (success, message) => - { - installationCompleted = true; - installationSucceeded = success; - installationMessage = message; - }; - - // Act - orchestrator.StartInstallation(dependencies); - - // Wait for completion - var timeout = DateTime.Now.AddSeconds(10); - while (!installationCompleted && DateTime.Now < timeout) - { - System.Threading.Thread.Sleep(100); - } - - // Assert - Assert.IsTrue(installationCompleted, "Installation should complete"); - Assert.IsFalse(installationSucceeded, "Installation should fail (Asset Store compliance)"); - Assert.IsTrue(installationMessage.Contains("Failed"), "Should indicate failure"); - } - - [Test] - public void AssetStoreCompliance_MCPServerInstallation_Allowed() - { - // This test verifies that MCP Server installation is allowed (not bundled, but auto-installable) - - var orchestrator = new InstallationOrchestrator(); - var dependencies = new List - { - new DependencyStatus { Name = "MCP Server", IsRequired = false, IsAvailable = false } - }; - - bool installationCompleted = false; - bool installationSucceeded = false; - - orchestrator.OnInstallationComplete += (success, message) => - { - installationCompleted = true; - installationSucceeded = success; - }; - - // Act - orchestrator.StartInstallation(dependencies); - - // Wait for completion - var timeout = DateTime.Now.AddSeconds(10); - while (!installationCompleted && DateTime.Now < timeout) - { - System.Threading.Thread.Sleep(100); - } - - // Assert - Assert.IsTrue(installationCompleted, "Installation should complete"); - // Note: Success depends on whether ServerInstaller.EnsureServerInstalled() works - // The important thing is that it attempts installation (doesn't fail due to compliance) - } - - [Test] - public void CrossPlatformCompatibility_PlatformDetection() - { - // This test verifies cross-platform compatibility - - // Act - var detector = DependencyManager.GetCurrentPlatformDetector(); - - // Assert - Assert.IsNotNull(detector, "Should detect current platform"); - Assert.IsTrue(detector.CanDetect, "Detector should be able to detect on current platform"); - Assert.IsNotEmpty(detector.PlatformName, "Platform name should not be empty"); - - // Verify platform-specific URLs are provided - var pythonUrl = detector.GetPythonInstallUrl(); - var uvUrl = detector.GetUVInstallUrl(); - - Assert.IsNotNull(pythonUrl, "Python install URL should be provided"); - Assert.IsNotNull(uvUrl, "UV install URL should be provided"); - Assert.IsTrue(pythonUrl.StartsWith("http"), "Python URL should be valid"); - Assert.IsTrue(uvUrl.StartsWith("http"), "UV URL should be valid"); - } - - [Test] - public void UserExperience_SetupWizardFlow() - { - // This test verifies the user experience flow - - // Scenario 1: First time user - var state = SetupWizard.GetSetupState(); - Assert.IsTrue(state.ShouldShowSetup("3.4.0"), "First time user should see setup"); - - // Scenario 2: User attempts setup - state.RecordSetupAttempt(); - Assert.AreEqual(1, state.SetupAttempts, "Setup attempt should be recorded"); - - // Scenario 3: User completes setup - SetupWizard.MarkSetupCompleted(); - state = SetupWizard.GetSetupState(); - Assert.IsTrue(state.HasCompletedSetup, "Setup should be marked complete"); - Assert.IsFalse(state.ShouldShowSetup("3.4.0"), "Should not show setup after completion"); - - // Scenario 4: Package upgrade - Assert.IsTrue(state.ShouldShowSetup("4.0.0"), "Should show setup after major version upgrade"); - } - - [Test] - public void ErrorHandling_GracefulDegradation() - { - // This test verifies that the system handles errors gracefully - - // Test dependency manager error handling - Assert.DoesNotThrow(() => DependencyManager.CheckAllDependencies(), - "Dependency check should not throw exceptions"); - - Assert.DoesNotThrow(() => DependencyManager.IsSystemReady(), - "System ready check should not throw exceptions"); - - Assert.DoesNotThrow(() => DependencyManager.GetMissingDependenciesSummary(), - "Missing dependencies summary should not throw exceptions"); - - // Test setup wizard error handling - Assert.DoesNotThrow(() => SetupWizard.GetSetupState(), - "Get setup state should not throw exceptions"); - - Assert.DoesNotThrow(() => SetupWizard.SaveSetupState(), - "Save setup state should not throw exceptions"); - } - - [Test] - public void MenuIntegration_MenuItemsAccessible() - { - // This test verifies that menu items are accessible and functional - - // Test that menu methods can be called without exceptions - Assert.DoesNotThrow(() => SetupWizard.ShowSetupWizardManual(), - "Manual setup wizard should be callable"); - - Assert.DoesNotThrow(() => SetupWizard.ResetAndShowSetup(), - "Reset and show setup should be callable"); - - Assert.DoesNotThrow(() => SetupWizard.CheckDependencies(), - "Check dependencies should be callable"); - } - - [Test] - public void PerformanceConsiderations_LazyLoading() - { - // This test verifies that the system uses lazy loading and doesn't impact Unity startup - - var startTime = DateTime.Now; - - // These operations should be fast (lazy loading) - var detector = DependencyManager.GetCurrentPlatformDetector(); - var state = SetupWizard.GetSetupState(); - - var elapsed = DateTime.Now - startTime; - - // Assert - Assert.IsNotNull(detector, "Platform detector should be available"); - Assert.IsNotNull(state, "Setup state should be available"); - Assert.IsTrue(elapsed.TotalMilliseconds < 1000, "Operations should be fast (< 1 second)"); - } - - [Test] - public void StateManagement_Persistence() - { - // This test verifies that state management works correctly across sessions - - // Set up initial state - var state = SetupWizard.GetSetupState(); - state.HasCompletedSetup = true; - state.SetupVersion = "3.4.0"; - state.SetupAttempts = 3; - state.PreferredInstallMode = "manual"; - - SetupWizard.SaveSetupState(); - - // Simulate Unity restart by clearing cached state - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - - // Load state again - var loadedState = SetupWizard.GetSetupState(); - - // Assert - Assert.IsTrue(loadedState.HasCompletedSetup, "Completion status should persist"); - Assert.AreEqual("3.4.0", loadedState.SetupVersion, "Version should persist"); - Assert.AreEqual(3, loadedState.SetupAttempts, "Attempts should persist"); - Assert.AreEqual("manual", loadedState.PreferredInstallMode, "Install mode should persist"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Mocks/MockPlatformDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Mocks/MockPlatformDetector.cs deleted file mode 100644 index 9346747c..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Mocks/MockPlatformDetector.cs +++ /dev/null @@ -1,107 +0,0 @@ -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Dependencies.PlatformDetectors; - -namespace MCPForUnity.Tests.Mocks -{ - /// - /// Mock platform detector for testing purposes - /// - public class MockPlatformDetector : IPlatformDetector - { - private bool _pythonAvailable = false; - private string _pythonVersion = ""; - private string _pythonPath = ""; - private string _pythonError = ""; - - private bool _uvAvailable = false; - private string _uvVersion = ""; - private string _uvPath = ""; - private string _uvError = ""; - - private bool _mcpServerAvailable = false; - private string _mcpServerPath = ""; - private string _mcpServerError = ""; - - public string PlatformName => "Mock Platform"; - public bool CanDetect => true; - - public void SetPythonAvailable(bool available, string version = "", string path = "", string error = "") - { - _pythonAvailable = available; - _pythonVersion = version; - _pythonPath = path; - _pythonError = error; - } - - public void SetUVAvailable(bool available, string version = "", string path = "", string error = "") - { - _uvAvailable = available; - _uvVersion = version; - _uvPath = path; - _uvError = error; - } - - public void SetMCPServerAvailable(bool available, string path = "", string error = "") - { - _mcpServerAvailable = available; - _mcpServerPath = path; - _mcpServerError = error; - } - - public DependencyStatus DetectPython() - { - return new DependencyStatus - { - Name = "Python", - IsAvailable = _pythonAvailable, - IsRequired = true, - Version = _pythonVersion, - Path = _pythonPath, - ErrorMessage = _pythonError, - Details = _pythonAvailable ? "Mock Python detected" : "Mock Python not found" - }; - } - - public DependencyStatus DetectUV() - { - return new DependencyStatus - { - Name = "UV Package Manager", - IsAvailable = _uvAvailable, - IsRequired = true, - Version = _uvVersion, - Path = _uvPath, - ErrorMessage = _uvError, - Details = _uvAvailable ? "Mock UV detected" : "Mock UV not found" - }; - } - - public DependencyStatus DetectMCPServer() - { - return new DependencyStatus - { - Name = "MCP Server", - IsAvailable = _mcpServerAvailable, - IsRequired = false, - Path = _mcpServerPath, - ErrorMessage = _mcpServerError, - Details = _mcpServerAvailable ? "Mock MCP Server detected" : "Mock MCP Server not found" - }; - } - - public string GetInstallationRecommendations() - { - return "Mock installation recommendations for testing"; - } - - public string GetPythonInstallUrl() - { - return "https://mock-python-install.com"; - } - - public string GetUVInstallUrl() - { - return "https://mock-uv-install.com"; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/PerformanceTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/PerformanceTests.cs deleted file mode 100644 index 286e9308..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/PerformanceTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using NUnit.Framework; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Installation; -using MCPForUnity.Editor.Dependencies.Models; - -namespace MCPForUnity.Tests -{ - [TestFixture] - public class PerformanceTests - { - private const int PERFORMANCE_THRESHOLD_MS = 1000; // 1 second threshold for most operations - private const int STARTUP_THRESHOLD_MS = 100; // 100ms threshold for startup operations - - [Test] - public void DependencyManager_CheckAllDependencies_PerformanceTest() - { - // Test that dependency checking completes within reasonable time - - var stopwatch = Stopwatch.StartNew(); - - // Act - var result = DependencyManager.CheckAllDependencies(); - - stopwatch.Stop(); - - // Assert - Assert.IsNotNull(result, "Should return valid result"); - Assert.Less(stopwatch.ElapsedMilliseconds, PERFORMANCE_THRESHOLD_MS, - $"Dependency check should complete within {PERFORMANCE_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"DependencyManager.CheckAllDependencies took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void DependencyManager_IsSystemReady_PerformanceTest() - { - // Test that system ready check is fast (should be cached or optimized) - - var stopwatch = Stopwatch.StartNew(); - - // Act - var isReady = DependencyManager.IsSystemReady(); - - stopwatch.Stop(); - - // Assert - Assert.Less(stopwatch.ElapsedMilliseconds, PERFORMANCE_THRESHOLD_MS, - $"System ready check should complete within {PERFORMANCE_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"DependencyManager.IsSystemReady took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void DependencyManager_GetCurrentPlatformDetector_PerformanceTest() - { - // Test that platform detector retrieval is fast (startup critical) - - var stopwatch = Stopwatch.StartNew(); - - // Act - var detector = DependencyManager.GetCurrentPlatformDetector(); - - stopwatch.Stop(); - - // Assert - Assert.IsNotNull(detector, "Should return valid detector"); - Assert.Less(stopwatch.ElapsedMilliseconds, STARTUP_THRESHOLD_MS, - $"Platform detector retrieval should complete within {STARTUP_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"DependencyManager.GetCurrentPlatformDetector took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void SetupWizard_GetSetupState_PerformanceTest() - { - // Test that setup state retrieval is fast (startup critical) - - var stopwatch = Stopwatch.StartNew(); - - // Act - var state = SetupWizard.GetSetupState(); - - stopwatch.Stop(); - - // Assert - Assert.IsNotNull(state, "Should return valid state"); - Assert.Less(stopwatch.ElapsedMilliseconds, STARTUP_THRESHOLD_MS, - $"Setup state retrieval should complete within {STARTUP_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"SetupWizard.GetSetupState took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void SetupWizard_SaveSetupState_PerformanceTest() - { - // Test that setup state saving is reasonably fast - - var stopwatch = Stopwatch.StartNew(); - - // Act - SetupWizard.SaveSetupState(); - - stopwatch.Stop(); - - // Assert - Assert.Less(stopwatch.ElapsedMilliseconds, STARTUP_THRESHOLD_MS, - $"Setup state saving should complete within {STARTUP_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"SetupWizard.SaveSetupState took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void DependencyManager_RepeatedCalls_PerformanceTest() - { - // Test performance of repeated dependency checks (should be optimized/cached) - - const int iterations = 10; - var times = new List(); - - for (int i = 0; i < iterations; i++) - { - var stopwatch = Stopwatch.StartNew(); - DependencyManager.IsSystemReady(); - stopwatch.Stop(); - times.Add(stopwatch.ElapsedMilliseconds); - } - - // Calculate average - long totalTime = 0; - foreach (var time in times) - { - totalTime += time; - } - var averageTime = totalTime / iterations; - - // Assert - Assert.Less(averageTime, PERFORMANCE_THRESHOLD_MS, - $"Average repeated dependency check should complete within {PERFORMANCE_THRESHOLD_MS}ms, average was {averageTime}ms"); - - UnityEngine.Debug.Log($"Average time for {iterations} dependency checks: {averageTime}ms"); - } - - [Test] - public void InstallationOrchestrator_Creation_PerformanceTest() - { - // Test that installation orchestrator creation is fast - - var stopwatch = Stopwatch.StartNew(); - - // Act - var orchestrator = new InstallationOrchestrator(); - - stopwatch.Stop(); - - // Assert - Assert.IsNotNull(orchestrator, "Should create valid orchestrator"); - Assert.Less(stopwatch.ElapsedMilliseconds, STARTUP_THRESHOLD_MS, - $"Installation orchestrator creation should complete within {STARTUP_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"InstallationOrchestrator creation took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void DependencyCheckResult_LargeDataSet_PerformanceTest() - { - // Test performance with large number of dependencies - - var result = new DependencyCheckResult(); - - // Add many dependencies - for (int i = 0; i < 1000; i++) - { - result.Dependencies.Add(new DependencyStatus - { - Name = $"Dependency {i}", - IsAvailable = i % 2 == 0, - IsRequired = i % 3 == 0, - Version = $"1.{i}.0", - Path = $"/path/to/dependency{i}", - Details = $"Details for dependency {i}" - }); - } - - var stopwatch = Stopwatch.StartNew(); - - // Act - result.GenerateSummary(); - var missing = result.GetMissingDependencies(); - var missingRequired = result.GetMissingRequired(); - - stopwatch.Stop(); - - // Assert - Assert.Less(stopwatch.ElapsedMilliseconds, PERFORMANCE_THRESHOLD_MS, - $"Large dataset processing should complete within {PERFORMANCE_THRESHOLD_MS}ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"Processing 1000 dependencies took {stopwatch.ElapsedMilliseconds}ms"); - } - - [Test] - public void SetupState_RepeatedOperations_PerformanceTest() - { - // Test performance of repeated setup state operations - - const int iterations = 100; - var stopwatch = Stopwatch.StartNew(); - - for (int i = 0; i < iterations; i++) - { - var state = SetupWizard.GetSetupState(); - state.RecordSetupAttempt($"Attempt {i}"); - state.ShouldShowSetup($"Version {i}"); - SetupWizard.SaveSetupState(); - } - - stopwatch.Stop(); - - var averageTime = stopwatch.ElapsedMilliseconds / iterations; - - // Assert - Assert.Less(averageTime, 10, // 10ms per operation - $"Average setup state operation should complete within 10ms, average was {averageTime}ms"); - - UnityEngine.Debug.Log($"Average time for {iterations} setup state operations: {averageTime}ms"); - } - - [Test] - public void DependencyManager_ConcurrentAccess_PerformanceTest() - { - // Test performance under concurrent access - - const int threadCount = 10; - const int operationsPerThread = 10; - - var tasks = new List(); - var stopwatch = Stopwatch.StartNew(); - - for (int i = 0; i < threadCount; i++) - { - tasks.Add(System.Threading.Tasks.Task.Run(() => - { - for (int j = 0; j < operationsPerThread; j++) - { - DependencyManager.IsSystemReady(); - DependencyManager.IsDependencyAvailable("python"); - DependencyManager.GetMissingDependenciesSummary(); - } - })); - } - - System.Threading.Tasks.Task.WaitAll(tasks.ToArray()); - stopwatch.Stop(); - - var totalOperations = threadCount * operationsPerThread * 3; // 3 operations per iteration - var averageTime = (double)stopwatch.ElapsedMilliseconds / totalOperations; - - // Assert - Assert.Less(averageTime, 100, // 100ms per operation under load - $"Average concurrent operation should complete within 100ms, average was {averageTime:F2}ms"); - - UnityEngine.Debug.Log($"Concurrent access: {totalOperations} operations in {stopwatch.ElapsedMilliseconds}ms, average {averageTime:F2}ms per operation"); - } - - [Test] - public void MemoryUsage_DependencyOperations_Test() - { - // Test memory usage of dependency operations - - var initialMemory = GC.GetTotalMemory(true); - - // Perform many operations - for (int i = 0; i < 100; i++) - { - var result = DependencyManager.CheckAllDependencies(); - var diagnostics = DependencyManager.GetDependencyDiagnostics(); - var summary = DependencyManager.GetMissingDependenciesSummary(); - - // Force garbage collection periodically - if (i % 10 == 0) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - - GC.Collect(); - GC.WaitForPendingFinalizers(); - var finalMemory = GC.GetTotalMemory(false); - - var memoryIncrease = finalMemory - initialMemory; - var memoryIncreaseMB = memoryIncrease / (1024.0 * 1024.0); - - // Assert reasonable memory usage (less than 10MB increase) - Assert.Less(memoryIncreaseMB, 10.0, - $"Memory usage should not increase significantly, increased by {memoryIncreaseMB:F2}MB"); - - UnityEngine.Debug.Log($"Memory usage increased by {memoryIncreaseMB:F2}MB after 100 dependency operations"); - } - - [Test] - public void StartupImpact_SimulatedUnityStartup_PerformanceTest() - { - // Simulate Unity startup scenario to measure impact - - var stopwatch = Stopwatch.StartNew(); - - // Simulate what happens during Unity startup - var detector = DependencyManager.GetCurrentPlatformDetector(); - var state = SetupWizard.GetSetupState(); - var shouldShow = state.ShouldShowSetup("3.4.0"); - - stopwatch.Stop(); - - // Assert minimal startup impact - Assert.Less(stopwatch.ElapsedMilliseconds, 200, // 200ms threshold for startup - $"Startup operations should complete within 200ms, took {stopwatch.ElapsedMilliseconds}ms"); - - UnityEngine.Debug.Log($"Simulated Unity startup impact: {stopwatch.ElapsedMilliseconds}ms"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Setup/SetupWizardTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Setup/SetupWizardTests.cs deleted file mode 100644 index 2e241e8f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/Setup/SetupWizardTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using NUnit.Framework; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Tests.Mocks; - -namespace MCPForUnity.Tests.Setup -{ - [TestFixture] - public class SetupWizardTests - { - private string _originalSetupState; - private const string SETUP_STATE_KEY = "MCPForUnity.SetupState"; - - [SetUp] - public void SetUp() - { - // Save original setup state - _originalSetupState = EditorPrefs.GetString(SETUP_STATE_KEY, ""); - - // Clear setup state for testing - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - - [TearDown] - public void TearDown() - { - // Restore original setup state - if (!string.IsNullOrEmpty(_originalSetupState)) - { - EditorPrefs.SetString(SETUP_STATE_KEY, _originalSetupState); - } - else - { - EditorPrefs.DeleteKey(SETUP_STATE_KEY); - } - } - - [Test] - public void GetSetupState_ReturnsValidState() - { - // Act - var state = SetupWizard.GetSetupState(); - - // Assert - Assert.IsNotNull(state, "Setup state should not be null"); - Assert.IsFalse(state.HasCompletedSetup, "Fresh state should not be completed"); - Assert.IsFalse(state.HasDismissedSetup, "Fresh state should not be dismissed"); - } - - [Test] - public void SaveSetupState_PersistsState() - { - // Arrange - var state = SetupWizard.GetSetupState(); - state.HasCompletedSetup = true; - state.SetupVersion = "1.0.0"; - - // Act - SetupWizard.SaveSetupState(); - - // Verify persistence by creating new instance - EditorPrefs.DeleteKey(SETUP_STATE_KEY); // Clear cached state - var loadedState = SetupWizard.GetSetupState(); - - // Assert - Assert.IsTrue(loadedState.HasCompletedSetup, "State should be persisted"); - Assert.AreEqual("1.0.0", loadedState.SetupVersion, "Version should be persisted"); - } - - [Test] - public void MarkSetupCompleted_UpdatesState() - { - // Act - SetupWizard.MarkSetupCompleted(); - - // Assert - var state = SetupWizard.GetSetupState(); - Assert.IsTrue(state.HasCompletedSetup, "Setup should be marked as completed"); - Assert.IsNotNull(state.SetupVersion, "Setup version should be set"); - } - - [Test] - public void MarkSetupDismissed_UpdatesState() - { - // Act - SetupWizard.MarkSetupDismissed(); - - // Assert - var state = SetupWizard.GetSetupState(); - Assert.IsTrue(state.HasDismissedSetup, "Setup should be marked as dismissed"); - } - - [Test] - public void ResetSetupState_ClearsState() - { - // Arrange - SetupWizard.MarkSetupCompleted(); - SetupWizard.MarkSetupDismissed(); - - // Act - SetupWizard.ResetSetupState(); - - // Assert - var state = SetupWizard.GetSetupState(); - Assert.IsFalse(state.HasCompletedSetup, "Setup completion should be reset"); - Assert.IsFalse(state.HasDismissedSetup, "Setup dismissal should be reset"); - } - - [Test] - public void ShowSetupWizard_WithNullDependencyResult_ChecksDependencies() - { - // This test verifies that ShowSetupWizard handles null dependency results - // by checking dependencies itself - - // Act & Assert (should not throw) - Assert.DoesNotThrow(() => SetupWizard.ShowSetupWizard(null), - "ShowSetupWizard should handle null dependency result gracefully"); - } - - [Test] - public void ShowSetupWizard_WithDependencyResult_RecordsAttempt() - { - // Arrange - var dependencyResult = new DependencyCheckResult(); - dependencyResult.Dependencies.Add(new DependencyStatus - { - Name = "Python", - IsRequired = true, - IsAvailable = false - }); - dependencyResult.GenerateSummary(); - - var initialAttempts = SetupWizard.GetSetupState().SetupAttempts; - - // Act - SetupWizard.ShowSetupWizard(dependencyResult); - - // Assert - var state = SetupWizard.GetSetupState(); - Assert.AreEqual(initialAttempts + 1, state.SetupAttempts, - "Setup attempts should be incremented"); - } - - [Test] - public void SetupState_LoadingCorruptedData_CreatesDefaultState() - { - // Arrange - Set corrupted JSON data - EditorPrefs.SetString(SETUP_STATE_KEY, "{ invalid json }"); - - // Act - var state = SetupWizard.GetSetupState(); - - // Assert - Assert.IsNotNull(state, "Should create default state when loading corrupted data"); - Assert.IsFalse(state.HasCompletedSetup, "Default state should not be completed"); - } - - [Test] - public void SetupState_ShouldShowSetup_Logic() - { - // Test various scenarios for when setup should be shown - var state = SetupWizard.GetSetupState(); - - // Scenario 1: Fresh install - Assert.IsTrue(state.ShouldShowSetup("1.0.0"), - "Should show setup on fresh install"); - - // Scenario 2: After completion - state.MarkSetupCompleted("1.0.0"); - Assert.IsFalse(state.ShouldShowSetup("1.0.0"), - "Should not show setup after completion for same version"); - - // Scenario 3: Version upgrade - Assert.IsTrue(state.ShouldShowSetup("2.0.0"), - "Should show setup after version upgrade"); - - // Scenario 4: After dismissal - state.MarkSetupDismissed(); - Assert.IsFalse(state.ShouldShowSetup("3.0.0"), - "Should not show setup after dismissal, even for new version"); - } - - [Test] - public void SetupWizard_MenuItems_Exist() - { - // This test verifies that the menu items are properly registered - // We can't easily test the actual menu functionality, but we can verify - // the methods exist and are callable - - Assert.DoesNotThrow(() => SetupWizard.ShowSetupWizardManual(), - "Manual setup wizard menu item should be callable"); - - Assert.DoesNotThrow(() => SetupWizard.ResetAndShowSetup(), - "Reset and show setup menu item should be callable"); - - Assert.DoesNotThrow(() => SetupWizard.CheckDependencies(), - "Check dependencies menu item should be callable"); - } - - [Test] - public void SetupWizard_BatchMode_Handling() - { - // Test that setup wizard respects batch mode settings - // This is important for CI/CD environments - - var originalBatchMode = Application.isBatchMode; - - try - { - // We can't actually change batch mode in tests, but we can verify - // the setup wizard handles the current mode gracefully - Assert.DoesNotThrow(() => SetupWizard.GetSetupState(), - "Setup wizard should handle batch mode gracefully"); - } - finally - { - // Restore original state (though we can't actually change it) - } - } - - [Test] - public void SetupWizard_ErrorHandling_InSaveLoad() - { - // Test error handling in save/load operations - - // This test verifies that the setup wizard handles errors gracefully - // when saving or loading state - - Assert.DoesNotThrow(() => SetupWizard.SaveSetupState(), - "Save setup state should handle errors gracefully"); - - Assert.DoesNotThrow(() => SetupWizard.GetSetupState(), - "Get setup state should handle errors gracefully"); - } - - [Test] - public void SetupWizard_StateTransitions() - { - // Test various state transitions - var state = SetupWizard.GetSetupState(); - - // Initial state - Assert.IsFalse(state.HasCompletedSetup); - Assert.IsFalse(state.HasDismissedSetup); - Assert.AreEqual(0, state.SetupAttempts); - - // Record attempt - state.RecordSetupAttempt("Test error"); - Assert.AreEqual(1, state.SetupAttempts); - Assert.AreEqual("Test error", state.LastSetupError); - - // Complete setup - SetupWizard.MarkSetupCompleted(); - state = SetupWizard.GetSetupState(); - Assert.IsTrue(state.HasCompletedSetup); - Assert.IsNull(state.LastSetupError); - - // Reset - SetupWizard.ResetSetupState(); - state = SetupWizard.GetSetupState(); - Assert.IsFalse(state.HasCompletedSetup); - Assert.AreEqual(0, state.SetupAttempts); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/TestRunner.cs b/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/TestRunner.cs deleted file mode 100644 index 9ecfde2b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/Tests/EditMode/TestRunner.cs +++ /dev/null @@ -1,380 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using NUnit.Framework; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Tests -{ - /// - /// Test runner for Asset Store compliance tests - /// Provides menu items to run specific test categories - /// - public static class TestRunner - { - [MenuItem("Window/MCP for Unity/Run All Asset Store Compliance Tests", priority = 200)] - public static void RunAllTests() - { - Debug.Log("MCP-FOR-UNITY: Running All Asset Store Compliance Tests..."); - - var testResults = new List(); - - // Run all test categories - testResults.AddRange(RunTestCategory("Dependencies")); - testResults.AddRange(RunTestCategory("Setup")); - testResults.AddRange(RunTestCategory("Installation")); - testResults.AddRange(RunTestCategory("Integration")); - testResults.AddRange(RunTestCategory("EdgeCases")); - testResults.AddRange(RunTestCategory("Performance")); - - // Generate summary report - GenerateTestReport(testResults); - } - - [MenuItem("Window/MCP for Unity/Run Dependency Tests", priority = 201)] - public static void RunDependencyTests() - { - Debug.Log("MCP-FOR-UNITY: Running Dependency Tests..."); - var results = RunTestCategory("Dependencies"); - GenerateTestReport(results, "Dependency Tests"); - } - - [MenuItem("Window/MCP for Unity/Run Setup Wizard Tests", priority = 202)] - public static void RunSetupTests() - { - Debug.Log("MCP-FOR-UNITY: Running Setup Wizard Tests..."); - var results = RunTestCategory("Setup"); - GenerateTestReport(results, "Setup Wizard Tests"); - } - - [MenuItem("Window/MCP for Unity/Run Installation Tests", priority = 203)] - public static void RunInstallationTests() - { - Debug.Log("MCP-FOR-UNITY: Running Installation Tests..."); - var results = RunTestCategory("Installation"); - GenerateTestReport(results, "Installation Tests"); - } - - [MenuItem("Window/MCP for Unity/Run Integration Tests", priority = 204)] - public static void RunIntegrationTests() - { - Debug.Log("MCP-FOR-UNITY: Running Integration Tests..."); - var results = RunTestCategory("Integration"); - GenerateTestReport(results, "Integration Tests"); - } - - [MenuItem("Window/MCP for Unity/Run Performance Tests", priority = 205)] - public static void RunPerformanceTests() - { - Debug.Log("MCP-FOR-UNITY: Running Performance Tests..."); - var results = RunTestCategory("Performance"); - GenerateTestReport(results, "Performance Tests"); - } - - [MenuItem("Window/MCP for Unity/Run Edge Case Tests", priority = 206)] - public static void RunEdgeCaseTests() - { - Debug.Log("MCP-FOR-UNITY: Running Edge Case Tests..."); - var results = RunTestCategory("EdgeCases"); - GenerateTestReport(results, "Edge Case Tests"); - } - - private static List RunTestCategory(string category) - { - var results = new List(); - - try - { - // Find all test classes in the specified category - var testClasses = FindTestClasses(category); - - foreach (var testClass in testClasses) - { - results.AddRange(RunTestClass(testClass)); - } - } - catch (Exception ex) - { - Debug.LogError($"Error running {category} tests: {ex.Message}"); - results.Add(new TestResult - { - TestName = $"{category} Category", - Success = false, - ErrorMessage = ex.Message, - Duration = TimeSpan.Zero - }); - } - - return results; - } - - private static List FindTestClasses(string category) - { - var testClasses = new List(); - - // Get all types in the test assembly - var assembly = Assembly.GetExecutingAssembly(); - var types = assembly.GetTypes(); - - foreach (var type in types) - { - // Check if it's a test class - if (type.GetCustomAttribute() != null) - { - // Check if it belongs to the specified category - if (type.Namespace != null && type.Namespace.Contains(category)) - { - testClasses.Add(type); - } - else if (type.Name.Contains(category)) - { - testClasses.Add(type); - } - } - } - - return testClasses; - } - - private static List RunTestClass(Type testClass) - { - var results = new List(); - - try - { - // Create instance of test class - var instance = Activator.CreateInstance(testClass); - - // Find and run SetUp method if it exists - var setupMethod = testClass.GetMethods() - .FirstOrDefault(m => m.GetCustomAttribute() != null); - - // Find all test methods - var testMethods = testClass.GetMethods() - .Where(m => m.GetCustomAttribute() != null) - .ToList(); - - foreach (var testMethod in testMethods) - { - var result = RunTestMethod(instance, setupMethod, testMethod, testClass); - results.Add(result); - } - - // Find and run TearDown method if it exists - var tearDownMethod = testClass.GetMethods() - .FirstOrDefault(m => m.GetCustomAttribute() != null); - - if (tearDownMethod != null) - { - try - { - tearDownMethod.Invoke(instance, null); - } - catch (Exception ex) - { - Debug.LogWarning($"TearDown failed for {testClass.Name}: {ex.Message}"); - } - } - } - catch (Exception ex) - { - Debug.LogError($"Error running test class {testClass.Name}: {ex.Message}"); - results.Add(new TestResult - { - TestName = testClass.Name, - Success = false, - ErrorMessage = ex.Message, - Duration = TimeSpan.Zero - }); - } - - return results; - } - - private static TestResult RunTestMethod(object instance, MethodInfo setupMethod, MethodInfo testMethod, Type testClass) - { - var result = new TestResult - { - TestName = $"{testClass.Name}.{testMethod.Name}" - }; - - var startTime = DateTime.Now; - - try - { - // Run SetUp if it exists - if (setupMethod != null) - { - setupMethod.Invoke(instance, null); - } - - // Run the test method - testMethod.Invoke(instance, null); - - result.Success = true; - result.Duration = DateTime.Now - startTime; - } - catch (Exception ex) - { - result.Success = false; - result.ErrorMessage = ex.InnerException?.Message ?? ex.Message; - result.Duration = DateTime.Now - startTime; - - Debug.LogError($"Test failed: {result.TestName}\nError: {result.ErrorMessage}"); - } - - return result; - } - - private static void GenerateTestReport(List results, string categoryName = "All Tests") - { - var totalTests = results.Count; - var passedTests = results.Count(r => r.Success); - var failedTests = totalTests - passedTests; - var totalDuration = results.Sum(r => r.Duration.TotalMilliseconds); - - var report = $@" -MCP-FOR-UNITY: {categoryName} Report -===================================== -Total Tests: {totalTests} -Passed: {passedTests} -Failed: {failedTests} -Success Rate: {(totalTests > 0 ? (passedTests * 100.0 / totalTests):0):F1}% -Total Duration: {totalDuration:F0}ms -Average Duration: {(totalTests > 0 ? totalDuration / totalTests : 0):F1}ms - -"; - - if (failedTests > 0) - { - report += "Failed Tests:\n"; - foreach (var failedTest in results.Where(r => !r.Success)) - { - report += $"❌ {failedTest.TestName}: {failedTest.ErrorMessage}\n"; - } - report += "\n"; - } - - if (passedTests > 0) - { - report += "Passed Tests:\n"; - foreach (var passedTest in results.Where(r => r.Success)) - { - report += $"✅ {passedTest.TestName} ({passedTest.Duration.TotalMilliseconds:F0}ms)\n"; - } - } - - Debug.Log(report); - - // Show dialog with summary - var dialogMessage = $"{categoryName} Complete!\n\n" + - $"Passed: {passedTests}/{totalTests}\n" + - $"Success Rate: {(totalTests > 0 ? (passedTests * 100.0 / totalTests) : 0):F1}%\n" + - $"Duration: {totalDuration:F0}ms"; - - if (failedTests > 0) - { - dialogMessage += $"\n\n{failedTests} tests failed. Check console for details."; - EditorUtility.DisplayDialog("Test Results", dialogMessage, "OK"); - } - else - { - EditorUtility.DisplayDialog("Test Results", dialogMessage + "\n\nAll tests passed! ✅", "OK"); - } - } - - private class TestResult - { - public string TestName { get; set; } - public bool Success { get; set; } - public string ErrorMessage { get; set; } - public TimeSpan Duration { get; set; } - } - - [MenuItem("Window/MCP for Unity/Generate Test Coverage Report", priority = 210)] - public static void GenerateTestCoverageReport() - { - Debug.Log("MCP-FOR-UNITY: Generating Test Coverage Report..."); - - var report = @" -MCP-FOR-UNITY: Asset Store Compliance Test Coverage Report -================================================================= - -Dependency Detection System: -✅ DependencyManager core functionality -✅ Platform detector implementations (Windows, macOS, Linux) -✅ Dependency status models and validation -✅ Cross-platform compatibility -✅ Error handling and edge cases - -Setup Wizard System: -✅ Auto-trigger logic and state management -✅ Setup state persistence and loading -✅ Version-aware setup completion tracking -✅ User interaction flows -✅ Error recovery and graceful degradation - -Installation Orchestrator: -✅ Asset Store compliance (no automatic downloads) -✅ Progress tracking and user feedback -✅ Platform-specific installation guidance -✅ Error handling and recovery suggestions -✅ Concurrent installation handling - -Integration Testing: -✅ End-to-end setup workflow -✅ Compatibility with existing MCP infrastructure -✅ Menu integration and accessibility -✅ Cross-platform behavior consistency -✅ State management across Unity sessions - -Edge Cases and Error Scenarios: -✅ Corrupted data handling -✅ Null/empty value handling -✅ Concurrent access scenarios -✅ Extreme value testing -✅ Memory and performance under stress - -Performance Testing: -✅ Startup impact measurement -✅ Dependency check performance -✅ Memory usage validation -✅ Concurrent access performance -✅ Large dataset handling - -Asset Store Compliance Verification: -✅ No bundled Python interpreter -✅ No bundled UV package manager -✅ No automatic external downloads -✅ User-guided installation process -✅ Clean package structure validation - -Coverage Summary: -• Core Components: 100% covered -• Platform Detectors: 100% covered -• Setup Wizard: 100% covered -• Installation System: 100% covered -• Integration Scenarios: 100% covered -• Edge Cases: 95% covered -• Performance: 90% covered - -Recommendations: -• All critical paths are thoroughly tested -• Asset Store compliance is verified -• Performance meets Unity standards -• Error handling is comprehensive -• Ready for production deployment -"; - - Debug.Log(report); - - EditorUtility.DisplayDialog( - "Test Coverage Report", - "Test coverage report generated successfully!\n\nCheck console for detailed coverage information.\n\nOverall Coverage: 98%", - "OK" - ); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/run_tests.py b/ava-worktrees/feature/ava-asset-store-compliance/run_tests.py deleted file mode 100644 index 86e99dbb..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/run_tests.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -""" -Unity MCP Bridge - Asset Store Compliance Test Runner -Validates the comprehensive test suite implementation -""" - -import os -import sys -import subprocess -import json -from pathlib import Path - -def main(): - """Run comprehensive validation of the test suite""" - - print("🧪 Unity MCP Bridge - Asset Store Compliance Test Suite Validation") - print("=" * 70) - - # Get the worktree path - worktree_path = Path(__file__).parent - tests_path = worktree_path / "Tests" - - if not tests_path.exists(): - print("❌ Tests directory not found!") - return False - - # Validate test structure - print("\n📁 Validating Test Structure...") - structure_valid = validate_test_structure(tests_path) - - # Validate test content - print("\n📝 Validating Test Content...") - content_valid = validate_test_content(tests_path) - - # Generate test metrics - print("\n📊 Generating Test Metrics...") - generate_test_metrics(tests_path) - - # Validate Asset Store compliance - print("\n🏪 Validating Asset Store Compliance...") - compliance_valid = validate_asset_store_compliance(worktree_path) - - # Summary - print("\n" + "=" * 70) - print("📋 VALIDATION SUMMARY") - print("=" * 70) - - results = { - "Test Structure": "✅ PASS" if structure_valid else "❌ FAIL", - "Test Content": "✅ PASS" if content_valid else "❌ FAIL", - "Asset Store Compliance": "✅ PASS" if compliance_valid else "❌ FAIL" - } - - for category, result in results.items(): - print(f"{category}: {result}") - - overall_success = all([structure_valid, content_valid, compliance_valid]) - - if overall_success: - print("\n🎉 ALL VALIDATIONS PASSED! Test suite is ready for production.") - print("\n📈 Test Coverage Summary:") - print(" • Dependency Detection: 100% covered") - print(" • Setup Wizard: 100% covered") - print(" • Installation Orchestrator: 100% covered") - print(" • Integration Scenarios: 100% covered") - print(" • Edge Cases: 95% covered") - print(" • Performance Tests: 90% covered") - print(" • Asset Store Compliance: 100% verified") - else: - print("\n❌ Some validations failed. Please review the issues above.") - - return overall_success - -def validate_test_structure(tests_path): - """Validate the test directory structure""" - - required_dirs = [ - "EditMode", - "EditMode/Dependencies", - "EditMode/Setup", - "EditMode/Installation", - "EditMode/Integration", - "EditMode/Mocks" - ] - - required_files = [ - "EditMode/AssetStoreComplianceTests.Editor.asmdef", - "EditMode/Dependencies/DependencyManagerTests.cs", - "EditMode/Dependencies/PlatformDetectorTests.cs", - "EditMode/Dependencies/DependencyModelsTests.cs", - "EditMode/Setup/SetupWizardTests.cs", - "EditMode/Installation/InstallationOrchestratorTests.cs", - "EditMode/Integration/AssetStoreComplianceIntegrationTests.cs", - "EditMode/Mocks/MockPlatformDetector.cs", - "EditMode/EdgeCasesTests.cs", - "EditMode/PerformanceTests.cs", - "EditMode/TestRunner.cs" - ] - - print(" Checking required directories...") - for dir_path in required_dirs: - full_path = tests_path / dir_path - if full_path.exists(): - print(f" ✅ {dir_path}") - else: - print(f" ❌ {dir_path} - MISSING") - return False - - print(" Checking required files...") - for file_path in required_files: - full_path = tests_path / file_path - if full_path.exists(): - print(f" ✅ {file_path}") - else: - print(f" ❌ {file_path} - MISSING") - return False - - return True - -def validate_test_content(tests_path): - """Validate test file content and coverage""" - - test_files = list(tests_path.rglob("*.cs")) - - if len(test_files) < 10: - print(f" ❌ Insufficient test files: {len(test_files)} (expected at least 10)") - return False - - print(f" ✅ Found {len(test_files)} test files") - - # Count test methods - total_test_methods = 0 - total_lines = 0 - - for test_file in test_files: - try: - with open(test_file, 'r', encoding='utf-8') as f: - content = f.read() - total_lines += len(content.splitlines()) - - # Count [Test] attributes - test_methods = content.count('[Test]') - total_test_methods += test_methods - - print(f" 📄 {test_file.name}: {test_methods} tests, {len(content.splitlines())} lines") - - except Exception as e: - print(f" ❌ Error reading {test_file}: {e}") - return False - - print(f" 📊 Total: {total_test_methods} test methods, {total_lines} lines of test code") - - if total_test_methods < 50: - print(f" ❌ Insufficient test coverage: {total_test_methods} tests (expected at least 50)") - return False - - if total_lines < 2000: - print(f" ❌ Insufficient test code: {total_lines} lines (expected at least 2000)") - return False - - print(" ✅ Test content validation passed") - return True - -def validate_asset_store_compliance(worktree_path): - """Validate Asset Store compliance requirements""" - - print(" Checking package structure...") - - # Check package.json - package_json = worktree_path / "UnityMcpBridge" / "package.json" - if not package_json.exists(): - print(" ❌ package.json not found") - return False - - try: - with open(package_json, 'r') as f: - package_data = json.load(f) - - # Check for compliance indicators - if "python" in package_data.get("description", "").lower(): - print(" ✅ Package description mentions Python requirements") - else: - print(" ⚠️ Package description should mention Python requirements") - - except Exception as e: - print(f" ❌ Error reading package.json: {e}") - return False - - # Check for bundled dependencies (should not exist) - bundled_paths = [ - "UnityMcpBridge/python", - "UnityMcpBridge/Python", - "UnityMcpBridge/uv", - "UnityMcpBridge/UV" - ] - - for bundled_path in bundled_paths: - full_path = worktree_path / bundled_path - if full_path.exists(): - print(f" ❌ Found bundled dependency: {bundled_path}") - return False - - print(" ✅ No bundled dependencies found") - - # Check implementation files exist - impl_files = [ - "UnityMcpBridge/Editor/Dependencies/DependencyManager.cs", - "UnityMcpBridge/Editor/Setup/SetupWizard.cs", - "UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs" - ] - - for impl_file in impl_files: - full_path = worktree_path / impl_file - if full_path.exists(): - print(f" ✅ {impl_file}") - else: - print(f" ❌ {impl_file} - MISSING") - return False - - print(" ✅ Asset Store compliance validation passed") - return True - -def generate_test_metrics(tests_path): - """Generate detailed test metrics""" - - test_files = list(tests_path.rglob("*.cs")) - - metrics = { - "total_files": len(test_files), - "total_lines": 0, - "total_tests": 0, - "categories": {} - } - - for test_file in test_files: - try: - with open(test_file, 'r', encoding='utf-8') as f: - content = f.read() - lines = len(content.splitlines()) - tests = content.count('[Test]') - - metrics["total_lines"] += lines - metrics["total_tests"] += tests - - # Categorize by directory - category = test_file.parent.name - if category not in metrics["categories"]: - metrics["categories"][category] = {"files": 0, "lines": 0, "tests": 0} - - metrics["categories"][category]["files"] += 1 - metrics["categories"][category]["lines"] += lines - metrics["categories"][category]["tests"] += tests - - except Exception as e: - print(f" ❌ Error processing {test_file}: {e}") - - print(" 📊 Test Metrics:") - print(f" Total Files: {metrics['total_files']}") - print(f" Total Lines: {metrics['total_lines']}") - print(f" Total Tests: {metrics['total_tests']}") - print(f" Average Tests per File: {metrics['total_tests'] / metrics['total_files']:.1f}") - print(f" Average Lines per File: {metrics['total_lines'] / metrics['total_files']:.0f}") - - print("\n 📋 Category Breakdown:") - for category, data in metrics["categories"].items(): - print(f" {category}: {data['tests']} tests, {data['lines']} lines, {data['files']} files") - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) \ No newline at end of file From 29a3f3aa7d8432d5845d79314bc2904300c781f6 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 20:35:19 -0400 Subject: [PATCH 05/30] Revert "feat(asset-store): implement post-installation prompt system for Asset Store compliance" This reverts commit ab25a71bc5bf008e395679dd621f18e505fc3022. --- .../FEATURE_CONTEXT.md | 43 - .../IMPLEMENTATION_README.md | 221 -- .../IMPLEMENTATION_SUMMARY.md | 206 -- .../UnityMcpBridge/Editor.meta | 8 - .../UnityMcpBridge/Editor/AssemblyInfo.cs | 3 - .../Editor/AssemblyInfo.cs.meta | 11 - .../UnityMcpBridge/Editor/Data.meta | 8 - .../Editor/Data/DefaultServerConfig.cs | 18 - .../Editor/Data/DefaultServerConfig.cs.meta | 11 - .../UnityMcpBridge/Editor/Data/McpClients.cs | 177 -- .../Editor/Data/McpClients.cs.meta | 11 - .../UnityMcpBridge/Editor/Dependencies.meta | 8 - .../Editor/Dependencies/DependencyManager.cs | 308 -- .../Dependencies/DependencyManager.cs.meta | 11 - .../Dependencies/DependencyManagerTests.cs | 102 - .../DependencyManagerTests.cs.meta | 11 - .../Editor/Dependencies/Models.meta | 8 - .../Models/DependencyCheckResult.cs | 96 - .../Models/DependencyCheckResult.cs.meta | 11 - .../Dependencies/Models/DependencyStatus.cs | 65 - .../Models/DependencyStatus.cs.meta | 11 - .../Editor/Dependencies/Models/SetupState.cs | 127 - .../Dependencies/Models/SetupState.cs.meta | 11 - .../Dependencies/PlatformDetectors.meta | 8 - .../PlatformDetectors/IPlatformDetector.cs | 50 - .../IPlatformDetector.cs.meta | 11 - .../LinuxPlatformDetector.cs | 351 --- .../LinuxPlatformDetector.cs.meta | 11 - .../MacOSPlatformDetector.cs | 351 --- .../MacOSPlatformDetector.cs.meta | 11 - .../WindowsPlatformDetector.cs | 330 -- .../WindowsPlatformDetector.cs.meta | 11 - .../UnityMcpBridge/Editor/Helpers.meta | 8 - .../Editor/Helpers/ConfigJsonBuilder.cs | 129 - .../Editor/Helpers/ConfigJsonBuilder.cs.meta | 11 - .../UnityMcpBridge/Editor/Helpers/ExecPath.cs | 280 -- .../Editor/Helpers/ExecPath.cs.meta | 11 - .../Editor/Helpers/GameObjectSerializer.cs | 527 ---- .../Helpers/GameObjectSerializer.cs.meta | 11 - .../UnityMcpBridge/Editor/Helpers/McpLog.cs | 33 - .../Editor/Helpers/McpLog.cs.meta | 13 - .../Editor/Helpers/PackageDetector.cs | 109 - .../Editor/Helpers/PackageDetector.cs.meta | 11 - .../Editor/Helpers/PackageInstaller.cs | 43 - .../Editor/Helpers/PackageInstaller.cs.meta | 11 - .../Editor/Helpers/PortManager.cs | 319 -- .../Editor/Helpers/PortManager.cs.meta | 11 - .../UnityMcpBridge/Editor/Helpers/Response.cs | 63 - .../Editor/Helpers/Response.cs.meta | 11 - .../Editor/Helpers/ServerInstaller.cs | 744 ----- .../Editor/Helpers/ServerInstaller.cs.meta | 11 - .../Editor/Helpers/ServerPathResolver.cs | 151 - .../Editor/Helpers/ServerPathResolver.cs.meta | 11 - .../Editor/Helpers/TelemetryHelper.cs | 224 -- .../Editor/Helpers/TelemetryHelper.cs.meta | 11 - .../Editor/Helpers/Vector3Helper.cs | 25 - .../Editor/Helpers/Vector3Helper.cs.meta | 11 - .../UnityMcpBridge/Editor/Installation.meta | 8 - .../Installation/InstallationOrchestrator.cs | 199 -- .../InstallationOrchestrator.cs.meta | 11 - .../Editor/MCPForUnity.Editor.asmdef | 19 - .../Editor/MCPForUnity.Editor.asmdef.meta | 7 - .../Editor/MCPForUnityBridge.cs | 1210 -------- .../Editor/MCPForUnityBridge.cs.meta | 11 - .../UnityMcpBridge/Editor/Models.meta | 8 - .../UnityMcpBridge/Editor/Models/Command.cs | 21 - .../Editor/Models/Command.cs.meta | 11 - .../Editor/Models/MCPConfigServer.cs | 19 - .../Editor/Models/MCPConfigServer.cs.meta | 11 - .../Editor/Models/MCPConfigServers.cs | 12 - .../Editor/Models/MCPConfigServers.cs.meta | 11 - .../UnityMcpBridge/Editor/Models/McpClient.cs | 47 - .../Editor/Models/McpClient.cs.meta | 11 - .../UnityMcpBridge/Editor/Models/McpConfig.cs | 12 - .../Editor/Models/McpConfig.cs.meta | 11 - .../UnityMcpBridge/Editor/Models/McpStatus.cs | 18 - .../Editor/Models/McpStatus.cs.meta | 11 - .../UnityMcpBridge/Editor/Models/McpTypes.cs | 13 - .../Editor/Models/McpTypes.cs.meta | 11 - .../Editor/Models/ServerConfig.cs | 36 - .../Editor/Models/ServerConfig.cs.meta | 11 - .../UnityMcpBridge/Editor/Setup.meta | 8 - .../Editor/Setup/SetupWizard.cs | 278 -- .../Editor/Setup/SetupWizard.cs.meta | 11 - .../Editor/Setup/SetupWizardWindow.cs | 465 --- .../Editor/Setup/SetupWizardWindow.cs.meta | 11 - .../UnityMcpBridge/Editor/Tools.meta | 8 - .../Editor/Tools/CommandRegistry.cs | 48 - .../Editor/Tools/CommandRegistry.cs.meta | 11 - .../Editor/Tools/ManageAsset.cs | 1340 --------- .../Editor/Tools/ManageAsset.cs.meta | 11 - .../Editor/Tools/ManageEditor.cs | 613 ---- .../Editor/Tools/ManageEditor.cs.meta | 11 - .../Editor/Tools/ManageGameObject.cs | 2460 --------------- .../Editor/Tools/ManageGameObject.cs.meta | 11 - .../Editor/Tools/ManageScene.cs | 475 --- .../Editor/Tools/ManageScene.cs.meta | 11 - .../Editor/Tools/ManageScript.cs | 2659 ----------------- .../Editor/Tools/ManageScript.cs.meta | 11 - .../Editor/Tools/ManageShader.cs | 342 --- .../Editor/Tools/ManageShader.cs.meta | 11 - .../Editor/Tools/MenuItems.meta | 8 - .../Editor/Tools/MenuItems/ManageMenuItem.cs | 47 - .../Tools/MenuItems/ManageMenuItem.cs.meta | 11 - .../Tools/MenuItems/MenuItemExecutor.cs | 66 - .../Tools/MenuItems/MenuItemExecutor.cs.meta | 11 - .../Editor/Tools/MenuItems/MenuItemsReader.cs | 96 - .../Tools/MenuItems/MenuItemsReader.cs.meta | 11 - .../Editor/Tools/ReadConsole.cs | 571 ---- .../Editor/Tools/ReadConsole.cs.meta | 11 - .../UnityMcpBridge/Editor/Windows.meta | 8 - .../Editor/Windows/MCPForUnityEditorWindow.cs | 2129 ------------- .../Windows/MCPForUnityEditorWindow.cs.meta | 11 - .../Windows/ManualConfigEditorWindow.cs | 289 -- .../Windows/ManualConfigEditorWindow.cs.meta | 11 - .../Editor/Windows/VSCodeManualSetupWindow.cs | 291 -- .../Windows/VSCodeManualSetupWindow.cs.meta | 11 - .../UnityMcpBridge/README.md | 88 - .../UnityMcpBridge/README.md.meta | 7 - .../UnityMcpBridge/Runtime.meta | 8 - .../Runtime/MCPForUnity.Runtime.asmdef | 16 - .../Runtime/MCPForUnity.Runtime.asmdef.meta | 7 - .../UnityMcpBridge/Runtime/Serialization.meta | 8 - .../Serialization/UnityTypeConverters.cs | 266 -- .../Serialization/UnityTypeConverters.cs.meta | 11 - .../UnityMcpServer~/src/Dockerfile | 27 - .../UnityMcpServer~/src/__init__.py | 3 - .../UnityMcpServer~/src/config.py | 43 - .../UnityMcpServer~/src/port_discovery.py | 155 - .../UnityMcpServer~/src/pyproject.toml | 15 - .../UnityMcpServer~/src/pyrightconfig.json | 11 - .../UnityMcpServer~/src/reload_sentinel.py | 8 - .../UnityMcpServer~/src/server.py | 190 -- .../UnityMcpServer~/src/server_version.txt | 1 - .../UnityMcpServer~/src/telemetry.py | 431 --- .../src/telemetry_decorator.py | 101 - .../UnityMcpServer~/src/test_telemetry.py | 156 - .../UnityMcpServer~/src/tools/__init__.py | 29 - .../UnityMcpServer~/src/tools/manage_asset.py | 101 - .../src/tools/manage_editor.py | 66 - .../src/tools/manage_gameobject.py | 140 - .../src/tools/manage_menu_item.py | 57 - .../UnityMcpServer~/src/tools/manage_scene.py | 69 - .../src/tools/manage_script.py | 622 ---- .../src/tools/manage_script_edits.py | 924 ------ .../src/tools/manage_shader.py | 71 - .../UnityMcpServer~/src/tools/read_console.py | 102 - .../src/tools/resource_tools.py | 408 --- .../UnityMcpServer~/src/unity_connection.py | 422 --- .../UnityMcpServer~/src/uv.lock | 400 --- .../UnityMcpBridge/package.json | 28 - .../UnityMcpBridge/package.json.meta | 7 - 152 files changed, 24523 deletions(-) delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/FEATURE_CONTEXT.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_README.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_SUMMARY.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/Dockerfile delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/__init__.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/config.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server_version.txt delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/uv.lock delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json delete mode 100644 ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json.meta diff --git a/ava-worktrees/feature/ava-asset-store-compliance/FEATURE_CONTEXT.md b/ava-worktrees/feature/ava-asset-store-compliance/FEATURE_CONTEXT.md deleted file mode 100644 index 5951495e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/FEATURE_CONTEXT.md +++ /dev/null @@ -1,43 +0,0 @@ -# Asset Store Compliance Feature Development - -## Project: Unity MCP Bridge - -### Compliance Objectives -- Separate Python server dependencies -- Create clean package structure -- Implement dependency management wizard -- Ensure Asset Store submission readiness - -### Key Development Areas -1. UnityMcpBridge/Editor/ - - Refactor dependency management - - Create setup wizard - - Implement optional dependency prompting - -2. Package Structure - - Modularize server dependencies - - Create clear installation paths - - Support optional component installation - -3. Dependency Management System - - Detect existing Python environments - - Provide guided installation steps - - Support multiple Python version compatibility - -4. Setup Wizard Requirements - - Detect Unity project Python configuration - - Offer manual and automatic setup modes - - Provide clear user guidance - - Validate Python environment - -### Technical Constraints -- Maintain existing Unity MCP Bridge functionality -- Minimize additional package size -- Support cross-platform compatibility -- Provide clear user documentation - -### Development Workflow -- Isolated worktree for focused development -- Incremental feature implementation -- Comprehensive testing -- Asset Store submission preparation \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_README.md b/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_README.md deleted file mode 100644 index 5e333447..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_README.md +++ /dev/null @@ -1,221 +0,0 @@ -# Unity MCP Bridge - Asset Store Compliance Implementation - -## Overview - -This implementation provides a comprehensive post-installation prompt system for Unity MCP Bridge that ensures Asset Store compliance while maintaining full functionality. The system guides users through dependency installation and setup without bundling external dependencies in the package. - -## Key Features - -### 1. Dependency Detection System -- **Cross-platform detection** for Windows, macOS, and Linux -- **Intelligent path resolution** for Python and UV installations -- **Version validation** to ensure compatibility -- **Comprehensive diagnostics** for troubleshooting - -### 2. Setup Wizard System -- **Automatic triggering** on first use or when dependencies are missing -- **Progressive disclosure** with step-by-step guidance -- **Persistent state management** to avoid repeated prompts -- **Manual invocation** via Window menu - -### 3. Installation Orchestrator -- **Guided installation workflow** with progress tracking -- **Asset Store compliant** - no automatic downloads of external tools -- **Clear instructions** for manual installation -- **Fallback modes** for incomplete installations - -### 4. Asset Store Compliance -- **No bundled Python dependencies** in package structure -- **External server distribution** strategy -- **Clean package structure** without embedded executables -- **User-guided installation** process - -## Architecture - -### Core Components - -``` -UnityMcpBridge/Editor/ -├── Dependencies/ -│ ├── DependencyManager.cs # Main orchestrator -│ ├── Models/ -│ │ ├── DependencyStatus.cs # Status representation -│ │ ├── DependencyCheckResult.cs # Check results -│ │ └── SetupState.cs # Persistent state -│ └── PlatformDetectors/ -│ ├── IPlatformDetector.cs # Platform interface -│ ├── WindowsPlatformDetector.cs -│ ├── MacOSPlatformDetector.cs -│ └── LinuxPlatformDetector.cs -├── Setup/ -│ ├── SetupWizard.cs # Auto-trigger logic -│ └── SetupWizardWindow.cs # UI implementation -└── Installation/ - └── InstallationOrchestrator.cs # Installation workflow -``` - -### Integration Points - -The system integrates seamlessly with existing Unity MCP Bridge components: - -- **ServerInstaller**: Enhanced with dependency validation -- **MCPForUnityBridge**: Maintains existing functionality -- **Menu System**: New setup options in Window menu -- **Logging**: Uses existing McpLog infrastructure - -## User Experience Flow - -### First-Time Setup -1. **Automatic Detection**: System checks for dependencies on first load -2. **Setup Wizard**: Shows if dependencies are missing -3. **Guided Installation**: Step-by-step instructions for each platform -4. **Validation**: Confirms successful installation -5. **Completion**: Marks setup as complete to avoid repeated prompts - -### Ongoing Usage -- **Background Checks**: Periodic validation of dependency availability -- **Error Recovery**: Helpful messages when dependencies become unavailable -- **Manual Access**: Setup wizard available via Window menu -- **Diagnostics**: Comprehensive dependency information for troubleshooting - -## Asset Store Compliance Features - -### No Bundled Dependencies -- Python interpreter not included in package -- UV package manager not included in package -- MCP server distributed separately (embedded in package as source only) - -### User-Guided Installation -- Platform-specific installation instructions -- Direct links to official installation sources -- Clear error messages with actionable guidance -- Fallback modes for partial installations - -### Clean Package Structure -- No executable files in package -- No large binary dependencies -- Minimal package size impact -- Clear separation of concerns - -## Platform Support - -### Windows -- **Python Detection**: Microsoft Store, python.org, and PATH resolution -- **UV Detection**: WinGet, direct installation, and PATH resolution -- **Installation Guidance**: PowerShell commands and direct download links - -### macOS -- **Python Detection**: Homebrew, Framework, system, and PATH resolution -- **UV Detection**: Homebrew, curl installation, and PATH resolution -- **Installation Guidance**: Homebrew commands and curl scripts - -### Linux -- **Python Detection**: Package managers, snap, and PATH resolution -- **UV Detection**: curl installation and PATH resolution -- **Installation Guidance**: Distribution-specific package manager commands - -## Error Handling - -### Graceful Degradation -- System continues to function with missing optional dependencies -- Clear error messages for missing required dependencies -- Fallback modes for partial installations -- Recovery suggestions for common issues - -### Comprehensive Diagnostics -- Detailed dependency status information -- Platform-specific troubleshooting guidance -- Version compatibility checking -- Path resolution diagnostics - -## Testing Strategy - -### Unit Testing -- Platform detector validation -- Dependency status modeling -- Setup state persistence -- Error condition handling - -### Integration Testing -- End-to-end setup workflow -- Cross-platform compatibility -- Existing functionality preservation -- Performance impact assessment - -### User Acceptance Testing -- First-time user experience -- Setup wizard usability -- Error recovery scenarios -- Documentation clarity - -## Performance Considerations - -### Minimal Impact -- Lazy loading of dependency checks -- Cached results where appropriate -- Background processing for non-critical operations -- Efficient platform detection - -### Resource Usage -- Minimal memory footprint -- No persistent background processes -- Efficient file system operations -- Optimized UI rendering - -## Future Enhancements - -### Planned Features -- **Automatic Updates**: Notification system for dependency updates -- **Advanced Diagnostics**: More detailed system information -- **Custom Installation Paths**: Support for non-standard installations -- **Offline Mode**: Enhanced functionality without internet access - -### Extensibility -- **Plugin Architecture**: Support for additional dependency types -- **Custom Detectors**: User-defined detection logic -- **Integration APIs**: Programmatic access to dependency system -- **Event System**: Hooks for custom setup workflows - -## Migration Strategy - -### Existing Users -- Automatic detection of existing installations -- Seamless upgrade path from previous versions -- Preservation of existing configuration -- Optional re-setup for enhanced features - -### New Users -- Guided onboarding experience -- Clear setup requirements -- Comprehensive documentation -- Community support resources - -## Documentation - -### User Documentation -- Setup guide for each platform -- Troubleshooting common issues -- FAQ for dependency management -- Video tutorials for complex setups - -### Developer Documentation -- API reference for dependency system -- Extension guide for custom detectors -- Integration examples -- Best practices guide - -## Support and Maintenance - -### Issue Resolution -- Comprehensive logging for debugging -- Diagnostic information collection -- Platform-specific troubleshooting -- Community support channels - -### Updates and Patches -- Backward compatibility maintenance -- Security update procedures -- Performance optimization -- Feature enhancement process - -This implementation ensures Unity MCP Bridge meets Asset Store requirements while providing an excellent user experience for dependency management and setup. \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_SUMMARY.md b/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 06ae2a92..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,206 +0,0 @@ -# Unity MCP Bridge - Asset Store Compliance Implementation Summary - -## Implementation Completed ✅ - -### 1. Dependency Detection System -**Location**: `UnityMcpBridge/Editor/Dependencies/` - -#### Core Components: -- **DependencyManager.cs**: Main orchestrator for dependency validation -- **Models/DependencyStatus.cs**: Represents individual dependency status -- **Models/DependencyCheckResult.cs**: Comprehensive check results -- **Models/SetupState.cs**: Persistent state management - -#### Platform Detectors: -- **IPlatformDetector.cs**: Interface for platform-specific detection -- **WindowsPlatformDetector.cs**: Windows-specific dependency detection -- **MacOSPlatformDetector.cs**: macOS-specific dependency detection -- **LinuxPlatformDetector.cs**: Linux-specific dependency detection - -#### Features: -✅ Cross-platform Python detection (3.10+ validation) -✅ UV package manager detection -✅ MCP server installation validation -✅ Platform-specific installation recommendations -✅ Comprehensive error handling and diagnostics - -### 2. Setup Wizard System -**Location**: `UnityMcpBridge/Editor/Setup/` - -#### Components: -- **SetupWizard.cs**: Auto-trigger logic with `[InitializeOnLoad]` -- **SetupWizardWindow.cs**: Complete EditorWindow implementation - -#### Features: -✅ Automatic triggering on missing dependencies -✅ 5-step progressive wizard (Welcome → Check → Options → Progress → Complete) -✅ Persistent state to avoid repeated prompts -✅ Manual access via Window menu -✅ Version-aware setup completion tracking - -### 3. Installation Orchestrator -**Location**: `UnityMcpBridge/Editor/Installation/` - -#### Components: -- **InstallationOrchestrator.cs**: Guided installation workflow - -#### Features: -✅ Asset Store compliant (no automatic downloads) -✅ Progress tracking and user feedback -✅ Platform-specific installation guidance -✅ Error handling and recovery suggestions - -### 4. Asset Store Compliance -#### Package Structure Changes: -✅ Updated package.json to remove Python references -✅ Added dependency requirements to description -✅ Clean separation of embedded vs external dependencies -✅ No bundled executables or large binaries - -#### User Experience: -✅ Clear setup requirements communication -✅ Guided installation process -✅ Fallback modes for incomplete installations -✅ Comprehensive error messages with actionable guidance - -### 5. Integration with Existing System -#### Maintained Compatibility: -✅ Integrates with existing ServerInstaller -✅ Uses existing McpLog infrastructure -✅ Preserves all existing MCP functionality -✅ No breaking changes to public APIs - -#### Enhanced Features: -✅ Menu items for dependency checking -✅ Diagnostic information collection -✅ Setup state persistence -✅ Platform-aware installation guidance - -## File Structure Created - -``` -UnityMcpBridge/Editor/ -├── Dependencies/ -│ ├── DependencyManager.cs -│ ├── DependencyManagerTests.cs -│ ├── Models/ -│ │ ├── DependencyStatus.cs -│ │ ├── DependencyCheckResult.cs -│ │ └── SetupState.cs -│ └── PlatformDetectors/ -│ ├── IPlatformDetector.cs -│ ├── WindowsPlatformDetector.cs -│ ├── MacOSPlatformDetector.cs -│ └── LinuxPlatformDetector.cs -├── Setup/ -│ ├── SetupWizard.cs -│ └── SetupWizardWindow.cs -└── Installation/ - └── InstallationOrchestrator.cs -``` - -## Key Features Implemented - -### 1. Automatic Dependency Detection -- **Multi-platform support**: Windows, macOS, Linux -- **Intelligent path resolution**: Common installation locations + PATH -- **Version validation**: Ensures Python 3.10+ compatibility -- **Comprehensive diagnostics**: Detailed status information - -### 2. User-Friendly Setup Wizard -- **Progressive disclosure**: 5-step guided process -- **Visual feedback**: Progress bars and status indicators -- **Persistent state**: Avoids repeated prompts -- **Manual access**: Available via Window menu - -### 3. Asset Store Compliance -- **No bundled dependencies**: Python/UV not included in package -- **External distribution**: MCP server as source code only -- **User-guided installation**: Clear instructions for each platform -- **Clean package structure**: Minimal size impact - -### 4. Error Handling & Recovery -- **Graceful degradation**: System works with partial dependencies -- **Clear error messages**: Actionable guidance for users -- **Diagnostic tools**: Comprehensive system information -- **Recovery suggestions**: Platform-specific troubleshooting - -## Testing & Validation - -### Test Infrastructure: -✅ DependencyManagerTests.cs with menu-driven test execution -✅ Basic functionality validation -✅ Setup wizard testing -✅ State management testing - -### Manual Testing Points: -- [ ] First-time user experience -- [ ] Cross-platform compatibility -- [ ] Error condition handling -- [ ] Setup wizard flow -- [ ] Dependency detection accuracy - -## Integration Points - -### With Existing Codebase: -✅ **ServerInstaller**: Enhanced with dependency validation -✅ **MCPForUnityBridge**: Maintains existing functionality -✅ **Menu System**: New setup options added -✅ **Logging**: Uses existing McpLog infrastructure - -### New Menu Items Added: -- Window/MCP for Unity/Setup Wizard -- Window/MCP for Unity/Reset Setup -- Window/MCP for Unity/Check Dependencies -- Window/MCP for Unity/Run Dependency Tests (debug) - -## Asset Store Readiness - -### Compliance Checklist: -✅ No bundled Python interpreter -✅ No bundled UV package manager -✅ No large binary dependencies -✅ Clear dependency requirements in description -✅ User-guided installation process -✅ Fallback modes for missing dependencies -✅ Clean package structure -✅ Comprehensive documentation - -### User Experience: -✅ Clear setup requirements -✅ Guided installation process -✅ Platform-specific instructions -✅ Error recovery guidance -✅ Minimal friction for users with dependencies - -## Next Steps - -### Before Asset Store Submission: -1. **Comprehensive Testing**: Test on all target platforms -2. **Documentation Update**: Update README with new setup process -3. **Performance Validation**: Ensure minimal impact on Unity startup -4. **User Acceptance Testing**: Validate setup wizard usability - -### Post-Implementation: -1. **Monitor User Feedback**: Track setup success rates -2. **Iterate on UX**: Improve based on user experience -3. **Add Advanced Features**: Enhanced diagnostics, auto-updates -4. **Expand Platform Support**: Additional installation methods - -## Technical Highlights - -### Architecture Strengths: -- **SOLID Principles**: Clear separation of concerns -- **Platform Abstraction**: Extensible detector pattern -- **State Management**: Persistent setup state -- **Error Handling**: Comprehensive exception management -- **Performance**: Lazy loading and efficient detection - -### Code Quality: -- **Documentation**: Comprehensive XML comments -- **Naming**: Clear, descriptive naming conventions -- **Error Handling**: Defensive programming practices -- **Maintainability**: Modular, testable design -- **Extensibility**: Easy to add new platforms/dependencies - -This implementation successfully addresses Asset Store compliance requirements while maintaining excellent user experience and full MCP functionality. \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor.meta deleted file mode 100644 index 26495d40..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 31e7fac5858840340a75cc6df0ad3d9e -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs deleted file mode 100644 index 30a86a36..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs.meta deleted file mode 100644 index 72bf5f72..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/AssemblyInfo.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: be61633e00d934610ac1ff8192ffbe3d -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data.meta deleted file mode 100644 index bb714ec4..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e59036660cc33d24596fbbf6d4657a83 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs deleted file mode 100644 index c7b0c9f6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Data -{ - public class DefaultServerConfig : ServerConfig - { - public new string unityHost = "localhost"; - public new int unityPort = 6400; - public new int mcpPort = 6500; - public new float connectionTimeout = 15.0f; - public new int bufferSize = 32768; - public new string logLevel = "INFO"; - public new string logFormat = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"; - public new int maxRetries = 3; - public new float retryDelay = 1.0f; - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta deleted file mode 100644 index 82e437f2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: de8f5721c34f7194392e9d8c7d0226c0 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs deleted file mode 100644 index 19e41284..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Data -{ - public class McpClients - { - public List clients = new() - { - // 1) Cursor - new() - { - name = "Cursor", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" - ), - mcpType = McpTypes.Cursor, - configStatus = "Not Configured", - }, - // 2) Claude Code - new() - { - name = "Claude Code", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".claude.json" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".claude.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".claude.json" - ), - mcpType = McpTypes.ClaudeCode, - configStatus = "Not Configured", - }, - // 3) Windsurf - new() - { - name = "Windsurf", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" - ), - mcpType = McpTypes.Windsurf, - configStatus = "Not Configured", - }, - // 4) Claude Desktop - new() - { - name = "Claude Desktop", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Claude", - "claude_desktop_config.json" - ), - - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Claude", - "claude_desktop_config.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Claude", - "claude_desktop_config.json" - ), - - mcpType = McpTypes.ClaudeDesktop, - configStatus = "Not Configured", - }, - // 5) VSCode GitHub Copilot - new() - { - name = "VSCode GitHub Copilot", - // Windows path is canonical under %AppData%\Code\User - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Code", - "User", - "mcp.json" - ), - // macOS: ~/Library/Application Support/Code/User/mcp.json - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Code", - "User", - "mcp.json" - ), - // Linux: ~/.config/Code/User/mcp.json - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Code", - "User", - "mcp.json" - ), - mcpType = McpTypes.VSCode, - configStatus = "Not Configured", - }, - // 3) Kiro - new() - { - name = "Kiro", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - mcpType = McpTypes.Kiro, - configStatus = "Not Configured", - }, - }; - - // Initialize status enums after construction - public McpClients() - { - foreach (var client in clients) - { - if (client.configStatus == "Not Configured") - { - client.status = McpStatus.NotConfigured; - } - } - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs.meta deleted file mode 100644 index e5a10813..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Data/McpClients.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 711b86bbc1f661e4fb2c822e14970e16 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies.meta deleted file mode 100644 index 1afc603b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a1b2c3d4e5f6789012345678901234ab -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs deleted file mode 100644 index 4fe94919..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Dependencies.PlatformDetectors; -using MCPForUnity.Editor.Helpers; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Dependencies -{ - /// - /// Main orchestrator for dependency validation and management - /// - public static class DependencyManager - { - private static readonly List _detectors = new List - { - new WindowsPlatformDetector(), - new MacOSPlatformDetector(), - new LinuxPlatformDetector() - }; - - private static IPlatformDetector _currentDetector; - - /// - /// Get the platform detector for the current operating system - /// - public static IPlatformDetector GetCurrentPlatformDetector() - { - if (_currentDetector == null) - { - _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); - if (_currentDetector == null) - { - throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); - } - } - return _currentDetector; - } - - /// - /// Perform a comprehensive dependency check - /// - public static DependencyCheckResult CheckAllDependencies() - { - var result = new DependencyCheckResult(); - - try - { - var detector = GetCurrentPlatformDetector(); - McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); - - // Check Python - var pythonStatus = detector.DetectPython(); - result.Dependencies.Add(pythonStatus); - - // Check UV - var uvStatus = detector.DetectUV(); - result.Dependencies.Add(uvStatus); - - // Check MCP Server - var serverStatus = detector.DetectMCPServer(); - result.Dependencies.Add(serverStatus); - - // Generate summary and recommendations - result.GenerateSummary(); - GenerateRecommendations(result, detector); - - McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); - } - catch (Exception ex) - { - McpLog.Error($"Error during dependency check: {ex.Message}"); - result.Summary = $"Dependency check failed: {ex.Message}"; - result.IsSystemReady = false; - } - - return result; - } - - /// - /// Quick check if system is ready for MCP operations - /// - public static bool IsSystemReady() - { - try - { - var result = CheckAllDependencies(); - return result.IsSystemReady; - } - catch - { - return false; - } - } - - /// - /// Get a summary of missing dependencies - /// - public static string GetMissingDependenciesSummary() - { - try - { - var result = CheckAllDependencies(); - var missing = result.GetMissingRequired(); - - if (missing.Count == 0) - { - return "All required dependencies are available."; - } - - var names = missing.Select(d => d.Name).ToArray(); - return $"Missing required dependencies: {string.Join(", ", names)}"; - } - catch (Exception ex) - { - return $"Error checking dependencies: {ex.Message}"; - } - } - - /// - /// Check if a specific dependency is available - /// - public static bool IsDependencyAvailable(string dependencyName) - { - try - { - var detector = GetCurrentPlatformDetector(); - - return dependencyName.ToLowerInvariant() switch - { - "python" => detector.DetectPython().IsAvailable, - "uv" => detector.DetectUV().IsAvailable, - "mcpserver" or "mcp-server" => detector.DetectMCPServer().IsAvailable, - _ => false - }; - } - catch - { - return false; - } - } - - /// - /// Get installation recommendations for the current platform - /// - public static string GetInstallationRecommendations() - { - try - { - var detector = GetCurrentPlatformDetector(); - return detector.GetInstallationRecommendations(); - } - catch (Exception ex) - { - return $"Error getting installation recommendations: {ex.Message}"; - } - } - - /// - /// Get platform-specific installation URLs - /// - public static (string pythonUrl, string uvUrl) GetInstallationUrls() - { - try - { - var detector = GetCurrentPlatformDetector(); - return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); - } - catch - { - return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); - } - } - - /// - /// Validate that the MCP server can be started - /// - public static bool ValidateMCPServerStartup() - { - try - { - // Check if Python and UV are available - if (!IsDependencyAvailable("python") || !IsDependencyAvailable("uv")) - { - return false; - } - - // Try to ensure server is installed - ServerInstaller.EnsureServerInstalled(); - - // Check if server files exist - var serverStatus = GetCurrentPlatformDetector().DetectMCPServer(); - return serverStatus.IsAvailable; - } - catch (Exception ex) - { - McpLog.Error($"Error validating MCP server startup: {ex.Message}"); - return false; - } - } - - /// - /// Attempt to repair the Python environment - /// - public static bool RepairPythonEnvironment() - { - try - { - McpLog.Info("Attempting to repair Python environment..."); - return ServerInstaller.RepairPythonEnvironment(); - } - catch (Exception ex) - { - McpLog.Error($"Error repairing Python environment: {ex.Message}"); - return false; - } - } - - /// - /// Get detailed dependency information for diagnostics - /// - public static string GetDependencyDiagnostics() - { - try - { - var result = CheckAllDependencies(); - var detector = GetCurrentPlatformDetector(); - - var diagnostics = new System.Text.StringBuilder(); - diagnostics.AppendLine($"Platform: {detector.PlatformName}"); - diagnostics.AppendLine($"Check Time: {result.CheckedAt:yyyy-MM-dd HH:mm:ss} UTC"); - diagnostics.AppendLine($"System Ready: {result.IsSystemReady}"); - diagnostics.AppendLine(); - - foreach (var dep in result.Dependencies) - { - diagnostics.AppendLine($"=== {dep.Name} ==="); - diagnostics.AppendLine($"Available: {dep.IsAvailable}"); - diagnostics.AppendLine($"Required: {dep.IsRequired}"); - - if (!string.IsNullOrEmpty(dep.Version)) - diagnostics.AppendLine($"Version: {dep.Version}"); - - if (!string.IsNullOrEmpty(dep.Path)) - diagnostics.AppendLine($"Path: {dep.Path}"); - - if (!string.IsNullOrEmpty(dep.Details)) - diagnostics.AppendLine($"Details: {dep.Details}"); - - if (!string.IsNullOrEmpty(dep.ErrorMessage)) - diagnostics.AppendLine($"Error: {dep.ErrorMessage}"); - - diagnostics.AppendLine(); - } - - if (result.RecommendedActions.Count > 0) - { - diagnostics.AppendLine("=== Recommended Actions ==="); - foreach (var action in result.RecommendedActions) - { - diagnostics.AppendLine($"- {action}"); - } - } - - return diagnostics.ToString(); - } - catch (Exception ex) - { - return $"Error generating diagnostics: {ex.Message}"; - } - } - - private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) - { - var missing = result.GetMissingDependencies(); - - if (missing.Count == 0) - { - result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); - return; - } - - foreach (var dep in missing) - { - if (dep.Name == "Python") - { - result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); - } - else if (dep.Name == "UV Package Manager") - { - result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); - } - else if (dep.Name == "MCP Server") - { - result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); - } - } - - if (result.GetMissingRequired().Count > 0) - { - result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta deleted file mode 100644 index ae03260a..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: f6789012345678901234abcdef012345 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs deleted file mode 100644 index 0215f98b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using UnityEngine; - -namespace MCPForUnity.Editor.Dependencies -{ - /// - /// Simple test class for dependency management functionality - /// This can be expanded into proper unit tests later - /// - public static class DependencyManagerTests - { - /// - /// Test basic dependency detection functionality - /// - [UnityEditor.MenuItem("Window/MCP for Unity/Run Dependency Tests", priority = 100)] - public static void RunBasicTests() - { - Debug.Log("MCP-FOR-UNITY: Running Dependency Manager Tests..."); - - try - { - // Test 1: Platform detector availability - var detector = DependencyManager.GetCurrentPlatformDetector(); - Debug.Log($"✓ Platform detector found: {detector.PlatformName}"); - - // Test 2: Dependency check - var result = DependencyManager.CheckAllDependencies(); - Debug.Log($"✓ Dependency check completed. System ready: {result.IsSystemReady}"); - - // Test 3: Individual dependency checks - bool pythonAvailable = DependencyManager.IsDependencyAvailable("python"); - bool uvAvailable = DependencyManager.IsDependencyAvailable("uv"); - bool serverAvailable = DependencyManager.IsDependencyAvailable("mcpserver"); - - Debug.Log($"✓ Python available: {pythonAvailable}"); - Debug.Log($"✓ UV available: {uvAvailable}"); - Debug.Log($"✓ MCP Server available: {serverAvailable}"); - - // Test 4: Installation recommendations - var recommendations = DependencyManager.GetInstallationRecommendations(); - Debug.Log($"✓ Installation recommendations generated ({recommendations.Length} characters)"); - - // Test 5: Setup state management - var setupState = Setup.SetupWizard.GetSetupState(); - Debug.Log($"✓ Setup state loaded. Completed: {setupState.HasCompletedSetup}"); - - // Test 6: Diagnostics - var diagnostics = DependencyManager.GetDependencyDiagnostics(); - Debug.Log($"✓ Diagnostics generated ({diagnostics.Length} characters)"); - - Debug.Log("MCP-FOR-UNITY: All tests completed successfully!"); - - // Show detailed results - Debug.Log($"Detailed Dependency Status:\n{diagnostics}"); - } - catch (System.Exception ex) - { - Debug.LogError($"MCP-FOR-UNITY: Test failed: {ex.Message}\n{ex.StackTrace}"); - } - } - - /// - /// Test setup wizard functionality - /// - [UnityEditor.MenuItem("Window/MCP for Unity/Test Setup Wizard", priority = 101)] - public static void TestSetupWizard() - { - Debug.Log("MCP-FOR-UNITY: Testing Setup Wizard..."); - - try - { - // Force show setup wizard for testing - Setup.SetupWizard.ShowSetupWizard(); - Debug.Log("✓ Setup wizard opened successfully"); - } - catch (System.Exception ex) - { - Debug.LogError($"MCP-FOR-UNITY: Setup wizard test failed: {ex.Message}"); - } - } - - /// - /// Reset setup state for testing - /// - [UnityEditor.MenuItem("Window/MCP for Unity/Reset Setup State (Test)", priority = 102)] - public static void ResetSetupStateForTesting() - { - Debug.Log("MCP-FOR-UNITY: Resetting setup state for testing..."); - - try - { - Setup.SetupWizard.ResetSetupState(); - Debug.Log("✓ Setup state reset successfully"); - } - catch (System.Exception ex) - { - Debug.LogError($"MCP-FOR-UNITY: Setup state reset failed: {ex.Message}"); - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs.meta deleted file mode 100644 index 52ab4d0d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/DependencyManagerTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 678901234abcdef0123456789abcdef0 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models.meta deleted file mode 100644 index 2174dd52..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b2c3d4e5f6789012345678901234abcd -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs deleted file mode 100644 index 3a3effad..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MCPForUnity.Editor.Dependencies.Models -{ - /// - /// Result of a comprehensive dependency check - /// - [Serializable] - public class DependencyCheckResult - { - /// - /// List of all dependency statuses checked - /// - public List Dependencies { get; set; } - - /// - /// Overall system readiness for MCP operations - /// - public bool IsSystemReady { get; set; } - - /// - /// Whether all required dependencies are available - /// - public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; - - /// - /// Whether any optional dependencies are missing - /// - public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; - - /// - /// Summary message about the dependency state - /// - public string Summary { get; set; } - - /// - /// Recommended next steps for the user - /// - public List RecommendedActions { get; set; } - - /// - /// Timestamp when this check was performed - /// - public DateTime CheckedAt { get; set; } - - public DependencyCheckResult() - { - Dependencies = new List(); - RecommendedActions = new List(); - CheckedAt = DateTime.UtcNow; - } - - /// - /// Get dependencies by availability status - /// - public List GetMissingDependencies() - { - return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List(); - } - - /// - /// Get missing required dependencies - /// - public List GetMissingRequired() - { - return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List(); - } - - /// - /// Generate a user-friendly summary of the dependency state - /// - public void GenerateSummary() - { - var missing = GetMissingDependencies(); - var missingRequired = GetMissingRequired(); - - if (missing.Count == 0) - { - Summary = "All dependencies are available and ready."; - IsSystemReady = true; - } - else if (missingRequired.Count == 0) - { - Summary = $"System is ready. {missing.Count} optional dependencies are missing."; - IsSystemReady = true; - } - else - { - Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; - IsSystemReady = false; - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta deleted file mode 100644 index a88c3bb2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 789012345678901234abcdef01234567 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs deleted file mode 100644 index 77e09cb9..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; - -namespace MCPForUnity.Editor.Dependencies.Models -{ - /// - /// Represents the status of a dependency check - /// - [Serializable] - public class DependencyStatus - { - /// - /// Name of the dependency being checked - /// - public string Name { get; set; } - - /// - /// Whether the dependency is available and functional - /// - public bool IsAvailable { get; set; } - - /// - /// Version information if available - /// - public string Version { get; set; } - - /// - /// Path to the dependency executable/installation - /// - public string Path { get; set; } - - /// - /// Additional details about the dependency status - /// - public string Details { get; set; } - - /// - /// Error message if dependency check failed - /// - public string ErrorMessage { get; set; } - - /// - /// Whether this dependency is required for basic functionality - /// - public bool IsRequired { get; set; } - - /// - /// Suggested installation method or URL - /// - public string InstallationHint { get; set; } - - public DependencyStatus(string name, bool isRequired = true) - { - Name = name; - IsRequired = isRequired; - IsAvailable = false; - } - - public override string ToString() - { - var status = IsAvailable ? "✓" : "✗"; - var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : ""; - return $"{status} {Name}{version}"; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta deleted file mode 100644 index d6eb1d59..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6789012345678901234abcdef0123456 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs deleted file mode 100644 index 338c2a03..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using UnityEngine; - -namespace MCPForUnity.Editor.Dependencies.Models -{ - /// - /// Persistent state for the setup wizard to avoid repeated prompts - /// - [Serializable] - public class SetupState - { - /// - /// Whether the user has completed the initial setup wizard - /// - public bool HasCompletedSetup { get; set; } - - /// - /// Whether the user has dismissed the setup wizard permanently - /// - public bool HasDismissedSetup { get; set; } - - /// - /// Last time dependencies were checked - /// - public string LastDependencyCheck { get; set; } - - /// - /// Version of the package when setup was last completed - /// - public string SetupVersion { get; set; } - - /// - /// Whether to show the setup wizard on next domain reload - /// - public bool ShowSetupOnReload { get; set; } - - /// - /// User's preferred installation mode (automatic/manual) - /// - public string PreferredInstallMode { get; set; } - - /// - /// Number of times setup has been attempted - /// - public int SetupAttempts { get; set; } - - /// - /// Last error encountered during setup - /// - public string LastSetupError { get; set; } - - public SetupState() - { - HasCompletedSetup = false; - HasDismissedSetup = false; - ShowSetupOnReload = false; - PreferredInstallMode = "automatic"; - SetupAttempts = 0; - } - - /// - /// Check if setup should be shown based on current state - /// - public bool ShouldShowSetup(string currentVersion) - { - // Don't show if user has permanently dismissed - if (HasDismissedSetup) - return false; - - // Show if never completed setup - if (!HasCompletedSetup) - return true; - - // Show if package version has changed significantly - if (!string.IsNullOrEmpty(currentVersion) && SetupVersion != currentVersion) - return true; - - // Show if explicitly requested - if (ShowSetupOnReload) - return true; - - return false; - } - - /// - /// Mark setup as completed for the current version - /// - public void MarkSetupCompleted(string version) - { - HasCompletedSetup = true; - SetupVersion = version; - ShowSetupOnReload = false; - LastSetupError = null; - } - - /// - /// Mark setup as dismissed permanently - /// - public void MarkSetupDismissed() - { - HasDismissedSetup = true; - ShowSetupOnReload = false; - } - - /// - /// Record a setup attempt with optional error - /// - public void RecordSetupAttempt(string error = null) - { - SetupAttempts++; - LastSetupError = error; - } - - /// - /// Reset setup state (for debugging or re-setup) - /// - public void Reset() - { - HasCompletedSetup = false; - HasDismissedSetup = false; - ShowSetupOnReload = false; - SetupAttempts = 0; - LastSetupError = null; - LastDependencyCheck = null; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs.meta deleted file mode 100644 index 3b16e249..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/Models/SetupState.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 89012345678901234abcdef012345678 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta deleted file mode 100644 index 22a6b1db..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: c3d4e5f6789012345678901234abcdef -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs deleted file mode 100644 index af00c85b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs +++ /dev/null @@ -1,50 +0,0 @@ -using MCPForUnity.Editor.Dependencies.Models; - -namespace MCPForUnity.Editor.Dependencies.PlatformDetectors -{ - /// - /// Interface for platform-specific dependency detection - /// - public interface IPlatformDetector - { - /// - /// Platform name this detector handles - /// - string PlatformName { get; } - - /// - /// Whether this detector can run on the current platform - /// - bool CanDetect { get; } - - /// - /// Detect Python installation on this platform - /// - DependencyStatus DetectPython(); - - /// - /// Detect UV package manager on this platform - /// - DependencyStatus DetectUV(); - - /// - /// Detect MCP server installation on this platform - /// - DependencyStatus DetectMCPServer(); - - /// - /// Get platform-specific installation recommendations - /// - string GetInstallationRecommendations(); - - /// - /// Get platform-specific Python installation URL - /// - string GetPythonInstallUrl(); - - /// - /// Get platform-specific UV installation URL - /// - string GetUVInstallUrl(); - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta deleted file mode 100644 index d2cd9f07..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9012345678901234abcdef0123456789 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs deleted file mode 100644 index 3b6723c6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Dependencies.PlatformDetectors -{ - /// - /// Linux-specific dependency detection - /// - public class LinuxPlatformDetector : IPlatformDetector - { - public string PlatformName => "Linux"; - - public bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - - public DependencyStatus DetectPython() - { - var status = new DependencyStatus("Python", isRequired: true) - { - InstallationHint = GetPythonInstallUrl() - }; - - try - { - // Check common Python installation paths on Linux - var candidates = new[] - { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/python/bin/python3", - "/snap/bin/python3" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } - } - - // Try PATH resolution using 'which' command - if (TryFindInPath("python3", out string pathResult) || - TryFindInPath("python", out pathResult)) - { - if (TryValidatePython(pathResult, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; - return status; - } - } - - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including system, snap, and user-local locations."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting Python: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectUV() - { - var status = new DependencyStatus("UV Package Manager", isRequired: true) - { - InstallationHint = GetUVInstallUrl() - }; - - try - { - // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) - { - if (TryValidateUV(uvPath, out string version)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; - return status; - } - } - - status.ErrorMessage = "UV package manager not found. Please install UV."; - status.Details = "UV is required for managing Python dependencies."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting UV: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectMCPServer() - { - var status = new DependencyStatus("MCP Server", isRequired: false); - - try - { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) - { - status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) - { - status.Version = File.ReadAllText(versionFile).Trim(); - } - - status.Details = $"MCP Server found at {serverPath}"; - } - else - { - // Check for embedded server - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) - { - status.IsAvailable = true; - status.Path = embeddedPath; - status.Details = "MCP Server available (embedded in package)"; - } - else - { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; - } - } - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; - } - - return status; - } - - public string GetInstallationRecommendations() - { - return @"Linux Installation Recommendations: - -1. Python: Install via package manager or pyenv - - Ubuntu/Debian: sudo apt install python3 python3-pip - - Fedora/RHEL: sudo dnf install python3 python3-pip - - Arch: sudo pacman -S python python-pip - - Or use pyenv: https://github.com/pyenv/pyenv - -2. UV Package Manager: Install via curl - - Run: curl -LsSf https://astral.sh/uv/install.sh | sh - - Or download from: https://github.com/astral-sh/uv/releases - -3. MCP Server: Will be installed automatically by Unity MCP Bridge - -Note: Make sure ~/.local/bin is in your PATH for user-local installations."; - } - - public string GetPythonInstallUrl() - { - return "https://www.python.org/downloads/source/"; - } - - public string GetUVInstallUrl() - { - return "https://docs.astral.sh/uv/getting-started/installation/#linux"; - } - - private bool TryValidatePython(string pythonPath, out string version, out string fullPath) - { - version = null; - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Set PATH to include common locations - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/usr/local/bin", - "/usr/bin", - "/bin", - "/snap/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("Python ")) - { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // Validate minimum version (3.10+) - if (TryParseVersion(version, out var major, out var minor)) - { - return major >= 3 && minor >= 10; - } - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryValidateUV(string uvPath, out string version) - { - version = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3); // Remove "uv " prefix - return true; - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryFindInPath(string executable, out string fullPath) - { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Enhance PATH for Unity's GUI environment - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/usr/local/bin", - "/usr/bin", - "/bin", - "/snap/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - fullPath = output; - return true; - } - } - catch - { - // Ignore errors - } - - return false; - } - - private bool TryParseVersion(string version, out int major, out int minor) - { - major = 0; - minor = 0; - - try - { - var parts = version.Split('.'); - if (parts.Length >= 2) - { - return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); - } - } - catch - { - // Ignore parsing errors - } - - return false; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta deleted file mode 100644 index 4f8267fd..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2345678901234abcdef0123456789abc -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs deleted file mode 100644 index 35ab38f2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Dependencies.PlatformDetectors -{ - /// - /// macOS-specific dependency detection - /// - public class MacOSPlatformDetector : IPlatformDetector - { - public string PlatformName => "macOS"; - - public bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - - public DependencyStatus DetectPython() - { - var status = new DependencyStatus("Python", isRequired: true) - { - InstallationHint = GetPythonInstallUrl() - }; - - try - { - // Check common Python installation paths on macOS - var candidates = new[] - { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/homebrew/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } - } - - // Try PATH resolution using 'which' command - if (TryFindInPath("python3", out string pathResult) || - TryFindInPath("python", out pathResult)) - { - if (TryValidatePython(pathResult, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; - return status; - } - } - - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting Python: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectUV() - { - var status = new DependencyStatus("UV Package Manager", isRequired: true) - { - InstallationHint = GetUVInstallUrl() - }; - - try - { - // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) - { - if (TryValidateUV(uvPath, out string version)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; - return status; - } - } - - status.ErrorMessage = "UV package manager not found. Please install UV."; - status.Details = "UV is required for managing Python dependencies."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting UV: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectMCPServer() - { - var status = new DependencyStatus("MCP Server", isRequired: false); - - try - { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) - { - status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) - { - status.Version = File.ReadAllText(versionFile).Trim(); - } - - status.Details = $"MCP Server found at {serverPath}"; - } - else - { - // Check for embedded server - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) - { - status.IsAvailable = true; - status.Path = embeddedPath; - status.Details = "MCP Server available (embedded in package)"; - } - else - { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; - } - } - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; - } - - return status; - } - - public string GetInstallationRecommendations() - { - return @"macOS Installation Recommendations: - -1. Python: Install via Homebrew (recommended) or python.org - - Homebrew: brew install python3 - - Direct download: https://python.org/downloads/macos/ - -2. UV Package Manager: Install via curl or Homebrew - - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh - - Homebrew: brew install uv - -3. MCP Server: Will be installed automatically by Unity MCP Bridge - -Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; - } - - public string GetPythonInstallUrl() - { - return "https://www.python.org/downloads/macos/"; - } - - public string GetUVInstallUrl() - { - return "https://docs.astral.sh/uv/getting-started/installation/#macos"; - } - - private bool TryValidatePython(string pythonPath, out string version, out string fullPath) - { - version = null; - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Set PATH to include common locations - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("Python ")) - { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // Validate minimum version (3.10+) - if (TryParseVersion(version, out var major, out var minor)) - { - return major >= 3 && minor >= 10; - } - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryValidateUV(string uvPath, out string version) - { - version = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3); // Remove "uv " prefix - return true; - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryFindInPath(string executable, out string fullPath) - { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - // Enhance PATH for Unity's GUI environment - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var pathAdditions = new[] - { - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - Path.Combine(homeDir, ".local", "bin") - }; - - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - fullPath = output; - return true; - } - } - catch - { - // Ignore errors - } - - return false; - } - - private bool TryParseVersion(string version, out int major, out int minor) - { - major = 0; - minor = 0; - - try - { - var parts = version.Split('.'); - if (parts.Length >= 2) - { - return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); - } - } - catch - { - // Ignore parsing errors - } - - return false; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta deleted file mode 100644 index b43864a2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 12345678901234abcdef0123456789ab -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs deleted file mode 100644 index 56d7f191..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Dependencies.PlatformDetectors -{ - /// - /// Windows-specific dependency detection - /// - public class WindowsPlatformDetector : IPlatformDetector - { - public string PlatformName => "Windows"; - - public bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - - public DependencyStatus DetectPython() - { - var status = new DependencyStatus("Python", isRequired: true) - { - InstallationHint = GetPythonInstallUrl() - }; - - try - { - // Check common Python installation paths - var candidates = new[] - { - "python.exe", - "python3.exe", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python313", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python312", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python311", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python313", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python312", "python.exe") - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } - } - - // Try PATH resolution using 'where' command - if (TryFindInPath("python.exe", out string pathResult) || - TryFindInPath("python3.exe", out pathResult)) - { - if (TryValidatePython(pathResult, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; - return status; - } - } - - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths and PATH environment variable."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting Python: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectUV() - { - var status = new DependencyStatus("UV Package Manager", isRequired: true) - { - InstallationHint = GetUVInstallUrl() - }; - - try - { - // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) - { - if (TryValidateUV(uvPath, out string version)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; - return status; - } - } - - status.ErrorMessage = "UV package manager not found. Please install UV."; - status.Details = "UV is required for managing Python dependencies."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting UV: {ex.Message}"; - } - - return status; - } - - public DependencyStatus DetectMCPServer() - { - var status = new DependencyStatus("MCP Server", isRequired: false); - - try - { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) - { - status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) - { - status.Version = File.ReadAllText(versionFile).Trim(); - } - - status.Details = $"MCP Server found at {serverPath}"; - } - else - { - // Check for embedded server - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) - { - status.IsAvailable = true; - status.Path = embeddedPath; - status.Details = "MCP Server available (embedded in package)"; - } - else - { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; - } - } - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; - } - - return status; - } - - public string GetInstallationRecommendations() - { - return @"Windows Installation Recommendations: - -1. Python: Install from Microsoft Store or python.org - - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' - - Direct download: https://python.org/downloads/windows/ - -2. UV Package Manager: Install via PowerShell - - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" - - Or download from: https://github.com/astral-sh/uv/releases - -3. MCP Server: Will be installed automatically by Unity MCP Bridge"; - } - - public string GetPythonInstallUrl() - { - return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; - } - - public string GetUVInstallUrl() - { - return "https://docs.astral.sh/uv/getting-started/installation/#windows"; - } - - private bool TryValidatePython(string pythonPath, out string version, out string fullPath) - { - version = null; - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = pythonPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("Python ")) - { - version = output.Substring(7); // Remove "Python " prefix - fullPath = pythonPath; - - // Validate minimum version (3.10+) - if (TryParseVersion(version, out var major, out var minor)) - { - return major >= 3 && minor >= 10; - } - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryValidateUV(string uvPath, out string version) - { - version = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3); // Remove "uv " prefix - return true; - } - } - catch - { - // Ignore validation errors - } - - return false; - } - - private bool TryFindInPath(string executable, out string fullPath) - { - fullPath = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(3000); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - // Take the first result - var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > 0) - { - fullPath = lines[0].Trim(); - return File.Exists(fullPath); - } - } - } - catch - { - // Ignore errors - } - - return false; - } - - private bool TryParseVersion(string version, out int major, out int minor) - { - major = 0; - minor = 0; - - try - { - var parts = version.Split('.'); - if (parts.Length >= 2) - { - return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); - } - } - catch - { - // Ignore parsing errors - } - - return false; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta deleted file mode 100644 index e7e53d7d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 012345678901234abcdef0123456789a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers.meta deleted file mode 100644 index c57a3420..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 94cb070dc5e15024da86150b27699ca0 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs deleted file mode 100644 index 5889e4f6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ConfigJsonBuilder - { - public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) - { - var root = new JObject(); - bool isVSCode = client?.mcpType == McpTypes.VSCode; - JObject container; - if (isVSCode) - { - container = EnsureObject(root, "servers"); - } - else - { - container = EnsureObject(root, "mcpServers"); - } - - var unity = new JObject(); - PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); - - container["unityMCP"] = unity; - - return root.ToString(Formatting.Indented); - } - - public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) - { - if (root == null) root = new JObject(); - bool isVSCode = client?.mcpType == McpTypes.VSCode; - JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); - JObject unity = container["unityMCP"] as JObject ?? new JObject(); - PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); - - container["unityMCP"] = unity; - return root; - } - - /// - /// Centralized builder that applies all caveats consistently. - /// - Sets command/args with provided directory - /// - Ensures env exists - /// - Adds type:"stdio" for VSCode - /// - Adds disabled:false for Windsurf/Kiro only when missing - /// - private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) - { - unity["command"] = uvPath; - - // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners - string effectiveDir = directory; -#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX - bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); - if (isCursor && !string.IsNullOrEmpty(directory)) - { - // Replace canonical path segment with the symlink path if present - const string canonical = "/Library/Application Support/"; - const string symlinkSeg = "/Library/AppSupport/"; - try - { - // Normalize to full path style - if (directory.Contains(canonical)) - { - var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - else - { - // If installer returned XDG-style on macOS, map to canonical symlink - string norm = directory.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); - if (idx >= 0) - { - string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - } - } - catch { /* fallback to original directory on any error */ } - } -#endif - - unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); - - if (isVSCode) - { - unity["type"] = "stdio"; - } - else - { - // Remove type if it somehow exists from previous clients - if (unity["type"] != null) unity.Remove("type"); - } - - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) - { - if (unity["env"] == null) - { - unity["env"] = new JObject(); - } - - if (unity["disabled"] == null) - { - unity["disabled"] = false; - } - } - } - - private static JObject EnsureObject(JObject parent, string name) - { - if (parent[name] is JObject o) return o; - var created = new JObject(); - parent[name] = created; - return created; - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta deleted file mode 100644 index f574fde7..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5c07c3369f73943919d9e086a81d1dcc -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs deleted file mode 100644 index 5130a21c..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ /dev/null @@ -1,280 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Runtime.InteropServices; -using UnityEditor; - -namespace MCPForUnity.Editor.Helpers -{ - internal static class ExecPath - { - private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; - - // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. - internal static string ResolveClaude() - { - try - { - string pref = EditorPrefs.GetString(PrefClaude, string.Empty); - if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; - } - catch { } - - string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); - if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - Path.Combine(home, ".local", "bin", "claude"), - }; - foreach (string c in candidates) { if (File.Exists(c)) return c; } - // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude - string nvmClaude = ResolveClaudeFromNvm(home); - if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; -#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX - return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); -#else - return null; -#endif - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { -#if UNITY_EDITOR_WIN - // Common npm global locations - string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - string[] candidates = - { - // Prefer .cmd (most reliable from non-interactive processes) - Path.Combine(appData, "npm", "claude.cmd"), - Path.Combine(localAppData, "npm", "claude.cmd"), - // Fall back to PowerShell shim if only .ps1 is present - Path.Combine(appData, "npm", "claude.ps1"), - Path.Combine(localAppData, "npm", "claude.ps1"), - }; - foreach (string c in candidates) { if (File.Exists(c)) return c; } - string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); - if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; -#endif - return null; - } - - // Linux - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/usr/local/bin/claude", - "/usr/bin/claude", - Path.Combine(home, ".local", "bin", "claude"), - }; - foreach (string c in candidates) { if (File.Exists(c)) return c; } - // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude - string nvmClaude = ResolveClaudeFromNvm(home); - if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; -#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX - return Which("claude", "/usr/local/bin:/usr/bin:/bin"); -#else - return null; -#endif - } - } - - // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version - private static string ResolveClaudeFromNvm(string home) - { - try - { - if (string.IsNullOrEmpty(home)) return null; - string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); - if (!Directory.Exists(nvmNodeDir)) return null; - - string bestPath = null; - Version bestVersion = null; - foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) - { - string name = Path.GetFileName(versionDir); - if (string.IsNullOrEmpty(name)) continue; - if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - { - // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 - string versionStr = name.Substring(1); - int dashIndex = versionStr.IndexOf('-'); - if (dashIndex > 0) - { - versionStr = versionStr.Substring(0, dashIndex); - } - if (Version.TryParse(versionStr, out Version parsed)) - { - string candidate = Path.Combine(versionDir, "bin", "claude"); - if (File.Exists(candidate)) - { - if (bestVersion == null || parsed > bestVersion) - { - bestVersion = parsed; - bestPath = candidate; - } - } - } - } - } - return bestPath; - } - catch { return null; } - } - - // Explicitly set the Claude CLI absolute path override in EditorPrefs - internal static void SetClaudeCliPath(string absolutePath) - { - try - { - if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) - { - EditorPrefs.SetString(PrefClaude, absolutePath); - } - } - catch { } - } - - // Clear any previously set Claude CLI override path - internal static void ClearClaudeCliPath() - { - try - { - if (EditorPrefs.HasKey(PrefClaude)) - { - EditorPrefs.DeleteKey(PrefClaude); - } - } - catch { } - } - - // Use existing UV resolver; returns absolute path or null. - internal static string ResolveUv() - { - return ServerInstaller.FindUvPath(); - } - - internal static bool TryRun( - string file, - string args, - string workingDir, - out string stdout, - out string stderr, - int timeoutMs = 15000, - string extraPathPrepend = null) - { - stdout = string.Empty; - stderr = string.Empty; - try - { - // Handle PowerShell scripts on Windows by invoking through powershell.exe - bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && - file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); - - var psi = new ProcessStartInfo - { - FileName = isPs1 ? "powershell.exe" : file, - Arguments = isPs1 - ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() - : args, - WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - if (!string.IsNullOrEmpty(extraPathPrepend)) - { - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) - ? extraPathPrepend - : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); - } - - using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; - - var so = new StringBuilder(); - var se = new StringBuilder(); - process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; - - if (!process.Start()) return false; - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - if (!process.WaitForExit(timeoutMs)) - { - try { process.Kill(); } catch { } - return false; - } - - // Ensure async buffers are flushed - process.WaitForExit(); - - stdout = so.ToString(); - stderr = se.ToString(); - return process.ExitCode == 0; - } - catch - { - return false; - } - } - -#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX - private static string Which(string exe, string prependPath) - { - try - { - var psi = new ProcessStartInfo("/usr/bin/which", exe) - { - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; - string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); - using var p = Process.Start(psi); - string output = p?.StandardOutput.ReadToEnd().Trim(); - p?.WaitForExit(1500); - return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; - } - catch { return null; } - } -#endif - -#if UNITY_EDITOR_WIN - private static string Where(string exe) - { - try - { - var psi = new ProcessStartInfo("where", exe) - { - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; - using var p = Process.Start(psi); - string first = p?.StandardOutput.ReadToEnd() - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(); - p?.WaitForExit(1500); - return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; - } - catch { return null; } - } -#endif - } -} - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta deleted file mode 100644 index aba921ed..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs deleted file mode 100644 index b143f487..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs +++ /dev/null @@ -1,527 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Runtime.Serialization; // For Converters - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Handles serialization of GameObjects and Components for MCP responses. - /// Includes reflection helpers and caching for performance. - /// - public static class GameObjectSerializer - { - // --- Data Serialization --- - - /// - /// Creates a serializable representation of a GameObject. - /// - public static object GetGameObjectData(GameObject go) - { - if (go == null) - return null; - return new - { - name = go.name, - instanceID = go.GetInstanceID(), - tag = go.tag, - layer = go.layer, - activeSelf = go.activeSelf, - activeInHierarchy = go.activeInHierarchy, - isStatic = go.isStatic, - scenePath = go.scene.path, // Identify which scene it belongs to - transform = new // Serialize transform components carefully to avoid JSON issues - { - // Serialize Vector3 components individually to prevent self-referencing loops. - // The default serializer can struggle with properties like Vector3.normalized. - position = new - { - x = go.transform.position.x, - y = go.transform.position.y, - z = go.transform.position.z, - }, - localPosition = new - { - x = go.transform.localPosition.x, - y = go.transform.localPosition.y, - z = go.transform.localPosition.z, - }, - rotation = new - { - x = go.transform.rotation.eulerAngles.x, - y = go.transform.rotation.eulerAngles.y, - z = go.transform.rotation.eulerAngles.z, - }, - localRotation = new - { - x = go.transform.localRotation.eulerAngles.x, - y = go.transform.localRotation.eulerAngles.y, - z = go.transform.localRotation.eulerAngles.z, - }, - scale = new - { - x = go.transform.localScale.x, - y = go.transform.localScale.y, - z = go.transform.localScale.z, - }, - forward = new - { - x = go.transform.forward.x, - y = go.transform.forward.y, - z = go.transform.forward.z, - }, - up = new - { - x = go.transform.up.x, - y = go.transform.up.y, - z = go.transform.up.z, - }, - right = new - { - x = go.transform.right.x, - y = go.transform.right.y, - z = go.transform.right.z, - }, - }, - parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent - // Optionally include components, but can be large - // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() - // Or just component names: - componentNames = go.GetComponents() - .Select(c => c.GetType().FullName) - .ToList(), - }; - } - - // --- Metadata Caching for Reflection --- - private class CachedMetadata - { - public readonly List SerializableProperties; - public readonly List SerializableFields; - - public CachedMetadata(List properties, List fields) - { - SerializableProperties = properties; - SerializableFields = fields; - } - } - // Key becomes Tuple - private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); - // --- End Metadata Caching --- - - /// - /// Creates a serializable representation of a Component, attempting to serialize - /// public properties and fields using reflection, with caching and control over non-public fields. - /// - // Add the flag parameter here - public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) - { - // --- Add Early Logging --- - // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); - // --- End Early Logging --- - - if (c == null) return null; - Type componentType = c.GetType(); - - // --- Special handling for Transform to avoid reflection crashes and problematic properties --- - if (componentType == typeof(Transform)) - { - Transform tr = c as Transform; - // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); - return new Dictionary - { - { "typeName", componentType.FullName }, - { "instanceID", tr.GetInstanceID() }, - // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. - { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, // Use Euler angles - { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject() ?? new JObject() }, - { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, - { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, - { "childCount", tr.childCount }, - // Include standard Object/Component properties - { "name", tr.name }, - { "tag", tr.tag }, - { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } - }; - } - // --- End Special handling for Transform --- - - // --- Special handling for Camera to avoid matrix-related crashes --- - if (componentType == typeof(Camera)) - { - Camera cam = c as Camera; - var cameraProperties = new Dictionary(); - - // List of safe properties to serialize - var safeProperties = new Dictionary> - { - { "nearClipPlane", () => cam.nearClipPlane }, - { "farClipPlane", () => cam.farClipPlane }, - { "fieldOfView", () => cam.fieldOfView }, - { "renderingPath", () => (int)cam.renderingPath }, - { "actualRenderingPath", () => (int)cam.actualRenderingPath }, - { "allowHDR", () => cam.allowHDR }, - { "allowMSAA", () => cam.allowMSAA }, - { "allowDynamicResolution", () => cam.allowDynamicResolution }, - { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, - { "orthographicSize", () => cam.orthographicSize }, - { "orthographic", () => cam.orthographic }, - { "opaqueSortMode", () => (int)cam.opaqueSortMode }, - { "transparencySortMode", () => (int)cam.transparencySortMode }, - { "depth", () => cam.depth }, - { "aspect", () => cam.aspect }, - { "cullingMask", () => cam.cullingMask }, - { "eventMask", () => cam.eventMask }, - { "backgroundColor", () => cam.backgroundColor }, - { "clearFlags", () => (int)cam.clearFlags }, - { "stereoEnabled", () => cam.stereoEnabled }, - { "stereoSeparation", () => cam.stereoSeparation }, - { "stereoConvergence", () => cam.stereoConvergence }, - { "enabled", () => cam.enabled }, - { "name", () => cam.name }, - { "tag", () => cam.tag }, - { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } - }; - - foreach (var prop in safeProperties) - { - try - { - var value = prop.Value(); - if (value != null) - { - AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); - } - } - catch (Exception) - { - // Silently skip any property that fails - continue; - } - } - - return new Dictionary - { - { "typeName", componentType.FullName }, - { "instanceID", cam.GetInstanceID() }, - { "properties", cameraProperties } - }; - } - // --- End Special handling for Camera --- - - var data = new Dictionary - { - { "typeName", componentType.FullName }, - { "instanceID", c.GetInstanceID() } - }; - - // --- Get Cached or Generate Metadata (using new cache key) --- - Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); - if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) - { - var propertiesToCache = new List(); - var fieldsToCache = new List(); - - // Traverse the hierarchy from the component type up to MonoBehaviour - Type currentType = componentType; - while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) - { - // Get properties declared only at the current type level - BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; - foreach (var propInfo in currentType.GetProperties(propFlags)) - { - // Basic filtering (readable, not indexer, not transform which is handled elsewhere) - if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; - // Add if not already added (handles overrides - keep the most derived version) - if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { - propertiesToCache.Add(propInfo); - } - } - - // Get fields declared only at the current type level (both public and non-public) - BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; - var declaredFields = currentType.GetFields(fieldFlags); - - // Process the declared Fields for caching - foreach (var fieldInfo in declaredFields) - { - if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields - - // Add if not already added (handles hiding - keep the most derived version) - if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; - - bool shouldInclude = false; - if (includeNonPublicSerializedFields) - { - // If TRUE, include Public OR NonPublic with [SerializeField] - shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); - } - else // includeNonPublicSerializedFields is FALSE - { - // If FALSE, include ONLY if it is explicitly Public. - shouldInclude = fieldInfo.IsPublic; - } - - if (shouldInclude) - { - fieldsToCache.Add(fieldInfo); - } - } - - // Move to the base type - currentType = currentType.BaseType; - } - // --- End Hierarchy Traversal --- - - cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); - _metadataCache[cacheKey] = cachedData; // Add to cache with combined key - } - // --- End Get Cached or Generate Metadata --- - - // --- Use cached metadata --- - var serializablePropertiesOutput = new Dictionary(); - - // --- Add Logging Before Property Loop --- - // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); - // --- End Logging Before Property Loop --- - - // Use cached properties - foreach (var propInfo in cachedData.SerializableProperties) - { - string propName = propInfo.Name; - - // --- Skip known obsolete/problematic Component shortcut properties --- - bool skipProperty = false; - if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || - propName == "light" || propName == "animation" || propName == "constantForce" || - propName == "renderer" || propName == "audio" || propName == "networkView" || - propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || - propName == "particleSystem" || - // Also skip potentially problematic Matrix properties prone to cycles/errors - propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") - { - // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log - skipProperty = true; - } - // --- End Skip Generic Properties --- - - // --- Skip specific potentially problematic Camera properties --- - if (componentType == typeof(Camera) && - (propName == "pixelRect" || - propName == "rect" || - propName == "cullingMatrix" || - propName == "useOcclusionCulling" || - propName == "worldToCameraMatrix" || - propName == "projectionMatrix" || - propName == "nonJitteredProjectionMatrix" || - propName == "previousViewProjectionMatrix" || - propName == "cameraToWorldMatrix")) - { - // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}"); - skipProperty = true; - } - // --- End Skip Camera Properties --- - - // --- Skip specific potentially problematic Transform properties --- - if (componentType == typeof(Transform) && - (propName == "lossyScale" || - propName == "rotation" || - propName == "worldToLocalMatrix" || - propName == "localToWorldMatrix")) - { - // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}"); - skipProperty = true; - } - // --- End Skip Transform Properties --- - - // Skip if flagged - if (skipProperty) - { - continue; - } - - try - { - // --- Add detailed logging --- - // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); - // --- End detailed logging --- - object value = propInfo.GetValue(c); - Type propType = propInfo.PropertyType; - AddSerializableValue(serializablePropertiesOutput, propName, propType, value); - } - catch (Exception) - { - // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); - } - } - - // --- Add Logging Before Field Loop --- - // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}..."); - // --- End Logging Before Field Loop --- - - // Use cached fields - foreach (var fieldInfo in cachedData.SerializableFields) - { - try - { - // --- Add detailed logging for fields --- - // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); - // --- End detailed logging for fields --- - object value = fieldInfo.GetValue(c); - string fieldName = fieldInfo.Name; - Type fieldType = fieldInfo.FieldType; - AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); - } - catch (Exception) - { - // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); - } - } - // --- End Use cached metadata --- - - if (serializablePropertiesOutput.Count > 0) - { - data["properties"] = serializablePropertiesOutput; - } - - return data; - } - - // Helper function to decide how to serialize different types - private static void AddSerializableValue(Dictionary dict, string name, Type type, object value) - { - // Simplified: Directly use CreateTokenFromValue which uses the serializer - if (value == null) - { - dict[name] = null; - return; - } - - try - { - // Use the helper that employs our custom serializer settings - JToken token = CreateTokenFromValue(value, type); - if (token != null) // Check if serialization succeeded in the helper - { - // Convert JToken back to a basic object structure for the dictionary - dict[name] = ConvertJTokenToPlainObject(token); - } - // If token is null, it means serialization failed and a warning was logged. - } - catch (Exception e) - { - // Catch potential errors during JToken conversion or addition to dictionary - Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); - } - } - - // Helper to convert JToken back to basic object structure - private static object ConvertJTokenToPlainObject(JToken token) - { - if (token == null) return null; - - switch (token.Type) - { - case JTokenType.Object: - var objDict = new Dictionary(); - foreach (var prop in ((JObject)token).Properties()) - { - objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); - } - return objDict; - - case JTokenType.Array: - var list = new List(); - foreach (var item in (JArray)token) - { - list.Add(ConvertJTokenToPlainObject(item)); - } - return list; - - case JTokenType.Integer: - return token.ToObject(); // Use long for safety - case JTokenType.Float: - return token.ToObject(); // Use double for safety - case JTokenType.String: - return token.ToObject(); - case JTokenType.Boolean: - return token.ToObject(); - case JTokenType.Date: - return token.ToObject(); - case JTokenType.Guid: - return token.ToObject(); - case JTokenType.Uri: - return token.ToObject(); - case JTokenType.TimeSpan: - return token.ToObject(); - case JTokenType.Bytes: - return token.ToObject(); - case JTokenType.Null: - return null; - case JTokenType.Undefined: - return null; // Treat undefined as null - - default: - // Fallback for simple value types not explicitly listed - if (token is JValue jValue && jValue.Value != null) - { - return jValue.Value; - } - // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null."); - return null; - } - } - - // --- Define custom JsonSerializerSettings for OUTPUT --- - private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings - { - Converters = new List - { - new Vector3Converter(), - new Vector2Converter(), - new QuaternionConverter(), - new ColorConverter(), - new RectConverter(), - new BoundsConverter(), - new UnityEngineObjectConverter() // Handles serialization of references - }, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed - }; - private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); - // --- End Define custom JsonSerializerSettings --- - - // Helper to create JToken using the output serializer - private static JToken CreateTokenFromValue(object value, Type type) - { - if (value == null) return JValue.CreateNull(); - - try - { - // Use the pre-configured OUTPUT serializer instance - return JToken.FromObject(value, _outputSerializer); - } - catch (JsonSerializationException e) - { - Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); - return null; // Indicate serialization failure - } - catch (Exception e) // Catch other unexpected errors - { - Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); - return null; // Indicate serialization failure - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta deleted file mode 100644 index 9eb69d04..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 64b8ff807bc9a401c82015cbafccffac -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs deleted file mode 100644 index 7e467187..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs +++ /dev/null @@ -1,33 +0,0 @@ -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - internal static class McpLog - { - private const string Prefix = "MCP-FOR-UNITY:"; - - private static bool IsDebugEnabled() - { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } - } - - public static void Info(string message, bool always = true) - { - if (!always && !IsDebugEnabled()) return; - Debug.Log($"{Prefix} {message}"); - } - - public static void Warn(string message) - { - Debug.LogWarning($"{Prefix} {message}"); - } - - public static void Error(string message) - { - Debug.LogError($"{Prefix} {message}"); - } - } -} - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta deleted file mode 100644 index b9e0fc38..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta +++ /dev/null @@ -1,13 +0,0 @@ -fileFormatVersion: 2 -guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs deleted file mode 100644 index d39685c2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs +++ /dev/null @@ -1,109 +0,0 @@ -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Auto-runs legacy/older install detection on package load/update (log-only). - /// Runs once per embedded server version using an EditorPrefs version-scoped key. - /// - [InitializeOnLoad] - public static class PackageDetector - { - private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; - - static PackageDetector() - { - try - { - string pkgVer = ReadPackageVersionOrFallback(); - string key = DetectOnceFlagKeyPrefix + pkgVer; - - // Always force-run if legacy roots exist or canonical install is missing - bool legacyPresent = LegacyRootsExist(); - bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); - - if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) - { - // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. - EditorApplication.delayCall += () => - { - string error = null; - System.Exception capturedEx = null; - try - { - // Ensure any UnityEditor API usage inside runs on the main thread - ServerInstaller.EnsureServerInstalled(); - } - catch (System.Exception ex) - { - error = ex.Message; - capturedEx = ex; - } - - // Unity APIs must stay on main thread - try { EditorPrefs.SetBool(key, true); } catch { } - // Ensure prefs cleanup happens on main thread - try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } - try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } - - if (!string.IsNullOrEmpty(error)) - { - Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); - // Alternatively: Debug.LogException(capturedEx); - } - }; - } - } - catch { /* ignore */ } - } - - private static string ReadEmbeddedVersionOrFallback() - { - try - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) - { - var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); - if (System.IO.File.Exists(p)) - return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); - } - } - catch { } - return "unknown"; - } - - private static string ReadPackageVersionOrFallback() - { - try - { - var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); - if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; - } - catch { } - // Fallback to embedded server version if package info unavailable - return ReadEmbeddedVersionOrFallback(); - } - - private static bool LegacyRootsExist() - { - try - { - string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] roots = - { - System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), - System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") - }; - foreach (var r in roots) - { - try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } - } - } - catch { } - return false; - } - } -} - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta deleted file mode 100644 index f1a5dbe4..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b82eaef548d164ca095f17db64d15af8 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs deleted file mode 100644 index be9f0a41..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs +++ /dev/null @@ -1,43 +0,0 @@ -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Handles automatic installation of the Python server when the package is first installed. - /// - [InitializeOnLoad] - public static class PackageInstaller - { - private const string InstallationFlagKey = "MCPForUnity.ServerInstalled"; - - static PackageInstaller() - { - // Check if this is the first time the package is loaded - if (!EditorPrefs.GetBool(InstallationFlagKey, false)) - { - // Schedule the installation for after Unity is fully loaded - EditorApplication.delayCall += InstallServerOnFirstLoad; - } - } - - private static void InstallServerOnFirstLoad() - { - try - { - Debug.Log("MCP-FOR-UNITY: Installing Python server..."); - ServerInstaller.EnsureServerInstalled(); - - // Mark as installed - EditorPrefs.SetBool(InstallationFlagKey, true); - - Debug.Log("MCP-FOR-UNITY: Python server installation completed successfully."); - } - catch (System.Exception ex) - { - Debug.LogError($"MCP-FOR-UNITY: Failed to install Python server: {ex.Message}"); - Debug.LogWarning("MCP-FOR-UNITY: You may need to manually install the Python server. Check the MCP for Unity Editor Window for instructions."); - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta deleted file mode 100644 index 156e75fb..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 19e6eaa637484e9fa19f9a0459809de2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs deleted file mode 100644 index f041ac23..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ /dev/null @@ -1,319 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using System.Net; -using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using Newtonsoft.Json; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Manages dynamic port allocation and persistent storage for MCP for Unity - /// - public static class PortManager - { - private static bool IsDebugEnabled() - { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } - catch { return false; } - } - - private const int DefaultPort = 6400; - private const int MaxPortAttempts = 100; - private const string RegistryFileName = "unity-mcp-port.json"; - - [Serializable] - public class PortConfig - { - public int unity_port; - public string created_date; - public string project_path; - } - - /// - /// Get the port to use - either from storage or discover a new one - /// Will try stored port first, then fallback to discovering new port - /// - /// Port number to use - public static int GetPortWithFallback() - { - // Try to load stored port first, but only if it's from the current project - var storedConfig = GetStoredPortConfig(); - if (storedConfig != null && - storedConfig.unity_port > 0 && - string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - IsPortAvailable(storedConfig.unity_port)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using stored port {storedConfig.unity_port} for current project"); - return storedConfig.unity_port; - } - - // If stored port exists but is currently busy, wait briefly for release - if (storedConfig != null && storedConfig.unity_port > 0) - { - if (WaitForPortRelease(storedConfig.unity_port, 1500)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} became available after short wait"); - return storedConfig.unity_port; - } - // Prefer sticking to the same port; let the caller handle bind retries/fallbacks - return storedConfig.unity_port; - } - - // If no valid stored port, find a new one and save it - int newPort = FindAvailablePort(); - SavePort(newPort); - return newPort; - } - - /// - /// Discover and save a new available port (used by Auto-Connect button) - /// - /// New available port - public static int DiscoverNewPort() - { - int newPort = FindAvailablePort(); - SavePort(newPort); - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Discovered and saved new port: {newPort}"); - return newPort; - } - - /// - /// Find an available port starting from the default port - /// - /// Available port number - private static int FindAvailablePort() - { - // Always try default port first - if (IsPortAvailable(DefaultPort)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using default port {DefaultPort}"); - return DefaultPort; - } - - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Default port {DefaultPort} is in use, searching for alternative..."); - - // Search for alternatives - for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) - { - if (IsPortAvailable(port)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Found available port {port}"); - return port; - } - } - - throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); - } - - /// - /// Check if a specific port is available for binding - /// - /// Port to check - /// True if port is available - public static bool IsPortAvailable(int port) - { - try - { - var testListener = new TcpListener(IPAddress.Loopback, port); - testListener.Start(); - testListener.Stop(); - return true; - } - catch (SocketException) - { - return false; - } - } - - /// - /// Check if a port is currently being used by MCP for Unity - /// This helps avoid unnecessary port changes when Unity itself is using the port - /// - /// Port to check - /// True if port appears to be used by MCP for Unity - public static bool IsPortUsedByMCPForUnity(int port) - { - try - { - // Try to make a quick connection to see if it's an MCP for Unity server - using var client = new TcpClient(); - var connectTask = client.ConnectAsync(IPAddress.Loopback, port); - if (connectTask.Wait(100)) // 100ms timeout - { - // If connection succeeded, it's likely the MCP for Unity server - return client.Connected; - } - return false; - } - catch - { - return false; - } - } - - /// - /// Wait for a port to become available for a limited amount of time. - /// Used to bridge the gap during domain reload when the old listener - /// hasn't released the socket yet. - /// - private static bool WaitForPortRelease(int port, int timeoutMs) - { - int waited = 0; - const int step = 100; - while (waited < timeoutMs) - { - if (IsPortAvailable(port)) - { - return true; - } - - // If the port is in use by an MCP instance, continue waiting briefly - if (!IsPortUsedByMCPForUnity(port)) - { - // In use by something else; don't keep waiting - return false; - } - - Thread.Sleep(step); - waited += step; - } - return IsPortAvailable(port); - } - - /// - /// Save port to persistent storage - /// - /// Port to save - private static void SavePort(int port) - { - try - { - var portConfig = new PortConfig - { - unity_port = port, - created_date = DateTime.UtcNow.ToString("O"), - project_path = Application.dataPath - }; - - string registryDir = GetRegistryDirectory(); - Directory.CreateDirectory(registryDir); - - string registryFile = GetRegistryFilePath(); - string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); - // Write to hashed, project-scoped file - File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); - // Also write to legacy stable filename to avoid hash/case drift across reloads - string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); - - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Saved port {port} to storage"); - } - catch (Exception ex) - { - Debug.LogWarning($"Could not save port to storage: {ex.Message}"); - } - } - - /// - /// Load port from persistent storage - /// - /// Stored port number, or 0 if not found - private static int LoadStoredPort() - { - try - { - string registryFile = GetRegistryFilePath(); - - if (!File.Exists(registryFile)) - { - // Backwards compatibility: try the legacy file name - string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - if (!File.Exists(legacy)) - { - return 0; - } - registryFile = legacy; - } - - string json = File.ReadAllText(registryFile); - var portConfig = JsonConvert.DeserializeObject(json); - - return portConfig?.unity_port ?? 0; - } - catch (Exception ex) - { - Debug.LogWarning($"Could not load port from storage: {ex.Message}"); - return 0; - } - } - - /// - /// Get the current stored port configuration - /// - /// Port configuration if exists, null otherwise - public static PortConfig GetStoredPortConfig() - { - try - { - string registryFile = GetRegistryFilePath(); - - if (!File.Exists(registryFile)) - { - // Backwards compatibility: try the legacy file - string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - if (!File.Exists(legacy)) - { - return null; - } - registryFile = legacy; - } - - string json = File.ReadAllText(registryFile); - return JsonConvert.DeserializeObject(json); - } - catch (Exception ex) - { - Debug.LogWarning($"Could not load port config: {ex.Message}"); - return null; - } - } - - private static string GetRegistryDirectory() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); - } - - private static string GetRegistryFilePath() - { - string dir = GetRegistryDirectory(); - string hash = ComputeProjectHash(Application.dataPath); - string fileName = $"unity-mcp-port-{hash}.json"; - return Path.Combine(dir, fileName); - } - - private static string ComputeProjectHash(string input) - { - try - { - using SHA1 sha1 = SHA1.Create(); - byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); - byte[] hashBytes = sha1.ComputeHash(bytes); - var sb = new StringBuilder(); - foreach (byte b in hashBytes) - { - sb.Append(b.ToString("x2")); - } - return sb.ToString()[..8]; // short, sufficient for filenames - } - catch - { - return "default"; - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta deleted file mode 100644 index ee3f667c..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a1b2c3d4e5f6789012345678901234ab -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs deleted file mode 100644 index 1a3bd520..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Provides static methods for creating standardized success and error response objects. - /// Ensures consistent JSON structure for communication back to the Python server. - /// - public static class Response - { - /// - /// Creates a standardized success response object. - /// - /// A message describing the successful operation. - /// Optional additional data to include in the response. - /// An object representing the success response. - public static object Success(string message, object data = null) - { - if (data != null) - { - return new - { - success = true, - message = message, - data = data, - }; - } - else - { - return new { success = true, message = message }; - } - } - - /// - /// Creates a standardized error response object. - /// - /// A message describing the error. - /// Optional additional data (e.g., error details) to include. - /// An object representing the error response. - public static object Error(string errorCodeOrMessage, object data = null) - { - if (data != null) - { - // Note: The key is "error" for error messages, not "message" - return new - { - success = false, - // Preserve original behavior while adding a machine-parsable code field. - // If callers pass a code string, it will be echoed in both code and error. - code = errorCodeOrMessage, - error = errorCodeOrMessage, - data = data, - }; - } - else - { - return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; - } - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs.meta deleted file mode 100644 index 6fd11e39..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Response.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 80c09a76b944f8c4691e06c4d76c4be8 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs deleted file mode 100644 index 56cf0952..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ /dev/null @@ -1,744 +0,0 @@ -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Collections.Generic; -using System.Linq; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerInstaller - { - private const string RootFolder = "UnityMCP"; - private const string ServerFolder = "UnityMcpServer"; - private const string VersionFileName = "server_version.txt"; - - /// - /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. - /// No network calls or Git operations are performed. - /// - public static void EnsureServerInstalled() - { - try - { - string saveLocation = GetSaveLocation(); - TryCreateMacSymlinkForAppSupport(); - string destRoot = Path.Combine(saveLocation, ServerFolder); - string destSrc = Path.Combine(destRoot, "src"); - - // Detect legacy installs and version state (logs) - DetectAndLogLegacyInstallStates(destRoot); - - // Resolve embedded source and versions - if (!TryGetEmbeddedServerSource(out string embeddedSrc)) - { - throw new Exception("Could not find embedded UnityMcpServer/src in the package."); - } - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; - string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); - - bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); - bool needOverwrite = !destHasServer - || string.IsNullOrEmpty(installedVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); - - // Ensure destination exists - Directory.CreateDirectory(destRoot); - - if (needOverwrite) - { - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); - // Write/refresh version file - try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } - McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); - } - - // Cleanup legacy installs that are missing version or older than embedded - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - try - { - string legacySrc = Path.Combine(legacyRoot, "src"); - if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - bool legacyOlder = string.IsNullOrEmpty(legacyVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); - if (legacyOlder) - { - TryKillUvForPath(legacySrc); - try - { - Directory.Delete(legacyRoot, recursive: true); - McpLog.Info($"Removed legacy server at '{legacyRoot}'."); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); - } - } - } - catch { } - } - - // Clear overrides that might point at legacy locations - try - { - EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); - EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); - } - catch { } - return; - } - catch (Exception ex) - { - // If a usable server is already present (installed or embedded), don't fail hard—just warn. - bool hasInstalled = false; - try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } - - if (hasInstalled || TryGetEmbeddedServerSource(out _)) - { - McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); - return; - } - - McpLog.Error($"Failed to ensure server installation: {ex.Message}"); - } - } - - public static string GetServerPath() - { - return Path.Combine(GetSaveLocation(), ServerFolder, "src"); - } - - /// - /// Gets the platform-specific save location for the server. - /// - private static string GetSaveLocation() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Use per-user LocalApplicationData for canonical install location - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) - { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, - ".local", "share"); - } - return Path.Combine(xdg, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On macOS, use LocalApplicationData (~/Library/Application Support) - var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support - bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); - if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) - { - // Fallback: construct from $HOME - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - localAppSupport = Path.Combine(home, "Library", "Application Support"); - } - TryCreateMacSymlinkForAppSupport(); - return Path.Combine(localAppSupport, RootFolder); - } - throw new Exception("Unsupported operating system."); - } - - /// - /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support - /// to mitigate arg parsing and quoting issues in some MCP clients. - /// Safe to call repeatedly. - /// - private static void TryCreateMacSymlinkForAppSupport() - { - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - if (string.IsNullOrEmpty(home)) return; - - string canonical = Path.Combine(home, "Library", "Application Support"); - string symlink = Path.Combine(home, "Library", "AppSupport"); - - // If symlink exists already, nothing to do - if (Directory.Exists(symlink) || File.Exists(symlink)) return; - - // Create symlink only if canonical exists - if (!Directory.Exists(canonical)) return; - - // Use 'ln -s' to create a directory symlink (macOS) - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "/bin/ln", - Arguments = $"-s \"{canonical}\" \"{symlink}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - p?.WaitForExit(2000); - } - catch { /* best-effort */ } - } - - private static bool IsDirectoryWritable(string path) - { - try - { - File.Create(Path.Combine(path, "test.txt")).Dispose(); - File.Delete(Path.Combine(path, "test.txt")); - return true; - } - catch - { - return false; - } - } - - /// - /// Checks if the server is installed at the specified location. - /// - private static bool IsServerInstalled(string location) - { - return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); - } - - /// - /// Detects legacy installs or older versions and logs findings (no deletion yet). - /// - private static void DetectAndLogLegacyInstallStates(string canonicalRoot) - { - try - { - string canonicalSrc = Path.Combine(canonicalRoot, "src"); - // Normalize canonical root for comparisons - string normCanonicalRoot = NormalizePathSafe(canonicalRoot); - string embeddedSrc = null; - TryGetEmbeddedServerSource(out embeddedSrc); - - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); - string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); - - // Legacy paths (macOS/Linux .config; Windows roaming as example) - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - // Skip logging for the canonical root itself - if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) - continue; - string legacySrc = Path.Combine(legacyRoot, "src"); - bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - - if (hasServer) - { - // Case 1: No version file - if (string.IsNullOrEmpty(legacyVer)) - { - McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); - } - - // Case 2: Lives in legacy path - McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); - - // Case 3: Has version but appears older than embedded - if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) - { - McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); - } - } - } - - // Also log if canonical is missing version (treated as older) - if (Directory.Exists(canonicalRoot)) - { - if (string.IsNullOrEmpty(installedVer)) - { - McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); - } - else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) - { - McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); - } - } - } - catch (Exception ex) - { - McpLog.Warn("Detect legacy/version state failed: " + ex.Message); - } - } - - private static string NormalizePathSafe(string path) - { - try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } - catch { return path; } - } - - private static bool PathsEqualSafe(string a, string b) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - string na = NormalizePathSafe(a); - string nb = NormalizePathSafe(b); - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - } - return string.Equals(na, nb, StringComparison.Ordinal); - } - catch { return false; } - } - - private static IEnumerable GetLegacyRootsForDetection() - { - var roots = new System.Collections.Generic.List(); - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - // macOS/Linux legacy - roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); - roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); - // Windows roaming example - try - { - string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(roaming)) - roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); - // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer - // Detect this location so we can clean up older copies during install/update. - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(localAppData)) - roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer")); - } - catch { } - return roots; - } - - private static void TryKillUvForPath(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "/usr/bin/pgrep", - Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return; - string outp = p.StandardOutput.ReadToEnd(); - p.WaitForExit(1500); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) - { - foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) - { - if (int.TryParse(line.Trim(), out int pid)) - { - try { System.Diagnostics.Process.GetProcessById(pid).Kill(); } catch { } - } - } - } - } - catch { } - } - - private static string ReadVersionFile(string path) - { - try - { - if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; - string v = File.ReadAllText(path).Trim(); - return string.IsNullOrEmpty(v) ? null : v; - } - catch { return null; } - } - - private static int CompareSemverSafe(string a, string b) - { - try - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; - var ap = a.Split('.'); - var bp = b.Split('.'); - for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) - { - int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; - int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; - if (ai != bi) return ai.CompareTo(bi); - } - return 0; - } - catch { return 0; } - } - - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. - /// - private static bool TryGetEmbeddedServerSource(out string srcPath) - { - return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); - } - - private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; - private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) - { - Directory.CreateDirectory(destinationDir); - - foreach (string filePath in Directory.GetFiles(sourceDir)) - { - string fileName = Path.GetFileName(filePath); - string destFile = Path.Combine(destinationDir, fileName); - File.Copy(filePath, destFile, overwrite: true); - } - - foreach (string dirPath in Directory.GetDirectories(sourceDir)) - { - string dirName = Path.GetFileName(dirPath); - foreach (var skip in _skipDirs) - { - if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) - goto NextDir; - } - try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } - string destSubDir = Path.Combine(destinationDir, dirName); - CopyDirectoryRecursive(dirPath, destSubDir); - NextDir: ; - } - } - - public static bool RepairPythonEnvironment() - { - try - { - string serverSrc = GetServerPath(); - bool hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); - if (!hasServer) - { - // In dev mode or if not installed yet, try the embedded/dev source - if (TryGetEmbeddedServerSource(out string embeddedSrc) && File.Exists(Path.Combine(embeddedSrc, "server.py"))) - { - serverSrc = embeddedSrc; - hasServer = true; - } - else - { - // Attempt to install then retry - EnsureServerInstalled(); - serverSrc = GetServerPath(); - hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); - } - } - - if (!hasServer) - { - Debug.LogWarning("RepairPythonEnvironment: server.py not found; ensure server is installed first."); - return false; - } - - // Remove stale venv and pinned version file if present - string venvPath = Path.Combine(serverSrc, ".venv"); - if (Directory.Exists(venvPath)) - { - try { Directory.Delete(venvPath, recursive: true); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .venv: {ex.Message}"); } - } - string pyPin = Path.Combine(serverSrc, ".python-version"); - if (File.Exists(pyPin)) - { - try { File.Delete(pyPin); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .python-version: {ex.Message}"); } - } - - string uvPath = FindUvPath(); - if (uvPath == null) - { - Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); - return false; - } - - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = uvPath, - Arguments = "sync", - WorkingDirectory = serverSrc, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var proc = new System.Diagnostics.Process { StartInfo = psi }; - var sbOut = new StringBuilder(); - var sbErr = new StringBuilder(); - proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; - proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; - - if (!proc.Start()) - { - Debug.LogError("Failed to start uv process."); - return false; - } - - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - - if (!proc.WaitForExit(60000)) - { - try { proc.Kill(); } catch { } - Debug.LogError("uv sync timed out."); - return false; - } - - // Ensure async buffers flushed - proc.WaitForExit(); - - string stdout = sbOut.ToString(); - string stderr = sbErr.ToString(); - - if (proc.ExitCode != 0) - { - Debug.LogError($"uv sync failed: {stderr}\n{stdout}"); - return false; - } - - Debug.Log("MCP-FOR-UNITY: Python environment repaired successfully."); - return true; - } - catch (Exception ex) - { - Debug.LogError($"RepairPythonEnvironment failed: {ex.Message}"); - return false; - } - } - - internal static string FindUvPath() - { - // Allow user override via EditorPrefs - try - { - string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - if (ValidateUvBinary(overridePath)) return overridePath; - } - } - catch { } - - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - - // Platform-specific candidate lists - string[] candidates; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; - string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - - // Fast path: resolve from PATH first - try - { - var wherePsi = new System.Diagnostics.ProcessStartInfo - { - FileName = "where", - Arguments = "uv.exe", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = System.Diagnostics.Process.Start(wherePsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(1500); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - string path = line.Trim(); - if (File.Exists(path) && ValidateUvBinary(path)) return path; - } - } - } - catch { } - - // Windows Store (PythonSoftwareFoundation) install location probe - // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe - try - { - string pkgsRoot = Path.Combine(localAppData, "Packages"); - if (Directory.Exists(pkgsRoot)) - { - var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly) - .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase); - foreach (var pkg in pythonPkgs) - { - string localCache = Path.Combine(pkg, "LocalCache", "local-packages"); - if (!Directory.Exists(localCache)) continue; - var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly) - .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase); - foreach (var pyRoot in pyRoots) - { - string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe"); - if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe; - } - } - } - } - catch { } - - candidates = new[] - { - // Preferred: WinGet Links shims (stable entrypoints) - // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) - Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), - Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), - - // Common per-user installs - Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), - - // Program Files style installs (if a native installer was used) - Path.Combine(programFiles, @"uv\uv.exe"), - - // Try simple name resolution later via PATH - "uv.exe", - "uv" - }; - } - else - { - candidates = new[] - { - "/opt/homebrew/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - "/opt/local/bin/uv", - Path.Combine(home, ".local", "bin", "uv"), - "/opt/homebrew/opt/uv/bin/uv", - // Framework Python installs - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", - // Fallback to PATH resolution by name - "uv" - }; - } - - foreach (string c in candidates) - { - try - { - if (File.Exists(c) && ValidateUvBinary(c)) return c; - } - catch { /* ignore */ } - } - - // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var whichPsi = new System.Diagnostics.ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - try - { - // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string prepend = string.Join(":", new[] - { - System.IO.Path.Combine(homeDir, ".local", "bin"), - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin" - }); - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); - } - catch { } - using var wp = System.Diagnostics.Process.Start(whichPsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - if (ValidateUvBinary(output)) return output; - } - } - } - catch { } - - // Manual PATH scan - try - { - string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - string[] parts = pathEnv.Split(Path.PathSeparator); - foreach (string part in parts) - { - try - { - // Check both uv and uv.exe - string candidateUv = Path.Combine(part, "uv"); - string candidateUvExe = Path.Combine(part, "uv.exe"); - if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; - if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; - } - catch { } - } - } - catch { } - - return null; - } - - private static bool ValidateUvBinary(string uvPath) - { - try - { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode == 0) - { - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - } - catch { } - return false; - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta deleted file mode 100644 index dfd9023b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5862c6a6d0a914f4d83224f8d039cf7b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs deleted file mode 100644 index 5684b19a..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerPathResolver - { - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. Returns true if found and sets srcPath to the folder - /// containing server.py. - /// - public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLegacyPackageId = true) - { - // 1) Repo development layouts commonly used alongside this package - try - { - string projectRoot = Path.GetDirectoryName(Application.dataPath); - string[] devCandidates = - { - Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), - }; - foreach (string candidate in devCandidates) - { - string full = Path.GetFullPath(candidate); - if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) - { - srcPath = full; - return true; - } - } - } - catch { /* ignore */ } - - // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. - try - { -#if UNITY_2021_2_OR_NEWER - // Primary: the package that owns this assembly - var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); - if (owner != null) - { - if (TryResolveWithinPackage(owner, out srcPath, warnOnLegacyPackageId)) - { - return true; - } - } - - // Secondary: scan all registered packages locally - foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) - { - if (TryResolveWithinPackage(p, out srcPath, warnOnLegacyPackageId)) - { - return true; - } - } -#else - // Older Unity versions: use Package Manager Client.List as a fallback - var list = UnityEditor.PackageManager.Client.List(); - while (!list.IsCompleted) { } - if (list.Status == UnityEditor.PackageManager.StatusCode.Success) - { - foreach (var pkg in list.Result) - { - if (TryResolveWithinPackage(pkg, out srcPath, warnOnLegacyPackageId)) - { - return true; - } - } - } -#endif - } - catch { /* ignore */ } - - // 3) Fallback to previous common install locations - try - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), - }; - foreach (string candidate in candidates) - { - if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) - { - srcPath = candidate; - return true; - } - } - } - catch { /* ignore */ } - - srcPath = null; - return false; - } - - private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath, bool warnOnLegacyPackageId) - { - const string CurrentId = "com.coplaydev.unity-mcp"; - const string LegacyId = "com.justinpbarnett.unity-mcp"; - - srcPath = null; - if (p == null || (p.name != CurrentId && p.name != LegacyId)) - { - return false; - } - - if (warnOnLegacyPackageId && p.name == LegacyId) - { - Debug.LogWarning( - "MCP for Unity: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " + - "Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage."); - } - - string packagePath = p.resolvedPath; - - // Preferred tilde folder (embedded but excluded from import) - string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); - if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) - { - srcPath = embeddedTilde; - return true; - } - - // Legacy non-tilde folder - string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); - if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) - { - srcPath = embedded; - return true; - } - - // Dev-linked sibling of the package folder - string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); - if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) - { - srcPath = sibling; - return true; - } - - return false; - } - } -} - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta deleted file mode 100644 index d02df608..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs deleted file mode 100644 index 4e068e99..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Unity Bridge telemetry helper for collecting usage analytics - /// Following privacy-first approach with easy opt-out mechanisms - /// - public static class TelemetryHelper - { - private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; - private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; - private static Action> s_sender; - - /// - /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) - /// - public static bool IsEnabled - { - get - { - // Check environment variables first - var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); - if (!string.IsNullOrEmpty(envDisable) && - (envDisable.ToLower() == "true" || envDisable == "1")) - { - return false; - } - - var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); - if (!string.IsNullOrEmpty(unityMcpDisable) && - (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) - { - return false; - } - - // Honor protocol-wide opt-out as well - var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); - if (!string.IsNullOrEmpty(mcpDisable) && - (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) - { - return false; - } - - // Check EditorPrefs - return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); - } - } - - /// - /// Get or generate customer UUID for anonymous tracking - /// - public static string GetCustomerUUID() - { - var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); - if (string.IsNullOrEmpty(uuid)) - { - uuid = System.Guid.NewGuid().ToString(); - UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); - } - return uuid; - } - - /// - /// Disable telemetry (stored in EditorPrefs) - /// - public static void DisableTelemetry() - { - UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); - } - - /// - /// Enable telemetry (stored in EditorPrefs) - /// - public static void EnableTelemetry() - { - UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); - } - - /// - /// Send telemetry data to Python server for processing - /// This is a lightweight bridge - the actual telemetry logic is in Python - /// - public static void RecordEvent(string eventType, Dictionary data = null) - { - if (!IsEnabled) - return; - - try - { - var telemetryData = new Dictionary - { - ["event_type"] = eventType, - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - ["customer_uuid"] = GetCustomerUUID(), - ["unity_version"] = Application.unityVersion, - ["platform"] = Application.platform.ToString(), - ["source"] = "unity_bridge" - }; - - if (data != null) - { - telemetryData["data"] = data; - } - - // Send to Python server via existing bridge communication - // The Python server will handle actual telemetry transmission - SendTelemetryToPythonServer(telemetryData); - } - catch (Exception e) - { - // Never let telemetry errors interfere with functionality - if (IsDebugEnabled()) - { - Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}"); - } - } - } - - /// - /// Allows the bridge to register a concrete sender for telemetry payloads. - /// - public static void RegisterTelemetrySender(Action> sender) - { - Interlocked.Exchange(ref s_sender, sender); - } - - public static void UnregisterTelemetrySender() - { - Interlocked.Exchange(ref s_sender, null); - } - - /// - /// Record bridge startup event - /// - public static void RecordBridgeStartup() - { - RecordEvent("bridge_startup", new Dictionary - { - ["bridge_version"] = "3.0.2", - ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() - }); - } - - /// - /// Record bridge connection event - /// - public static void RecordBridgeConnection(bool success, string error = null) - { - var data = new Dictionary - { - ["success"] = success - }; - - if (!string.IsNullOrEmpty(error)) - { - data["error"] = error.Substring(0, Math.Min(200, error.Length)); - } - - RecordEvent("bridge_connection", data); - } - - /// - /// Record tool execution from Unity side - /// - public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) - { - var data = new Dictionary - { - ["tool_name"] = toolName, - ["success"] = success, - ["duration_ms"] = Math.Round(durationMs, 2) - }; - - if (!string.IsNullOrEmpty(error)) - { - data["error"] = error.Substring(0, Math.Min(200, error.Length)); - } - - RecordEvent("tool_execution_unity", data); - } - - private static void SendTelemetryToPythonServer(Dictionary telemetryData) - { - var sender = Volatile.Read(ref s_sender); - if (sender != null) - { - try - { - sender(telemetryData); - return; - } - catch (Exception e) - { - if (IsDebugEnabled()) - { - Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}"); - } - } - } - - // Fallback: log when debug is enabled - if (IsDebugEnabled()) - { - Debug.Log($"MCP-TELEMETRY: {telemetryData["event_type"]}"); - } - } - - private static bool IsDebugEnabled() - { - try - { - return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); - } - catch - { - return false; - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs.meta deleted file mode 100644 index d7fd7b1f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs deleted file mode 100644 index 1075a199..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Newtonsoft.Json.Linq; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Helper class for Vector3 operations - /// - public static class Vector3Helper - { - /// - /// Parses a JArray into a Vector3 - /// - /// The array containing x, y, z coordinates - /// A Vector3 with the parsed coordinates - /// Thrown when array is invalid - public static Vector3 ParseVector3(JArray array) - { - if (array == null || array.Count != 3) - throw new System.Exception("Vector3 must be an array of 3 floats [x, y, z]."); - return new Vector3((float)array[0], (float)array[1], (float)array[2]); - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta deleted file mode 100644 index 280381ca..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: f8514fd42f23cb641a36e52550825b35 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation.meta deleted file mode 100644 index aa2326b5..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e5f6789012345678901234abcdef0123 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs deleted file mode 100644 index 36f72a8b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; -using UnityEngine; - -namespace MCPForUnity.Editor.Installation -{ - /// - /// Orchestrates the installation of missing dependencies - /// - public class InstallationOrchestrator - { - public event Action OnProgressUpdate; - public event Action OnInstallationComplete; - - private bool _isInstalling = false; - - /// - /// Start installation of missing dependencies - /// - public async void StartInstallation(List missingDependencies) - { - if (_isInstalling) - { - McpLog.Warn("Installation already in progress"); - return; - } - - _isInstalling = true; - - try - { - OnProgressUpdate?.Invoke("Starting installation process..."); - - bool allSuccessful = true; - string finalMessage = ""; - - foreach (var dependency in missingDependencies) - { - OnProgressUpdate?.Invoke($"Installing {dependency.Name}..."); - - bool success = await InstallDependency(dependency); - if (!success) - { - allSuccessful = false; - finalMessage += $"Failed to install {dependency.Name}. "; - } - else - { - finalMessage += $"Successfully installed {dependency.Name}. "; - } - } - - if (allSuccessful) - { - OnProgressUpdate?.Invoke("Installation completed successfully!"); - OnInstallationComplete?.Invoke(true, "All dependencies installed successfully."); - } - else - { - OnProgressUpdate?.Invoke("Installation completed with errors."); - OnInstallationComplete?.Invoke(false, finalMessage); - } - } - catch (Exception ex) - { - McpLog.Error($"Installation failed: {ex.Message}"); - OnInstallationComplete?.Invoke(false, $"Installation failed: {ex.Message}"); - } - finally - { - _isInstalling = false; - } - } - - /// - /// Install a specific dependency - /// - private async Task InstallDependency(DependencyStatus dependency) - { - try - { - switch (dependency.Name) - { - case "Python": - return await InstallPython(); - - case "UV Package Manager": - return await InstallUV(); - - case "MCP Server": - return await InstallMCPServer(); - - default: - McpLog.Warn($"Unknown dependency: {dependency.Name}"); - return false; - } - } - catch (Exception ex) - { - McpLog.Error($"Error installing {dependency.Name}: {ex.Message}"); - return false; - } - } - - /// - /// Attempt to install Python (limited automatic options) - /// - private async Task InstallPython() - { - OnProgressUpdate?.Invoke("Python installation requires manual intervention..."); - - // For Asset Store compliance, we cannot automatically install Python - // We can only guide the user to install it manually - await Task.Delay(1000); // Simulate some work - - OnProgressUpdate?.Invoke("Python must be installed manually. Please visit the installation URL provided."); - return false; // Always return false since we can't auto-install - } - - /// - /// Attempt to install UV package manager - /// - private async Task InstallUV() - { - OnProgressUpdate?.Invoke("UV installation requires manual intervention..."); - - // For Asset Store compliance, we cannot automatically install UV - // We can only guide the user to install it manually - await Task.Delay(1000); // Simulate some work - - OnProgressUpdate?.Invoke("UV must be installed manually. Please visit the installation URL provided."); - return false; // Always return false since we can't auto-install - } - - /// - /// Install MCP Server (this we can do automatically) - /// - private async Task InstallMCPServer() - { - try - { - OnProgressUpdate?.Invoke("Installing MCP Server..."); - - // Run server installation on a background thread - bool success = await Task.Run(() => - { - try - { - ServerInstaller.EnsureServerInstalled(); - return true; - } - catch (Exception ex) - { - McpLog.Error($"Server installation failed: {ex.Message}"); - return false; - } - }); - - if (success) - { - OnProgressUpdate?.Invoke("MCP Server installed successfully."); - return true; - } - else - { - OnProgressUpdate?.Invoke("MCP Server installation failed."); - return false; - } - } - catch (Exception ex) - { - McpLog.Error($"Error during MCP Server installation: {ex.Message}"); - OnProgressUpdate?.Invoke($"MCP Server installation error: {ex.Message}"); - return false; - } - } - - /// - /// Check if installation is currently in progress - /// - public bool IsInstalling => _isInstalling; - - /// - /// Cancel ongoing installation (if possible) - /// - public void CancelInstallation() - { - if (_isInstalling) - { - OnProgressUpdate?.Invoke("Cancelling installation..."); - _isInstalling = false; - OnInstallationComplete?.Invoke(false, "Installation cancelled by user."); - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs.meta deleted file mode 100644 index 314cc262..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Installation/InstallationOrchestrator.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5678901234abcdef0123456789abcdef -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef deleted file mode 100644 index 88448922..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "MCPForUnity.Editor", - "rootNamespace": "MCPForUnity.Editor", - "references": [ - "MCPForUnity.Runtime", - "GUID:560b04d1a97f54a46a2660c3cc343a6f" - ], - "includePlatforms": [ - "Editor" - ], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef.meta deleted file mode 100644 index b819bd4d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnity.Editor.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 98f702da6ca044be59a864a9419c4eab -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs deleted file mode 100644 index 88b30deb..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ /dev/null @@ -1,1210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Concurrent; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; -using MCPForUnity.Editor.Tools; -using MCPForUnity.Editor.Tools.MenuItems; - -namespace MCPForUnity.Editor -{ - [InitializeOnLoad] - public static partial class MCPForUnityBridge - { - private static TcpListener listener; - private static bool isRunning = false; - private static readonly object lockObj = new(); - private static readonly object startStopLock = new(); - private static readonly object clientsLock = new(); - private static readonly System.Collections.Generic.HashSet activeClients = new(); - // Single-writer outbox for framed responses - private class Outbound - { - public byte[] Payload; - public string Tag; - public int? ReqId; - } - private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); - private static CancellationTokenSource cts; - private static Task listenerTask; - private static int processingCommands = 0; - private static bool initScheduled = false; - private static bool ensureUpdateHooked = false; - private static bool isStarting = false; - private static double nextStartAt = 0.0f; - private static double nextHeartbeatAt = 0.0f; - private static int heartbeatSeq = 0; - private static Dictionary< - string, - (string commandJson, TaskCompletionSource tcs) - > commandQueue = new(); - private static int mainThreadId; - private static int currentUnityPort = 6400; // Dynamic port, starts with default - private static bool isAutoConnectMode = false; - private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads - private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients - - // IO diagnostics - private static long _ioSeq = 0; - private static void IoInfo(string s) { McpLog.Info(s, always: false); } - - // Debug helpers - private static bool IsDebugEnabled() - { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } - } - - private static void LogBreadcrumb(string stage) - { - if (IsDebugEnabled()) - { - McpLog.Info($"[{stage}]", always: false); - } - } - - public static bool IsRunning => isRunning; - public static int GetCurrentPort() => currentUnityPort; - public static bool IsAutoConnectMode() => isAutoConnectMode; - - /// - /// Start with Auto-Connect mode - discovers new port and saves it - /// - public static void StartAutoConnect() - { - Stop(); // Stop current connection - - try - { - // Prefer stored project port and start using the robust Start() path (with retries/options) - currentUnityPort = PortManager.GetPortWithFallback(); - Start(); - isAutoConnectMode = true; - - // Record telemetry for bridge startup - TelemetryHelper.RecordBridgeStartup(); - } - catch (Exception ex) - { - Debug.LogError($"Auto-connect failed: {ex.Message}"); - - // Record telemetry for connection failure - TelemetryHelper.RecordBridgeConnection(false, ex.Message); - throw; - } - } - - public static bool FolderExists(string path) - { - if (string.IsNullOrEmpty(path)) - { - return false; - } - - if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - string fullPath = Path.Combine( - Application.dataPath, - path.StartsWith("Assets/") ? path[7..] : path - ); - return Directory.Exists(fullPath); - } - - static MCPForUnityBridge() - { - // Record the main thread ID for safe thread checks - try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } - // Start single writer thread for framed responses - try - { - var writerThread = new Thread(() => - { - foreach (var item in _outbox.GetConsumingEnumerable()) - { - try - { - long seq = Interlocked.Increment(ref _ioSeq); - IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, - // writes are performed inline there. This outbox provides single-writer semantics; if a shared - // stream is introduced, redirect here accordingly. - // No-op: actual write happens in client loop using WriteFrameAsync - sw.Stop(); - IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); - } - } - }) { IsBackground = true, Name = "MCP-Writer" }; - writerThread.Start(); - } - catch { } - - // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env - // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode - if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) - { - return; - } - // Defer start until the editor is idle and not compiling - ScheduleInitRetry(); - // Add a safety net update hook in case delayCall is missed during reload churn - if (!ensureUpdateHooked) - { - ensureUpdateHooked = true; - EditorApplication.update += EnsureStartedOnEditorIdle; - } - EditorApplication.quitting += Stop; - AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; - AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; - // Also coalesce play mode transitions into a deferred init - EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); - } - - /// - /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. - /// This prevents repeated restarts during script compilation that cause port hopping. - /// - private static void InitializeAfterCompilation() - { - initScheduled = false; - - // Play-mode friendly: allow starting in play mode; only defer while compiling - if (IsCompiling()) - { - ScheduleInitRetry(); - return; - } - - if (!isRunning) - { - Start(); - if (!isRunning) - { - // If a race prevented start, retry later - ScheduleInitRetry(); - } - } - } - - private static void ScheduleInitRetry() - { - if (initScheduled) - { - return; - } - initScheduled = true; - // Debounce: start ~200ms after the last trigger - nextStartAt = EditorApplication.timeSinceStartup + 0.20f; - // Ensure the update pump is active - if (!ensureUpdateHooked) - { - ensureUpdateHooked = true; - EditorApplication.update += EnsureStartedOnEditorIdle; - } - // Keep the original delayCall as a secondary path - EditorApplication.delayCall += InitializeAfterCompilation; - } - - // Safety net: ensure the bridge starts shortly after domain reload when editor is idle - private static void EnsureStartedOnEditorIdle() - { - // Do nothing while compiling - if (IsCompiling()) - { - return; - } - - // If already running, remove the hook - if (isRunning) - { - EditorApplication.update -= EnsureStartedOnEditorIdle; - ensureUpdateHooked = false; - return; - } - - // Debounced start: wait until the scheduled time - if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) - { - return; - } - - if (isStarting) - { - return; - } - - isStarting = true; - try - { - // Attempt start; if it succeeds, remove the hook to avoid overhead - Start(); - } - finally - { - isStarting = false; - } - if (isRunning) - { - EditorApplication.update -= EnsureStartedOnEditorIdle; - ensureUpdateHooked = false; - } - } - - // Helper to check compilation status across Unity versions - private static bool IsCompiling() - { - if (EditorApplication.isCompiling) - { - return true; - } - try - { - System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); - var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); - if (prop != null) - { - return (bool)prop.GetValue(null); - } - } - catch { } - return false; - } - - public static void Start() - { - lock (startStopLock) - { - // Don't restart if already running on a working port - if (isRunning && listener != null) - { - if (IsDebugEnabled()) - { - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); - } - return; - } - - Stop(); - - // Attempt fast bind with stored-port preference (sticky per-project) - try - { - // Always consult PortManager first so we prefer the persisted project port - currentUnityPort = PortManager.GetPortWithFallback(); - - // Breadcrumb: Start - LogBreadcrumb("Start"); - - const int maxImmediateRetries = 3; - const int retrySleepMs = 75; - int attempt = 0; - for (;;) - { - try - { - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true - ); -#if UNITY_EDITOR_WIN - try - { - listener.ExclusiveAddressUse = false; - } - catch { } -#endif - // Minimize TIME_WAIT by sending RST on close - try - { - listener.Server.LingerState = new LingerOption(true, 0); - } - catch (Exception) - { - // Ignore if not supported on platform - } - listener.Start(); - break; - } - catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) - { - attempt++; - Thread.Sleep(retrySleepMs); - continue; - } - catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) - { - currentUnityPort = PortManager.GetPortWithFallback(); - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true - ); -#if UNITY_EDITOR_WIN - try - { - listener.ExclusiveAddressUse = false; - } - catch { } -#endif - try - { - listener.Server.LingerState = new LingerOption(true, 0); - } - catch (Exception) - { - } - listener.Start(); - break; - } - } - - isRunning = true; - isAutoConnectMode = false; - string platform = Application.platform.ToString(); - string serverVer = ReadInstalledServerVersionSafe(); - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); - // Start background listener with cooperative cancellation - cts = new CancellationTokenSource(); - listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); - EditorApplication.update += ProcessCommands; - // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain - try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } - try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } - try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } - try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } - try { EditorApplication.quitting -= Stop; } catch { } - try { EditorApplication.quitting += Stop; } catch { } - // Write initial heartbeat immediately - heartbeatSeq++; - WriteHeartbeat(false, "ready"); - nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; - } - catch (SocketException ex) - { - Debug.LogError($"Failed to start TCP listener: {ex.Message}"); - } - } - } - - public static void Stop() - { - Task toWait = null; - lock (startStopLock) - { - if (!isRunning) - { - return; - } - - try - { - // Mark as stopping early to avoid accept logging during disposal - isRunning = false; - - // Quiesce background listener quickly - var cancel = cts; - cts = null; - try { cancel?.Cancel(); } catch { } - - try { listener?.Stop(); } catch { } - listener = null; - - // Capture background task to wait briefly outside the lock - toWait = listenerTask; - listenerTask = null; - } - catch (Exception ex) - { - Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}"); - } - } - - // Proactively close all active client sockets to unblock any pending reads - TcpClient[] toClose; - lock (clientsLock) - { - toClose = activeClients.ToArray(); - activeClients.Clear(); - } - foreach (var c in toClose) - { - try { c.Close(); } catch { } - } - - // Give the background loop a short window to exit without blocking the editor - if (toWait != null) - { - try { toWait.Wait(100); } catch { } - } - - // Now unhook editor events safely - try { EditorApplication.update -= ProcessCommands; } catch { } - try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } - try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } - try { EditorApplication.quitting -= Stop; } catch { } - - if (IsDebugEnabled()) Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); - } - - private static async Task ListenerLoopAsync(CancellationToken token) - { - while (isRunning && !token.IsCancellationRequested) - { - try - { - TcpClient client = await listener.AcceptTcpClientAsync(); - // Enable basic socket keepalive - client.Client.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.KeepAlive, - true - ); - - // Set longer receive timeout to prevent quick disconnections - client.ReceiveTimeout = 60000; // 60 seconds - - // Fire and forget each client connection - _ = Task.Run(() => HandleClientAsync(client, token), token); - } - catch (ObjectDisposedException) - { - // Listener was disposed during stop/reload; exit quietly - if (!isRunning || token.IsCancellationRequested) - { - break; - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - if (isRunning && !token.IsCancellationRequested) - { - if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); - } - } - } - } - - private static async Task HandleClientAsync(TcpClient client, CancellationToken token) - { - using (client) - using (NetworkStream stream = client.GetStream()) - { - lock (clientsLock) { activeClients.Add(client); } - try - { - // Framed I/O only; legacy mode removed - try - { - 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 - try - { - client.NoDelay = true; - } - catch { } - try - { - string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; - byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); - using var cts = new CancellationTokenSource(FrameIOTimeoutMs); -#if NETSTANDARD2_1 || NET6_0_OR_GREATER - await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); -#else - await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); -#endif - if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); - } - catch (Exception ex) - { - if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); - return; // abort this client - } - - while (isRunning && !token.IsCancellationRequested) - { - try - { - // Strict framed mode only: enforced framed I/O for this connection - string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); - - try - { - 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(); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // Special handling for ping command to avoid JSON parsing - if (commandText.Trim() == "ping") - { - // Direct response to ping without going through JSON parsing - byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( - /*lang=json,strict*/ - "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" - ); - await WriteFrameAsync(stream, pingResponseBytes); - continue; - } - - lock (lockObj) - { - commandQueue[commandId] = (commandText, tcs); - } - - // Wait for the handler to produce a response, but do not block indefinitely - string response; - try - { - using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); - var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); - if (completed == tcs.Task) - { - // Got a result from the handler - respCts.Cancel(); - response = tcs.Task.Result; - } - else - { - // Timeout: return a structured error so the client can recover - var timeoutResponse = new - { - status = "error", - error = $"Command processing timed out after {FrameIOTimeoutMs} ms", - }; - response = JsonConvert.SerializeObject(timeoutResponse); - } - } - catch (Exception ex) - { - var errorResponse = new - { - status = "error", - error = ex.Message, - }; - response = JsonConvert.SerializeObject(errorResponse); - } - - if (IsDebugEnabled()) - { - try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { } - } - // Crash-proof and self-reporting writer logs (direct write to this client's stream) - long seq = System.Threading.Interlocked.Increment(ref _ioSeq); - byte[] responseBytes; - try - { - responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); - throw; - } - - var swDirect = System.Diagnostics.Stopwatch.StartNew(); - try - { - await WriteFrameAsync(stream, responseBytes); - swDirect.Stop(); - IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); - throw; - } - } - catch (Exception ex) - { - // 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; - } - } - } - finally - { - lock (clientsLock) { activeClients.Remove(client); } - } - } - } - - // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks - private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) - { - byte[] buffer = new byte[count]; - int offset = 0; - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (offset < count) - { - int remaining = count - offset; - int remainingTimeout = timeoutMs <= 0 - ? Timeout.Infinite - : timeoutMs - (int)stopwatch.ElapsedMilliseconds; - - // If a finite timeout is configured and already elapsed, fail immediately - if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) - { - throw new System.IO.IOException("Read timed out"); - } - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); - if (remainingTimeout != Timeout.Infinite) - { - cts.CancelAfter(remainingTimeout); - } - - try - { -#if NETSTANDARD2_1 || NET6_0_OR_GREATER - int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); -#else - int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); -#endif - if (read == 0) - { - throw new System.IO.IOException("Connection closed before reading expected bytes"); - } - offset += read; - } - catch (OperationCanceledException) when (!cancel.IsCancellationRequested) - { - throw new System.IO.IOException("Read timed out"); - } - } - - return buffer; - } - - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) - { - using var cts = new CancellationTokenSource(FrameIOTimeoutMs); - await WriteFrameAsync(stream, payload, cts.Token); - } - - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) - { - if (payload == null) - { - throw new System.ArgumentNullException(nameof(payload)); - } - if ((ulong)payload.LongLength > MaxFrameBytes) - { - throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); - } - byte[] header = new byte[8]; - WriteUInt64BigEndian(header, (ulong)payload.LongLength); -#if NETSTANDARD2_1 || NET6_0_OR_GREATER - await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); - await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); -#else - await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); - await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); -#endif - } - - private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) - { - byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); - ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen > MaxFrameBytes) - { - throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); - } - if (payloadLen == 0UL) - throw new System.IO.IOException("Zero-length frames are not allowed"); - if (payloadLen > int.MaxValue) - { - throw new System.IO.IOException("Frame too large for buffer"); - } - int count = (int)payloadLen; - byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); - return System.Text.Encoding.UTF8.GetString(payload); - } - - private static ulong ReadUInt64BigEndian(byte[] buffer) - { - if (buffer == null || buffer.Length < 8) return 0UL; - return ((ulong)buffer[0] << 56) - | ((ulong)buffer[1] << 48) - | ((ulong)buffer[2] << 40) - | ((ulong)buffer[3] << 32) - | ((ulong)buffer[4] << 24) - | ((ulong)buffer[5] << 16) - | ((ulong)buffer[6] << 8) - | buffer[7]; - } - - private static void WriteUInt64BigEndian(byte[] dest, ulong value) - { - if (dest == null || dest.Length < 8) - { - throw new System.ArgumentException("Destination buffer too small for UInt64"); - } - dest[0] = (byte)(value >> 56); - dest[1] = (byte)(value >> 48); - dest[2] = (byte)(value >> 40); - dest[3] = (byte)(value >> 32); - dest[4] = (byte)(value >> 24); - dest[5] = (byte)(value >> 16); - dest[6] = (byte)(value >> 8); - dest[7] = (byte)(value); - } - - private static void ProcessCommands() - { - if (!isRunning) return; - if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard - try - { - // Heartbeat without holding the queue lock - double now = EditorApplication.timeSinceStartup; - if (now >= nextHeartbeatAt) - { - WriteHeartbeat(false); - nextHeartbeatAt = now + 0.5f; - } - - // Snapshot under lock, then process outside to reduce contention - List<(string id, string text, TaskCompletionSource tcs)> work; - lock (lockObj) - { - work = commandQueue - .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) - .ToList(); - } - - foreach (var item in work) - { - string id = item.id; - string commandText = item.text; - TaskCompletionSource tcs = item.tcs; - - try - { - // Special case handling - if (string.IsNullOrEmpty(commandText)) - { - var emptyResponse = new - { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Trim the command text to remove any whitespace - commandText = commandText.Trim(); - - // Non-JSON direct commands handling (like ping) - if (commandText == "ping") - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Check if the command is valid JSON before attempting to deserialize - if (!IsValidJson(commandText)) - { - var invalidJsonResponse = new - { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Normal JSON command processing - Command command = JsonConvert.DeserializeObject(commandText); - - if (command == null) - { - var nullCommandResponse = new - { - status = "error", - error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object", - }; - tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); - } - else - { - string responseJson = ExecuteCommand(command); - tcs.SetResult(responseJson); - } - } - catch (Exception ex) - { - Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - - var response = new - { - status = "error", - error = ex.Message, - commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - string responseJson = JsonConvert.SerializeObject(response); - tcs.SetResult(responseJson); - } - - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - } - } - finally - { - Interlocked.Exchange(ref processingCommands, 0); - } - } - - // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. - // Returns null on timeout or error; caller should provide a fallback error response. - private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) - { - if (func == null) return null; - try - { - // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. - if (mainThreadId == 0) - { - try { return func(); } - catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } - } - // If we are already on the main thread, execute directly to avoid deadlocks - try - { - if (Thread.CurrentThread.ManagedThreadId == mainThreadId) - { - return func(); - } - } - catch { } - - object result = null; - Exception captured = null; - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - EditorApplication.delayCall += () => - { - try - { - result = func(); - } - catch (Exception ex) - { - captured = ex; - } - finally - { - try { tcs.TrySetResult(true); } catch { } - } - }; - - // Wait for completion with timeout (Editor thread will pump delayCall) - bool completed = tcs.Task.Wait(timeoutMs); - if (!completed) - { - return null; // timeout - } - if (captured != null) - { - throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); - } - return result; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); - } - } - - // Helper method to check if a string is valid JSON - private static bool IsValidJson(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } - - text = text.Trim(); - if ( - (text.StartsWith("{") && text.EndsWith("}")) - || // Object - (text.StartsWith("[") && text.EndsWith("]")) - ) // Array - { - try - { - JToken.Parse(text); - return true; - } - catch - { - return false; - } - } - - return false; - } - - private static string ExecuteCommand(Command command) - { - try - { - if (string.IsNullOrEmpty(command.type)) - { - var errorResponse = new - { - status = "error", - error = "Command type cannot be empty", - details = "A valid command type is required for processing", - }; - return JsonConvert.SerializeObject(errorResponse); - } - - // Handle ping command for connection verification - if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - return JsonConvert.SerializeObject(pingResponse); - } - - // Use JObject for parameters as the new handlers likely expect this - JObject paramsObject = command.@params ?? new JObject(); - - // Route command based on the new tool structure from the refactor plan - object result = command.type switch - { - // Maps the command type (tool name) to the corresponding handler's static HandleCommand method - // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters - "manage_script" => ManageScript.HandleCommand(paramsObject), - // Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag) - "manage_scene" => HandleManageScene(paramsObject) - ?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"), - "manage_editor" => ManageEditor.HandleCommand(paramsObject), - "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), - "manage_asset" => ManageAsset.HandleCommand(paramsObject), - "manage_shader" => ManageShader.HandleCommand(paramsObject), - "read_console" => ReadConsole.HandleCommand(paramsObject), - "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject), - _ => throw new ArgumentException( - $"Unknown or unsupported command type: {command.type}" - ), - }; - - // Standard success response format - var response = new { status = "success", result }; - return JsonConvert.SerializeObject(response); - } - catch (Exception ex) - { - // Log the detailed error in Unity for debugging - Debug.LogError( - $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}" - ); - - // Standard error response format - var response = new - { - status = "error", - error = ex.Message, // Provide the specific error message - command = command?.type ?? "Unknown", // Include the command type if available - stackTrace = ex.StackTrace, // Include stack trace for detailed debugging - paramsSummary = command?.@params != null - ? GetParamsSummary(command.@params) - : "No parameters", // Summarize parameters for context - }; - return JsonConvert.SerializeObject(response); - } - } - - private static object HandleManageScene(JObject paramsObject) - { - try - { - if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); - sw.Stop(); - if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); - return r ?? Response.Error("manage_scene returned null (timeout or error)"); - } - catch (Exception ex) - { - return Response.Error($"manage_scene dispatch error: {ex.Message}"); - } - } - - // Helper method to get a summary of parameters for error reporting - private static string GetParamsSummary(JObject @params) - { - try - { - return @params == null || !@params.HasValues - ? "No parameters" - : string.Join( - ", ", - @params - .Properties() - .Select(static p => - $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" - ) - ); - } - catch - { - return "Could not summarize parameters"; - } - } - - // Heartbeat/status helpers - private static void OnBeforeAssemblyReload() - { - // Stop cleanly before reload so sockets close and clients see 'reloading' - try { Stop(); } catch { } - // Avoid file I/O or heavy work here - } - - private static void OnAfterAssemblyReload() - { - // Will be overwritten by Start(), but mark as alive quickly - WriteHeartbeat(false, "idle"); - LogBreadcrumb("Idle"); - // Schedule a safe restart after reload to avoid races during compilation - ScheduleInitRetry(); - } - - private static void WriteHeartbeat(bool reloading, string reason = null) - { - try - { - // Allow override of status directory (useful in CI/containers) - string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); - if (string.IsNullOrWhiteSpace(dir)) - { - dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); - } - Directory.CreateDirectory(dir); - string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - var payload = new - { - unity_port = currentUnityPort, - reloading, - reason = reason ?? (reloading ? "reloading" : "ready"), - seq = heartbeatSeq, - project_path = Application.dataPath, - last_heartbeat = DateTime.UtcNow.ToString("O") - }; - File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); - } - catch (Exception) - { - // Best-effort only - } - } - - private static string ReadInstalledServerVersionSafe() - { - try - { - string serverSrc = ServerInstaller.GetServerPath(); - string verFile = Path.Combine(serverSrc, "server_version.txt"); - if (File.Exists(verFile)) - { - string v = File.ReadAllText(verFile)?.Trim(); - if (!string.IsNullOrEmpty(v)) return v; - } - } - catch { } - return "unknown"; - } - - private static string ComputeProjectHash(string input) - { - try - { - using var sha1 = System.Security.Cryptography.SHA1.Create(); - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); - byte[] hashBytes = sha1.ComputeHash(bytes); - var sb = new System.Text.StringBuilder(); - foreach (byte b in hashBytes) - { - sb.Append(b.ToString("x2")); - } - return sb.ToString()[..8]; - } - catch - { - return "default"; - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs.meta deleted file mode 100644 index f8d1f46e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/MCPForUnityBridge.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 96dc847eb7f7a45e0b91241db934a4be -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models.meta deleted file mode 100644 index 85404561..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 16d3ab36890b6c14f9afeabee30e03e3 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs deleted file mode 100644 index 02a89d88..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json.Linq; - -namespace MCPForUnity.Editor.Models -{ - /// - /// Represents a command received from the MCP client - /// - public class Command - { - /// - /// The type of command to execute - /// - public string type { get; set; } - - /// - /// The parameters for the command - /// - public JObject @params { get; set; } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs.meta deleted file mode 100644 index 63618f53..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/Command.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6754c84e5deb74749bc3a19e0c9aa280 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs deleted file mode 100644 index fbffed37..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace MCPForUnity.Editor.Models -{ - [Serializable] - public class McpConfigServer - { - [JsonProperty("command")] - public string command; - - [JsonProperty("args")] - public string[] args; - - // VSCode expects a transport type; include only when explicitly set - [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] - public string type; - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta deleted file mode 100644 index 0574c5a6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5fae9d995f514e9498e9613e2cdbeca9 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs deleted file mode 100644 index d5065a16..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace MCPForUnity.Editor.Models -{ - [Serializable] - public class McpConfigServers - { - [JsonProperty("unityMCP")] - public McpConfigServer unityMCP; - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta deleted file mode 100644 index 1fb5f0b2..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: bcb583553e8173b49be71a5c43bd9502 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs deleted file mode 100644 index a32f7f59..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace MCPForUnity.Editor.Models -{ - public class McpClient - { - public string name; - public string windowsConfigPath; - public string macConfigPath; - public string linuxConfigPath; - public McpTypes mcpType; - public string configStatus; - public McpStatus status = McpStatus.NotConfigured; - - // Helper method to convert the enum to a display string - public string GetStatusDisplayString() - { - return status switch - { - McpStatus.NotConfigured => "Not Configured", - McpStatus.Configured => "Configured", - McpStatus.Running => "Running", - McpStatus.Connected => "Connected", - McpStatus.IncorrectPath => "Incorrect Path", - McpStatus.CommunicationError => "Communication Error", - McpStatus.NoResponse => "No Response", - McpStatus.UnsupportedOS => "Unsupported OS", - McpStatus.MissingConfig => "Missing MCPForUnity Config", - McpStatus.Error => configStatus.StartsWith("Error:") ? configStatus : "Error", - _ => "Unknown", - }; - } - - // Helper method to set both status enum and string for backward compatibility - public void SetStatus(McpStatus newStatus, string errorDetails = null) - { - status = newStatus; - - if (newStatus == McpStatus.Error && !string.IsNullOrEmpty(errorDetails)) - { - configStatus = $"Error: {errorDetails}"; - } - else - { - configStatus = GetStatusDisplayString(); - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs.meta deleted file mode 100644 index b08dcf3b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpClient.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b1afa56984aec0d41808edcebf805e6a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs deleted file mode 100644 index 9ddf9d09..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace MCPForUnity.Editor.Models -{ - [Serializable] - public class McpConfig - { - [JsonProperty("mcpServers")] - public McpConfigServers mcpServers; - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs.meta deleted file mode 100644 index 2a407c31..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpConfig.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: c17c09908f0c1524daa8b6957ce1f7f5 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs deleted file mode 100644 index d041667d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MCPForUnity.Editor.Models -{ - // Enum representing the various status states for MCP clients - public enum McpStatus - { - NotConfigured, // Not set up yet - Configured, // Successfully configured - Running, // Service is running - Connected, // Successfully connected - IncorrectPath, // Configuration has incorrect paths - CommunicationError, // Connected but communication issues - NoResponse, // Connected but not responding - MissingConfig, // Config file exists but missing required elements - UnsupportedOS, // OS is not supported - Error, // General error state - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs.meta deleted file mode 100644 index e8e930d3..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpStatus.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: aa63057c9e5282d4887352578bf49971 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs deleted file mode 100644 index 32a30e2f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MCPForUnity.Editor.Models -{ - public enum McpTypes - { - ClaudeCode, - ClaudeDesktop, - Cursor, - VSCode, - Windsurf, - Kiro, - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs.meta deleted file mode 100644 index 377a6d0b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/McpTypes.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs deleted file mode 100644 index 4b185f1f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace MCPForUnity.Editor.Models -{ - [Serializable] - public class ServerConfig - { - [JsonProperty("unity_host")] - public string unityHost = "localhost"; - - [JsonProperty("unity_port")] - public int unityPort; - - [JsonProperty("mcp_port")] - public int mcpPort; - - [JsonProperty("connection_timeout")] - public float connectionTimeout; - - [JsonProperty("buffer_size")] - public int bufferSize; - - [JsonProperty("log_level")] - public string logLevel; - - [JsonProperty("log_format")] - public string logFormat; - - [JsonProperty("max_retries")] - public int maxRetries; - - [JsonProperty("retry_delay")] - public float retryDelay; - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta deleted file mode 100644 index 6e675e9e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e4e45386fcc282249907c2e3c7e5d9c6 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup.meta deleted file mode 100644 index 286db233..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d4e5f6789012345678901234abcdef01 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs deleted file mode 100644 index 6b1673eb..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Setup -{ - /// - /// Handles automatic triggering of the setup wizard based on dependency state - /// - [InitializeOnLoad] - public static class SetupWizard - { - private const string SETUP_STATE_KEY = "MCPForUnity.SetupState"; - private const string PACKAGE_VERSION = "3.4.0"; // Should match package.json version - - private static SetupState _setupState; - private static bool _hasCheckedThisSession = false; - - static SetupWizard() - { - // Skip in batch mode unless explicitly allowed - if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) - { - return; - } - - // Defer setup check until editor is ready - EditorApplication.delayCall += CheckSetupNeeded; - } - - /// - /// Get the current setup state - /// - public static SetupState GetSetupState() - { - if (_setupState == null) - { - LoadSetupState(); - } - return _setupState; - } - - /// - /// Save the current setup state - /// - public static void SaveSetupState() - { - if (_setupState != null) - { - try - { - string json = JsonUtility.ToJson(_setupState, true); - EditorPrefs.SetString(SETUP_STATE_KEY, json); - McpLog.Info("Setup state saved", always: false); - } - catch (Exception ex) - { - McpLog.Error($"Failed to save setup state: {ex.Message}"); - } - } - } - - /// - /// Load setup state from EditorPrefs - /// - private static void LoadSetupState() - { - try - { - string json = EditorPrefs.GetString(SETUP_STATE_KEY, ""); - if (!string.IsNullOrEmpty(json)) - { - _setupState = JsonUtility.FromJson(json); - } - } - catch (Exception ex) - { - McpLog.Warn($"Failed to load setup state: {ex.Message}"); - } - - // Create default state if loading failed - if (_setupState == null) - { - _setupState = new SetupState(); - } - } - - /// - /// Check if setup wizard should be shown - /// - private static void CheckSetupNeeded() - { - // Only check once per session - if (_hasCheckedThisSession) - return; - - _hasCheckedThisSession = true; - - try - { - var setupState = GetSetupState(); - - // Don't show setup if user has dismissed it or if already completed for this version - if (!setupState.ShouldShowSetup(PACKAGE_VERSION)) - { - McpLog.Info("Setup wizard not needed - already completed or dismissed", always: false); - return; - } - - // Check if dependencies are missing - var dependencyResult = DependencyManager.CheckAllDependencies(); - if (dependencyResult.IsSystemReady) - { - McpLog.Info("All dependencies available - marking setup as completed", always: false); - setupState.MarkSetupCompleted(PACKAGE_VERSION); - SaveSetupState(); - return; - } - - // Show setup wizard if dependencies are missing - var missingRequired = dependencyResult.GetMissingRequired(); - if (missingRequired.Count > 0) - { - McpLog.Info($"Missing required dependencies: {string.Join(", ", missingRequired.ConvertAll(d => d.Name))}"); - - // Delay showing the wizard slightly to ensure Unity is fully loaded - EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); - } - } - catch (Exception ex) - { - McpLog.Error($"Error checking setup requirements: {ex.Message}"); - } - } - - /// - /// Show the setup wizard window - /// - public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) - { - try - { - // If no dependency result provided, check now - if (dependencyResult == null) - { - dependencyResult = DependencyManager.CheckAllDependencies(); - } - - // Show the setup wizard window - SetupWizardWindow.ShowWindow(dependencyResult); - - // Record that we've attempted setup - var setupState = GetSetupState(); - setupState.RecordSetupAttempt(); - SaveSetupState(); - } - catch (Exception ex) - { - McpLog.Error($"Error showing setup wizard: {ex.Message}"); - } - } - - /// - /// Mark setup as completed - /// - public static void MarkSetupCompleted() - { - try - { - var setupState = GetSetupState(); - setupState.MarkSetupCompleted(PACKAGE_VERSION); - SaveSetupState(); - - McpLog.Info("Setup marked as completed"); - } - catch (Exception ex) - { - McpLog.Error($"Error marking setup as completed: {ex.Message}"); - } - } - - /// - /// Mark setup as dismissed - /// - public static void MarkSetupDismissed() - { - try - { - var setupState = GetSetupState(); - setupState.MarkSetupDismissed(); - SaveSetupState(); - - McpLog.Info("Setup marked as dismissed"); - } - catch (Exception ex) - { - McpLog.Error($"Error marking setup as dismissed: {ex.Message}"); - } - } - - /// - /// Reset setup state (for debugging or re-setup) - /// - public static void ResetSetupState() - { - try - { - var setupState = GetSetupState(); - setupState.Reset(); - SaveSetupState(); - - McpLog.Info("Setup state reset"); - } - catch (Exception ex) - { - McpLog.Error($"Error resetting setup state: {ex.Message}"); - } - } - - /// - /// Force show setup wizard (for manual invocation) - /// - [MenuItem("Window/MCP for Unity/Setup Wizard", priority = 1)] - public static void ShowSetupWizardManual() - { - ShowSetupWizard(); - } - - /// - /// Reset setup and show wizard again - /// - [MenuItem("Window/MCP for Unity/Reset Setup", priority = 2)] - public static void ResetAndShowSetup() - { - ResetSetupState(); - _hasCheckedThisSession = false; - ShowSetupWizard(); - } - - /// - /// Check dependencies and show status - /// - [MenuItem("Window/MCP for Unity/Check Dependencies", priority = 3)] - public static void CheckDependencies() - { - var result = DependencyManager.CheckAllDependencies(); - var diagnostics = DependencyManager.GetDependencyDiagnostics(); - - Debug.Log($"MCP-FOR-UNITY: Dependency Check Results\n{diagnostics}"); - - if (!result.IsSystemReady) - { - bool showWizard = EditorUtility.DisplayDialog( - "MCP for Unity - Dependencies", - $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", - "Open Setup Wizard", - "Close" - ); - - if (showWizard) - { - ShowSetupWizard(result); - } - } - else - { - EditorUtility.DisplayDialog( - "MCP for Unity - Dependencies", - "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", - "OK" - ); - } - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta deleted file mode 100644 index 1a0e4e5f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 345678901234abcdef0123456789abcd -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs deleted file mode 100644 index 3d648d69..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs +++ /dev/null @@ -1,465 +0,0 @@ -using System; -using System.Collections.Generic; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Installation; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Setup -{ - /// - /// Setup wizard window for guiding users through dependency installation - /// - public class SetupWizardWindow : EditorWindow - { - private DependencyCheckResult _dependencyResult; - private Vector2 _scrollPosition; - private int _currentStep = 0; - private bool _isInstalling = false; - private string _installationStatus = ""; - private InstallationOrchestrator _orchestrator; - - private readonly string[] _stepTitles = { - "Welcome", - "Dependency Check", - "Installation Options", - "Installation Progress", - "Complete" - }; - - public static void ShowWindow(DependencyCheckResult dependencyResult = null) - { - var window = GetWindow("MCP for Unity Setup"); - window.minSize = new Vector2(500, 400); - window.maxSize = new Vector2(800, 600); - window._dependencyResult = dependencyResult ?? DependencyManager.CheckAllDependencies(); - window.Show(); - } - - private void OnEnable() - { - if (_dependencyResult == null) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - - _orchestrator = new InstallationOrchestrator(); - _orchestrator.OnProgressUpdate += OnInstallationProgress; - _orchestrator.OnInstallationComplete += OnInstallationComplete; - } - - private void OnDisable() - { - if (_orchestrator != null) - { - _orchestrator.OnProgressUpdate -= OnInstallationProgress; - _orchestrator.OnInstallationComplete -= OnInstallationComplete; - } - } - - private void OnGUI() - { - DrawHeader(); - DrawProgressBar(); - - _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); - - switch (_currentStep) - { - case 0: DrawWelcomeStep(); break; - case 1: DrawDependencyCheckStep(); break; - case 2: DrawInstallationOptionsStep(); break; - case 3: DrawInstallationProgressStep(); break; - case 4: DrawCompleteStep(); break; - } - - EditorGUILayout.EndScrollView(); - - DrawFooter(); - } - - private void DrawHeader() - { - EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - GUILayout.Label("MCP for Unity Setup Wizard", EditorStyles.boldLabel); - GUILayout.FlexibleSpace(); - GUILayout.Label($"Step {_currentStep + 1} of {_stepTitles.Length}"); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Step title - var titleStyle = new GUIStyle(EditorStyles.largeLabel) - { - fontSize = 16, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(_stepTitles[_currentStep], titleStyle); - EditorGUILayout.Space(); - } - - private void DrawProgressBar() - { - var rect = EditorGUILayout.GetControlRect(false, 4); - var progress = (_currentStep + 1) / (float)_stepTitles.Length; - EditorGUI.ProgressBar(rect, progress, ""); - EditorGUILayout.Space(); - } - - private void DrawWelcomeStep() - { - EditorGUILayout.LabelField("Welcome to MCP for Unity!", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - EditorGUILayout.LabelField( - "This wizard will help you set up the required dependencies for MCP for Unity to work properly.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - EditorGUILayout.LabelField("What is MCP for Unity?", EditorStyles.boldLabel); - EditorGUILayout.LabelField( - "MCP for Unity is a bridge that connects AI assistants like Claude Desktop to your Unity Editor, " + - "allowing them to help you with Unity development tasks directly.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - EditorGUILayout.LabelField("Required Dependencies:", EditorStyles.boldLabel); - EditorGUILayout.LabelField("• Python 3.10 or later", EditorStyles.label); - EditorGUILayout.LabelField("• UV package manager", EditorStyles.label); - EditorGUILayout.Space(); - - EditorGUILayout.HelpBox( - "This wizard will check for these dependencies and guide you through installation if needed.", - MessageType.Info - ); - } - - private void DrawDependencyCheckStep() - { - EditorGUILayout.LabelField("Checking Dependencies", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - if (GUILayout.Button("Refresh Dependency Check")) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - EditorGUILayout.Space(); - - // Show dependency status - foreach (var dep in _dependencyResult.Dependencies) - { - DrawDependencyStatus(dep); - } - - EditorGUILayout.Space(); - - // Overall status - var statusColor = _dependencyResult.IsSystemReady ? Color.green : Color.red; - var statusText = _dependencyResult.IsSystemReady ? "✓ System Ready" : "✗ Dependencies Missing"; - - var originalColor = GUI.color; - GUI.color = statusColor; - EditorGUILayout.LabelField(statusText, EditorStyles.boldLabel); - GUI.color = originalColor; - - EditorGUILayout.Space(); - EditorGUILayout.LabelField(_dependencyResult.Summary, EditorStyles.wordWrappedLabel); - - if (!_dependencyResult.IsSystemReady) - { - EditorGUILayout.Space(); - EditorGUILayout.HelpBox( - "Some dependencies are missing. The next step will help you install them.", - MessageType.Warning - ); - } - } - - private void DrawDependencyStatus(DependencyStatus dep) - { - EditorGUILayout.BeginHorizontal(); - - // Status icon - var statusIcon = dep.IsAvailable ? "✓" : "✗"; - var statusColor = dep.IsAvailable ? Color.green : (dep.IsRequired ? Color.red : Color.yellow); - - var originalColor = GUI.color; - GUI.color = statusColor; - GUILayout.Label(statusIcon, GUILayout.Width(20)); - GUI.color = originalColor; - - // Dependency name and details - EditorGUILayout.BeginVertical(); - EditorGUILayout.LabelField(dep.Name, EditorStyles.boldLabel); - - if (!string.IsNullOrEmpty(dep.Version)) - { - EditorGUILayout.LabelField($"Version: {dep.Version}", EditorStyles.miniLabel); - } - - if (!string.IsNullOrEmpty(dep.Details)) - { - EditorGUILayout.LabelField(dep.Details, EditorStyles.miniLabel); - } - - if (!string.IsNullOrEmpty(dep.ErrorMessage)) - { - EditorGUILayout.LabelField($"Error: {dep.ErrorMessage}", EditorStyles.miniLabel); - } - - EditorGUILayout.EndVertical(); - EditorGUILayout.EndHorizontal(); - EditorGUILayout.Space(); - } - - private void DrawInstallationOptionsStep() - { - EditorGUILayout.LabelField("Installation Options", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - var missingDeps = _dependencyResult.GetMissingRequired(); - if (missingDeps.Count == 0) - { - EditorGUILayout.HelpBox("All required dependencies are already available!", MessageType.Info); - return; - } - - EditorGUILayout.LabelField("Missing Dependencies:", EditorStyles.boldLabel); - foreach (var dep in missingDeps) - { - EditorGUILayout.LabelField($"• {dep.Name}", EditorStyles.label); - } - EditorGUILayout.Space(); - - EditorGUILayout.LabelField("Installation Methods:", EditorStyles.boldLabel); - - // Automatic installation option - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Automatic Installation (Recommended)", EditorStyles.boldLabel); - EditorGUILayout.LabelField( - "The wizard will attempt to install missing dependencies automatically.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - if (GUILayout.Button("Start Automatic Installation", GUILayout.Height(30))) - { - StartAutomaticInstallation(); - } - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(); - - // Manual installation option - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Manual Installation", EditorStyles.boldLabel); - EditorGUILayout.LabelField( - "Install dependencies manually using the platform-specific instructions below.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - var recommendations = DependencyManager.GetInstallationRecommendations(); - EditorGUILayout.LabelField(recommendations, EditorStyles.wordWrappedLabel); - - EditorGUILayout.Space(); - if (GUILayout.Button("Open Installation URLs")) - { - OpenInstallationUrls(); - } - EditorGUILayout.EndVertical(); - } - - private void DrawInstallationProgressStep() - { - EditorGUILayout.LabelField("Installation Progress", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - if (_isInstalling) - { - EditorGUILayout.LabelField("Installing dependencies...", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - // Show progress - var rect = EditorGUILayout.GetControlRect(false, 20); - EditorGUI.ProgressBar(rect, 0.5f, "Installing..."); - - EditorGUILayout.Space(); - EditorGUILayout.LabelField(_installationStatus, EditorStyles.wordWrappedLabel); - - EditorGUILayout.Space(); - EditorGUILayout.HelpBox( - "Please wait while dependencies are being installed. This may take a few minutes.", - MessageType.Info - ); - } - else - { - EditorGUILayout.LabelField("Installation completed!", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - if (GUILayout.Button("Check Dependencies Again")) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - if (_dependencyResult.IsSystemReady) - { - _currentStep = 4; // Go to complete step - } - } - } - } - - private void DrawCompleteStep() - { - EditorGUILayout.LabelField("Setup Complete!", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - if (_dependencyResult.IsSystemReady) - { - EditorGUILayout.HelpBox( - "✓ All dependencies are now available! MCP for Unity is ready to use.", - MessageType.Info - ); - - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Next Steps:", EditorStyles.boldLabel); - EditorGUILayout.LabelField("1. Configure your AI assistant (Claude Desktop, Cursor, etc.)", EditorStyles.label); - EditorGUILayout.LabelField("2. Add MCP for Unity to your AI assistant's configuration", EditorStyles.label); - EditorGUILayout.LabelField("3. Start using AI assistance in Unity!", EditorStyles.label); - - EditorGUILayout.Space(); - if (GUILayout.Button("Open Documentation")) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp"); - } - } - else - { - EditorGUILayout.HelpBox( - "Some dependencies are still missing. Please install them manually or try the automatic installation again.", - MessageType.Warning - ); - } - } - - private void DrawFooter() - { - EditorGUILayout.Space(); - EditorGUILayout.BeginHorizontal(); - - // Back button - GUI.enabled = _currentStep > 0 && !_isInstalling; - if (GUILayout.Button("Back")) - { - _currentStep--; - } - - GUILayout.FlexibleSpace(); - - // Skip/Dismiss button - GUI.enabled = !_isInstalling; - if (GUILayout.Button("Skip Setup")) - { - bool dismiss = EditorUtility.DisplayDialog( - "Skip Setup", - "Are you sure you want to skip the setup? You can run it again later from the Window menu.", - "Skip", - "Cancel" - ); - - if (dismiss) - { - SetupWizard.MarkSetupDismissed(); - Close(); - } - } - - // Next/Finish button - GUI.enabled = !_isInstalling; - string nextButtonText = _currentStep == _stepTitles.Length - 1 ? "Finish" : "Next"; - - if (GUILayout.Button(nextButtonText)) - { - if (_currentStep == _stepTitles.Length - 1) - { - // Finish setup - SetupWizard.MarkSetupCompleted(); - Close(); - } - else - { - _currentStep++; - } - } - - GUI.enabled = true; - EditorGUILayout.EndHorizontal(); - } - - private void StartAutomaticInstallation() - { - _currentStep = 3; // Go to progress step - _isInstalling = true; - _installationStatus = "Starting installation..."; - - var missingDeps = _dependencyResult.GetMissingRequired(); - _orchestrator.StartInstallation(missingDeps); - } - - private void OpenInstallationUrls() - { - var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); - - bool openPython = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open Python installation page?", - "Yes", - "No" - ); - - if (openPython) - { - Application.OpenURL(pythonUrl); - } - - bool openUV = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open UV installation page?", - "Yes", - "No" - ); - - if (openUV) - { - Application.OpenURL(uvUrl); - } - } - - private void OnInstallationProgress(string status) - { - _installationStatus = status; - Repaint(); - } - - private void OnInstallationComplete(bool success, string message) - { - _isInstalling = false; - _installationStatus = message; - - if (success) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - if (_dependencyResult.IsSystemReady) - { - _currentStep = 4; // Go to complete step - } - } - - Repaint(); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta deleted file mode 100644 index 5361de3d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 45678901234abcdef0123456789abcde -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools.meta deleted file mode 100644 index 2bc55f08..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: c97b83a6ac92a704b864eef27c3d285b -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs deleted file mode 100644 index 912ddf59..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Tools.MenuItems; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Registry for all MCP command handlers (Refactored Version) - /// - public static class CommandRegistry - { - // Maps command names (matching those called from Python via ctx.bridge.unity_editor.HandlerName) - // to the corresponding static HandleCommand method in the appropriate tool class. - private static readonly Dictionary> _handlers = new() - { - { "HandleManageScript", ManageScript.HandleCommand }, - { "HandleManageScene", ManageScene.HandleCommand }, - { "HandleManageEditor", ManageEditor.HandleCommand }, - { "HandleManageGameObject", ManageGameObject.HandleCommand }, - { "HandleManageAsset", ManageAsset.HandleCommand }, - { "HandleReadConsole", ReadConsole.HandleCommand }, - { "HandleManageMenuItem", ManageMenuItem.HandleCommand }, - { "HandleManageShader", ManageShader.HandleCommand}, - }; - - /// - /// Gets a command handler by name. - /// - /// Name of the command handler (e.g., "HandleManageAsset"). - /// The command handler function if found, null otherwise. - public static Func GetHandler(string commandName) - { - // Use case-insensitive comparison for flexibility, although Python side should be consistent - return _handlers.TryGetValue(commandName, out var handler) ? handler : null; - // Consider adding logging here if a handler is not found - /* - if (_handlers.TryGetValue(commandName, out var handler)) { - return handler; - } else { - UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\"); - return null; - } - */ - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta deleted file mode 100644 index 15ec884b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5b61b5a84813b5749a5c64422694a0fa -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs deleted file mode 100644 index 70e3ff65..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ /dev/null @@ -1,1340 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class -using static MCPForUnity.Editor.Tools.ManageGameObject; - -#if UNITY_6000_0_OR_NEWER -using PhysicsMaterialType = UnityEngine.PhysicsMaterial; -using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; -#else -using PhysicsMaterialType = UnityEngine.PhysicMaterial; -using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; -#endif - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles asset management operations within the Unity project. - /// - public static class ManageAsset - { - // --- Main Handler --- - - // Define the list of valid actions - private static readonly List ValidActions = new List - { - "import", - "create", - "modify", - "delete", - "duplicate", - "move", - "rename", - "search", - "get_info", - "create_folder", - "get_components", - }; - - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString().ToLower(); - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - // Check if the action is valid before switching - if (!ValidActions.Contains(action)) - { - string validActionsList = string.Join(", ", ValidActions); - return Response.Error( - $"Unknown action: '{action}'. Valid actions are: {validActionsList}" - ); - } - - // Common parameters - string path = @params["path"]?.ToString(); - - try - { - switch (action) - { - case "import": - // Note: Unity typically auto-imports. This might re-import or configure import settings. - return ReimportAsset(path, @params["properties"] as JObject); - case "create": - return CreateAsset(@params); - case "modify": - return ModifyAsset(path, @params["properties"] as JObject); - case "delete": - return DeleteAsset(path); - case "duplicate": - return DuplicateAsset(path, @params["destination"]?.ToString()); - case "move": // Often same as rename if within Assets/ - case "rename": - return MoveOrRenameAsset(path, @params["destination"]?.ToString()); - case "search": - return SearchAssets(@params); - case "get_info": - return GetAssetInfo( - path, - @params["generatePreview"]?.ToObject() ?? false - ); - case "create_folder": // Added specific action for clarity - return CreateFolder(path); - case "get_components": - return GetComponentsFromAsset(path); - - default: - // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. - string validActionsListDefault = string.Join(", ", ValidActions); - return Response.Error( - $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" - ); - } - } - catch (Exception e) - { - Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); - return Response.Error( - $"Internal error processing action '{action}' on '{path}': {e.Message}" - ); - } - } - - // --- Action Implementations --- - - private static object ReimportAsset(string path, JObject properties) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for reimport."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - // TODO: Apply importer properties before reimporting? - // This is complex as it requires getting the AssetImporter, casting it, - // applying properties via reflection or specific methods, saving, then reimporting. - if (properties != null && properties.HasValues) - { - Debug.LogWarning( - "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." - ); - // AssetImporter importer = AssetImporter.GetAtPath(fullPath); - // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } - } - - AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); - // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh - return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); - } - catch (Exception e) - { - return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); - } - } - - private static object CreateAsset(JObject @params) - { - string path = @params["path"]?.ToString(); - string assetType = @params["assetType"]?.ToString(); - JObject properties = @params["properties"] as JObject; - - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create."); - if (string.IsNullOrEmpty(assetType)) - return Response.Error("'assetType' is required for create."); - - string fullPath = SanitizeAssetPath(path); - string directory = Path.GetDirectoryName(fullPath); - - // Ensure directory exists - if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) - { - Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); - AssetDatabase.Refresh(); // Make sure Unity knows about the new folder - } - - if (AssetExists(fullPath)) - return Response.Error($"Asset already exists at path: {fullPath}"); - - try - { - UnityEngine.Object newAsset = null; - string lowerAssetType = assetType.ToLowerInvariant(); - - // Handle common asset types - if (lowerAssetType == "folder") - { - return CreateFolder(path); // Use dedicated method - } - else if (lowerAssetType == "material") - { - // Prefer provided shader; fall back to common pipelines - var requested = properties?["shader"]?.ToString(); - Shader shader = - (!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null) - ?? Shader.Find("Universal Render Pipeline/Lit") - ?? Shader.Find("HDRP/Lit") - ?? Shader.Find("Standard") - ?? Shader.Find("Unlit/Color"); - if (shader == null) - return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); - - var mat = new Material(shader); - if (properties != null) - ApplyMaterialProperties(mat, properties); - AssetDatabase.CreateAsset(mat, fullPath); - newAsset = mat; - } - else if (lowerAssetType == "physicsmaterial") - { - PhysicsMaterialType pmat = new PhysicsMaterialType(); - if (properties != null) - ApplyPhysicsMaterialProperties(pmat, properties); - AssetDatabase.CreateAsset(pmat, fullPath); - newAsset = pmat; - } - else if (lowerAssetType == "scriptableobject") - { - string scriptClassName = properties?["scriptClass"]?.ToString(); - if (string.IsNullOrEmpty(scriptClassName)) - return Response.Error( - "'scriptClass' property required when creating ScriptableObject asset." - ); - - Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null; - if ( - scriptType == null - || !typeof(ScriptableObject).IsAssignableFrom(scriptType) - ) - { - var reason = scriptType == null - ? (string.IsNullOrEmpty(error) ? "Type not found." : error) - : "Type found but does not inherit from ScriptableObject."; - return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); - } - - ScriptableObject so = ScriptableObject.CreateInstance(scriptType); - // TODO: Apply properties from JObject to the ScriptableObject instance? - AssetDatabase.CreateAsset(so, fullPath); - newAsset = so; - } - else if (lowerAssetType == "prefab") - { - // Creating prefabs usually involves saving an existing GameObject hierarchy. - // A common pattern is to create an empty GameObject, configure it, and then save it. - return Response.Error( - "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." - ); - // Example (conceptual): - // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); - // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); - } - // TODO: Add more asset types (Animation Controller, Scene, etc.) - else - { - // Generic creation attempt (might fail or create empty files) - // For some types, just creating the file might be enough if Unity imports it. - // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); - // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it - // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); - return Response.Error( - $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." - ); - } - - if ( - newAsset == null - && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) - ) // Check if it wasn't a folder and asset wasn't created - { - return Response.Error( - $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." - ); - } - - AssetDatabase.SaveAssets(); - // AssetDatabase.Refresh(); // CreateAsset often handles refresh - return Response.Success( - $"Asset '{fullPath}' created successfully.", - GetAssetData(fullPath) - ); - } - catch (Exception e) - { - return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); - } - } - - private static object CreateFolder(string path) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create_folder."); - string fullPath = SanitizeAssetPath(path); - string parentDir = Path.GetDirectoryName(fullPath); - string folderName = Path.GetFileName(fullPath); - - if (AssetExists(fullPath)) - { - // Check if it's actually a folder already - if (AssetDatabase.IsValidFolder(fullPath)) - { - return Response.Success( - $"Folder already exists at path: {fullPath}", - GetAssetData(fullPath) - ); - } - else - { - return Response.Error( - $"An asset (not a folder) already exists at path: {fullPath}" - ); - } - } - - try - { - // Ensure parent exists - if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) - { - // Recursively create parent folders if needed (AssetDatabase handles this internally) - // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); - } - - string guid = AssetDatabase.CreateFolder(parentDir, folderName); - if (string.IsNullOrEmpty(guid)) - { - return Response.Error( - $"Failed to create folder '{fullPath}'. Check logs and permissions." - ); - } - - // AssetDatabase.Refresh(); // CreateFolder usually handles refresh - return Response.Success( - $"Folder '{fullPath}' created successfully.", - GetAssetData(fullPath) - ); - } - catch (Exception e) - { - return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); - } - } - - private static object ModifyAsset(string path, JObject properties) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for modify."); - if (properties == null || !properties.HasValues) - return Response.Error("'properties' are required for modify."); - - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( - fullPath - ); - if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); - - bool modified = false; // Flag to track if any changes were made - - // --- NEW: Handle GameObject / Prefab Component Modification --- - if (asset is GameObject gameObject) - { - // Iterate through the properties JSON: keys are component names, values are properties objects for that component - foreach (var prop in properties.Properties()) - { - string componentName = prop.Name; // e.g., "Collectible" - // Check if the value associated with the component name is actually an object containing properties - if ( - prop.Value is JObject componentProperties - && componentProperties.HasValues - ) // e.g., {"bobSpeed": 2.0} - { - // Resolve component type via ComponentResolver, then fetch by Type - Component targetComponent = null; - bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); - if (resolved) - { - targetComponent = gameObject.GetComponent(compType); - } - - // Only warn about resolution failure if component also not found - if (targetComponent == null && !resolved) - { - Debug.LogWarning( - $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" - ); - } - - if (targetComponent != null) - { - // Apply the nested properties (e.g., bobSpeed) to the found component instance - // Use |= to ensure 'modified' becomes true if any component is successfully modified - modified |= ApplyObjectProperties( - targetComponent, - componentProperties - ); - } - else - { - // Log a warning if a specified component couldn't be found - Debug.LogWarning( - $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." - ); - } - } - else - { - // Log a warning if the structure isn't {"ComponentName": {"prop": value}} - // We could potentially try to apply this property directly to the GameObject here if needed, - // but the primary goal is component modification. - Debug.LogWarning( - $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." - ); - } - } - // Note: 'modified' is now true if ANY component property was successfully changed. - } - // --- End NEW --- - - // --- Existing logic for other asset types (now as else-if) --- - // Example: Modifying a Material - else if (asset is Material material) - { - // Apply properties directly to the material. If this modifies, it sets modified=true. - // Use |= in case the asset was already marked modified by previous logic (though unlikely here) - modified |= ApplyMaterialProperties(material, properties); - } - // Example: Modifying a ScriptableObject - else if (asset is ScriptableObject so) - { - // Apply properties directly to the ScriptableObject. - modified |= ApplyObjectProperties(so, properties); // General helper - } - // Example: Modifying TextureImporter settings - else if (asset is Texture) - { - AssetImporter importer = AssetImporter.GetAtPath(fullPath); - if (importer is TextureImporter textureImporter) - { - bool importerModified = ApplyObjectProperties(textureImporter, properties); - if (importerModified) - { - // Importer settings need saving and reimporting - AssetDatabase.WriteImportSettingsIfDirty(fullPath); - AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes - modified = true; // Mark overall operation as modified - } - } - else - { - Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); - } - } - // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) - else // Fallback for other asset types OR direct properties on non-GameObject assets - { - // This block handles non-GameObject/Material/ScriptableObject/Texture assets. - // Attempts to apply properties directly to the asset itself. - Debug.LogWarning( - $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." - ); - modified |= ApplyObjectProperties(asset, properties); - } - // --- End Existing Logic --- - - // Check if any modification happened (either component or direct asset modification) - if (modified) - { - // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. - EditorUtility.SetDirty(asset); - // Save all modified assets to disk. - AssetDatabase.SaveAssets(); - // Refresh might be needed in some edge cases, but SaveAssets usually covers it. - // AssetDatabase.Refresh(); - return Response.Success( - $"Asset '{fullPath}' modified successfully.", - GetAssetData(fullPath) - ); - } - else - { - // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. - return Response.Success( - $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", - GetAssetData(fullPath) - ); - // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); - } - } - catch (Exception e) - { - // Log the detailed error internally - Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); - // Return a user-friendly error message - return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); - } - } - - private static object DeleteAsset(string path) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for delete."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - bool success = AssetDatabase.DeleteAsset(fullPath); - if (success) - { - // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh - return Response.Success($"Asset '{fullPath}' deleted successfully."); - } - else - { - // This might happen if the file couldn't be deleted (e.g., locked) - return Response.Error( - $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." - ); - } - } - catch (Exception e) - { - return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); - } - } - - private static object DuplicateAsset(string path, string destinationPath) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for duplicate."); - - string sourcePath = SanitizeAssetPath(path); - if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); - - string destPath; - if (string.IsNullOrEmpty(destinationPath)) - { - // Generate a unique path if destination is not provided - destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); - } - else - { - destPath = SanitizeAssetPath(destinationPath); - if (AssetExists(destPath)) - return Response.Error($"Asset already exists at destination path: {destPath}"); - // Ensure destination directory exists - EnsureDirectoryExists(Path.GetDirectoryName(destPath)); - } - - try - { - bool success = AssetDatabase.CopyAsset(sourcePath, destPath); - if (success) - { - // AssetDatabase.Refresh(); - return Response.Success( - $"Asset '{sourcePath}' duplicated to '{destPath}'.", - GetAssetData(destPath) - ); - } - else - { - return Response.Error( - $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." - ); - } - } - catch (Exception e) - { - return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); - } - } - - private static object MoveOrRenameAsset(string path, string destinationPath) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for move/rename."); - if (string.IsNullOrEmpty(destinationPath)) - return Response.Error("'destination' path is required for move/rename."); - - string sourcePath = SanitizeAssetPath(path); - string destPath = SanitizeAssetPath(destinationPath); - - if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); - if (AssetExists(destPath)) - return Response.Error( - $"An asset already exists at the destination path: {destPath}" - ); - - // Ensure destination directory exists - EnsureDirectoryExists(Path.GetDirectoryName(destPath)); - - try - { - // Validate will return an error string if failed, null if successful - string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); - if (!string.IsNullOrEmpty(error)) - { - return Response.Error( - $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" - ); - } - - string guid = AssetDatabase.MoveAsset(sourcePath, destPath); - if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success - { - // AssetDatabase.Refresh(); // MoveAsset usually handles refresh - return Response.Success( - $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", - GetAssetData(destPath) - ); - } - else - { - // This case might not be reachable if ValidateMoveAsset passes, but good to have - return Response.Error( - $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." - ); - } - } - catch (Exception e) - { - return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); - } - } - - private static object SearchAssets(JObject @params) - { - string searchPattern = @params["searchPattern"]?.ToString(); - string filterType = @params["filterType"]?.ToString(); - string pathScope = @params["path"]?.ToString(); // Use path as folder scope - string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); - int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size - int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) - bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; - - List searchFilters = new List(); - if (!string.IsNullOrEmpty(searchPattern)) - searchFilters.Add(searchPattern); - if (!string.IsNullOrEmpty(filterType)) - searchFilters.Add($"t:{filterType}"); - - string[] folderScope = null; - if (!string.IsNullOrEmpty(pathScope)) - { - folderScope = new string[] { SanitizeAssetPath(pathScope) }; - if (!AssetDatabase.IsValidFolder(folderScope[0])) - { - // Maybe the user provided a file path instead of a folder? - // We could search in the containing folder, or return an error. - Debug.LogWarning( - $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." - ); - folderScope = null; // Search everywhere if path isn't a folder - } - } - - DateTime? filterDateAfter = null; - if (!string.IsNullOrEmpty(filterDateAfterStr)) - { - if ( - DateTime.TryParse( - filterDateAfterStr, - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out DateTime parsedDate - ) - ) - { - filterDateAfter = parsedDate; - } - else - { - Debug.LogWarning( - $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." - ); - } - } - - try - { - string[] guids = AssetDatabase.FindAssets( - string.Join(" ", searchFilters), - folderScope - ); - List results = new List(); - int totalFound = 0; - - foreach (string guid in guids) - { - string assetPath = AssetDatabase.GUIDToAssetPath(guid); - if (string.IsNullOrEmpty(assetPath)) - continue; - - // Apply date filter if present - if (filterDateAfter.HasValue) - { - DateTime lastWriteTime = File.GetLastWriteTimeUtc( - Path.Combine(Directory.GetCurrentDirectory(), assetPath) - ); - if (lastWriteTime <= filterDateAfter.Value) - { - continue; // Skip assets older than or equal to the filter date - } - } - - totalFound++; // Count matching assets before pagination - results.Add(GetAssetData(assetPath, generatePreview)); - } - - // Apply pagination - int startIndex = (pageNumber - 1) * pageSize; - var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); - - return Response.Success( - $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", - new - { - totalAssets = totalFound, - pageSize = pageSize, - pageNumber = pageNumber, - assets = pagedResults, - } - ); - } - catch (Exception e) - { - return Response.Error($"Error searching assets: {e.Message}"); - } - } - - private static object GetAssetInfo(string path, bool generatePreview) - { - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_info."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - return Response.Success( - "Asset info retrieved.", - GetAssetData(fullPath, generatePreview) - ); - } - catch (Exception e) - { - return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); - } - } - - /// - /// Retrieves components attached to a GameObject asset (like a Prefab). - /// - /// The asset path of the GameObject or Prefab. - /// A response object containing a list of component type names or an error. - private static object GetComponentsFromAsset(string path) - { - // 1. Validate input path - if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_components."); - - // 2. Sanitize and check existence - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - // 3. Load the asset - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( - fullPath - ); - if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); - - // 4. Check if it's a GameObject (Prefabs load as GameObjects) - GameObject gameObject = asset as GameObject; - if (gameObject == null) - { - // Also check if it's *directly* a Component type (less common for primary assets) - Component componentAsset = asset as Component; - if (componentAsset != null) - { - // If the asset itself *is* a component, maybe return just its info? - // This is an edge case. Let's stick to GameObjects for now. - return Response.Error( - $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." - ); - } - return Response.Error( - $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." - ); - } - - // 5. Get components - Component[] components = gameObject.GetComponents(); - - // 6. Format component data - List componentList = components - .Select(comp => new - { - typeName = comp.GetType().FullName, - instanceID = comp.GetInstanceID(), - // TODO: Add more component-specific details here if needed in the future? - // Requires reflection or specific handling per component type. - }) - .ToList(); // Explicit cast for clarity if needed - - // 7. Return success response - return Response.Success( - $"Found {componentList.Count} component(s) on asset '{fullPath}'.", - componentList - ); - } - catch (Exception e) - { - Debug.LogError( - $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" - ); - return Response.Error( - $"Error getting components for asset '{fullPath}': {e.Message}" - ); - } - } - - // --- Internal Helpers --- - - /// - /// Ensures the asset path starts with "Assets/". - /// - private static string SanitizeAssetPath(string path) - { - if (string.IsNullOrEmpty(path)) - return path; - path = path.Replace('\\', '/'); // Normalize separators - if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - return "Assets/" + path.TrimStart('/'); - } - return path; - } - - /// - /// Checks if an asset exists at the given path (file or folder). - /// - private static bool AssetExists(string sanitizedPath) - { - // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. - // Check if it's a known asset GUID. - if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) - { - return true; - } - // AssetPathToGUID might not work for newly created folders not yet refreshed. - // Check directory explicitly for folders. - if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) - { - // Check if it's considered a *valid* folder by Unity - return AssetDatabase.IsValidFolder(sanitizedPath); - } - // Check file existence for non-folder assets. - if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) - { - return true; // Assume if file exists, it's an asset or will be imported - } - - return false; - // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); - } - - /// - /// Ensures the directory for a given asset path exists, creating it if necessary. - /// - private static void EnsureDirectoryExists(string directoryPath) - { - if (string.IsNullOrEmpty(directoryPath)) - return; - string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); - if (!Directory.Exists(fullDirPath)) - { - Directory.CreateDirectory(fullDirPath); - AssetDatabase.Refresh(); // Let Unity know about the new folder - } - } - - /// - /// Applies properties from JObject to a Material. - /// - private static bool ApplyMaterialProperties(Material mat, JObject properties) - { - if (mat == null || properties == null) - return false; - bool modified = false; - - // Example: Set shader - if (properties["shader"]?.Type == JTokenType.String) - { - Shader newShader = Shader.Find(properties["shader"].ToString()); - if (newShader != null && mat.shader != newShader) - { - mat.shader = newShader; - modified = true; - } - } - // Example: Set color property - if (properties["color"] is JObject colorProps) - { - string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color - if (colorProps["value"] is JArray colArr && colArr.Count >= 3) - { - try - { - Color newColor = new Color( - colArr[0].ToObject(), - colArr[1].ToObject(), - colArr[2].ToObject(), - colArr.Count > 3 ? colArr[3].ToObject() : 1.0f - ); - if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) - { - mat.SetColor(propName, newColor); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Error parsing color property '{propName}': {ex.Message}" - ); - } - } - } else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py - { - string propName = "_Color"; - try { - if (colorArr.Count >= 3) - { - Color newColor = new Color( - colorArr[0].ToObject(), - colorArr[1].ToObject(), - colorArr[2].ToObject(), - colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f - ); - if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) - { - mat.SetColor(propName, newColor); - modified = true; - } - } - } - catch (Exception ex) { - Debug.LogWarning( - $"Error parsing color property '{propName}': {ex.Message}" - ); - } - } - // Example: Set float property - if (properties["float"] is JObject floatProps) - { - string propName = floatProps["name"]?.ToString(); - if ( - !string.IsNullOrEmpty(propName) && - (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) - ) - { - try - { - float newVal = floatProps["value"].ToObject(); - if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) - { - mat.SetFloat(propName, newVal); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Error parsing float property '{propName}': {ex.Message}" - ); - } - } - } - // Example: Set texture property - if (properties["texture"] is JObject texProps) - { - string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture - string texPath = texProps["path"]?.ToString(); - if (!string.IsNullOrEmpty(texPath)) - { - Texture newTex = AssetDatabase.LoadAssetAtPath( - SanitizeAssetPath(texPath) - ); - if ( - newTex != null - && mat.HasProperty(propName) - && mat.GetTexture(propName) != newTex - ) - { - mat.SetTexture(propName, newTex); - modified = true; - } - else if (newTex == null) - { - Debug.LogWarning($"Texture not found at path: {texPath}"); - } - } - } - - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) - return modified; - } - - /// - /// Applies properties from JObject to a PhysicsMaterial. - /// - private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) - { - if (pmat == null || properties == null) - return false; - bool modified = false; - - // Example: Set dynamic friction - if (properties["dynamicFriction"]?.Type == JTokenType.Float) - { - float dynamicFriction = properties["dynamicFriction"].ToObject(); - pmat.dynamicFriction = dynamicFriction; - modified = true; - } - - // Example: Set static friction - if (properties["staticFriction"]?.Type == JTokenType.Float) - { - float staticFriction = properties["staticFriction"].ToObject(); - pmat.staticFriction = staticFriction; - modified = true; - } - - // Example: Set bounciness - if (properties["bounciness"]?.Type == JTokenType.Float) - { - float bounciness = properties["bounciness"].ToObject(); - pmat.bounciness = bounciness; - modified = true; - } - - List averageList = new List { "ave", "Ave", "average", "Average" }; - List multiplyList = new List { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; - List minimumList = new List { "min", "Min", "minimum", "Minimum" }; - List maximumList = new List { "max", "Max", "maximum", "Maximum" }; - - // Example: Set friction combine - if (properties["frictionCombine"]?.Type == JTokenType.String) - { - string frictionCombine = properties["frictionCombine"].ToString(); - if (averageList.Contains(frictionCombine)) - pmat.frictionCombine = PhysicsMaterialCombine.Average; - else if (multiplyList.Contains(frictionCombine)) - pmat.frictionCombine = PhysicsMaterialCombine.Multiply; - else if (minimumList.Contains(frictionCombine)) - pmat.frictionCombine = PhysicsMaterialCombine.Minimum; - else if (maximumList.Contains(frictionCombine)) - pmat.frictionCombine = PhysicsMaterialCombine.Maximum; - modified = true; - } - - // Example: Set bounce combine - if (properties["bounceCombine"]?.Type == JTokenType.String) - { - string bounceCombine = properties["bounceCombine"].ToString(); - if (averageList.Contains(bounceCombine)) - pmat.bounceCombine = PhysicsMaterialCombine.Average; - else if (multiplyList.Contains(bounceCombine)) - pmat.bounceCombine = PhysicsMaterialCombine.Multiply; - else if (minimumList.Contains(bounceCombine)) - pmat.bounceCombine = PhysicsMaterialCombine.Minimum; - else if (maximumList.Contains(bounceCombine)) - pmat.bounceCombine = PhysicsMaterialCombine.Maximum; - modified = true; - } - - return modified; - } - - /// - /// Generic helper to set properties on any UnityEngine.Object using reflection. - /// - private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) - { - if (target == null || properties == null) - return false; - bool modified = false; - Type type = target.GetType(); - - foreach (var prop in properties.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - if (SetPropertyOrField(target, propName, propValue, type)) - { - modified = true; - } - } - return modified; - } - - /// - /// Helper to set a property or field via reflection, handling basic types and Unity objects. - /// - private static bool SetPropertyOrField( - object target, - string memberName, - JToken value, - Type type = null - ) - { - type = type ?? target.GetType(); - System.Reflection.BindingFlags flags = - System.Reflection.BindingFlags.Public - | System.Reflection.BindingFlags.Instance - | System.Reflection.BindingFlags.IgnoreCase; - - try - { - System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); - if (propInfo != null && propInfo.CanWrite) - { - object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); - if ( - convertedValue != null - && !object.Equals(propInfo.GetValue(target), convertedValue) - ) - { - propInfo.SetValue(target, convertedValue); - return true; - } - } - else - { - System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); - if (fieldInfo != null) - { - object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); - if ( - convertedValue != null - && !object.Equals(fieldInfo.GetValue(target), convertedValue) - ) - { - fieldInfo.SetValue(target, convertedValue); - return true; - } - } - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" - ); - } - return false; - } - - /// - /// Simple JToken to Type conversion for common Unity types and primitives. - /// - private static object ConvertJTokenToType(JToken token, Type targetType) - { - try - { - if (token == null || token.Type == JTokenType.Null) - return null; - - if (targetType == typeof(string)) - return token.ToObject(); - if (targetType == typeof(int)) - return token.ToObject(); - if (targetType == typeof(float)) - return token.ToObject(); - if (targetType == typeof(bool)) - return token.ToObject(); - if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) - return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); - if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) - return new Vector3( - arrV3[0].ToObject(), - arrV3[1].ToObject(), - arrV3[2].ToObject() - ); - if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) - return new Vector4( - arrV4[0].ToObject(), - arrV4[1].ToObject(), - arrV4[2].ToObject(), - arrV4[3].ToObject() - ); - if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) - return new Quaternion( - arrQ[0].ToObject(), - arrQ[1].ToObject(), - arrQ[2].ToObject(), - arrQ[3].ToObject() - ); - if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA - return new Color( - arrC[0].ToObject(), - arrC[1].ToObject(), - arrC[2].ToObject(), - arrC.Count > 3 ? arrC[3].ToObject() : 1.0f - ); - if (targetType.IsEnum) - return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing - - // Handle loading Unity Objects (Materials, Textures, etc.) by path - if ( - typeof(UnityEngine.Object).IsAssignableFrom(targetType) - && token.Type == JTokenType.String - ) - { - string assetPath = SanitizeAssetPath(token.ToString()); - UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( - assetPath, - targetType - ); - if (loadedAsset == null) - { - Debug.LogWarning( - $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" - ); - } - return loadedAsset; - } - - // Fallback: Try direct conversion (might work for other simple value types) - return token.ToObject(targetType); - } - catch (Exception ex) - { - Debug.LogWarning( - $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" - ); - return null; - } - } - - - // --- Data Serialization --- - - /// - /// Creates a serializable representation of an asset. - /// - private static object GetAssetData(string path, bool generatePreview = false) - { - if (string.IsNullOrEmpty(path) || !AssetExists(path)) - return null; - - string guid = AssetDatabase.AssetPathToGUID(path); - Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); - string previewBase64 = null; - int previewWidth = 0; - int previewHeight = 0; - - if (generatePreview && asset != null) - { - Texture2D preview = AssetPreview.GetAssetPreview(asset); - - if (preview != null) - { - try - { - // Ensure texture is readable for EncodeToPNG - // Creating a temporary readable copy is safer - RenderTexture rt = null; - Texture2D readablePreview = null; - RenderTexture previous = RenderTexture.active; - try - { - rt = RenderTexture.GetTemporary(preview.width, preview.height); - Graphics.Blit(preview, rt); - RenderTexture.active = rt; - readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); - readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); - readablePreview.Apply(); - - var pngData = readablePreview.EncodeToPNG(); - if (pngData != null && pngData.Length > 0) - { - previewBase64 = Convert.ToBase64String(pngData); - previewWidth = readablePreview.width; - previewHeight = readablePreview.height; - } - } - finally - { - RenderTexture.active = previous; - if (rt != null) RenderTexture.ReleaseTemporary(rt); - if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." - ); - // Fallback: Try getting static preview if available? - // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); - } - } - else - { - Debug.LogWarning( - $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" - ); - } - } - - return new - { - path = path, - guid = guid, - assetType = assetType?.FullName ?? "Unknown", - name = Path.GetFileNameWithoutExtension(path), - fileName = Path.GetFileName(path), - isFolder = AssetDatabase.IsValidFolder(path), - instanceID = asset?.GetInstanceID() ?? 0, - lastWriteTimeUtc = File.GetLastWriteTimeUtc( - Path.Combine(Directory.GetCurrentDirectory(), path) - ) - .ToString("o"), // ISO 8601 - // --- Preview Data --- - previewBase64 = previewBase64, // PNG data as Base64 string - previewWidth = previewWidth, - previewHeight = previewHeight, - // TODO: Add more metadata? Importer settings? Dependencies? - }; - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta deleted file mode 100644 index 3dbc2e2f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: de90a1d9743a2874cb235cf0b83444b1 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs deleted file mode 100644 index 7ed6300b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ /dev/null @@ -1,613 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.IO; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditorInternal; // Required for tag management -using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles operations related to controlling and querying the Unity Editor state, - /// including managing Tags and Layers. - /// - public static class ManageEditor - { - // Constant for starting user layer index - private const int FirstUserLayerIndex = 8; - - // Constant for total layer count - private const int TotalLayerCount = 32; - - /// - /// Main handler for editor management actions. - /// - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString().ToLower(); - // Parameters for specific actions - string tagName = @params["tagName"]?.ToString(); - string layerName = @params["layerName"]?.ToString(); - bool waitForCompletion = @params["waitForCompletion"]?.ToObject() ?? false; // Example - not used everywhere - - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - // Route action - switch (action) - { - // Play Mode Control - case "play": - try - { - if (!EditorApplication.isPlaying) - { - EditorApplication.isPlaying = true; - return Response.Success("Entered play mode."); - } - return Response.Success("Already in play mode."); - } - catch (Exception e) - { - return Response.Error($"Error entering play mode: {e.Message}"); - } - case "pause": - try - { - if (EditorApplication.isPlaying) - { - EditorApplication.isPaused = !EditorApplication.isPaused; - return Response.Success( - EditorApplication.isPaused ? "Game paused." : "Game resumed." - ); - } - return Response.Error("Cannot pause/resume: Not in play mode."); - } - catch (Exception e) - { - return Response.Error($"Error pausing/resuming game: {e.Message}"); - } - case "stop": - try - { - if (EditorApplication.isPlaying) - { - EditorApplication.isPlaying = false; - return Response.Success("Exited play mode."); - } - return Response.Success("Already stopped (not in play mode)."); - } - catch (Exception e) - { - return Response.Error($"Error stopping play mode: {e.Message}"); - } - - // Editor State/Info - case "get_state": - return GetEditorState(); - case "get_project_root": - return GetProjectRoot(); - case "get_windows": - return GetEditorWindows(); - case "get_active_tool": - return GetActiveTool(); - case "get_selection": - return GetSelection(); - case "set_active_tool": - string toolName = @params["toolName"]?.ToString(); - if (string.IsNullOrEmpty(toolName)) - return Response.Error("'toolName' parameter required for set_active_tool."); - return SetActiveTool(toolName); - - // Tag Management - case "add_tag": - if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for add_tag."); - return AddTag(tagName); - case "remove_tag": - if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for remove_tag."); - return RemoveTag(tagName); - case "get_tags": - return GetTags(); // Helper to list current tags - - // Layer Management - case "add_layer": - if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for add_layer."); - return AddLayer(layerName); - case "remove_layer": - if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for remove_layer."); - return RemoveLayer(layerName); - case "get_layers": - return GetLayers(); // Helper to list current layers - - // --- Settings (Example) --- - // case "set_resolution": - // int? width = @params["width"]?.ToObject(); - // int? height = @params["height"]?.ToObject(); - // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); - // return SetGameViewResolution(width.Value, height.Value); - // case "set_quality": - // // Handle string name or int index - // return SetQualityLevel(@params["qualityLevel"]); - - default: - return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." - ); - } - } - - // --- Editor State/Info Methods --- - private static object GetEditorState() - { - try - { - var state = new - { - isPlaying = EditorApplication.isPlaying, - isPaused = EditorApplication.isPaused, - isCompiling = EditorApplication.isCompiling, - isUpdating = EditorApplication.isUpdating, - applicationPath = EditorApplication.applicationPath, - applicationContentsPath = EditorApplication.applicationContentsPath, - timeSinceStartup = EditorApplication.timeSinceStartup, - }; - return Response.Success("Retrieved editor state.", state); - } - catch (Exception e) - { - return Response.Error($"Error getting editor state: {e.Message}"); - } - } - - private static object GetProjectRoot() - { - try - { - // Application.dataPath points to /Assets - string assetsPath = Application.dataPath.Replace('\\', '/'); - string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); - if (string.IsNullOrEmpty(projectRoot)) - { - return Response.Error("Could not determine project root from Application.dataPath"); - } - return Response.Success("Project root resolved.", new { projectRoot }); - } - catch (Exception e) - { - return Response.Error($"Error getting project root: {e.Message}"); - } - } - - private static object GetEditorWindows() - { - try - { - // Get all types deriving from EditorWindow - var windowTypes = AppDomain - .CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => type.IsSubclassOf(typeof(EditorWindow))) - .ToList(); - - var openWindows = new List(); - - // Find currently open instances - // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows - EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll(); - - foreach (EditorWindow window in allWindows) - { - if (window == null) - continue; // Skip potentially destroyed windows - - try - { - openWindows.Add( - new - { - title = window.titleContent.text, - typeName = window.GetType().FullName, - isFocused = EditorWindow.focusedWindow == window, - position = new - { - x = window.position.x, - y = window.position.y, - width = window.position.width, - height = window.position.height, - }, - instanceID = window.GetInstanceID(), - } - ); - } - catch (Exception ex) - { - Debug.LogWarning( - $"Could not get info for window {window.GetType().Name}: {ex.Message}" - ); - } - } - - return Response.Success("Retrieved list of open editor windows.", openWindows); - } - catch (Exception e) - { - return Response.Error($"Error getting editor windows: {e.Message}"); - } - } - - private static object GetActiveTool() - { - try - { - Tool currentTool = UnityEditor.Tools.current; - string toolName = currentTool.ToString(); // Enum to string - bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active - string activeToolName = customToolActive - ? EditorTools.GetActiveToolName() - : toolName; // Get custom name if needed - - var toolInfo = new - { - activeTool = activeToolName, - isCustom = customToolActive, - pivotMode = UnityEditor.Tools.pivotMode.ToString(), - pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), - handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity - handlePosition = UnityEditor.Tools.handlePosition, - }; - - return Response.Success("Retrieved active tool information.", toolInfo); - } - catch (Exception e) - { - return Response.Error($"Error getting active tool: {e.Message}"); - } - } - - private static object SetActiveTool(string toolName) - { - try - { - Tool targetTool; - if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse - { - // Check if it's a valid built-in tool - if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool - { - UnityEditor.Tools.current = targetTool; - return Response.Success($"Set active tool to '{targetTool}'."); - } - else - { - return Response.Error( - $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." - ); - } - } - else - { - // Potentially try activating a custom tool by name here if needed - // This often requires specific editor scripting knowledge for that tool. - return Response.Error( - $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." - ); - } - } - catch (Exception e) - { - return Response.Error($"Error setting active tool: {e.Message}"); - } - } - - private static object GetSelection() - { - try - { - var selectionInfo = new - { - activeObject = Selection.activeObject?.name, - activeGameObject = Selection.activeGameObject?.name, - activeTransform = Selection.activeTransform?.name, - activeInstanceID = Selection.activeInstanceID, - count = Selection.count, - objects = Selection - .objects.Select(obj => new - { - name = obj?.name, - type = obj?.GetType().FullName, - instanceID = obj?.GetInstanceID(), - }) - .ToList(), - gameObjects = Selection - .gameObjects.Select(go => new - { - name = go?.name, - instanceID = go?.GetInstanceID(), - }) - .ToList(), - assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view - }; - - return Response.Success("Retrieved current selection details.", selectionInfo); - } - catch (Exception e) - { - return Response.Error($"Error getting selection: {e.Message}"); - } - } - - // --- Tag Management Methods --- - - private static object AddTag(string tagName) - { - if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); - - // Check if tag already exists - if (InternalEditorUtility.tags.Contains(tagName)) - { - return Response.Error($"Tag '{tagName}' already exists."); - } - - try - { - // Add the tag using the internal utility - InternalEditorUtility.AddTag(tagName); - // Force save assets to ensure the change persists in the TagManager asset - AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' added successfully."); - } - catch (Exception e) - { - return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); - } - } - - private static object RemoveTag(string tagName) - { - if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); - if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) - return Response.Error("Cannot remove the built-in 'Untagged' tag."); - - // Check if tag exists before attempting removal - if (!InternalEditorUtility.tags.Contains(tagName)) - { - return Response.Error($"Tag '{tagName}' does not exist."); - } - - try - { - // Remove the tag using the internal utility - InternalEditorUtility.RemoveTag(tagName); - // Force save assets - AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' removed successfully."); - } - catch (Exception e) - { - // Catch potential issues if the tag is somehow in use or removal fails - return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); - } - } - - private static object GetTags() - { - try - { - string[] tags = InternalEditorUtility.tags; - return Response.Success("Retrieved current tags.", tags); - } - catch (Exception e) - { - return Response.Error($"Failed to retrieve tags: {e.Message}"); - } - } - - // --- Layer Management Methods --- - - private static object AddLayer(string layerName) - { - if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); - - // Access the TagManager asset - SerializedObject tagManager = GetTagManager(); - if (tagManager == null) - return Response.Error("Could not access TagManager asset."); - - SerializedProperty layersProp = tagManager.FindProperty("layers"); - if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); - - // Check if layer name already exists (case-insensitive check recommended) - for (int i = 0; i < TotalLayerCount; i++) - { - SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); - if ( - layerSP != null - && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) - ) - { - return Response.Error($"Layer '{layerName}' already exists at index {i}."); - } - } - - // Find the first empty user layer slot (indices 8 to 31) - int firstEmptyUserLayer = -1; - for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) - { - SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); - if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) - { - firstEmptyUserLayer = i; - break; - } - } - - if (firstEmptyUserLayer == -1) - { - return Response.Error("No empty User Layer slots available (8-31 are full)."); - } - - // Assign the name to the found slot - try - { - SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( - firstEmptyUserLayer - ); - targetLayerSP.stringValue = layerName; - // Apply the changes to the TagManager asset - tagManager.ApplyModifiedProperties(); - // Save assets to make sure it's written to disk - AssetDatabase.SaveAssets(); - return Response.Success( - $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." - ); - } - catch (Exception e) - { - return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); - } - } - - private static object RemoveLayer(string layerName) - { - if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); - - // Access the TagManager asset - SerializedObject tagManager = GetTagManager(); - if (tagManager == null) - return Response.Error("Could not access TagManager asset."); - - SerializedProperty layersProp = tagManager.FindProperty("layers"); - if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); - - // Find the layer by name (must be user layer) - int layerIndexToRemove = -1; - for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers - { - SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); - // Case-insensitive comparison is safer - if ( - layerSP != null - && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) - ) - { - layerIndexToRemove = i; - break; - } - } - - if (layerIndexToRemove == -1) - { - return Response.Error($"User layer '{layerName}' not found."); - } - - // Clear the name for that index - try - { - SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( - layerIndexToRemove - ); - targetLayerSP.stringValue = string.Empty; // Set to empty string to remove - // Apply the changes - tagManager.ApplyModifiedProperties(); - // Save assets - AssetDatabase.SaveAssets(); - return Response.Success( - $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." - ); - } - catch (Exception e) - { - return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); - } - } - - private static object GetLayers() - { - try - { - var layers = new Dictionary(); - for (int i = 0; i < TotalLayerCount; i++) - { - string layerName = LayerMask.LayerToName(i); - if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names - { - layers.Add(i, layerName); - } - } - return Response.Success("Retrieved current named layers.", layers); - } - catch (Exception e) - { - return Response.Error($"Failed to retrieve layers: {e.Message}"); - } - } - - // --- Helper Methods --- - - /// - /// Gets the SerializedObject for the TagManager asset. - /// - private static SerializedObject GetTagManager() - { - try - { - // Load the TagManager asset from the ProjectSettings folder - UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( - "ProjectSettings/TagManager.asset" - ); - if (tagManagerAssets == null || tagManagerAssets.Length == 0) - { - Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); - return null; - } - // The first object in the asset file should be the TagManager - return new SerializedObject(tagManagerAssets[0]); - } - catch (Exception e) - { - Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); - return null; - } - } - - // --- Example Implementations for Settings --- - /* - private static object SetGameViewResolution(int width, int height) { ... } - private static object SetQualityLevel(JToken qualityLevelToken) { ... } - */ - } - - // Helper class to get custom tool names (remains the same) - internal static class EditorTools - { - public static string GetActiveToolName() - { - // This is a placeholder. Real implementation depends on how custom tools - // are registered and tracked in the specific Unity project setup. - // It might involve checking static variables, calling methods on specific tool managers, etc. - if (UnityEditor.Tools.current == Tool.Custom) - { - // Example: Check a known custom tool manager - // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; - return "Unknown Custom Tool"; - } - return UnityEditor.Tools.current.ToString(); - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta deleted file mode 100644 index 8b55fb87..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 43ac60aa36b361b4dbe4a038ae9f35c8 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs deleted file mode 100644 index c3357ed9..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ /dev/null @@ -1,2460 +0,0 @@ -#nullable disable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json; // Added for JsonSerializationException -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.Compilation; // For CompilationPipeline -using UnityEditor.SceneManagement; -using UnityEditorInternal; -using UnityEngine; -using UnityEngine.SceneManagement; -using MCPForUnity.Editor.Helpers; // For Response class -using MCPForUnity.Runtime.Serialization; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles GameObject manipulation within the current scene (CRUD, find, components). - /// - public static class ManageGameObject - { - // Shared JsonSerializer to avoid per-call allocation overhead - private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings - { - Converters = new List - { - new Vector3Converter(), - new Vector2Converter(), - new QuaternionConverter(), - new ColorConverter(), - new RectConverter(), - new BoundsConverter(), - new UnityEngineObjectConverter() - } - }); - - // --- Main Handler --- - - public static object HandleCommand(JObject @params) - { - if (@params == null) - { - return Response.Error("Parameters cannot be null."); - } - - string action = @params["action"]?.ToString().ToLower(); - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - // Parameters used by various actions - JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) - string searchMethod = @params["searchMethod"]?.ToString().ToLower(); - - // Get common parameters (consolidated) - string name = @params["name"]?.ToString(); - string tag = @params["tag"]?.ToString(); - string layer = @params["layer"]?.ToString(); - JToken parentToken = @params["parent"]; - - // --- Add parameter for controlling non-public field inclusion --- - bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true - // --- End add parameter --- - - // --- Prefab Redirection Check --- - string targetPath = - targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; - if ( - !string.IsNullOrEmpty(targetPath) - && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) - ) - { - // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) - if (action == "modify" || action == "set_component_property") - { - Debug.Log( - $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." - ); - // Prepare params for ManageAsset.ModifyAsset - JObject assetParams = new JObject(); - assetParams["action"] = "modify"; // ManageAsset uses "modify" - assetParams["path"] = targetPath; - - // Extract properties. - // For 'set_component_property', combine componentName and componentProperties. - // For 'modify', directly use componentProperties. - JObject properties = null; - if (action == "set_component_property") - { - string compName = @params["componentName"]?.ToString(); - JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting - if (string.IsNullOrEmpty(compName)) - return Response.Error( - "Missing 'componentName' for 'set_component_property' on prefab." - ); - if (compProps == null) - return Response.Error( - $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." - ); - - properties = new JObject(); - properties[compName] = compProps; - } - else // action == "modify" - { - properties = @params["componentProperties"] as JObject; - if (properties == null) - return Response.Error( - "Missing 'componentProperties' for 'modify' action on prefab." - ); - } - - assetParams["properties"] = properties; - - // Call ManageAsset handler - return ManageAsset.HandleCommand(assetParams); - } - else if ( - action == "delete" - || action == "add_component" - || action == "remove_component" - || action == "get_components" - ) // Added get_components here too - { - // Explicitly block other modifications on the prefab asset itself via manage_gameobject - return Response.Error( - $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." - ); - } - // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. - // No specific handling needed here, the code below will run. - } - // --- End Prefab Redirection Check --- - - try - { - switch (action) - { - case "create": - return CreateGameObject(@params); - case "modify": - return ModifyGameObject(@params, targetToken, searchMethod); - case "delete": - return DeleteGameObject(targetToken, searchMethod); - case "find": - return FindGameObjects(@params, targetToken, searchMethod); - case "get_components": - string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string - if (getCompTarget == null) - return Response.Error( - "'target' parameter required for get_components." - ); - // Pass the includeNonPublicSerialized flag here - return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); - case "add_component": - return AddComponentToTarget(@params, targetToken, searchMethod); - case "remove_component": - return RemoveComponentFromTarget(@params, targetToken, searchMethod); - case "set_component_property": - return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); - - default: - return Response.Error($"Unknown action: '{action}'."); - } - } - catch (Exception e) - { - Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object CreateGameObject(JObject @params) - { - string name = @params["name"]?.ToString(); - if (string.IsNullOrEmpty(name)) - { - return Response.Error("'name' parameter is required for 'create' action."); - } - - // Get prefab creation parameters - bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; - string prefabPath = @params["prefabPath"]?.ToString(); - string tag = @params["tag"]?.ToString(); // Get tag for creation - string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check - GameObject newGo = null; // Initialize as null - - // --- Try Instantiating Prefab First --- - string originalPrefabPath = prefabPath; // Keep original for messages - if (!string.IsNullOrEmpty(prefabPath)) - { - // If no extension, search for the prefab by name - if ( - !prefabPath.Contains("/") - && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) - ) - { - string prefabNameOnly = prefabPath; - Debug.Log( - $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" - ); - string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); - if (guids.Length == 0) - { - return Response.Error( - $"Prefab named '{prefabNameOnly}' not found anywhere in the project." - ); - } - else if (guids.Length > 1) - { - string foundPaths = string.Join( - ", ", - guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) - ); - return Response.Error( - $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." - ); - } - else // Exactly one found - { - prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path - Debug.Log( - $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" - ); - } - } - else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. - Debug.LogWarning( - $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." - ); - prefabPath += ".prefab"; - // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. - } - // The logic above now handles finding or assuming the .prefab extension. - - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); - if (prefabAsset != null) - { - try - { - // Instantiate the prefab, initially place it at the root - // Parent will be set later if specified - newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; - - if (newGo == null) - { - // This might happen if the asset exists but isn't a valid GameObject prefab somehow - Debug.LogError( - $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." - ); - return Response.Error( - $"Failed to instantiate prefab at '{prefabPath}'." - ); - } - // Name the instance based on the 'name' parameter, not the prefab's default name - if (!string.IsNullOrEmpty(name)) - { - newGo.name = name; - } - // Register Undo for prefab instantiation - Undo.RegisterCreatedObjectUndo( - newGo, - $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" - ); - Debug.Log( - $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." - ); - } - catch (Exception e) - { - return Response.Error( - $"Error instantiating prefab '{prefabPath}': {e.Message}" - ); - } - } - else - { - // Only return error if prefabPath was specified but not found. - // If prefabPath was empty/null, we proceed to create primitive/empty. - Debug.LogWarning( - $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." - ); - // Do not return error here, allow fallback to primitive/empty creation - } - } - - // --- Fallback: Create Primitive or Empty GameObject --- - bool createdNewObject = false; // Flag to track if we created (not instantiated) - if (newGo == null) // Only proceed if prefab instantiation didn't happen - { - if (!string.IsNullOrEmpty(primitiveType)) - { - try - { - PrimitiveType type = (PrimitiveType) - Enum.Parse(typeof(PrimitiveType), primitiveType, true); - newGo = GameObject.CreatePrimitive(type); - // Set name *after* creation for primitives - if (!string.IsNullOrEmpty(name)) - { - newGo.name = name; - } - else - { - UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak - return Response.Error( - "'name' parameter is required when creating a primitive." - ); - } - createdNewObject = true; - } - catch (ArgumentException) - { - return Response.Error( - $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" - ); - } - catch (Exception e) - { - return Response.Error( - $"Failed to create primitive '{primitiveType}': {e.Message}" - ); - } - } - else // Create empty GameObject - { - if (string.IsNullOrEmpty(name)) - { - return Response.Error( - "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." - ); - } - newGo = new GameObject(name); - createdNewObject = true; - } - // Record creation for Undo *only* if we created a new object - if (createdNewObject) - { - Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); - } - } - // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- - if (newGo == null) - { - // Should theoretically not happen if logic above is correct, but safety check. - return Response.Error("Failed to create or instantiate the GameObject."); - } - - // Record potential changes to the existing prefab instance or the new GO - // Record transform separately in case parent changes affect it - Undo.RecordObject(newGo.transform, "Set GameObject Transform"); - Undo.RecordObject(newGo, "Set GameObject Properties"); - - // Set Parent - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding - if (parentGo == null) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object - return Response.Error($"Parent specified ('{parentToken}') but not found."); - } - newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true - } - - // Set Transform - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue) - newGo.transform.localPosition = position.Value; - if (rotation.HasValue) - newGo.transform.localEulerAngles = rotation.Value; - if (scale.HasValue) - newGo.transform.localScale = scale.Value; - - // Set Tag (added for create action) - if (!string.IsNullOrEmpty(tag)) - { - // Similar logic as in ModifyGameObject for setting/creating tags - string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - try - { - newGo.tag = tagToSet; - } - catch (UnityException ex) - { - if (ex.Message.Contains("is not defined")) - { - Debug.LogWarning( - $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." - ); - try - { - InternalEditorUtility.AddTag(tagToSet); - newGo.tag = tagToSet; // Retry - Debug.Log( - $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." - ); - } - catch (Exception innerEx) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( - $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." - ); - } - } - else - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( - $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." - ); - } - } - } - - // Set Layer (new for create action) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId != -1) - { - newGo.layer = layerId; - } - else - { - Debug.LogWarning( - $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." - ); - } - } - - // Add Components - if (@params["componentsToAdd"] is JArray componentsToAddArray) - { - foreach (var compToken in componentsToAddArray) - { - string typeName = null; - JObject properties = null; - - if (compToken.Type == JTokenType.String) - { - typeName = compToken.ToString(); - } - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(newGo, typeName, properties); - if (addResult != null) // Check if AddComponentInternal returned an error object - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return addResult; // Return the error response - } - } - else - { - Debug.LogWarning( - $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" - ); - } - } - } - - // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true - GameObject finalInstance = newGo; // Use this for selection and return data - if (createdNewObject && saveAsPrefab) - { - string finalPrefabPath = prefabPath; // Use a separate variable for saving path - // This check should now happen *before* attempting to save - if (string.IsNullOrEmpty(finalPrefabPath)) - { - // Clean up the created object before returning error - UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( - "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." - ); - } - // Ensure the *saving* path ends with .prefab - if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - Debug.Log( - $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" - ); - finalPrefabPath += ".prefab"; - } - - try - { - // Ensure directory exists using the final saving path - string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); - if ( - !string.IsNullOrEmpty(directoryPath) - && !System.IO.Directory.Exists(directoryPath) - ) - { - System.IO.Directory.CreateDirectory(directoryPath); - AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder - Debug.Log( - $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" - ); - } - // Use SaveAsPrefabAssetAndConnect with the final saving path - finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( - newGo, - finalPrefabPath, - InteractionMode.UserAction - ); - - if (finalInstance == null) - { - // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) - UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( - $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." - ); - } - Debug.Log( - $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." - ); - // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. - // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect - } - catch (Exception e) - { - // Clean up the instance if prefab saving fails - UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt - return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); - } - } - - // Select the instance in the scene (either prefab instance or newly created/saved one) - Selection.activeGameObject = finalInstance; - - // Determine appropriate success message using the potentially updated or original path - string messagePrefabPath = - finalInstance == null - ? originalPrefabPath - : AssetDatabase.GetAssetPath( - PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) - ?? (UnityEngine.Object)finalInstance - ); - string successMessage; - if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab - { - successMessage = - $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; - } - else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab - { - successMessage = - $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; - } - else // Created new primitive or empty GO, didn't save as prefab - { - successMessage = - $"GameObject '{finalInstance.name}' created successfully in scene."; - } - - // Use the new serializer helper - //return Response.Success(successMessage, GetGameObjectData(finalInstance)); - return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); - } - - private static object ModifyGameObject( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return Response.Error( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - // Record state for Undo *before* modifications - Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); - Undo.RecordObject(targetGo, "Modify GameObject Properties"); - - bool modified = false; - - // Rename (using consolidated 'name' parameter) - string name = @params["name"]?.ToString(); - if (!string.IsNullOrEmpty(name) && targetGo.name != name) - { - targetGo.name = name; - modified = true; - } - - // Change Parent (using consolidated 'parent' parameter) - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); - // Check for hierarchy loops - if ( - newParentGo == null - && !( - parentToken.Type == JTokenType.Null - || ( - parentToken.Type == JTokenType.String - && string.IsNullOrEmpty(parentToken.ToString()) - ) - ) - ) - { - return Response.Error($"New parent ('{parentToken}') not found."); - } - if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) - { - return Response.Error( - $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." - ); - } - if (targetGo.transform.parent != (newParentGo?.transform)) - { - targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true - modified = true; - } - } - - // Set Active State - bool? setActive = @params["setActive"]?.ToObject(); - if (setActive.HasValue && targetGo.activeSelf != setActive.Value) - { - targetGo.SetActive(setActive.Value); - modified = true; - } - - // Change Tag (using consolidated 'tag' parameter) - string tag = @params["tag"]?.ToString(); - // Only attempt to change tag if a non-null tag is provided and it's different from the current one. - // Allow setting an empty string to remove the tag (Unity uses "Untagged"). - if (tag != null && targetGo.tag != tag) - { - // Ensure the tag is not empty, if empty, it means "Untagged" implicitly - string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - try - { - targetGo.tag = tagToSet; - modified = true; - } - catch (UnityException ex) - { - // Check if the error is specifically because the tag doesn't exist - if (ex.Message.Contains("is not defined")) - { - Debug.LogWarning( - $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." - ); - try - { - // Attempt to create the tag using internal utility - InternalEditorUtility.AddTag(tagToSet); - // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. - // yield return null; // Cannot yield here, editor script limitation - - // Retry setting the tag immediately after creation - targetGo.tag = tagToSet; - modified = true; - Debug.Log( - $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." - ); - } - catch (Exception innerEx) - { - // Handle failure during tag creation or the second assignment attempt - Debug.LogError( - $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" - ); - return Response.Error( - $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." - ); - } - } - else - { - // If the exception was for a different reason, return the original error - return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); - } - } - } - - // Change Layer (using consolidated 'layer' parameter) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId == -1 && layerName != "Default") - { - return Response.Error( - $"Invalid layer specified: '{layerName}'. Use a valid layer name." - ); - } - if (layerId != -1 && targetGo.layer != layerId) - { - targetGo.layer = layerId; - modified = true; - } - } - - // Transform Modifications - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue && targetGo.transform.localPosition != position.Value) - { - targetGo.transform.localPosition = position.Value; - modified = true; - } - if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) - { - targetGo.transform.localEulerAngles = rotation.Value; - modified = true; - } - if (scale.HasValue && targetGo.transform.localScale != scale.Value) - { - targetGo.transform.localScale = scale.Value; - modified = true; - } - - // --- Component Modifications --- - // Note: These might need more specific Undo recording per component - - // Remove Components - if (@params["componentsToRemove"] is JArray componentsToRemoveArray) - { - foreach (var compToken in componentsToRemoveArray) - { - // ... (parsing logic as in CreateGameObject) ... - string typeName = compToken.ToString(); - if (!string.IsNullOrEmpty(typeName)) - { - var removeResult = RemoveComponentInternal(targetGo, typeName); - if (removeResult != null) - return removeResult; // Return error if removal failed - modified = true; - } - } - } - - // Add Components (similar to create) - if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) - { - foreach (var compToken in componentsToAddArrayModify) - { - string typeName = null; - JObject properties = null; - if (compToken.Type == JTokenType.String) - typeName = compToken.ToString(); - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(targetGo, typeName, properties); - if (addResult != null) - return addResult; - modified = true; - } - } - } - - // Set Component Properties - var componentErrors = new List(); - if (@params["componentProperties"] is JObject componentPropertiesObj) - { - foreach (var prop in componentPropertiesObj.Properties()) - { - string compName = prop.Name; - JObject propertiesToSet = prop.Value as JObject; - if (propertiesToSet != null) - { - var setResult = SetComponentPropertiesInternal( - targetGo, - compName, - propertiesToSet - ); - if (setResult != null) - { - componentErrors.Add(setResult); - } - else - { - modified = true; - } - } - } - } - - // Return component errors if any occurred (after processing all components) - if (componentErrors.Count > 0) - { - // Aggregate flattened error strings to make tests/API assertions simpler - var aggregatedErrors = new System.Collections.Generic.List(); - foreach (var errorObj in componentErrors) - { - try - { - var dataProp = errorObj?.GetType().GetProperty("data"); - var dataVal = dataProp?.GetValue(errorObj); - if (dataVal != null) - { - var errorsProp = dataVal.GetType().GetProperty("errors"); - var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; - if (errorsEnum != null) - { - foreach (var item in errorsEnum) - { - var s = item?.ToString(); - if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); - } - } - } - } - catch { } - } - - return Response.Error( - $"One or more component property operations failed on '{targetGo.name}'.", - new { componentErrors = componentErrors, errors = aggregatedErrors } - ); - } - - if (!modified) - { - // Use the new serializer helper - // return Response.Success( - // $"No modifications applied to GameObject '{targetGo.name}'.", - // GetGameObjectData(targetGo)); - - return Response.Success( - $"No modifications applied to GameObject '{targetGo.name}'.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - } - - EditorUtility.SetDirty(targetGo); // Mark scene as dirty - // Use the new serializer helper - return Response.Success( - $"GameObject '{targetGo.name}' modified successfully.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - // return Response.Success( - // $"GameObject '{targetGo.name}' modified successfully.", - // GetGameObjectData(targetGo)); - - } - - private static object DeleteGameObject(JToken targetToken, string searchMethod) - { - // Find potentially multiple objects if name/tag search is used without find_all=false implicitly - List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety - - if (targets.Count == 0) - { - return Response.Error( - $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - List deletedObjects = new List(); - foreach (var targetGo in targets) - { - if (targetGo != null) - { - string goName = targetGo.name; - int goId = targetGo.GetInstanceID(); - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(targetGo); - deletedObjects.Add(new { name = goName, instanceID = goId }); - } - } - - if (deletedObjects.Count > 0) - { - string message = - targets.Count == 1 - ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." - : $"{deletedObjects.Count} GameObjects deleted successfully."; - return Response.Success(message, deletedObjects); - } - else - { - // Should not happen if targets.Count > 0 initially, but defensive check - return Response.Error("Failed to delete target GameObject(s)."); - } - } - - private static object FindGameObjects( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - bool findAll = @params["findAll"]?.ToObject() ?? false; - List foundObjects = FindObjectsInternal( - targetToken, - searchMethod, - findAll, - @params - ); - - if (foundObjects.Count == 0) - { - return Response.Success("No matching GameObjects found.", new List()); - } - - // Use the new serializer helper - //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); - var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); - return Response.Success($"Found {results.Count} GameObject(s).", results); - } - - private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) - { - GameObject targetGo = FindObjectInternal(target, searchMethod); - if (targetGo == null) - { - return Response.Error( - $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." - ); - } - - try - { - // --- Get components, immediately copy to list, and null original array --- - Component[] originalComponents = targetGo.GetComponents(); - List componentsToIterate = new List(originalComponents ?? Array.Empty()); // Copy immediately, handle null case - int componentCount = componentsToIterate.Count; - originalComponents = null; // Null the original reference - // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); - // --- End Copy and Null --- - - var componentData = new List(); - - for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY - { - Component c = componentsToIterate[i]; // Use the copy - if (c == null) - { - // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); - continue; // Safety check - } - // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); - try - { - var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); - if (data != null) // Ensure GetComponentData didn't return null - { - componentData.Insert(0, data); // Insert at beginning to maintain original order in final list - } - // else - // { - // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); - // } - } - catch (Exception ex) - { - Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); - // Optionally add placeholder data or just skip - componentData.Insert(0, new JObject( // Insert error marker at beginning - new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), - new JProperty("instanceID", c.GetInstanceID()), - new JProperty("error", ex.Message) - )); - } - } - // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); - - // Cleanup the list we created - componentsToIterate.Clear(); - componentsToIterate = null; - - return Response.Success( - $"Retrieved {componentData.Count} components from '{targetGo.name}'.", - componentData // List was built in original order - ); - } - catch (Exception e) - { - return Response.Error( - $"Error getting components from '{targetGo.name}': {e.Message}" - ); - } - } - - private static object AddComponentToTarget( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return Response.Error( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - string typeName = null; - JObject properties = null; - - // Allow adding component specified directly or via componentsToAdd array (take first) - if (@params["componentName"] != null) - { - typeName = @params["componentName"]?.ToString(); - properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name - } - else if ( - @params["componentsToAdd"] is JArray componentsToAddArray - && componentsToAddArray.Count > 0 - ) - { - var compToken = componentsToAddArray.First; - if (compToken.Type == JTokenType.String) - typeName = compToken.ToString(); - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - } - - if (string.IsNullOrEmpty(typeName)) - { - return Response.Error( - "Component type name ('componentName' or first element in 'componentsToAdd') is required." - ); - } - - var addResult = AddComponentInternal(targetGo, typeName, properties); - if (addResult != null) - return addResult; // Return error - - EditorUtility.SetDirty(targetGo); - // Use the new serializer helper - return Response.Success( - $"Component '{typeName}' added to '{targetGo.name}'.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); // Return updated GO data - } - - private static object RemoveComponentFromTarget( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return Response.Error( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - string typeName = null; - // Allow removing component specified directly or via componentsToRemove array (take first) - if (@params["componentName"] != null) - { - typeName = @params["componentName"]?.ToString(); - } - else if ( - @params["componentsToRemove"] is JArray componentsToRemoveArray - && componentsToRemoveArray.Count > 0 - ) - { - typeName = componentsToRemoveArray.First?.ToString(); - } - - if (string.IsNullOrEmpty(typeName)) - { - return Response.Error( - "Component type name ('componentName' or first element in 'componentsToRemove') is required." - ); - } - - var removeResult = RemoveComponentInternal(targetGo, typeName); - if (removeResult != null) - return removeResult; // Return error - - EditorUtility.SetDirty(targetGo); - // Use the new serializer helper - return Response.Success( - $"Component '{typeName}' removed from '{targetGo.name}'.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - } - - private static object SetComponentPropertyOnTarget( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return Response.Error( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - string compName = @params["componentName"]?.ToString(); - JObject propertiesToSet = null; - - if (!string.IsNullOrEmpty(compName)) - { - // Properties might be directly under componentProperties or nested under the component name - if (@params["componentProperties"] is JObject compProps) - { - propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure - } - } - else - { - return Response.Error("'componentName' parameter is required."); - } - - if (propertiesToSet == null || !propertiesToSet.HasValues) - { - return Response.Error( - "'componentProperties' dictionary for the specified component is required and cannot be empty." - ); - } - - var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); - if (setResult != null) - return setResult; // Return error - - EditorUtility.SetDirty(targetGo); - // Use the new serializer helper - return Response.Success( - $"Properties set for component '{compName}' on '{targetGo.name}'.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - } - - // --- Internal Helpers --- - - /// - /// Parses a JArray like [x, y, z] into a Vector3. - /// - private static Vector3? ParseVector3(JArray array) - { - if (array != null && array.Count == 3) - { - try - { - return new Vector3( - array[0].ToObject(), - array[1].ToObject(), - array[2].ToObject() - ); - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); - } - } - return null; - } - - /// - /// Finds a single GameObject based on token (ID, name, path) and search method. - /// - private static GameObject FindObjectInternal( - JToken targetToken, - string searchMethod, - JObject findParams = null - ) - { - // If find_all is not explicitly false, we still want only one for most single-target operations. - bool findAll = findParams?["findAll"]?.ToObject() ?? false; - // If a specific target ID is given, always find just that one. - if ( - targetToken?.Type == JTokenType.Integer - || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) - ) - { - findAll = false; - } - List results = FindObjectsInternal( - targetToken, - searchMethod, - findAll, - findParams - ); - return results.Count > 0 ? results[0] : null; - } - - /// - /// Core logic for finding GameObjects based on various criteria. - /// - private static List FindObjectsInternal( - JToken targetToken, - string searchMethod, - bool findAll, - JObject findParams = null - ) - { - List results = new List(); - string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself - bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; - bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; - - // Default search method if not specified - if (string.IsNullOrEmpty(searchMethod)) - { - if (targetToken?.Type == JTokenType.Integer) - searchMethod = "by_id"; - else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) - searchMethod = "by_path"; - else - searchMethod = "by_name"; // Default fallback - } - - GameObject rootSearchObject = null; - // If searching in children, find the initial target first - if (searchInChildren && targetToken != null) - { - rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search - if (rootSearchObject == null) - { - Debug.LogWarning( - $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." - ); - return results; // Return empty if root not found - } - } - - switch (searchMethod) - { - case "by_id": - if (int.TryParse(searchTerm, out int instanceId)) - { - // EditorUtility.InstanceIDToObject is slow, iterate manually if possible - // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; - var allObjects = GetAllSceneObjects(searchInactive); // More efficient - GameObject obj = allObjects.FirstOrDefault(go => - go.GetInstanceID() == instanceId - ); - if (obj != null) - results.Add(obj); - } - break; - case "by_name": - var searchPoolName = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); - break; - case "by_path": - // Path is relative to scene root or rootSearchObject - Transform foundTransform = rootSearchObject - ? rootSearchObject.transform.Find(searchTerm) - : GameObject.Find(searchTerm)?.transform; - if (foundTransform != null) - results.Add(foundTransform.gameObject); - break; - case "by_tag": - var searchPoolTag = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); - break; - case "by_layer": - var searchPoolLayer = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - if (int.TryParse(searchTerm, out int layerIndex)) - { - results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); - } - else - { - int namedLayer = LayerMask.NameToLayer(searchTerm); - if (namedLayer != -1) - results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); - } - break; - case "by_component": - Type componentType = FindType(searchTerm); - if (componentType != null) - { - // Determine FindObjectsInactive based on the searchInactive flag - FindObjectsInactive findInactive = searchInactive - ? FindObjectsInactive.Include - : FindObjectsInactive.Exclude; - // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state - var searchPoolComp = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(componentType, searchInactive) - .Select(c => (c as Component).gameObject) - : UnityEngine - .Object.FindObjectsByType( - componentType, - findInactive, - FindObjectsSortMode.None - ) - .Select(c => (c as Component).gameObject); - results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid - } - else - { - Debug.LogWarning( - $"[ManageGameObject.Find] Component type not found: {searchTerm}" - ); - } - break; - case "by_id_or_name_or_path": // Helper method used internally - if (int.TryParse(searchTerm, out int id)) - { - var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup - GameObject objById = allObjectsId.FirstOrDefault(go => - go.GetInstanceID() == id - ); - if (objById != null) - { - results.Add(objById); - break; - } - } - GameObject objByPath = GameObject.Find(searchTerm); - if (objByPath != null) - { - results.Add(objByPath); - break; - } - - var allObjectsName = GetAllSceneObjects(true); - results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); - break; - default: - Debug.LogWarning( - $"[ManageGameObject.Find] Unknown search method: {searchMethod}" - ); - break; - } - - // If only one result is needed, return just the first one found. - if (!findAll && results.Count > 1) - { - return new List { results[0] }; - } - - return results.Distinct().ToList(); // Ensure uniqueness - } - - // Helper to get all scene objects efficiently - private static IEnumerable GetAllSceneObjects(bool includeInactive) - { - // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() - var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); - var allObjects = new List(); - foreach (var root in rootObjects) - { - allObjects.AddRange( - root.GetComponentsInChildren(includeInactive) - .Select(t => t.gameObject) - ); - } - return allObjects; - } - - /// - /// Adds a component by type name and optionally sets properties. - /// Returns null on success, or an error response object on failure. - /// - private static object AddComponentInternal( - GameObject targetGo, - string typeName, - JObject properties - ) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return Response.Error( - $"Component type '{typeName}' not found or is not a valid Component." - ); - } - if (!typeof(Component).IsAssignableFrom(componentType)) - { - return Response.Error($"Type '{typeName}' is not a Component."); - } - - // Prevent adding Transform again - if (componentType == typeof(Transform)) - { - return Response.Error("Cannot add another Transform component."); - } - - // Check for 2D/3D physics component conflicts - bool isAdding2DPhysics = - typeof(Rigidbody2D).IsAssignableFrom(componentType) - || typeof(Collider2D).IsAssignableFrom(componentType); - bool isAdding3DPhysics = - typeof(Rigidbody).IsAssignableFrom(componentType) - || typeof(Collider).IsAssignableFrom(componentType); - - if (isAdding2DPhysics) - { - // Check if the GameObject already has any 3D Rigidbody or Collider - if ( - targetGo.GetComponent() != null - || targetGo.GetComponent() != null - ) - { - return Response.Error( - $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." - ); - } - } - else if (isAdding3DPhysics) - { - // Check if the GameObject already has any 2D Rigidbody or Collider - if ( - targetGo.GetComponent() != null - || targetGo.GetComponent() != null - ) - { - return Response.Error( - $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." - ); - } - } - - try - { - // Use Undo.AddComponent for undo support - Component newComponent = Undo.AddComponent(targetGo, componentType); - if (newComponent == null) - { - return Response.Error( - $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." - ); - } - - // Set default values for specific component types - if (newComponent is Light light) - { - // Default newly added lights to directional - light.type = LightType.Directional; - } - - // Set properties if provided - if (properties != null) - { - var setResult = SetComponentPropertiesInternal( - targetGo, - typeName, - properties, - newComponent - ); // Pass the new component instance - if (setResult != null) - { - // If setting properties failed, maybe remove the added component? - Undo.DestroyObjectImmediate(newComponent); - return setResult; // Return the error from setting properties - } - } - - return null; // Success - } - catch (Exception e) - { - return Response.Error( - $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" - ); - } - } - - /// - /// Removes a component by type name. - /// Returns null on success, or an error response object on failure. - /// - private static object RemoveComponentInternal(GameObject targetGo, string typeName) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return Response.Error($"Component type '{typeName}' not found for removal."); - } - - // Prevent removing essential components - if (componentType == typeof(Transform)) - { - return Response.Error("Cannot remove the Transform component."); - } - - Component componentToRemove = targetGo.GetComponent(componentType); - if (componentToRemove == null) - { - return Response.Error( - $"Component '{typeName}' not found on '{targetGo.name}' to remove." - ); - } - - try - { - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(componentToRemove); - return null; // Success - } - catch (Exception e) - { - return Response.Error( - $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" - ); - } - } - - /// - /// Sets properties on a component. - /// Returns null on success, or an error response object on failure. - /// - private static object SetComponentPropertiesInternal( - GameObject targetGo, - string compName, - JObject propertiesToSet, - Component targetComponentInstance = null - ) - { - Component targetComponent = targetComponentInstance; - if (targetComponent == null) - { - if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) - { - targetComponent = targetGo.GetComponent(compType); - } - else - { - targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup - } - } - if (targetComponent == null) - { - return Response.Error( - $"Component '{compName}' not found on '{targetGo.name}' to set properties." - ); - } - - Undo.RecordObject(targetComponent, "Set Component Properties"); - - var failures = new List(); - foreach (var prop in propertiesToSet.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - - try - { - bool setResult = SetProperty(targetComponent, propName, propValue); - if (!setResult) - { - var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); - var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); - var msg = suggestions.Any() - ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" - : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; - Debug.LogWarning($"[ManageGameObject] {msg}"); - failures.Add(msg); - } - } - catch (Exception e) - { - Debug.LogError( - $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" - ); - failures.Add($"Error setting '{propName}': {e.Message}"); - } - } - EditorUtility.SetDirty(targetComponent); - return failures.Count == 0 - ? null - : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); - } - - /// - /// Helper to set a property or field via reflection, handling basic types. - /// - private static bool SetProperty(object target, string memberName, JToken value) - { - Type type = target.GetType(); - BindingFlags flags = - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - // Use shared serializer to avoid per-call allocation - var inputSerializer = InputSerializer; - - try - { - // Handle special case for materials with dot notation (material.property) - // Examples: material.color, sharedMaterial.color, materials[0].color - if (memberName.Contains('.') || memberName.Contains('[')) - { - // Pass the inputSerializer down for nested conversions - return SetNestedProperty(target, memberName, value, inputSerializer); - } - - PropertyInfo propInfo = type.GetProperty(memberName, flags); - if (propInfo != null && propInfo.CanWrite) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null - { - propInfo.SetValue(target, convertedValue); - return true; - } - else { - Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - FieldInfo fieldInfo = type.GetField(memberName, flags); - if (fieldInfo != null) // Check if !IsLiteral? - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null - { - fieldInfo.SetValue(target, convertedValue); - return true; - } - else { - Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - // Try NonPublic [SerializeField] fields - var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (npField != null && npField.GetCustomAttribute() != null) - { - object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - npField.SetValue(target, convertedValue); - return true; - } - } - } - } - } - catch (Exception ex) - { - Debug.LogError( - $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}" - ); - } - return false; - } - - /// - /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") - /// - // Pass the input serializer for conversions - //Using the serializer helper - private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) - { - try - { - // Split the path into parts (handling both dot notation and array indexing) - string[] pathParts = SplitPropertyPath(path); - if (pathParts.Length == 0) - return false; - - object currentObject = target; - Type currentType = currentObject.GetType(); - BindingFlags flags = - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - // Traverse the path until we reach the final property - for (int i = 0; i < pathParts.Length - 1; i++) - { - string part = pathParts[i]; - bool isArray = false; - int arrayIndex = -1; - - // Check if this part contains array indexing - if (part.Contains("[")) - { - int startBracket = part.IndexOf('['); - int endBracket = part.IndexOf(']'); - if (startBracket > 0 && endBracket > startBracket) - { - string indexStr = part.Substring( - startBracket + 1, - endBracket - startBracket - 1 - ); - if (int.TryParse(indexStr, out arrayIndex)) - { - isArray = true; - part = part.Substring(0, startBracket); - } - } - } - // Get the property/field - PropertyInfo propInfo = currentType.GetProperty(part, flags); - FieldInfo fieldInfo = null; - if (propInfo == null) - { - fieldInfo = currentType.GetField(part, flags); - if (fieldInfo == null) - { - Debug.LogWarning( - $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" - ); - return false; - } - } - - // Get the value - currentObject = - propInfo != null - ? propInfo.GetValue(currentObject) - : fieldInfo.GetValue(currentObject); - //Need to stop if current property is null - if (currentObject == null) - { - Debug.LogWarning( - $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." - ); - return false; - } - // If this part was an array or list, access the specific index - if (isArray) - { - if (currentObject is Material[]) - { - var materials = currentObject as Material[]; - if (arrayIndex < 0 || arrayIndex >= materials.Length) - { - Debug.LogWarning( - $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" - ); - return false; - } - currentObject = materials[arrayIndex]; - } - else if (currentObject is System.Collections.IList) - { - var list = currentObject as System.Collections.IList; - if (arrayIndex < 0 || arrayIndex >= list.Count) - { - Debug.LogWarning( - $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" - ); - return false; - } - currentObject = list[arrayIndex]; - } - else - { - Debug.LogWarning( - $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." - ); - return false; - } - } - currentType = currentObject.GetType(); - } - - // Set the final property - string finalPart = pathParts[pathParts.Length - 1]; - - // Special handling for Material properties (shader properties) - if (currentObject is Material material && finalPart.StartsWith("_")) - { - // Use the serializer to convert the JToken value first - if (value is JArray jArray) - { - // Try converting to known types that SetColor/SetVector accept - if (jArray.Count == 4) { - try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } - try { Vector4 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } - } else if (jArray.Count == 3) { - try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color - } else if (jArray.Count == 2) { - try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } - } - } - else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) - { - try { material.SetFloat(finalPart, value.ToObject(inputSerializer)); return true; } catch { } - } - else if (value.Type == JTokenType.Boolean) - { - try { material.SetFloat(finalPart, value.ToObject(inputSerializer) ? 1f : 0f); return true; } catch { } - } - else if (value.Type == JTokenType.String) - { - // Try converting to Texture using the serializer/converter - try { - Texture texture = value.ToObject(inputSerializer); - if (texture != null) { - material.SetTexture(finalPart, texture); - return true; - } - } catch { } - } - - Debug.LogWarning( - $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" - ); - return false; - } - - // For standard properties (not shader specific) - PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); - if (finalPropInfo != null && finalPropInfo.CanWrite) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - finalPropInfo.SetValue(currentObject, convertedValue); - return true; - } - else { - Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); - if (finalFieldInfo != null) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - finalFieldInfo.SetValue(currentObject, convertedValue); - return true; - } - else { - Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - Debug.LogWarning( - $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" - ); - } - } - } - catch (Exception ex) - { - Debug.LogError( - $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" - ); - } - - return false; - } - - - /// - /// Split a property path into parts, handling both dot notation and array indexers - /// - private static string[] SplitPropertyPath(string path) - { - // Handle complex paths with both dots and array indexers - List parts = new List(); - int startIndex = 0; - bool inBrackets = false; - - for (int i = 0; i < path.Length; i++) - { - char c = path[i]; - - if (c == '[') - { - inBrackets = true; - } - else if (c == ']') - { - inBrackets = false; - } - else if (c == '.' && !inBrackets) - { - // Found a dot separator outside of brackets - parts.Add(path.Substring(startIndex, i - startIndex)); - startIndex = i + 1; - } - } - if (startIndex < path.Length) - { - parts.Add(path.Substring(startIndex)); - } - return parts.ToArray(); - } - - /// - /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. - /// - // Pass the input serializer - private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) - { - if (token == null || token.Type == JTokenType.Null) - { - if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) - { - Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); - return Activator.CreateInstance(targetType); - } - return null; - } - - try - { - // Use the provided serializer instance which includes our custom converters - return token.ToObject(targetType, inputSerializer); - } - catch (JsonSerializationException jsonEx) - { - Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); - // Optionally re-throw or return null/default - // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; - throw; // Re-throw to indicate failure higher up - } - catch (ArgumentException argEx) - { - Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); - throw; - } - catch (Exception ex) - { - Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); - throw; - } - // If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here. - // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. - // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); - // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; - } - - // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- - // Keep them temporarily for reference or if specific fallback logic is ever needed. - - private static Vector3 ParseJTokenToVector3(JToken token) - { - // ... (implementation - likely replaced by Vector3Converter) ... - // Consider removing these if the serializer handles them reliably. - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) - { - return new Vector3(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject()); - } - if (token is JArray arr && arr.Count >= 3) - { - return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); - } - Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); - return Vector3.zero; - - } - private static Vector2 ParseJTokenToVector2(JToken token) - { - // ... (implementation - likely replaced by Vector2Converter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) - { - return new Vector2(obj["x"].ToObject(), obj["y"].ToObject()); - } - if (token is JArray arr && arr.Count >= 2) - { - return new Vector2(arr[0].ToObject(), arr[1].ToObject()); - } - Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); - return Vector2.zero; - } - private static Quaternion ParseJTokenToQuaternion(JToken token) - { - // ... (implementation - likely replaced by QuaternionConverter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) - { - return new Quaternion(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject(), obj["w"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); - return Quaternion.identity; - } - private static Color ParseJTokenToColor(JToken token) - { - // ... (implementation - likely replaced by ColorConverter) ... - if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) - { - return new Color(obj["r"].ToObject(), obj["g"].ToObject(), obj["b"].ToObject(), obj["a"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); - return Color.white; - } - private static Rect ParseJTokenToRect(JToken token) - { - // ... (implementation - likely replaced by RectConverter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) - { - return new Rect(obj["x"].ToObject(), obj["y"].ToObject(), obj["width"].ToObject(), obj["height"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); - return Rect.zero; - } - private static Bounds ParseJTokenToBounds(JToken token) - { - // ... (implementation - likely replaced by BoundsConverter) ... - if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) - { - // Requires Vector3 conversion, which should ideally use the serializer too - Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) - Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) - return new Bounds(center, size); - } - // Array fallback for Bounds is less intuitive, maybe remove? - // if (token is JArray arr && arr.Count >= 6) - // { - // return new Bounds(new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()), new Vector3(arr[3].ToObject(), arr[4].ToObject(), arr[5].ToObject())); - // } - Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); - return new Bounds(Vector3.zero, Vector3.zero); - } - // --- End Redundant Parse Helpers --- - - /// - /// Finds a specific UnityEngine.Object based on a find instruction JObject. - /// Primarily used by UnityEngineObjectConverter during deserialization. - /// - // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. - public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) - { - string findTerm = instruction["find"]?.ToString(); - string method = instruction["method"]?.ToString()?.ToLower(); - string componentName = instruction["component"]?.ToString(); // Specific component to get - - if (string.IsNullOrEmpty(findTerm)) - { - Debug.LogWarning("Find instruction missing 'find' term."); - return null; - } - - // Use a flexible default search method if none provided - string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; - - // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first - if (typeof(Material).IsAssignableFrom(targetType) || - typeof(Texture).IsAssignableFrom(targetType) || - typeof(ScriptableObject).IsAssignableFrom(targetType) || - targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. - typeof(AudioClip).IsAssignableFrom(targetType) || - typeof(AnimationClip).IsAssignableFrom(targetType) || - typeof(Font).IsAssignableFrom(targetType) || - typeof(Shader).IsAssignableFrom(targetType) || - typeof(ComputeShader).IsAssignableFrom(targetType) || - typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check - { - // Try loading directly by path/GUID first - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); - if (asset != null) return asset; - asset = AssetDatabase.LoadAssetAtPath(findTerm); // Try generic if type specific failed - if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; - - - // If direct path failed, try finding by name/type using FindAssets - string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name - string[] guids = AssetDatabase.FindAssets(searchFilter); - - if (guids.Length == 1) - { - asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); - if (asset != null) return asset; - } - else if (guids.Length > 1) - { - Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); - // Optionally return the first one? Or null? Returning null is safer. - return null; - } - // If still not found, fall through to scene search (though unlikely for assets) - } - - - // --- Scene Object Search --- - // Find the GameObject using the internal finder - GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); - - if (foundGo == null) - { - // Don't warn yet, could still be an asset not found above - // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); - return null; - } - - // Now, get the target object/component from the found GameObject - if (targetType == typeof(GameObject)) - { - return foundGo; // We were looking for a GameObject - } - else if (typeof(Component).IsAssignableFrom(targetType)) - { - Type componentToGetType = targetType; - if (!string.IsNullOrEmpty(componentName)) - { - Type specificCompType = FindType(componentName); - if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) - { - componentToGetType = specificCompType; - } - else - { - Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); - } - } - - Component foundComp = foundGo.GetComponent(componentToGetType); - if (foundComp == null) - { - Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); - } - return foundComp; - } - else - { - Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); - return null; - } - } - - - /// - /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. - /// Searches already-loaded assemblies, prioritizing runtime script assemblies. - /// - private static Type FindType(string typeName) - { - if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) - { - return resolvedType; - } - - // Log the resolver error if type wasn't found - if (!string.IsNullOrEmpty(error)) - { - Debug.LogWarning($"[FindType] {error}"); - } - - return null; - } - } - - /// - /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. - /// Prioritizes runtime (Player) assemblies over Editor assemblies. - /// - internal static class ComponentResolver - { - private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); - private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); - - /// - /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. - /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. - /// Never uses Assembly.LoadFrom. - /// - public static bool TryResolve(string nameOrFullName, out Type type, out string error) - { - error = string.Empty; - type = null!; - - // Handle null/empty input - if (string.IsNullOrWhiteSpace(nameOrFullName)) - { - error = "Component name cannot be null or empty"; - return false; - } - - // 1) Exact cache hits - if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; - if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; - type = Type.GetType(nameOrFullName, throwOnError: false); - if (IsValidComponent(type)) { Cache(type); return true; } - - // 2) Search loaded assemblies (prefer Player assemblies) - var candidates = FindCandidates(nameOrFullName); - if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } - if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } - -#if UNITY_EDITOR - // 3) Last resort: Editor-only TypeCache (fast index) - var tc = TypeCache.GetTypesDerivedFrom() - .Where(t => NamesMatch(t, nameOrFullName)); - candidates = PreferPlayer(tc).ToList(); - if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } - if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } -#endif - - error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + - "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; - type = null!; - return false; - } - - private static bool NamesMatch(Type t, string q) => - t.Name.Equals(q, StringComparison.Ordinal) || - (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); - - private static bool IsValidComponent(Type t) => - t != null && typeof(Component).IsAssignableFrom(t); - - private static void Cache(Type t) - { - if (t.FullName != null) CacheByFqn[t.FullName] = t; - CacheByName[t.Name] = t; - } - - private static List FindCandidates(string query) - { - bool isShort = !query.Contains('.'); - var loaded = AppDomain.CurrentDomain.GetAssemblies(); - -#if UNITY_EDITOR - // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) - var playerAsmNames = new HashSet( - UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), - StringComparer.Ordinal); - - IEnumerable playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); - IEnumerable editorAsms = loaded.Except(playerAsms); -#else - IEnumerable playerAsms = loaded; - IEnumerable editorAsms = Array.Empty(); -#endif - static IEnumerable SafeGetTypes(System.Reflection.Assembly a) - { - try { return a.GetTypes(); } - catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } - } - - Func match = isShort - ? (t => t.Name.Equals(query, StringComparison.Ordinal)) - : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); - - var fromPlayer = playerAsms.SelectMany(SafeGetTypes) - .Where(IsValidComponent) - .Where(match); - var fromEditor = editorAsms.SelectMany(SafeGetTypes) - .Where(IsValidComponent) - .Where(match); - - var list = new List(fromPlayer); - if (list.Count == 0) list.AddRange(fromEditor); - return list; - } - -#if UNITY_EDITOR - private static IEnumerable PreferPlayer(IEnumerable seq) - { - var player = new HashSet( - UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), - StringComparer.Ordinal); - - return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); - } -#endif - - private static string Ambiguity(string query, IEnumerable cands) - { - var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); - return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + - "\nProvide a fully qualified type name to disambiguate."; - } - - /// - /// Gets all accessible property and field names from a component type. - /// - public static List GetAllComponentProperties(Type componentType) - { - if (componentType == null) return new List(); - - var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.CanWrite) - .Select(p => p.Name); - - var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) - .Where(f => !f.IsInitOnly && !f.IsLiteral) - .Select(f => f.Name); - - // Also include SerializeField private fields (common in Unity) - var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .Where(f => f.GetCustomAttribute() != null) - .Select(f => f.Name); - - return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); - } - - /// - /// Uses AI to suggest the most likely property matches for a user's input. - /// - public static List GetAIPropertySuggestions(string userInput, List availableProperties) - { - if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) - return new List(); - - // Simple caching to avoid repeated AI calls for the same input - var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; - if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) - return cached; - - try - { - var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + - $"User requested: \"{userInput}\"\n" + - $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + - $"Find 1-3 most likely matches considering:\n" + - $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + - $"- camelCase vs PascalCase vs spaces\n" + - $"- Similar meaning/semantics\n" + - $"- Common Unity naming patterns\n\n" + - $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + - $"If confidence is low (<70%), return empty string.\n\n" + - $"Examples:\n" + - $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + - $"- \"Health Points\" → \"healthPoints, hp\"\n" + - $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; - - // For now, we'll use a simple rule-based approach that mimics AI behavior - // This can be replaced with actual AI calls later - var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); - - PropertySuggestionCache[cacheKey] = suggestions; - return suggestions; - } - catch (Exception ex) - { - Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); - return new List(); - } - } - - private static readonly Dictionary> PropertySuggestionCache = new(); - - /// - /// Rule-based suggestions that mimic AI behavior for property matching. - /// This provides immediate value while we could add real AI integration later. - /// - private static List GetRuleBasedSuggestions(string userInput, List availableProperties) - { - var suggestions = new List(); - var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); - - foreach (var property in availableProperties) - { - var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); - - // Exact match after cleaning - if (cleanedProperty == cleanedInput) - { - suggestions.Add(property); - continue; - } - - // Check if property contains all words from input - var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); - if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) - { - suggestions.Add(property); - continue; - } - - // Levenshtein distance for close matches - if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) - { - suggestions.Add(property); - } - } - - // Prioritize exact matches, then by similarity - return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) - .Take(3) - .ToList(); - } - - /// - /// Calculates Levenshtein distance between two strings for similarity matching. - /// - private static int LevenshteinDistance(string s1, string s2) - { - if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; - if (string.IsNullOrEmpty(s2)) return s1.Length; - - var matrix = new int[s1.Length + 1, s2.Length + 1]; - - for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; - for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; - - for (int i = 1; i <= s1.Length; i++) - { - for (int j = 1; j <= s2.Length; j++) - { - int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; - matrix[i, j] = Math.Min(Math.Min( - matrix[i - 1, j] + 1, // deletion - matrix[i, j - 1] + 1), // insertion - matrix[i - 1, j - 1] + cost); // substitution - } - } - - return matrix[s1.Length, s2.Length]; - } - - // Removed duplicate ParseVector3 - using the one at line 1114 - - // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. - // They are now in Helpers.GameObjectSerializer - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta deleted file mode 100644 index 5093c861..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 7641d7388f0f6634b9d83d34de87b2ee -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs deleted file mode 100644 index e68c19d7..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs +++ /dev/null @@ -1,475 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.SceneManagement; -using UnityEngine; -using UnityEngine.SceneManagement; -using MCPForUnity.Editor.Helpers; // For Response class - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles scene management operations like loading, saving, creating, and querying hierarchy. - /// - public static class ManageScene - { - private sealed class SceneCommand - { - public string action { get; set; } = string.Empty; - public string name { get; set; } = string.Empty; - public string path { get; set; } = string.Empty; - public int? buildIndex { get; set; } - } - - private static SceneCommand ToSceneCommand(JObject p) - { - if (p == null) return new SceneCommand(); - int? BI(JToken t) - { - if (t == null || t.Type == JTokenType.Null) return null; - var s = t.ToString().Trim(); - if (s.Length == 0) return null; - if (int.TryParse(s, out var i)) return i; - if (double.TryParse(s, out var d)) return (int)d; - return t.Type == JTokenType.Integer ? t.Value() : (int?)null; - } - return new SceneCommand - { - action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), - name = p["name"]?.ToString() ?? string.Empty, - path = p["path"]?.ToString() ?? string.Empty, - buildIndex = BI(p["buildIndex"] ?? p["build_index"]) - }; - } - - /// - /// Main handler for scene management actions. - /// - public static object HandleCommand(JObject @params) - { - try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } - var cmd = ToSceneCommand(@params); - string action = cmd.action; - string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; - string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ - int? buildIndex = cmd.buildIndex; - // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension - - // Ensure path is relative to Assets/, removing any leading "Assets/" - string relativeDir = path ?? string.Empty; - if (!string.IsNullOrEmpty(relativeDir)) - { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } - } - - // Apply default *after* sanitizing, using the original path variable for the check - if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness - { - relativeDir = "Scenes"; // Default relative directory - } - - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; - // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) - string fullPath = string.IsNullOrEmpty(sceneFileName) - ? null - : Path.Combine(fullPathDir, sceneFileName); - // Ensure relativePath always starts with "Assets/" and uses forward slashes - string relativePath = string.IsNullOrEmpty(sceneFileName) - ? null - : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); - - // Ensure directory exists for 'create' - if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) - { - try - { - Directory.CreateDirectory(fullPathDir); - } - catch (Exception e) - { - return Response.Error( - $"Could not create directory '{fullPathDir}': {e.Message}" - ); - } - } - - // Route action - try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } - switch (action) - { - case "create": - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) - return Response.Error( - "'name' and 'path' parameters are required for 'create' action." - ); - return CreateScene(fullPath, relativePath); - case "load": - // Loading can be done by path/name or build index - if (!string.IsNullOrEmpty(relativePath)) - return LoadScene(relativePath); - else if (buildIndex.HasValue) - return LoadScene(buildIndex.Value); - else - return Response.Error( - "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." - ); - case "save": - // Save current scene, optionally to a new path - return SaveScene(fullPath, relativePath); - case "get_hierarchy": - try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } - var gh = GetSceneHierarchy(); - try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } - return gh; - case "get_active": - try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } - var ga = GetActiveSceneInfo(); - try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } - return ga; - case "get_build_settings": - return GetBuildSettingsScenes(); - // Add cases for modifying build settings, additive loading, unloading etc. - default: - return Response.Error( - $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." - ); - } - } - - private static object CreateScene(string fullPath, string relativePath) - { - if (File.Exists(fullPath)) - { - return Response.Error($"Scene already exists at '{relativePath}'."); - } - - try - { - // Create a new empty scene - Scene newScene = EditorSceneManager.NewScene( - NewSceneSetup.EmptyScene, - NewSceneMode.Single - ); - // Save it to the specified path - bool saved = EditorSceneManager.SaveScene(newScene, relativePath); - - if (saved) - { - AssetDatabase.Refresh(); // Ensure Unity sees the new scene file - return Response.Success( - $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", - new { path = relativePath } - ); - } - else - { - // If SaveScene fails, it might leave an untitled scene open. - // Optionally try to close it, but be cautious. - return Response.Error($"Failed to save new scene to '{relativePath}'."); - } - } - catch (Exception e) - { - return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); - } - } - - private static object LoadScene(string relativePath) - { - if ( - !File.Exists( - Path.Combine( - Application.dataPath.Substring( - 0, - Application.dataPath.Length - "Assets".Length - ), - relativePath - ) - ) - ) - { - return Response.Error($"Scene file not found at '{relativePath}'."); - } - - // Check for unsaved changes in the current scene - if (EditorSceneManager.GetActiveScene().isDirty) - { - // Optionally prompt the user or save automatically before loading - return Response.Error( - "Current scene has unsaved changes. Please save or discard changes before loading a new scene." - ); - // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); - // if (!saveOK) return Response.Error("Load cancelled by user."); - } - - try - { - EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); - return Response.Success( - $"Scene '{relativePath}' loaded successfully.", - new - { - path = relativePath, - name = Path.GetFileNameWithoutExtension(relativePath), - } - ); - } - catch (Exception e) - { - return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); - } - } - - private static object LoadScene(int buildIndex) - { - if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) - { - return Response.Error( - $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." - ); - } - - // Check for unsaved changes - if (EditorSceneManager.GetActiveScene().isDirty) - { - return Response.Error( - "Current scene has unsaved changes. Please save or discard changes before loading a new scene." - ); - } - - try - { - string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); - EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); - return Response.Success( - $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", - new - { - path = scenePath, - name = Path.GetFileNameWithoutExtension(scenePath), - buildIndex = buildIndex, - } - ); - } - catch (Exception e) - { - return Response.Error( - $"Error loading scene with build index {buildIndex}: {e.Message}" - ); - } - } - - private static object SaveScene(string fullPath, string relativePath) - { - try - { - Scene currentScene = EditorSceneManager.GetActiveScene(); - if (!currentScene.IsValid()) - { - return Response.Error("No valid scene is currently active to save."); - } - - bool saved; - string finalPath = currentScene.path; // Path where it was last saved or will be saved - - if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) - { - // Save As... - // Ensure directory exists - string dir = Path.GetDirectoryName(fullPath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - saved = EditorSceneManager.SaveScene(currentScene, relativePath); - finalPath = relativePath; - } - else - { - // Save (overwrite existing or save untitled) - if (string.IsNullOrEmpty(currentScene.path)) - { - // Scene is untitled, needs a path - return Response.Error( - "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." - ); - } - saved = EditorSceneManager.SaveScene(currentScene); - } - - if (saved) - { - AssetDatabase.Refresh(); - return Response.Success( - $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", - new { path = finalPath, name = currentScene.name } - ); - } - else - { - return Response.Error($"Failed to save scene '{currentScene.name}'."); - } - } - catch (Exception e) - { - return Response.Error($"Error saving scene: {e.Message}"); - } - } - - private static object GetActiveSceneInfo() - { - try - { - try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } - Scene activeScene = EditorSceneManager.GetActiveScene(); - try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } - if (!activeScene.IsValid()) - { - return Response.Error("No active scene found."); - } - - var sceneInfo = new - { - name = activeScene.name, - path = activeScene.path, - buildIndex = activeScene.buildIndex, // -1 if not in build settings - isDirty = activeScene.isDirty, - isLoaded = activeScene.isLoaded, - rootCount = activeScene.rootCount, - }; - - return Response.Success("Retrieved active scene information.", sceneInfo); - } - catch (Exception e) - { - try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } - return Response.Error($"Error getting active scene info: {e.Message}"); - } - } - - private static object GetBuildSettingsScenes() - { - try - { - var scenes = new List(); - for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) - { - var scene = EditorBuildSettings.scenes[i]; - scenes.Add( - new - { - path = scene.path, - guid = scene.guid.ToString(), - enabled = scene.enabled, - buildIndex = i, // Actual build index considering only enabled scenes might differ - } - ); - } - return Response.Success("Retrieved scenes from Build Settings.", scenes); - } - catch (Exception e) - { - return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); - } - } - - private static object GetSceneHierarchy() - { - try - { - try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } - Scene activeScene = EditorSceneManager.GetActiveScene(); - try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } - if (!activeScene.IsValid() || !activeScene.isLoaded) - { - return Response.Error( - "No valid and loaded scene is active to get hierarchy from." - ); - } - - try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } - GameObject[] rootObjects = activeScene.GetRootGameObjects(); - try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } - var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); - - var resp = Response.Success( - $"Retrieved hierarchy for scene '{activeScene.name}'.", - hierarchy - ); - try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } - return resp; - } - catch (Exception e) - { - try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } - return Response.Error($"Error getting scene hierarchy: {e.Message}"); - } - } - - /// - /// Recursively builds a data representation of a GameObject and its children. - /// - private static object GetGameObjectDataRecursive(GameObject go) - { - if (go == null) - return null; - - var childrenData = new List(); - foreach (Transform child in go.transform) - { - childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); - } - - var gameObjectData = new Dictionary - { - { "name", go.name }, - { "activeSelf", go.activeSelf }, - { "activeInHierarchy", go.activeInHierarchy }, - { "tag", go.tag }, - { "layer", go.layer }, - { "isStatic", go.isStatic }, - { "instanceID", go.GetInstanceID() }, // Useful unique identifier - { - "transform", - new - { - position = new - { - x = go.transform.localPosition.x, - y = go.transform.localPosition.y, - z = go.transform.localPosition.z, - }, - rotation = new - { - x = go.transform.localRotation.eulerAngles.x, - y = go.transform.localRotation.eulerAngles.y, - z = go.transform.localRotation.eulerAngles.z, - }, // Euler for simplicity - scale = new - { - x = go.transform.localScale.x, - y = go.transform.localScale.y, - z = go.transform.localScale.z, - }, - } - }, - { "children", childrenData }, - }; - - return gameObjectData; - } - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta deleted file mode 100644 index 532618aa..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b6ddda47f4077e74fbb5092388cefcc2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs deleted file mode 100644 index 0ed65afa..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ /dev/null @@ -1,2659 +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) - { - // Handle null parameters - if (@params == null) - { - return Response.Error("invalid_params", "Parameters cannot be null."); - } - - // 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": - McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); - return ReadScript(fullPath, relativePath); - case "update": - 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); - 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 = false } - ); - - ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); - - 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); - - 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 - int originalLength = sp.end - sp.start; - int newLength = sp.text?.Length ?? 0; - int endPos = sp.start + newLength; - if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 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" } - } - ); - } - - // Always check final structural balance regardless of relaxed mode - if (!CheckBalancedDelimiters(working, out int line, out char expected)) - { - int startLine = Math.Max(1, line - 5); - int endLine = line + 5; - string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; - return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), 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) - { - McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif - } - else - { - McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); - 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, - scheduledRefresh = !immediate - } - ); - } - 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--; - // Allow temporary negative balance - will check tolerance at end - } - return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more 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 = "Sort ranges descending by start and compute from the same 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; - } - - // Guard against structural imbalance before validation - if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) - return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); - - // 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) - { - McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); - ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); - } - 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); - // Nudge the editor loop so ticks run even if the window is unfocused - EditorApplication.QueuePlayerLoopUpdate(); - } - - 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) - { - var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); - AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); - } -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif - // Fallback if needed: - // AssetDatabase.Refresh(); - } - } -} - -static class ManageScriptRefreshHelpers -{ - public static string SanitizeAssetsPath(string p) - { - if (string.IsNullOrEmpty(p)) return p; - p = p.Replace('\\', '/').Trim(); - if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) - p = p.Substring("unity://path/".Length); - while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) - p = p.Substring("Assets/".Length); - if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - p = "Assets/" + p.TrimStart('/'); - return p; - } - - public static void ScheduleScriptRefresh(string relPath) - { - var sp = SanitizeAssetsPath(relPath); - RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); - } - - public static void ImportAndRequestCompile(string relPath, bool synchronous = true) - { - var sp = SanitizeAssetsPath(relPath); - var opts = ImportAssetOptions.ForceUpdate; - if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; - AssetDatabase.ImportAsset(sp, opts); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta deleted file mode 100644 index 091cfe1c..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 626d2d44668019a45ae52e9ee066b7ec -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs deleted file mode 100644 index c2dfbc2f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs +++ /dev/null @@ -1,342 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles CRUD operations for shader files within the Unity project. - /// - public static class ManageShader - { - /// - /// Main handler for shader 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 shader contents: {e.Message}"); - } - } - else - { - contents = @params["contents"]?.ToString(); - } - - // 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_]*$")) - { - return Response.Error( - $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." - ); - } - - // Ensure path is relative to Assets/, removing any leading "Assets/" - // Set default directory to "Shaders" if path is not provided - string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null - if (!string.IsNullOrEmpty(relativeDir)) - { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } - } - // Handle empty string case explicitly after processing - if (string.IsNullOrEmpty(relativeDir)) - { - relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" - } - - // Construct paths - string shaderFileName = $"{name}.shader"; - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); - string fullPath = Path.Combine(fullPathDir, shaderFileName); - string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) - .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes - - // Ensure the target directory exists for create/update - if (action == "create" || action == "update") - { - try - { - if (!Directory.Exists(fullPathDir)) - { - Directory.CreateDirectory(fullPathDir); - // Refresh AssetDatabase to recognize new folders - AssetDatabase.Refresh(); - } - } - catch (Exception e) - { - return Response.Error( - $"Could not create directory '{fullPathDir}': {e.Message}" - ); - } - } - - // Route to specific action handlers - switch (action) - { - case "create": - return CreateShader(fullPath, relativePath, name, contents); - case "read": - return ReadShader(fullPath, relativePath); - case "update": - return UpdateShader(fullPath, relativePath, name, contents); - case "delete": - return DeleteShader(fullPath, relativePath); - default: - return Response.Error( - $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." - ); - } - } - - /// - /// 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 CreateShader( - string fullPath, - string relativePath, - string name, - string contents - ) - { - // Check if shader already exists - if (File.Exists(fullPath)) - { - return Response.Error( - $"Shader already exists at '{relativePath}'. Use 'update' action to modify." - ); - } - - // Add validation for shader name conflicts in Unity - if (Shader.Find(name) != null) - { - return Response.Error( - $"A shader with name '{name}' already exists in the project. Choose a different name." - ); - } - - // Generate default content if none provided - if (string.IsNullOrEmpty(contents)) - { - contents = GenerateDefaultShaderContent(name); - } - - try - { - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader - return Response.Success( - $"Shader '{name}.shader' created successfully at '{relativePath}'.", - new { path = relativePath } - ); - } - catch (Exception e) - { - return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); - } - } - - private static object ReadShader(string fullPath, string relativePath) - { - if (!File.Exists(fullPath)) - { - return Response.Error($"Shader not found at '{relativePath}'."); - } - - try - { - string contents = File.ReadAllText(fullPath); - - // Return both normal and encoded contents for larger files - //TODO: Consider a threshold for large files - bool isLarge = contents.Length > 10000; // If content is large, include encoded version - var responseData = new - { - path = relativePath, - contents = contents, - // For large files, also include base64-encoded version - encodedContents = isLarge ? EncodeBase64(contents) : null, - contentsEncoded = isLarge, - }; - - return Response.Success( - $"Shader '{Path.GetFileName(relativePath)}' read successfully.", - responseData - ); - } - catch (Exception e) - { - return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); - } - } - - private static object UpdateShader( - string fullPath, - string relativePath, - string name, - string contents - ) - { - if (!File.Exists(fullPath)) - { - return Response.Error( - $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." - ); - } - if (string.IsNullOrEmpty(contents)) - { - return Response.Error("Content is required for the 'update' action."); - } - - try - { - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); - return Response.Success( - $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", - new { path = relativePath } - ); - } - catch (Exception e) - { - return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); - } - } - - private static object DeleteShader(string fullPath, string relativePath) - { - if (!File.Exists(fullPath)) - { - return Response.Error($"Shader not found at '{relativePath}'."); - } - - try - { - // Delete the asset through Unity's AssetDatabase first - bool success = AssetDatabase.DeleteAsset(relativePath); - if (!success) - { - return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); - } - - // If the file still exists (rare case), try direct deletion - if (File.Exists(fullPath)) - { - File.Delete(fullPath); - } - - return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); - } - catch (Exception e) - { - return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); - } - } - - //This is a CGProgram template - //TODO: making a HLSL template as well? - private static string GenerateDefaultShaderContent(string name) - { - return @"Shader """ + name + @""" - { - Properties - { - _MainTex (""Texture"", 2D) = ""white"" {} - } - SubShader - { - Tags { ""RenderType""=""Opaque"" } - LOD 100 - - Pass - { - CGPROGRAM - #pragma vertex vert - #pragma fragment frag - #include ""UnityCG.cginc"" - - struct appdata - { - float4 vertex : POSITION; - float2 uv : TEXCOORD0; - }; - - struct v2f - { - float2 uv : TEXCOORD0; - float4 vertex : SV_POSITION; - }; - - sampler2D _MainTex; - float4 _MainTex_ST; - - v2f vert (appdata v) - { - v2f o; - o.vertex = UnityObjectToClipPos(v.vertex); - o.uv = TRANSFORM_TEX(v.uv, _MainTex); - return o; - } - - fixed4 frag (v2f i) : SV_Target - { - fixed4 col = tex2D(_MainTex, i.uv); - return col; - } - ENDCG - } - } - }"; - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta deleted file mode 100644 index 89d10cdd..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: bcf4f1f3110494344b2af9324cf5c571 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems.meta deleted file mode 100644 index ffbda8e7..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 2df8f144c6e684ec3bfd53e4a48f06ee -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs deleted file mode 100644 index 8cca35a6..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Tools.MenuItems -{ - /// - /// Facade handler for managing Unity Editor menu items. - /// Routes actions to read or execute implementations. - /// - public static class ManageMenuItem - { - /// - /// Routes actions: execute, list, exists, refresh - /// - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString()?.ToLowerInvariant(); - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required. Valid actions are: execute, list, exists, refresh."); - } - - try - { - switch (action) - { - case "execute": - return MenuItemExecutor.Execute(@params); - case "list": - return MenuItemsReader.List(@params); - case "exists": - return MenuItemsReader.Exists(@params); - default: - return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh."); - } - } - catch (Exception e) - { - McpLog.Error($"[ManageMenuItem] Action '{action}' failed: {e}"); - return Response.Error($"Internal error: {e.Message}"); - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta deleted file mode 100644 index aba1f496..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 77808278b21a6474a90f3abb91483f71 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs deleted file mode 100644 index fe6180f7..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Tools.MenuItems -{ - /// - /// Executes Unity Editor menu items by path with safety checks. - /// - public static class MenuItemExecutor - { - // Basic blacklist to prevent execution of disruptive menu items. - private static readonly HashSet _menuPathBlacklist = new HashSet( - StringComparer.OrdinalIgnoreCase) - { - "File/Quit", - }; - - /// - /// Execute a specific menu item. Expects 'menu_path' or 'menuPath' in params. - /// - public static object Execute(JObject @params) - { - string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); - if (string.IsNullOrWhiteSpace(menuPath)) - { - return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); - } - - if (_menuPathBlacklist.Contains(menuPath)) - { - return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); - } - - try - { - // Execute on main thread using delayCall - EditorApplication.delayCall += () => - { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - if (!executed) - { - McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); - } - } - catch (Exception delayEx) - { - McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}"); - } - }; - - return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); - } - catch (Exception e) - { - McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); - return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta deleted file mode 100644 index 2e9f4223..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs deleted file mode 100644 index db91feb3..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Tools.MenuItems -{ - /// - /// Provides read/list/exists capabilities for Unity menu items with caching. - /// - public static class MenuItemsReader - { - private static List _cached; - - [InitializeOnLoadMethod] - private static void Build() => Refresh(); - - /// - /// Returns the cached list, refreshing if necessary. - /// - public static IReadOnlyList AllMenuItems() => _cached ??= Refresh(); - - /// - /// Rebuilds the cached list from reflection. - /// - private static List Refresh() - { - try - { - var methods = TypeCache.GetMethodsWithAttribute(); - _cached = methods - // Methods can have multiple [MenuItem] attributes; collect them all - .SelectMany(m => m - .GetCustomAttributes(typeof(MenuItem), false) - .OfType() - .Select(attr => attr.menuItem)) - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(StringComparer.Ordinal) // Ensure no duplicates - .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering - .ToList(); - return _cached; - } - catch (Exception e) - { - McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); - _cached = _cached ?? new List(); - return _cached; - } - } - - /// - /// Returns a list of menu items. Optional 'search' param filters results. - /// - public static object List(JObject @params) - { - string search = @params["search"]?.ToString(); - bool doRefresh = @params["refresh"]?.ToObject() ?? false; - if (doRefresh || _cached == null) - { - Refresh(); - } - - IEnumerable result = _cached ?? Enumerable.Empty(); - if (!string.IsNullOrEmpty(search)) - { - result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0); - } - - return Response.Success("Menu items retrieved.", result.ToList()); - } - - /// - /// Checks if a given menu path exists in the cache. - /// - public static object Exists(JObject @params) - { - string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); - if (string.IsNullOrWhiteSpace(menuPath)) - { - return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); - } - - bool doRefresh = @params["refresh"]?.ToObject() ?? false; - if (doRefresh || _cached == null) - { - Refresh(); - } - - bool exists = (_cached ?? new List()).Contains(menuPath); - return Response.Success($"Exists check completed for '{menuPath}'.", new { exists }); - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta deleted file mode 100644 index 78fd7ab4..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 37f212f83e8854ed7b5454d3733e4bfa -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs deleted file mode 100644 index 5bbf557b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ /dev/null @@ -1,571 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditorInternal; -using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles reading and clearing Unity Editor console log entries. - /// Uses reflection to access internal LogEntry methods/properties. - /// - public static class ReadConsole - { - // (Calibration removed) - - // Reflection members for accessing internal LogEntry data - // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection - private static MethodInfo _startGettingEntriesMethod; - private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... - private static MethodInfo _clearMethod; - private static MethodInfo _getCountMethod; - private static MethodInfo _getEntryMethod; - private static FieldInfo _modeField; - private static FieldInfo _messageField; - private static FieldInfo _fileField; - private static FieldInfo _lineField; - private static FieldInfo _instanceIdField; - - // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? - - // Static constructor for reflection setup - static ReadConsole() - { - try - { - Type logEntriesType = typeof(EditorApplication).Assembly.GetType( - "UnityEditor.LogEntries" - ); - if (logEntriesType == null) - throw new Exception("Could not find internal type UnityEditor.LogEntries"); - - - - // Include NonPublic binding flags as internal APIs might change accessibility - BindingFlags staticFlags = - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - BindingFlags instanceFlags = - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - _startGettingEntriesMethod = logEntriesType.GetMethod( - "StartGettingEntries", - staticFlags - ); - if (_startGettingEntriesMethod == null) - throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); - - // Try reflecting EndGettingEntries based on warning message - _endGettingEntriesMethod = logEntriesType.GetMethod( - "EndGettingEntries", - staticFlags - ); - if (_endGettingEntriesMethod == null) - throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); - - _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); - if (_clearMethod == null) - throw new Exception("Failed to reflect LogEntries.Clear"); - - _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); - if (_getCountMethod == null) - throw new Exception("Failed to reflect LogEntries.GetCount"); - - _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); - if (_getEntryMethod == null) - throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); - - Type logEntryType = typeof(EditorApplication).Assembly.GetType( - "UnityEditor.LogEntry" - ); - if (logEntryType == null) - throw new Exception("Could not find internal type UnityEditor.LogEntry"); - - _modeField = logEntryType.GetField("mode", instanceFlags); - if (_modeField == null) - throw new Exception("Failed to reflect LogEntry.mode"); - - _messageField = logEntryType.GetField("message", instanceFlags); - if (_messageField == null) - throw new Exception("Failed to reflect LogEntry.message"); - - _fileField = logEntryType.GetField("file", instanceFlags); - if (_fileField == null) - throw new Exception("Failed to reflect LogEntry.file"); - - _lineField = logEntryType.GetField("line", instanceFlags); - if (_lineField == null) - throw new Exception("Failed to reflect LogEntry.line"); - - _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); - if (_instanceIdField == null) - throw new Exception("Failed to reflect LogEntry.instanceID"); - - // (Calibration removed) - - } - catch (Exception e) - { - Debug.LogError( - $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" - ); - // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. - _startGettingEntriesMethod = - _endGettingEntriesMethod = - _clearMethod = - _getCountMethod = - _getEntryMethod = - null; - _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; - } - } - - // --- Main Handler --- - - public static object HandleCommand(JObject @params) - { - // Check if ALL required reflection members were successfully initialized. - if ( - _startGettingEntriesMethod == null - || _endGettingEntriesMethod == null - || _clearMethod == null - || _getCountMethod == null - || _getEntryMethod == null - || _modeField == null - || _messageField == null - || _fileField == null - || _lineField == null - || _instanceIdField == null - ) - { - // Log the error here as well for easier debugging in Unity Console - Debug.LogError( - "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." - ); - return Response.Error( - "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." - ); - } - - string action = @params["action"]?.ToString().ToLower() ?? "get"; - - try - { - if (action == "clear") - { - return ClearConsole(); - } - else if (action == "get") - { - // Extract parameters for 'get' - var types = - (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() - ?? new List { "error", "warning", "log" }; - int? count = @params["count"]?.ToObject(); - string filterText = @params["filterText"]?.ToString(); - string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering - string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); - bool includeStacktrace = - @params["includeStacktrace"]?.ToObject() ?? true; - - if (types.Contains("all")) - { - types = new List { "error", "warning", "log" }; // Expand 'all' - } - - if (!string.IsNullOrEmpty(sinceTimestampStr)) - { - Debug.LogWarning( - "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." - ); - // Need a way to get timestamp per log entry. - } - - return GetConsoleEntries(types, count, filterText, format, includeStacktrace); - } - else - { - return Response.Error( - $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." - ); - } - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object ClearConsole() - { - try - { - _clearMethod.Invoke(null, null); // Static method, no instance, no parameters - return Response.Success("Console cleared successfully."); - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); - return Response.Error($"Failed to clear console: {e.Message}"); - } - } - - private static object GetConsoleEntries( - List types, - int? count, - string filterText, - string format, - bool includeStacktrace - ) - { - List formattedEntries = new List(); - int retrievedCount = 0; - - try - { - // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal - _startGettingEntriesMethod.Invoke(null, null); - - int totalEntries = (int)_getCountMethod.Invoke(null, null); - // Create instance to pass to GetEntryInternal - Ensure the type is correct - Type logEntryType = typeof(EditorApplication).Assembly.GetType( - "UnityEditor.LogEntry" - ); - if (logEntryType == null) - throw new Exception( - "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." - ); - object logEntryInstance = Activator.CreateInstance(logEntryType); - - for (int i = 0; i < totalEntries; i++) - { - // Get the entry data into our instance using reflection - _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); - - // Extract data using reflection - int mode = (int)_modeField.GetValue(logEntryInstance); - string message = (string)_messageField.GetValue(logEntryInstance); - string file = (string)_fileField.GetValue(logEntryInstance); - - int line = (int)_lineField.GetValue(logEntryInstance); - // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); - - if (string.IsNullOrEmpty(message)) - { - continue; // Skip empty messages - } - - // (Calibration removed) - - // --- Filtering --- - // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed - LogType unityType = InferTypeFromMessage(message); - bool isExplicitDebug = IsExplicitDebugLog(message); - if (!isExplicitDebug && unityType == LogType.Log) - { - unityType = GetLogTypeFromMode(mode); - } - - bool want; - // Treat Exception/Assert as errors for filtering convenience - if (unityType == LogType.Exception) - { - want = types.Contains("error") || types.Contains("exception"); - } - else if (unityType == LogType.Assert) - { - want = types.Contains("error") || types.Contains("assert"); - } - else - { - want = types.Contains(unityType.ToString().ToLowerInvariant()); - } - - if (!want) continue; - - // Filter by text (case-insensitive) - if ( - !string.IsNullOrEmpty(filterText) - && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 - ) - { - continue; - } - - // TODO: Filter by timestamp (requires timestamp data) - - // --- Formatting --- - string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; - // Get first line if stack is present and requested, otherwise use full message - string messageOnly = - (includeStacktrace && !string.IsNullOrEmpty(stackTrace)) - ? message.Split( - new[] { '\n', '\r' }, - StringSplitOptions.RemoveEmptyEntries - )[0] - : message; - - object formattedEntry = null; - switch (format) - { - case "plain": - formattedEntry = messageOnly; - break; - case "json": - case "detailed": // Treat detailed as json for structured return - default: - formattedEntry = new - { - type = unityType.ToString(), - message = messageOnly, - file = file, - line = line, - // timestamp = "", // TODO - stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found - }; - break; - } - - formattedEntries.Add(formattedEntry); - retrievedCount++; - - // Apply count limit (after filtering) - if (count.HasValue && retrievedCount >= count.Value) - { - break; - } - } - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); - // Ensure EndGettingEntries is called even if there's an error during iteration - try - { - _endGettingEntriesMethod.Invoke(null, null); - } - catch - { /* Ignore nested exception */ - } - return Response.Error($"Error retrieving log entries: {e.Message}"); - } - finally - { - // Ensure we always call EndGettingEntries - try - { - _endGettingEntriesMethod.Invoke(null, null); - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); - // Don't return error here as we might have valid data, but log it. - } - } - - // Return the filtered and formatted list (might be empty) - return Response.Success( - $"Retrieved {formattedEntries.Count} log entries.", - formattedEntries - ); - } - - // --- Internal Helpers --- - - // Mapping bits from LogEntry.mode. These may vary by Unity version. - private const int ModeBitError = 1 << 0; - private const int ModeBitAssert = 1 << 1; - private const int ModeBitWarning = 1 << 2; - private const int ModeBitLog = 1 << 3; - private const int ModeBitException = 1 << 4; // often combined with Error bits - private const int ModeBitScriptingError = 1 << 9; - private const int ModeBitScriptingWarning = 1 << 10; - private const int ModeBitScriptingLog = 1 << 11; - private const int ModeBitScriptingException = 1 << 18; - private const int ModeBitScriptingAssertion = 1 << 22; - - private static LogType GetLogTypeFromMode(int mode) - { - // Preserve Unity's real type (no remapping); bits may vary by version - if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; - if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; - if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; - if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; - return LogType.Log; - } - - // (Calibration helpers removed) - - /// - /// Classifies severity using message/stacktrace content. Works across Unity versions. - /// - private static LogType InferTypeFromMessage(string fullMessage) - { - if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; - - // Fast path: look for explicit Debug API names in the appended stack trace - // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" - if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Error; - if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Warning; - - // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" - if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 - || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Warning; - if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 - || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Error; - - // Exceptions (avoid misclassifying compiler diagnostics) - if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Exception; - - // Unity assertions - if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) - return LogType.Assert; - - return LogType.Log; - } - - private static bool IsExplicitDebugLog(string fullMessage) - { - if (string.IsNullOrEmpty(fullMessage)) return false; - if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; - return false; - } - - /// - /// Applies the "one level lower" remapping for filtering, like the old version. - /// This ensures compatibility with the filtering logic that expects remapped types. - /// - private static LogType GetRemappedTypeForFiltering(LogType unityType) - { - switch (unityType) - { - case LogType.Error: - return LogType.Warning; // Error becomes Warning - case LogType.Warning: - return LogType.Log; // Warning becomes Log - case LogType.Assert: - return LogType.Assert; // Assert remains Assert - case LogType.Log: - return LogType.Log; // Log remains Log - case LogType.Exception: - return LogType.Warning; // Exception becomes Warning - default: - return LogType.Log; // Default fallback - } - } - - /// - /// Attempts to extract the stack trace part from a log message. - /// Unity log messages often have the stack trace appended after the main message, - /// starting on a new line and typically indented or beginning with "at ". - /// - /// The complete log message including potential stack trace. - /// The extracted stack trace string, or null if none is found. - private static string ExtractStackTrace(string fullMessage) - { - if (string.IsNullOrEmpty(fullMessage)) - return null; - - // Split into lines, removing empty ones to handle different line endings gracefully. - // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. - string[] lines = fullMessage.Split( - new[] { '\r', '\n' }, - StringSplitOptions.RemoveEmptyEntries - ); - - // If there's only one line or less, there's no separate stack trace. - if (lines.Length <= 1) - return null; - - int stackStartIndex = -1; - - // Start checking from the second line onwards. - for (int i = 1; i < lines.Length; ++i) - { - // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. - string trimmedLine = lines[i].TrimStart(); - - // Check for common stack trace patterns. - if ( - trimmedLine.StartsWith("at ") - || trimmedLine.StartsWith("UnityEngine.") - || trimmedLine.StartsWith("UnityEditor.") - || trimmedLine.Contains("(at ") - || // Covers "(at Assets/..." pattern - // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) - ( - trimmedLine.Length > 0 - && char.IsUpper(trimmedLine[0]) - && trimmedLine.Contains('.') - ) - ) - { - stackStartIndex = i; - break; // Found the likely start of the stack trace - } - } - - // If a potential start index was found... - if (stackStartIndex > 0) - { - // Join the lines from the stack start index onwards using standard newline characters. - // This reconstructs the stack trace part of the message. - return string.Join("\n", lines.Skip(stackStartIndex)); - } - - // No clear stack trace found based on the patterns. - return null; - } - - /* LogEntry.mode bits exploration (based on Unity decompilation/observation): - May change between versions. - - Basic Types: - kError = 1 << 0 (1) - kAssert = 1 << 1 (2) - kWarning = 1 << 2 (4) - kLog = 1 << 3 (8) - kFatal = 1 << 4 (16) - Often treated as Exception/Error - - Modifiers/Context: - kAssetImportError = 1 << 7 (128) - kAssetImportWarning = 1 << 8 (256) - kScriptingError = 1 << 9 (512) - kScriptingWarning = 1 << 10 (1024) - kScriptingLog = 1 << 11 (2048) - kScriptCompileError = 1 << 12 (4096) - kScriptCompileWarning = 1 << 13 (8192) - kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play - kMayIgnoreLineNumber = 1 << 15 (32768) - kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button - kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) - kScriptingException = 1 << 18 (262144) - kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI - kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior - kGraphCompileError = 1 << 21 (2097152) - kScriptingAssertion = 1 << 22 (4194304) - kVisualScriptingError = 1 << 23 (8388608) - - Example observed values: - Log: 2048 (ScriptingLog) or 8 (Log) - Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) - Error: 513 (ScriptingError | Error) or 1 (Error) - Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination - Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) - */ - } -} - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta deleted file mode 100644 index 039895f8..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 46c4f3614ed61f547ba823f0b2790267 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows.meta deleted file mode 100644 index eda016e5..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d2ee39f5d4171184eb208e865c1ef4c1 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs deleted file mode 100644 index 84113f7d..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ /dev/null @@ -1,2129 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Security.Cryptography; -using System.Text; -using System.Net.Sockets; -using System.Net; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Windows -{ - public class MCPForUnityEditorWindow : EditorWindow - { - private bool isUnityBridgeRunning = false; - private Vector2 scrollPosition; - private string pythonServerInstallationStatus = "Not Installed"; - private Color pythonServerInstallationStatusColor = Color.red; - private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) - private readonly McpClients mcpClients = new(); - private bool autoRegisterEnabled; - private bool lastClientRegisteredOk; - private bool lastBridgeVerifiedOk; - private string pythonDirOverride = null; - private bool debugLogsEnabled; - - // Script validation settings - private int validationLevelIndex = 1; // Default to Standard - private readonly string[] validationLevelOptions = new string[] - { - "Basic - Only syntax checks", - "Standard - Syntax + Unity practices", - "Comprehensive - All checks + semantic analysis", - "Strict - Full semantic validation (requires Roslyn)" - }; - - // UI state - private int selectedClientIndex = 0; - - [MenuItem("Window/MCP for Unity")] - public static void ShowWindow() - { - GetWindow("MCP for Unity"); - } - - private void OnEnable() - { - UpdatePythonServerInstallationStatus(); - - // Refresh bridge status - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); - debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); - if (debugLogsEnabled) - { - LogDebugPrefsState(); - } - foreach (McpClient mcpClient in mcpClients.clients) - { - CheckMcpConfiguration(mcpClient); - } - - // Load validation level setting - LoadValidationLevelSetting(); - - // First-run auto-setup only if Claude CLI is available - if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) - { - AutoFirstRunSetup(); - } - } - - private void OnFocus() - { - // Refresh bridge running state on focus in case initialization completed after domain reload - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) - { - McpClient selectedClient = mcpClients.clients[selectedClientIndex]; - CheckMcpConfiguration(selectedClient); - } - Repaint(); - } - - private Color GetStatusColor(McpStatus status) - { - // Return appropriate color based on the status enum - return status switch - { - McpStatus.Configured => Color.green, - McpStatus.Running => Color.green, - McpStatus.Connected => Color.green, - McpStatus.IncorrectPath => Color.yellow, - McpStatus.CommunicationError => Color.yellow, - McpStatus.NoResponse => Color.yellow, - _ => Color.red, // Default to red for error states or not configured - }; - } - - private void UpdatePythonServerInstallationStatus() - { - try - { - string installedPath = ServerInstaller.GetServerPath(); - bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); - if (installedOk) - { - pythonServerInstallationStatus = "Installed"; - pythonServerInstallationStatusColor = Color.green; - return; - } - - // Fall back to embedded/dev source via our existing resolution logic - string embeddedPath = FindPackagePythonDirectory(); - bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); - if (embeddedOk) - { - pythonServerInstallationStatus = "Installed (Embedded)"; - pythonServerInstallationStatusColor = Color.green; - } - else - { - pythonServerInstallationStatus = "Not Installed"; - pythonServerInstallationStatusColor = Color.red; - } - } - catch - { - pythonServerInstallationStatus = "Not Installed"; - pythonServerInstallationStatusColor = Color.red; - } - } - - - private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) - { - float offsetX = (statusRect.width - size) / 2; - float offsetY = (statusRect.height - size) / 2; - Rect dotRect = new(statusRect.x + offsetX, statusRect.y + offsetY, size, size); - Vector3 center = new( - dotRect.x + (dotRect.width / 2), - dotRect.y + (dotRect.height / 2), - 0 - ); - float radius = size / 2; - - // Draw the main dot - Handles.color = statusColor; - Handles.DrawSolidDisc(center, Vector3.forward, radius); - - // Draw the border - Color borderColor = new( - statusColor.r * 0.7f, - statusColor.g * 0.7f, - statusColor.b * 0.7f - ); - Handles.color = borderColor; - Handles.DrawWireDisc(center, Vector3.forward, radius); - } - - private void OnGUI() - { - scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); - - // Header - DrawHeader(); - - // Compute equal column widths for uniform layout - float horizontalSpacing = 2f; - float outerPadding = 20f; // approximate padding - // Make columns a bit less wide for a tighter layout - float computed = (position.width - outerPadding - horizontalSpacing) / 2f; - float colWidth = Mathf.Clamp(computed, 220f, 340f); - // Use fixed heights per row so paired panels match exactly - float topPanelHeight = 190f; - float bottomPanelHeight = 230f; - - // Top row: Server Status (left) and Unity Bridge (right) - EditorGUILayout.BeginHorizontal(); - { - EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); - DrawServerStatusSection(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(horizontalSpacing); - - EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); - DrawBridgeSection(); - EditorGUILayout.EndVertical(); - } - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(10); - - // Second row: MCP Client Configuration (left) and Script Validation (right) - EditorGUILayout.BeginHorizontal(); - { - EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); - DrawUnifiedClientConfiguration(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(horizontalSpacing); - - EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); - DrawValidationSection(); - EditorGUILayout.EndVertical(); - } - EditorGUILayout.EndHorizontal(); - - // Minimal bottom padding - EditorGUILayout.Space(2); - - EditorGUILayout.EndScrollView(); - } - - private void DrawHeader() - { - EditorGUILayout.Space(15); - Rect titleRect = EditorGUILayout.GetControlRect(false, 40); - EditorGUI.DrawRect(titleRect, new Color(0.2f, 0.2f, 0.2f, 0.1f)); - - GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 16, - alignment = TextAnchor.MiddleLeft - }; - - GUI.Label( - new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), - "MCP for Unity Editor", - titleStyle - ); - - // Place the Show Debug Logs toggle on the same header row, right-aligned - float toggleWidth = 160f; - Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); - bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); - if (newDebug != debugLogsEnabled) - { - debugLogsEnabled = newDebug; - EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); - if (debugLogsEnabled) - { - LogDebugPrefsState(); - } - } - EditorGUILayout.Space(15); - } - - private void LogDebugPrefsState() - { - try - { - string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); - string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); - string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); - bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); - - // Version-scoped detection key - string embeddedVer = ReadEmbeddedVersionOrFallback(); - string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; - bool detectLogged = SafeGetPrefBool(detectKey); - - // Project-scoped auto-register key - string projectPath = Application.dataPath ?? string.Empty; - string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; - bool autoRegistered = SafeGetPrefBool(autoKey); - - MCPForUnity.Editor.Helpers.McpLog.Info( - "MCP Debug Prefs:\n" + - $" DebugLogs: {debugLogsEnabled}\n" + - $" PythonDirOverride: '{pythonDirOverridePref}'\n" + - $" UvPath: '{uvPathPref}'\n" + - $" ServerSrc: '{serverSrcPref}'\n" + - $" UseEmbeddedServer: {useEmbedded}\n" + - $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + - $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", - always: false - ); - } - catch (Exception ex) - { - UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); - } - } - - private static string SafeGetPrefString(string key) - { - try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } - } - - private static bool SafeGetPrefBool(string key) - { - try { return EditorPrefs.GetBool(key, false); } catch { return false; } - } - - private static string ReadEmbeddedVersionOrFallback() - { - try - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) - { - var p = Path.Combine(embeddedSrc, "server_version.txt"); - if (File.Exists(p)) - { - var s = File.ReadAllText(p)?.Trim(); - if (!string.IsNullOrEmpty(s)) return s; - } - } - } - catch { } - return "unknown"; - } - - private void DrawServerStatusSection() - { - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 14 - }; - EditorGUILayout.LabelField("Server Status", sectionTitleStyle); - EditorGUILayout.Space(8); - - EditorGUILayout.BeginHorizontal(); - Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); - DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); - - GUIStyle statusStyle = new GUIStyle(EditorStyles.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(pythonServerInstallationStatus, statusStyle, GUILayout.Height(28)); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(5); - - EditorGUILayout.BeginHorizontal(); - bool isAutoMode = MCPForUnityBridge.IsAutoConnectMode(); - GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; - EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - - int currentUnityPort = MCPForUnityBridge.GetCurrentPort(); - GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) - { - fontSize = 11 - }; - EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); - EditorGUILayout.Space(5); - - /// Auto-Setup button below ports - string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; - if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) - { - RunSetupNow(); - } - EditorGUILayout.Space(4); - - // Repair Python Env button with tooltip tag - using (new EditorGUILayout.HorizontalScope()) - { - GUILayout.FlexibleSpace(); - GUIContent repairLabel = new GUIContent( - "Repair Python Env", - "Deletes the server's .venv and runs 'uv sync' to rebuild a clean environment. Use this if modules are missing or Python upgraded." - ); - if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) - { - bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RepairPythonEnvironment(); - if (ok) - { - EditorUtility.DisplayDialog("MCP for Unity", "Python environment repaired.", "OK"); - UpdatePythonServerInstallationStatus(); - } - else - { - EditorUtility.DisplayDialog("MCP for Unity", "Repair failed. Please check Console for details.", "OK"); - } - } - } - // (Removed descriptive tool tag under the Repair button) - - // (Show Debug Logs toggle moved to header) - EditorGUILayout.Space(2); - - // Python detection warning with link - if (!IsPythonDetected()) - { - GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; - EditorGUILayout.LabelField("Warning: No Python installation found.", warnStyle); - using (new EditorGUILayout.HorizontalScope()) - { - if (GUILayout.Button("Open Install Instructions", GUILayout.Width(200))) - { - Application.OpenURL("https://www.python.org/downloads/"); - } - } - EditorGUILayout.Space(4); - } - - // Troubleshooting helpers - if (pythonServerInstallationStatusColor != Color.green) - { - using (new EditorGUILayout.HorizontalScope()) - { - if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) - { - string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); - if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) - { - pythonDirOverride = picked; - EditorPrefs.SetString("MCPForUnity.PythonDirOverride", pythonDirOverride); - UpdatePythonServerInstallationStatus(); - } - else if (!string.IsNullOrEmpty(picked)) - { - EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); - } - } - if (GUILayout.Button("Verify again", GUILayout.Width(120))) - { - UpdatePythonServerInstallationStatus(); - } - } - } - EditorGUILayout.EndVertical(); - } - - private void DrawBridgeSection() - { - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Always reflect the live state each repaint to avoid stale UI after recompiles - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - - GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 14 - }; - EditorGUILayout.LabelField("Unity Bridge", sectionTitleStyle); - EditorGUILayout.Space(8); - - EditorGUILayout.BeginHorizontal(); - Color bridgeColor = isUnityBridgeRunning ? Color.green : Color.red; - Rect bridgeStatusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); - DrawStatusDot(bridgeStatusRect, bridgeColor, 16); - - GUIStyle bridgeStatusStyle = new GUIStyle(EditorStyles.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(isUnityBridgeRunning ? "Running" : "Stopped", bridgeStatusStyle, GUILayout.Height(28)); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) - { - ToggleUnityBridge(); - } - EditorGUILayout.Space(5); - EditorGUILayout.EndVertical(); - } - - private void DrawValidationSection() - { - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 14 - }; - EditorGUILayout.LabelField("Script Validation", sectionTitleStyle); - EditorGUILayout.Space(8); - - EditorGUI.BeginChangeCheck(); - validationLevelIndex = EditorGUILayout.Popup("Validation Level", validationLevelIndex, validationLevelOptions, GUILayout.Height(20)); - if (EditorGUI.EndChangeCheck()) - { - SaveValidationLevelSetting(); - } - - EditorGUILayout.Space(8); - string description = GetValidationLevelDescription(validationLevelIndex); - EditorGUILayout.HelpBox(description, MessageType.Info); - EditorGUILayout.Space(4); - // (Show Debug Logs toggle moved to header) - EditorGUILayout.Space(2); - EditorGUILayout.EndVertical(); - } - - private void DrawUnifiedClientConfiguration() - { - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 14 - }; - EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); - EditorGUILayout.Space(10); - - // (Auto-connect toggle removed per design) - - // Client selector - string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); - EditorGUI.BeginChangeCheck(); - selectedClientIndex = EditorGUILayout.Popup("Select Client", selectedClientIndex, clientNames, GUILayout.Height(20)); - if (EditorGUI.EndChangeCheck()) - { - selectedClientIndex = Mathf.Clamp(selectedClientIndex, 0, mcpClients.clients.Count - 1); - } - - EditorGUILayout.Space(10); - - if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) - { - McpClient selectedClient = mcpClients.clients[selectedClientIndex]; - DrawClientConfigurationCompact(selectedClient); - } - - EditorGUILayout.Space(5); - EditorGUILayout.EndVertical(); - } - - private void AutoFirstRunSetup() - { - try - { - // Project-scoped one-time flag - string projectPath = Application.dataPath ?? string.Empty; - string key = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; - if (EditorPrefs.GetBool(key, false)) - { - return; - } - - // Attempt client registration using discovered Python server dir - pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); - string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); - if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) - { - bool anyRegistered = false; - foreach (McpClient client in mcpClients.clients) - { - try - { - if (client.mcpType == McpTypes.ClaudeCode) - { - // Only attempt if Claude CLI is present - if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) - { - RegisterWithClaudeCode(pythonDir); - anyRegistered = true; - } - } - else - { - // For Cursor/others, skip if already configured - if (!IsCursorConfigured(pythonDir)) - { - ConfigureMcpClient(client); - anyRegistered = true; - } - } - } - catch (Exception ex) - { - MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); - } - } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); - } - - // Ensure the bridge is listening and has a fresh saved port - if (!MCPForUnityBridge.IsRunning) - { - try - { - MCPForUnityBridge.StartAutoConnect(); - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - Repaint(); - } - catch (Exception ex) - { - MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); - } - } - - // Verify bridge with a quick ping - lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); - - EditorPrefs.SetBool(key, true); - } - catch (Exception e) - { - MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); - } - } - - private static string ComputeSha1(string input) - { - try - { - using SHA1 sha1 = SHA1.Create(); - byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); - byte[] hash = sha1.ComputeHash(bytes); - StringBuilder sb = new StringBuilder(hash.Length * 2); - foreach (byte b in hash) - { - sb.Append(b.ToString("x2")); - } - return sb.ToString(); - } - catch - { - return ""; - } - } - - private void RunSetupNow() - { - // Force a one-shot setup regardless of first-run flag - try - { - pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); - string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); - if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); - return; - } - - bool anyRegistered = false; - foreach (McpClient client in mcpClients.clients) - { - try - { - if (client.mcpType == McpTypes.ClaudeCode) - { - if (!IsClaudeConfigured()) - { - RegisterWithClaudeCode(pythonDir); - anyRegistered = true; - } - } - else - { - if (!IsCursorConfigured(pythonDir)) - { - ConfigureMcpClient(client); - anyRegistered = true; - } - } - } - catch (Exception ex) - { - UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); - } - } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); - - // Restart/ensure bridge - MCPForUnityBridge.StartAutoConnect(); - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - - // Verify - lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); - Repaint(); - } - catch (Exception e) - { - EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); - } - } - - private static bool IsCursorConfigured(string pythonDir) - { - try - { - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", "mcp.json") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", "mcp.json"); - if (!File.Exists(configPath)) return false; - string json = File.ReadAllText(configPath); - dynamic cfg = JsonConvert.DeserializeObject(json); - var servers = cfg?.mcpServers; - if (servers == null) return false; - var unity = servers.unityMCP ?? servers.UnityMCP; - if (unity == null) return false; - var args = unity.args; - if (args == null) return false; - // Prefer exact extraction of the --directory value and compare normalized paths - string[] strArgs = ((System.Collections.Generic.IEnumerable)args) - .Select(x => x?.ToString() ?? string.Empty) - .ToArray(); - string dir = ExtractDirectoryArg(strArgs); - if (string.IsNullOrEmpty(dir)) return false; - return PathsEqual(dir, pythonDir); - } - catch { return false; } - } - - private static bool PathsEqual(string a, string b) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - try - { - string na = System.IO.Path.GetFullPath(a.Trim()); - string nb = System.IO.Path.GetFullPath(b.Trim()); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - // Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed - return string.Equals(na, nb, StringComparison.Ordinal); - } - catch { return false; } - } - - private static bool IsClaudeConfigured() - { - try - { - string claudePath = ExecPath.ResolveClaude(); - if (string.IsNullOrEmpty(claudePath)) return false; - - // Only prepend PATH on Unix - string pathPrepend = null; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : "/usr/local/bin:/usr/bin:/bin"; - } - - if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) - { - return false; - } - return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; - } - catch { return false; } - } - - private static bool VerifyBridgePing(int port) - { - // Use strict framed protocol to match bridge (FRAMING=1) - const int ConnectTimeoutMs = 1000; - const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout - - try - { - using TcpClient client = new TcpClient(); - var connectTask = client.ConnectAsync(IPAddress.Loopback, port); - if (!connectTask.Wait(ConnectTimeoutMs)) return false; - - using NetworkStream stream = client.GetStream(); - try { client.NoDelay = true; } catch { } - - // 1) Read handshake line (ASCII, newline-terminated) - string handshake = ReadLineAscii(stream, 2000); - if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) - { - UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); - return false; - } - - // 2) Send framed "ping" - byte[] payload = Encoding.UTF8.GetBytes("ping"); - WriteFrame(stream, payload, FrameTimeoutMs); - - // 3) Read framed response and check for pong - string response = ReadFrameUtf8(stream, FrameTimeoutMs); - bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; - if (!ok) - { - UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); - } - return ok; - } - catch (Exception ex) - { - UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); - return false; - } - } - - // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts - private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) - { - if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); - byte[] header = new byte[8]; - ulong len = (ulong)payload.LongLength; - header[0] = (byte)(len >> 56); - header[1] = (byte)(len >> 48); - header[2] = (byte)(len >> 40); - header[3] = (byte)(len >> 32); - header[4] = (byte)(len >> 24); - header[5] = (byte)(len >> 16); - header[6] = (byte)(len >> 8); - header[7] = (byte)(len); - - stream.WriteTimeout = timeoutMs; - stream.Write(header, 0, header.Length); - stream.Write(payload, 0, payload.Length); - } - - private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) - { - byte[] header = ReadExact(stream, 8, timeoutMs); - ulong len = ((ulong)header[0] << 56) - | ((ulong)header[1] << 48) - | ((ulong)header[2] << 40) - | ((ulong)header[3] << 32) - | ((ulong)header[4] << 24) - | ((ulong)header[5] << 16) - | ((ulong)header[6] << 8) - | header[7]; - if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); - if (len > int.MaxValue) throw new IOException("Frame too large"); - byte[] payload = ReadExact(stream, (int)len, timeoutMs); - return Encoding.UTF8.GetString(payload); - } - - private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) - { - byte[] buffer = new byte[count]; - int offset = 0; - stream.ReadTimeout = timeoutMs; - while (offset < count) - { - int read = stream.Read(buffer, offset, count - offset); - if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); - offset += read; - } - return buffer; - } - - private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) - { - stream.ReadTimeout = timeoutMs; - using var ms = new MemoryStream(); - byte[] one = new byte[1]; - while (ms.Length < maxLen) - { - int n = stream.Read(one, 0, 1); - if (n <= 0) break; - if (one[0] == (byte)'\n') break; - ms.WriteByte(one[0]); - } - return Encoding.ASCII.GetString(ms.ToArray()); - } - - private void DrawClientConfigurationCompact(McpClient mcpClient) - { - // Special pre-check for Claude Code: if CLI missing, reflect in status UI - if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - string claudeCheck = ExecPath.ResolveClaude(); - if (string.IsNullOrEmpty(claudeCheck)) - { - mcpClient.configStatus = "Claude Not Found"; - mcpClient.status = McpStatus.NotConfigured; - } - } - - // Pre-check for clients that require uv (all except Claude Code) - bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; - bool uvMissingEarly = false; - if (uvRequired) - { - string uvPathEarly = FindUvPath(); - if (string.IsNullOrEmpty(uvPathEarly)) - { - uvMissingEarly = true; - mcpClient.configStatus = "uv Not Found"; - mcpClient.status = McpStatus.NotConfigured; - } - } - - // Status display - EditorGUILayout.BeginHorizontal(); - Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); - Color statusColor = GetStatusColor(mcpClient.status); - DrawStatusDot(statusRect, statusColor, 16); - - GUIStyle clientStatusStyle = new GUIStyle(EditorStyles.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); - EditorGUILayout.EndHorizontal(); - // When Claude CLI is missing, show a clear install hint directly below status - if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) - { - GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); - installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange - EditorGUILayout.BeginHorizontal(); - GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); - Vector2 textSize = installHintStyle.CalcSize(installText); - EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); - GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; - GUILayout.Space(6); - if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); - } - EditorGUILayout.EndHorizontal(); - } - - EditorGUILayout.Space(10); - - // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls - if (uvRequired && uvMissingEarly) - { - GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold, - wordWrap = false - }; - installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); - EditorGUILayout.BeginHorizontal(); - GUIContent installText2 = new GUIContent("Make sure uv is installed!"); - Vector2 sz = installHintStyle2.CalcSize(installText2); - EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); - GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; - GUILayout.Space(6); - if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); - } - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) - { - string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); - if (!string.IsNullOrEmpty(picked)) - { - EditorPrefs.SetString("MCPForUnity.UvPath", picked); - ConfigureMcpClient(mcpClient); - Repaint(); - } - } - EditorGUILayout.EndHorizontal(); - return; - } - - // Action buttons in horizontal layout - EditorGUILayout.BeginHorizontal(); - - if (mcpClient.mcpType == McpTypes.VSCode) - { - if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) - { - ConfigureMcpClient(mcpClient); - } - } - else if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); - if (claudeAvailable) - { - bool isConfigured = mcpClient.status == McpStatus.Configured; - string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; - if (GUILayout.Button(buttonText, GUILayout.Height(32))) - { - if (isConfigured) - { - UnregisterWithClaudeCode(); - } - else - { - string pythonDir = FindPackagePythonDirectory(); - RegisterWithClaudeCode(pythonDir); - } - } - // Hide the picker once a valid binary is available - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; - string resolvedClaude = ExecPath.ResolveClaude(); - EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - } - // CLI picker row (only when not found) - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - if (!claudeAvailable) - { - // Only show the picker button in not-found state (no redundant "not found" label) - if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) - { - string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); - if (!string.IsNullOrEmpty(picked)) - { - ExecPath.SetClaudeCliPath(picked); - // Auto-register after setting a valid path - string pythonDir = FindPackagePythonDirectory(); - RegisterWithClaudeCode(pythonDir); - Repaint(); - } - } - } - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - } - else - { - if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) - { - ConfigureMcpClient(mcpClient); - } - } - - if (mcpClient.mcpType != McpTypes.ClaudeCode) - { - if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) - { - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? mcpClient.windowsConfigPath - : mcpClient.linuxConfigPath; - - if (mcpClient.mcpType == McpTypes.VSCode) - { - string pythonDir = FindPackagePythonDirectory(); - string uvPath = FindUvPath(); - if (uvPath == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); - return; - } - // VSCode now reads from mcp.json with a top-level "servers" block - var vscodeConfig = new - { - servers = new - { - unityMCP = new - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" } - } - } - }; - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); - VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); - } - else - { - ShowManualInstructionsWindow(configPath, mcpClient); - } - } - } - - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - // Quick info (hide when Claude is not found to avoid confusion) - bool hideConfigInfo = - (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) - || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); - if (!hideConfigInfo) - { - GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) - { - fontSize = 10 - }; - EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); - } - } - - private void ToggleUnityBridge() - { - if (isUnityBridgeRunning) - { - MCPForUnityBridge.Stop(); - } - else - { - MCPForUnityBridge.Start(); - } - // Reflect the actual state post-operation (avoid optimistic toggle) - isUnityBridgeRunning = MCPForUnityBridge.IsRunning; - Repaint(); - } - - private static bool IsValidUv(string path) - { - return !string.IsNullOrEmpty(path) - && System.IO.Path.IsPathRooted(path) - && System.IO.File.Exists(path); - } - - private static bool ValidateUvBinarySafe(string path) - { - try - { - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = path, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return false; - if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode != 0) return false; - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - catch { return false; } - } - - private static string ExtractDirectoryArg(string[] args) - { - if (args == null) return null; - for (int i = 0; i < args.Length - 1; i++) - { - if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) - { - return args[i + 1]; - } - } - return null; - } - - private static bool ArgsEqual(string[] a, string[] b) - { - if (a == null || b == null) return a == b; - if (a.Length != b.Length) return false; - for (int i = 0; i < a.Length; i++) - { - if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; - } - return true; - } - - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) - { - // 0) Respect explicit lock (hidden pref or UI toggle) - try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } - - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - - // Read existing config if it exists - string existingJson = "{}"; - if (File.Exists(configPath)) - { - try - { - existingJson = File.ReadAllText(configPath); - } - catch (Exception e) - { - UnityEngine.Debug.LogWarning($"Error reading existing config: {e.Message}."); - } - } - - // Parse the existing JSON while preserving all properties - dynamic existingConfig; - try - { - if (string.IsNullOrWhiteSpace(existingJson)) - { - existingConfig = new Newtonsoft.Json.Linq.JObject(); - } - else - { - existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new Newtonsoft.Json.Linq.JObject(); - } - } - catch - { - // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object - if (!string.IsNullOrWhiteSpace(existingJson)) - { - UnityEngine.Debug.LogWarning("UnityMCP: VSCode mcp.json could not be parsed; rewriting servers block."); - } - existingConfig = new Newtonsoft.Json.Linq.JObject(); - } - - // Determine existing entry references (command/args) - string existingCommand = null; - string[] existingArgs = null; - bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); - try - { - if (isVSCode) - { - existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); - existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); - } - else - { - existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); - existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); - } - } - catch { } - - // 1) Start from existing, only fill gaps (prefer trusted resolver) - string uvPath = ServerInstaller.FindUvPath(); - // Optionally trust existingCommand if it looks like uv/uv.exe - try - { - var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ExtractDirectoryArg(existingArgs); - bool serverValid = !string.IsNullOrEmpty(serverSrc) - && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); - if (!serverValid) - { - // Prefer the provided pythonDir if valid; fall back to resolver - if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py"))) - { - serverSrc = pythonDir; - } - else - { - serverSrc = ResolveServerSrc(); - } - } - - // macOS normalization: map XDG-style ~/.local/share to canonical Application Support - try - { - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) - && !string.IsNullOrEmpty(serverSrc)) - { - string norm = serverSrc.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); - if (idx >= 0) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix); - } - } - } - catch { } - - // Hard-block PackageCache on Windows unless dev override is set - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - && !string.IsNullOrEmpty(serverSrc) - && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 - && !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - serverSrc = ServerInstaller.GetServerPath(); - } - - // 2) Canonical args order - var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; - - // 3) Only write if changed - bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) - || !ArgsEqual(existingArgs, newArgs); - if (!changed) - { - return "Configured successfully"; // nothing to do - } - - // 4) Ensure containers exist and write back minimal changes - JObject existingRoot; - if (existingConfig is JObject eo) - existingRoot = eo; - else - existingRoot = JObject.FromObject(existingConfig); - - existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); - - string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - - // Robust atomic write without redundant backup or race on existence - string tmp = configPath + ".tmp"; - string backup = configPath + ".backup"; - bool writeDone = false; - try - { - // Write to temp file first (in same directory for atomicity) - System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - - try - { - // Try atomic replace; creates 'backup' only on success (platform-dependent) - System.IO.File.Replace(tmp, configPath, backup); - writeDone = true; - } - catch (System.IO.FileNotFoundException) - { - // Destination didn't exist; fall back to move - System.IO.File.Move(tmp, configPath); - writeDone = true; - } - catch (System.PlatformNotSupportedException) - { - // Fallback: rename existing to backup, then move tmp into place - if (System.IO.File.Exists(configPath)) - { - try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } - System.IO.File.Move(configPath, backup); - } - System.IO.File.Move(tmp, configPath); - writeDone = true; - } - } - catch (Exception ex) - { - - // If write did not complete, attempt restore from backup without deleting current file first - try - { - if (!writeDone && System.IO.File.Exists(backup)) - { - try { System.IO.File.Copy(backup, configPath, true); } catch { } - } - } - catch { } - throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); - } - finally - { - // Best-effort cleanup of temp - try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } - // Only remove backup after a confirmed successful write - try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } - } - - try - { - if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - - return "Configured successfully"; - } - - private void ShowManualConfigurationInstructions( - string configPath, - McpClient mcpClient - ) - { - mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); - - ShowManualInstructionsWindow(configPath, mcpClient); - } - - // New method to show manual instructions without changing status - private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) - { - // Get the Python directory path using Package Manager API - string pythonDir = FindPackagePythonDirectory(); - // Build manual JSON centrally using the shared builder - string uvPathForManual = FindUvPath(); - if (uvPathForManual == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); - return; - } - - string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); - ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); - } - - private static string ResolveServerSrc() - { - try - { - string remembered = UnityEditor.EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); - if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) - { - return remembered; - } - - ServerInstaller.EnsureServerInstalled(); - string installed = ServerInstaller.GetServerPath(); - if (File.Exists(Path.Combine(installed, "server.py"))) - { - return installed; - } - - bool useEmbedded = UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); - if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) - && File.Exists(Path.Combine(embedded, "server.py"))) - { - return embedded; - } - - return installed; - } - catch { return ServerInstaller.GetServerPath(); } - } - - private string FindPackagePythonDirectory() - { - string pythonDir = ResolveServerSrc(); - - try - { - // Only check dev paths if we're using a file-based package (development mode) - bool isDevelopmentMode = IsDevelopmentMode(); - if (isDevelopmentMode) - { - string currentPackagePath = Path.GetDirectoryName(Application.dataPath); - string[] devPaths = { - Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), - }; - - foreach (string devPath in devPaths) - { - if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) - { - if (debugLogsEnabled) - { - UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); - } - return devPath; - } - } - } - - // Resolve via shared helper (handles local registry and older fallback) only if dev override on - if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) - { - return embedded; - } - } - - // Log only if the resolved path does not actually contain server.py - if (debugLogsEnabled) - { - bool hasServer = false; - try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } - if (!hasServer) - { - UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); - } - } - } - catch (Exception e) - { - UnityEngine.Debug.LogError($"Error finding package path: {e.Message}"); - } - - return pythonDir; - } - - private bool IsDevelopmentMode() - { - try - { - // Only treat as development if manifest explicitly references a local file path for the package - string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); - if (!File.Exists(manifestPath)) return false; - - string manifestContent = File.ReadAllText(manifestPath); - // Look specifically for our package dependency set to a file: URL - // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk - if (manifestContent.IndexOf("\"com.justinpbarnett.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - int idx = manifestContent.IndexOf("com.justinpbarnett.unity-mcp", StringComparison.OrdinalIgnoreCase); - // Crude but effective: check for "file:" in the same line/value - if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 - && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - catch - { - return false; - } - } - - private string ConfigureMcpClient(McpClient mcpClient) - { - try - { - // Determine the config file path based on OS - string configPath; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - configPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - configPath = mcpClient.linuxConfigPath; - } - else - { - return "Unsupported OS"; - } - - // Create directory if it doesn't exist - Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - - // Find the server.py file location using the same logic as FindPackagePythonDirectory - string pythonDir = FindPackagePythonDirectory(); - - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - ShowManualInstructionsWindow(configPath, mcpClient); - return "Manual Configuration Required"; - } - - string result = WriteToConfig(pythonDir, configPath, mcpClient); - - // Update the client status after successful configuration - if (result == "Configured successfully") - { - mcpClient.SetStatus(McpStatus.Configured); - } - - return result; - } - catch (Exception e) - { - // Determine the config file path based on OS for error message - string configPath = ""; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - configPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - configPath = mcpClient.linuxConfigPath; - } - - ShowManualInstructionsWindow(configPath, mcpClient); - UnityEngine.Debug.LogError( - $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" - ); - return $"Failed to configure {mcpClient.name}"; - } - } - - private void ShowCursorManualConfigurationInstructions( - string configPath, - McpClient mcpClient - ) - { - mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); - - // Get the Python directory path using Package Manager API - string pythonDir = FindPackagePythonDirectory(); - - // Create the manual configuration message - string uvPath = FindUvPath(); - if (uvPath == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot configure manual setup."); - return; - } - - McpConfig jsonConfig = new() - { - mcpServers = new McpConfigServers - { - unityMCP = new McpConfigServer - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" }, - }, - }, - }; - - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); - - ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); - } - - private void LoadValidationLevelSetting() - { - string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); - validationLevelIndex = savedLevel.ToLower() switch - { - "basic" => 0, - "standard" => 1, - "comprehensive" => 2, - "strict" => 3, - _ => 1 // Default to Standard - }; - } - - private void SaveValidationLevelSetting() - { - string levelString = validationLevelIndex switch - { - 0 => "basic", - 1 => "standard", - 2 => "comprehensive", - 3 => "strict", - _ => "standard" - }; - EditorPrefs.SetString("MCPForUnity_ScriptValidationLevel", levelString); - } - - private string GetValidationLevelDescription(int index) - { - return index switch - { - 0 => "Only basic syntax checks (braces, quotes, comments)", - 1 => "Syntax checks + Unity best practices and warnings", - 2 => "All checks + semantic analysis and performance warnings", - 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", - _ => "Standard validation" - }; - } - - public static string GetCurrentValidationLevel() - { - string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); - return savedLevel; - } - - private void CheckMcpConfiguration(McpClient mcpClient) - { - try - { - // Special handling for Claude Code - if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - CheckClaudeCodeConfiguration(mcpClient); - return; - } - - string configPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - configPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - configPath = mcpClient.linuxConfigPath; - } - else - { - mcpClient.SetStatus(McpStatus.UnsupportedOS); - return; - } - - if (!File.Exists(configPath)) - { - mcpClient.SetStatus(McpStatus.NotConfigured); - return; - } - - string configJson = File.ReadAllText(configPath); - // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode - string pythonDir = FindPackagePythonDirectory(); - - // Use switch statement to handle different client types, extracting common logic - string[] args = null; - bool configExists = false; - - switch (mcpClient.mcpType) - { - case McpTypes.VSCode: - dynamic config = JsonConvert.DeserializeObject(configJson); - - // New schema: top-level servers - if (config?.servers?.unityMCP != null) - { - args = config.servers.unityMCP.args.ToObject(); - configExists = true; - } - // Back-compat: legacy mcp.servers - else if (config?.mcp?.servers?.unityMCP != null) - { - args = config.mcp.servers.unityMCP.args.ToObject(); - configExists = true; - } - break; - - default: - // Standard MCP configuration check for Claude Desktop, Cursor, etc. - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); - - if (standardConfig?.mcpServers?.unityMCP != null) - { - args = standardConfig.mcpServers.unityMCP.args; - configExists = true; - } - break; - } - - // Common logic for checking configuration status - if (configExists) - { - string configuredDir = ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); - if (matches) - { - mcpClient.SetStatus(McpStatus.Configured); - } - else - { - // Attempt auto-rewrite once if the package path changed - try - { - string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); - if (rewriteResult == "Configured successfully") - { - if (debugLogsEnabled) - { - MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); - } - mcpClient.SetStatus(McpStatus.Configured); - } - else - { - mcpClient.SetStatus(McpStatus.IncorrectPath); - } - } - catch (Exception ex) - { - mcpClient.SetStatus(McpStatus.IncorrectPath); - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); - } - } - } - } - else - { - mcpClient.SetStatus(McpStatus.MissingConfig); - } - } - catch (Exception e) - { - mcpClient.SetStatus(McpStatus.Error, e.Message); - } - } - - private void RegisterWithClaudeCode(string pythonDir) - { - // Resolve claude and uv; then run register command - string claudePath = ExecPath.ResolveClaude(); - if (string.IsNullOrEmpty(claudePath)) - { - UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); - return; - } - string uvPath = ExecPath.ResolveUv() ?? "uv"; - - // Prefer embedded/dev path when available - string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); - if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; - - string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; - - string projectDir = Path.GetDirectoryName(Application.dataPath); - // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is - string pathPrepend = null; - if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) - { - pathPrepend = Application.platform == RuntimePlatform.OSXEditor - ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : "/usr/local/bin:/usr/bin:/bin"; - } - if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) - { - string combined = ($"{stdout}\n{stderr}") ?? string.Empty; - if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) - { - // Treat as success if Claude reports existing registration - var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); - Repaint(); - UnityEngine.Debug.Log("MCP-FOR-UNITY: MCP for Unity already registered with Claude Code."); - } - else - { - UnityEngine.Debug.LogError($"MCP for Unity: Failed to start Claude CLI.\n{stderr}\n{stdout}"); - } - return; - } - - // Update status - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); - Repaint(); - UnityEngine.Debug.Log("MCP-FOR-UNITY: Registered with Claude Code."); - } - - private void UnregisterWithClaudeCode() - { - string claudePath = ExecPath.ResolveClaude(); - if (string.IsNullOrEmpty(claudePath)) - { - UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); - return; - } - - string projectDir = Path.GetDirectoryName(Application.dataPath); - string pathPrepend = Application.platform == RuntimePlatform.OSXEditor - ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : null; // On Windows, don't modify PATH - use system PATH as-is - - // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get ` - string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; - List existingNames = new List(); - foreach (var candidate in candidateNamesForGet) - { - if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) - { - // Success exit code indicates the server exists - existingNames.Add(candidate); - } - } - - if (existingNames.Count == 0) - { - // Nothing to unregister – set status and bail early - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - claudeClient.SetStatus(McpStatus.NotConfigured); - UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); - Repaint(); - } - return; - } - - // Try different possible server names - string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; - bool success = false; - - foreach (string serverName in possibleNames) - { - if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) - { - success = true; - UnityEngine.Debug.Log($"MCP for Unity: Successfully removed MCP server: {serverName}"); - break; - } - else if (!string.IsNullOrEmpty(stderr) && - !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) - { - // If it's not a "not found" error, log it and stop trying - UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); - break; - } - } - - if (success) - { - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - // Optimistically flip to NotConfigured; then verify - claudeClient.SetStatus(McpStatus.NotConfigured); - CheckClaudeCodeConfiguration(claudeClient); - } - Repaint(); - UnityEngine.Debug.Log("MCP for Unity: MCP server successfully unregistered from Claude Code."); - } - else - { - // If no servers were found to remove, they're already unregistered - // Force status to NotConfigured and update the UI - UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - claudeClient.SetStatus(McpStatus.NotConfigured); - CheckClaudeCodeConfiguration(claudeClient); - } - Repaint(); - } - } - - // Removed unused ParseTextOutput - - private string FindUvPath() - { - try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } - } - - // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() - - // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead - - // Removed unused FindClaudeCommand - - private void CheckClaudeCodeConfiguration(McpClient mcpClient) - { - try - { - // Get the Unity project directory to check project-specific config - string unityProjectDir = Application.dataPath; - string projectDir = Path.GetDirectoryName(unityProjectDir); - - // Read the global Claude config file (honor macConfigPath on macOS) - string configPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - configPath = mcpClient.windowsConfigPath; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; - else - configPath = mcpClient.linuxConfigPath; - - if (debugLogsEnabled) - { - MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); - } - - if (!File.Exists(configPath)) - { - UnityEngine.Debug.LogWarning($"Claude config file not found at: {configPath}"); - mcpClient.SetStatus(McpStatus.NotConfigured); - return; - } - - string configJson = File.ReadAllText(configPath); - dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); - - // Check for "UnityMCP" server in the mcpServers section (current format) - if (claudeConfig?.mcpServers != null) - { - var servers = claudeConfig.mcpServers; - if (servers.UnityMCP != null || servers.unityMCP != null) - { - // Found MCP for Unity configured - mcpClient.SetStatus(McpStatus.Configured); - return; - } - } - - // Also check if there's a project-specific configuration for this Unity project (legacy format) - if (claudeConfig?.projects != null) - { - // Look for the project path in the config - foreach (var project in claudeConfig.projects) - { - string projectPath = project.Name; - - // Normalize paths for comparison (handle forward/back slash differences) - string normalizedProjectPath = Path.GetFullPath(projectPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - string normalizedProjectDir = Path.GetFullPath(projectDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - if (string.Equals(normalizedProjectPath, normalizedProjectDir, StringComparison.OrdinalIgnoreCase) && project.Value?.mcpServers != null) - { - // Check for "UnityMCP" (case variations) - var servers = project.Value.mcpServers; - if (servers.UnityMCP != null || servers.unityMCP != null) - { - // Found MCP for Unity configured for this project - mcpClient.SetStatus(McpStatus.Configured); - return; - } - } - } - } - - // No configuration found for this project - mcpClient.SetStatus(McpStatus.NotConfigured); - } - catch (Exception e) - { - UnityEngine.Debug.LogWarning($"Error checking Claude Code config: {e.Message}"); - mcpClient.SetStatus(McpStatus.Error, e.Message); - } - } - - private bool IsPythonDetected() - { - try - { - // Windows-specific Python detection - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Common Windows Python installation paths - string[] windowsCandidates = - { - @"C:\Python313\python.exe", - @"C:\Python312\python.exe", - @"C:\Python311\python.exe", - @"C:\Python310\python.exe", - @"C:\Python39\python.exe", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), - }; - - foreach (string c in windowsCandidates) - { - if (File.Exists(c)) return true; - } - - // Try 'where python' command (Windows equivalent of 'which') - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = "python", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) - { - string[] lines = outp.Split('\n'); - foreach (string line in lines) - { - string trimmed = line.Trim(); - if (File.Exists(trimmed)) return true; - } - } - } - else - { - // macOS/Linux detection (existing code) - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/local/bin/python3", - Path.Combine(home, ".local", "bin", "python3"), - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - }; - foreach (string c in candidates) - { - if (File.Exists(c)) return true; - } - - // Try 'which python3' - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "python3", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; - } - } - catch { } - return false; - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs.meta deleted file mode 100644 index 94b00cc5..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 4f740bec3a8d04716adeab35c412a15f -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs deleted file mode 100644 index 501e37a4..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System.Runtime.InteropServices; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Windows -{ - // Editor window to display manual configuration instructions - public class ManualConfigEditorWindow : EditorWindow - { - protected string configPath; - protected string configJson; - protected Vector2 scrollPos; - protected bool pathCopied = false; - protected bool jsonCopied = false; - protected float copyFeedbackTimer = 0; - protected McpClient mcpClient; - - public static void ShowWindow(string configPath, string configJson, McpClient mcpClient) - { - var window = GetWindow("Manual Configuration"); - window.configPath = configPath; - window.configJson = configJson; - window.mcpClient = mcpClient; - window.minSize = new Vector2(500, 400); - window.Show(); - } - - protected virtual void OnGUI() - { - scrollPos = EditorGUILayout.BeginScrollView(scrollPos); - - // Header with improved styling - EditorGUILayout.Space(10); - Rect titleRect = EditorGUILayout.GetControlRect(false, 30); - EditorGUI.DrawRect( - new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), - new Color(0.2f, 0.2f, 0.2f, 0.1f) - ); - GUI.Label( - new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - (mcpClient?.name ?? "Unknown") + " Manual Configuration", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(10); - - // Instructions with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - Rect headerRect = EditorGUILayout.GetControlRect(false, 24); - EditorGUI.DrawRect( - new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), - new Color(0.1f, 0.1f, 0.1f, 0.2f) - ); - GUI.Label( - new Rect( - headerRect.x + 8, - headerRect.y + 4, - headerRect.width - 16, - headerRect.height - ), - "The automatic configuration failed. Please follow these steps:", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(10); - - GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) - { - margin = new RectOffset(10, 10, 5, 5), - }; - - EditorGUILayout.LabelField( - "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:", - instructionStyle - ); - if (mcpClient?.mcpType == McpTypes.ClaudeDesktop) - { - EditorGUILayout.LabelField( - " a) Going to Settings > Developer > Edit Config", - instructionStyle - ); - } - else if (mcpClient?.mcpType == McpTypes.Cursor) - { - EditorGUILayout.LabelField( - " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", - instructionStyle - ); - } - else if (mcpClient?.mcpType == McpTypes.Windsurf) - { - EditorGUILayout.LabelField( - " a) Going to File > Preferences > Windsurf Settings > MCP > Manage MCPs -> View raw config", - instructionStyle - ); - } - else if (mcpClient?.mcpType == McpTypes.Kiro) - { - EditorGUILayout.LabelField( - " a) Going to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config", - instructionStyle - ); - } - EditorGUILayout.LabelField(" OR", instructionStyle); - EditorGUILayout.LabelField( - " b) Opening the configuration file at:", - instructionStyle - ); - - // Path section with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - string displayPath; - if (mcpClient != null) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - displayPath = mcpClient.windowsConfigPath; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - - ? configPath - - : mcpClient.macConfigPath; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - displayPath = mcpClient.linuxConfigPath; - } - else - { - displayPath = configPath; - } - } - else - { - displayPath = configPath; - } - - // Prevent text overflow by allowing the text field to wrap - GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; - - EditorGUILayout.TextField( - displayPath, - pathStyle, - GUILayout.Height(EditorGUIUtility.singleLineHeight) - ); - - // Copy button with improved styling - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - GUIStyle copyButtonStyle = new(GUI.skin.button) - { - padding = new RectOffset(15, 15, 5, 5), - margin = new RectOffset(10, 10, 5, 5), - }; - - if ( - GUILayout.Button( - "Copy Path", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - EditorGUIUtility.systemCopyBuffer = displayPath; - pathCopied = true; - copyFeedbackTimer = 2f; - } - - if ( - GUILayout.Button( - "Open File", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - // Open the file using the system's default application - System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo - { - FileName = displayPath, - UseShellExecute = true, - } - ); - } - - if (pathCopied) - { - GUIStyle feedbackStyle = new(EditorStyles.label); - feedbackStyle.normal.textColor = Color.green; - EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); - } - - EditorGUILayout.EndHorizontal(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - - EditorGUILayout.LabelField( - "2. Paste the following JSON configuration:", - instructionStyle - ); - - // JSON section with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Improved text area for JSON with syntax highlighting colors - GUIStyle jsonStyle = new(EditorStyles.textArea) - { - font = EditorStyles.boldFont, - wordWrap = true, - }; - jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue - - // Draw the JSON in a text area with a taller height for better readability - EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); - - // Copy JSON button with improved styling - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - - if ( - GUILayout.Button( - "Copy JSON", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - EditorGUIUtility.systemCopyBuffer = configJson; - jsonCopied = true; - copyFeedbackTimer = 2f; - } - - if (jsonCopied) - { - GUIStyle feedbackStyle = new(EditorStyles.label); - feedbackStyle.normal.textColor = Color.green; - EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); - } - - EditorGUILayout.EndHorizontal(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - EditorGUILayout.LabelField( - "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"), - instructionStyle - ); - - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - - // Close button at the bottom - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) - { - Close(); - } - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.EndScrollView(); - } - - protected virtual void Update() - { - // Handle the feedback message timer - if (copyFeedbackTimer > 0) - { - copyFeedbackTimer -= Time.deltaTime; - if (copyFeedbackTimer <= 0) - { - pathCopied = false; - jsonCopied = false; - Repaint(); - } - } - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta deleted file mode 100644 index 41646e62..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 36798bd7b867b8e43ac86885e94f928f -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs deleted file mode 100644 index e5544510..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System.Runtime.InteropServices; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Windows -{ - public class VSCodeManualSetupWindow : ManualConfigEditorWindow - { - public static void ShowWindow(string configPath, string configJson) - { - var window = GetWindow("VSCode GitHub Copilot Setup"); - window.configPath = configPath; - window.configJson = configJson; - window.minSize = new Vector2(550, 500); - - // Create a McpClient for VSCode - window.mcpClient = new McpClient - { - name = "VSCode GitHub Copilot", - mcpType = McpTypes.VSCode - }; - - window.Show(); - } - - protected override void OnGUI() - { - scrollPos = EditorGUILayout.BeginScrollView(scrollPos); - - // Header with improved styling - EditorGUILayout.Space(10); - Rect titleRect = EditorGUILayout.GetControlRect(false, 30); - EditorGUI.DrawRect( - new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), - new Color(0.2f, 0.2f, 0.2f, 0.1f) - ); - GUI.Label( - new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - "VSCode GitHub Copilot MCP Setup", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(10); - - // Instructions with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - Rect headerRect = EditorGUILayout.GetControlRect(false, 24); - EditorGUI.DrawRect( - new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), - new Color(0.1f, 0.1f, 0.1f, 0.2f) - ); - GUI.Label( - new Rect( - headerRect.x + 8, - headerRect.y + 4, - headerRect.width - 16, - headerRect.height - ), - "Setting up GitHub Copilot in VSCode with MCP for Unity", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(10); - - GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) - { - margin = new RectOffset(10, 10, 5, 5), - }; - - EditorGUILayout.LabelField( - "1. Prerequisites", - EditorStyles.boldLabel - ); - EditorGUILayout.LabelField( - "• Ensure you have VSCode installed", - instructionStyle - ); - EditorGUILayout.LabelField( - "• Ensure you have GitHub Copilot extension installed in VSCode", - instructionStyle - ); - EditorGUILayout.LabelField( - "• Ensure you have a valid GitHub Copilot subscription", - instructionStyle - ); - EditorGUILayout.Space(5); - - EditorGUILayout.LabelField( - "2. Steps to Configure", - EditorStyles.boldLabel - ); - EditorGUILayout.LabelField( - "a) Open or create your VSCode MCP config file (mcp.json) at the path below", - instructionStyle - ); - EditorGUILayout.LabelField( - "b) Paste the JSON shown below into mcp.json", - instructionStyle - ); - EditorGUILayout.LabelField( - "c) Save the file and restart VSCode", - instructionStyle - ); - EditorGUILayout.Space(5); - - EditorGUILayout.LabelField( - "3. VSCode mcp.json location:", - EditorStyles.boldLabel - ); - - // Path section with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - string displayPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - displayPath = System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "Code", - "User", - "mcp.json" - ); - } - else - { - displayPath = System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Code", - "User", - "mcp.json" - ); - } - - // Store the path in the base class config path - if (string.IsNullOrEmpty(configPath)) - { - configPath = displayPath; - } - - // Prevent text overflow by allowing the text field to wrap - GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; - - EditorGUILayout.TextField( - displayPath, - pathStyle, - GUILayout.Height(EditorGUIUtility.singleLineHeight) - ); - - // Copy button with improved styling - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - GUIStyle copyButtonStyle = new(GUI.skin.button) - { - padding = new RectOffset(15, 15, 5, 5), - margin = new RectOffset(10, 10, 5, 5), - }; - - if ( - GUILayout.Button( - "Copy Path", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - EditorGUIUtility.systemCopyBuffer = displayPath; - pathCopied = true; - copyFeedbackTimer = 2f; - } - - if ( - GUILayout.Button( - "Open File", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - // Open the file using the system's default application - System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo - { - FileName = displayPath, - UseShellExecute = true, - } - ); - } - - if (pathCopied) - { - GUIStyle feedbackStyle = new(EditorStyles.label); - feedbackStyle.normal.textColor = Color.green; - EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); - } - - EditorGUILayout.EndHorizontal(); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(10); - - EditorGUILayout.LabelField( - "4. Add this configuration to your mcp.json:", - EditorStyles.boldLabel - ); - - // JSON section with improved styling - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Improved text area for JSON with syntax highlighting colors - GUIStyle jsonStyle = new(EditorStyles.textArea) - { - font = EditorStyles.boldFont, - wordWrap = true, - }; - jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue - - // Draw the JSON in a text area with a taller height for better readability - EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); - - // Copy JSON button with improved styling - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - - if ( - GUILayout.Button( - "Copy JSON", - copyButtonStyle, - GUILayout.Height(25), - GUILayout.Width(100) - ) - ) - { - EditorGUIUtility.systemCopyBuffer = configJson; - jsonCopied = true; - copyFeedbackTimer = 2f; - } - - if (jsonCopied) - { - GUIStyle feedbackStyle = new(EditorStyles.label); - feedbackStyle.normal.textColor = Color.green; - EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); - } - - EditorGUILayout.EndHorizontal(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - EditorGUILayout.LabelField( - "5. After configuration:", - EditorStyles.boldLabel - ); - EditorGUILayout.LabelField( - "• Restart VSCode", - instructionStyle - ); - EditorGUILayout.LabelField( - "• GitHub Copilot will now be able to interact with your Unity project through the MCP protocol", - instructionStyle - ); - EditorGUILayout.LabelField( - "• Remember to have the MCP for Unity Bridge running in Unity Editor", - instructionStyle - ); - - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - - // Close button at the bottom - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) - { - Close(); - } - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.EndScrollView(); - } - - protected override void Update() - { - // Call the base implementation which handles the copy feedback timer - base.Update(); - } - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta deleted file mode 100644 index fb13126b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 377fe73d52cf0435fabead5f50a0d204 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md deleted file mode 100644 index b073a5fc..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# MCP for Unity — Editor Plugin Guide - -Use this guide to configure and run MCP for Unity inside the Unity Editor. Installation is covered elsewhere; this document focuses on the Editor window, client configuration, and troubleshooting. - -## Open the window -- Unity menu: Window > MCP for Unity - -The window has four areas: Server Status, Unity Bridge, MCP Client Configuration, and Script Validation. - ---- - -## Quick start -1. Open Window > MCP for Unity. -2. Click “Auto-Setup”. -3. If prompted: - - Select the server folder that contains `server.py` (UnityMcpServer~/src). - - Install Python and/or uv if missing. - - For Claude Code, ensure the `claude` CLI is installed. -4. Click “Start Bridge” if the Unity Bridge shows “Stopped”. -5. Use your MCP client (Cursor, VS Code, Windsurf, Claude Code) to connect. - ---- - -## Server Status -- Status dot and label: - - Installed / Installed (Embedded) / Not Installed. -- Mode and ports: - - Mode: Auto or Standard. - - Ports: Unity (varies; shown in UI), MCP 6500. -- Actions: - - Auto-Setup: Registers/updates your selected MCP client(s), ensures bridge connectivity. Shows “Connected ✓” after success. - - Repair Python Env: Rebuilds a clean Python environment (deletes `.venv`, runs `uv sync`). - - Select server folder…: Choose the folder containing `server.py`. - - Verify again: Re-checks server presence. - - If Python isn’t detected, use “Open Install Instructions”. - ---- - -## Unity Bridge -- Shows Running or Stopped with a status dot. -- Start/Stop Bridge button toggles the Unity bridge process used by MCP clients to talk to Unity. -- Tip: After Auto-Setup, the bridge may auto-start in Auto mode. - ---- - -## MCP Client Configuration -- Select Client: Choose your target MCP client (e.g., Cursor, VS Code, Windsurf, Claude Code). -- Per-client actions: - - Cursor / VS Code / Windsurf: - - Auto Configure: Writes/updates your config to launch the server via uv: - - Command: uv - - Args: run --directory server.py - - Manual Setup: Opens a window with a pre-filled JSON snippet to copy/paste into your client config. - - Choose `uv` Install Location: If uv isn’t on PATH, select the uv binary. - - A compact “Config:” line shows the resolved config file name once uv/server are detected. - - Claude Code: - - Register with Claude Code / Unregister MCP for Unity with Claude Code. - - If the CLI isn’t found, click “Choose Claude Install Location”. - - The window displays the resolved Claude CLI path when detected. - -Notes: -- The UI shows a status dot and a short status text (e.g., “Configured”, “uv Not Found”, “Claude Not Found”). -- Use “Auto Configure” for one-click setup; use “Manual Setup” when you prefer to review/copy config. - ---- - -## Script Validation -- Validation Level options: - - Basic — Only syntax checks - - Standard — Syntax + Unity practices - - Comprehensive — All checks + semantic analysis - - Strict — Full semantic validation (requires Roslyn) -- Pick a level based on your project’s needs. A description is shown under the dropdown. - ---- - -## Troubleshooting -- Python or `uv` not found: - - Help: [Fix MCP for Unity with Cursor, VS Code & Windsurf](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf) -- Claude CLI not found: - - Help: [Fix MCP for Unity with Claude Code](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code) - ---- - -## Tips -- Enable “Show Debug Logs” in the header for more details in the Console when diagnosing issues. - ---- \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md.meta deleted file mode 100644 index 6ef03ff5..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: c3d9e362fb93e46f59ce7213fbe4f2b1 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime.meta deleted file mode 100644 index ae1e4dfa..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b5cc10fd969474b3680332e542416860 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef deleted file mode 100644 index 52b509f8..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "MCPForUnity.Runtime", - "rootNamespace": "MCPForUnity.Runtime", - "references": [ - "GUID:560b04d1a97f54a46a2660c3cc343a6f" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef.meta deleted file mode 100644 index 74c20289..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/MCPForUnity.Runtime.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 562a750ff18ee4193928e885c708fee1 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization.meta deleted file mode 100644 index 89cd67ad..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: c7e33d6224fe6473f9bc69fe6d40e508 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs deleted file mode 100644 index 05503f42..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs +++ /dev/null @@ -1,266 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using UnityEngine; -#if UNITY_EDITOR -using UnityEditor; // Required for AssetDatabase and EditorUtility -#endif - -namespace MCPForUnity.Runtime.Serialization -{ - public class Vector3Converter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("x"); - writer.WriteValue(value.x); - writer.WritePropertyName("y"); - writer.WriteValue(value.y); - writer.WritePropertyName("z"); - writer.WriteValue(value.z); - writer.WriteEndObject(); - } - - public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - return new Vector3( - (float)jo["x"], - (float)jo["y"], - (float)jo["z"] - ); - } - } - - public class Vector2Converter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("x"); - writer.WriteValue(value.x); - writer.WritePropertyName("y"); - writer.WriteValue(value.y); - writer.WriteEndObject(); - } - - public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - return new Vector2( - (float)jo["x"], - (float)jo["y"] - ); - } - } - - public class QuaternionConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Quaternion value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("x"); - writer.WriteValue(value.x); - writer.WritePropertyName("y"); - writer.WriteValue(value.y); - writer.WritePropertyName("z"); - writer.WriteValue(value.z); - writer.WritePropertyName("w"); - writer.WriteValue(value.w); - writer.WriteEndObject(); - } - - public override Quaternion ReadJson(JsonReader reader, Type objectType, Quaternion existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - return new Quaternion( - (float)jo["x"], - (float)jo["y"], - (float)jo["z"], - (float)jo["w"] - ); - } - } - - public class ColorConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Color value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("r"); - writer.WriteValue(value.r); - writer.WritePropertyName("g"); - writer.WriteValue(value.g); - writer.WritePropertyName("b"); - writer.WriteValue(value.b); - writer.WritePropertyName("a"); - writer.WriteValue(value.a); - writer.WriteEndObject(); - } - - public override Color ReadJson(JsonReader reader, Type objectType, Color existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - return new Color( - (float)jo["r"], - (float)jo["g"], - (float)jo["b"], - (float)jo["a"] - ); - } - } - - public class RectConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("x"); - writer.WriteValue(value.x); - writer.WritePropertyName("y"); - writer.WriteValue(value.y); - writer.WritePropertyName("width"); - writer.WriteValue(value.width); - writer.WritePropertyName("height"); - writer.WriteValue(value.height); - writer.WriteEndObject(); - } - - public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - return new Rect( - (float)jo["x"], - (float)jo["y"], - (float)jo["width"], - (float)jo["height"] - ); - } - } - - public class BoundsConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) - { - writer.WriteStartObject(); - writer.WritePropertyName("center"); - serializer.Serialize(writer, value.center); // Use serializer to handle nested Vector3 - writer.WritePropertyName("size"); - serializer.Serialize(writer, value.size); // Use serializer to handle nested Vector3 - writer.WriteEndObject(); - } - - public override Bounds ReadJson(JsonReader reader, Type objectType, Bounds existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jo = JObject.Load(reader); - Vector3 center = jo["center"].ToObject(serializer); // Use serializer to handle nested Vector3 - Vector3 size = jo["size"].ToObject(serializer); // Use serializer to handle nested Vector3 - return new Bounds(center, size); - } - } - - // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) - public class UnityEngineObjectConverter : JsonConverter - { - public override bool CanRead => true; // We need to implement ReadJson - public override bool CanWrite => true; - - public override void WriteJson(JsonWriter writer, UnityEngine.Object value, JsonSerializer serializer) - { - if (value == null) - { - writer.WriteNull(); - return; - } - -#if UNITY_EDITOR // AssetDatabase and EditorUtility are Editor-only - if (UnityEditor.AssetDatabase.Contains(value)) - { - // It's an asset (Material, Texture, Prefab, etc.) - string path = UnityEditor.AssetDatabase.GetAssetPath(value); - if (!string.IsNullOrEmpty(path)) - { - writer.WriteValue(path); - } - else - { - // Asset exists but path couldn't be found? Write minimal info. - writer.WriteStartObject(); - writer.WritePropertyName("name"); - writer.WriteValue(value.name); - writer.WritePropertyName("instanceID"); - writer.WriteValue(value.GetInstanceID()); - writer.WritePropertyName("isAssetWithoutPath"); - writer.WriteValue(true); - writer.WriteEndObject(); - } - } - else - { - // It's a scene object (GameObject, Component, etc.) - writer.WriteStartObject(); - writer.WritePropertyName("name"); - writer.WriteValue(value.name); - writer.WritePropertyName("instanceID"); - writer.WriteValue(value.GetInstanceID()); - writer.WriteEndObject(); - } -#else - // Runtime fallback: Write basic info without AssetDatabase - writer.WriteStartObject(); - writer.WritePropertyName("name"); - writer.WriteValue(value.name); - writer.WritePropertyName("instanceID"); - writer.WriteValue(value.GetInstanceID()); - writer.WritePropertyName("warning"); - writer.WriteValue("UnityEngineObjectConverter running in non-Editor mode, asset path unavailable."); - writer.WriteEndObject(); -#endif - } - - public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, UnityEngine.Object existingValue, bool hasExistingValue, JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) - { - return null; - } - -#if UNITY_EDITOR - if (reader.TokenType == JsonToken.String) - { - // Assume it's an asset path - string path = reader.Value.ToString(); - return UnityEditor.AssetDatabase.LoadAssetAtPath(path, objectType); - } - - if (reader.TokenType == JsonToken.StartObject) - { - JObject jo = JObject.Load(reader); - if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) - { - int instanceId = idToken.ToObject(); - UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); - if (obj != null && objectType.IsAssignableFrom(obj.GetType())) - { - return obj; - } - } - // Could potentially try finding by name as a fallback if ID lookup fails/isn't present - // but that's less reliable. - } -#else - // Runtime deserialization is tricky without AssetDatabase/EditorUtility - // Maybe log a warning and return null or existingValue? - Debug.LogWarning("UnityEngineObjectConverter cannot deserialize complex objects in non-Editor mode."); - // Skip the token to avoid breaking the reader - if (reader.TokenType == JsonToken.StartObject) JObject.Load(reader); - else if (reader.TokenType == JsonToken.String) reader.ReadAsString(); - // Return null or existing value, depending on desired behavior - return existingValue; -#endif - - throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); - } - } -} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta deleted file mode 100644 index caaf2859..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e65311c160f0d41d4a1b45a3dba8dd5a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/Dockerfile b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/Dockerfile deleted file mode 100644 index 3f884f37..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM python:3.12-slim - -# Install required system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Install uv package manager -RUN pip install uv - -# Copy required files -COPY config.py /app/ -COPY server.py /app/ -COPY unity_connection.py /app/ -COPY pyproject.toml /app/ -COPY __init__.py /app/ -COPY tools/ /app/tools/ - -# Install dependencies using uv -RUN uv pip install --system -e . - - -# Command to run the server -CMD ["uv", "run", "server.py"] \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/__init__.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/__init__.py deleted file mode 100644 index bf3404d1..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -MCP for Unity Server package. -""" \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/config.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/config.py deleted file mode 100644 index 4c7d8049..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/config.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Configuration settings for the MCP for Unity Server. -This file contains all configurable parameters for the server. -""" - -from dataclasses import dataclass - -@dataclass -class ServerConfig: - """Main configuration class for the MCP server.""" - - # Network settings - unity_host: str = "localhost" - unity_port: int = 6400 - mcp_port: int = 6500 - - # Connection settings - connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts - buffer_size: int = 16 * 1024 * 1024 # 16MB buffer - # Framed receive behavior - framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only - max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up - - # Logging settings - log_level: str = "INFO" - log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - # Server settings - max_retries: int = 10 - retry_delay: float = 0.25 - # Backoff hint returned to clients when Unity is reloading (milliseconds) - reload_retry_ms: int = 250 - # Number of polite retries when Unity reports reloading - # 40 × 250ms ≈ 10s default window - reload_max_retries: int = 40 - - # Telemetry settings - telemetry_enabled: bool = True - # Align with telemetry.py default Cloud Run endpoint - telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" - -# Create a global config instance -config = ServerConfig() \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py deleted file mode 100644 index 6f154159..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Port discovery utility for MCP for Unity Server. - -What changed and why: -- Unity now writes a per-project port file named like - `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting - each other's saved port. The legacy file `unity-mcp-port.json` may still - exist. -- This module now scans for both patterns, prefers the most recently - modified file, and verifies that the port is actually a MCP for Unity listener - (quick socket connect + ping) before choosing it. -""" - -import json -import os -import logging -from pathlib import Path -from typing import Optional, List -import glob -import socket - -logger = logging.getLogger("mcp-for-unity-server") - -class PortDiscovery: - """Handles port discovery from Unity Bridge registry""" - REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file - DEFAULT_PORT = 6400 - CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery - - @staticmethod - def get_registry_path() -> Path: - """Get the path to the port registry file""" - return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - - @staticmethod - def get_registry_dir() -> Path: - return Path.home() / ".unity-mcp" - - @staticmethod - def list_candidate_files() -> List[Path]: - """Return candidate registry files, newest first. - Includes hashed per-project files and the legacy file (if present). - """ - base = PortDiscovery.get_registry_dir() - hashed = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - legacy = PortDiscovery.get_registry_path() - if legacy.exists(): - # Put legacy at the end so hashed, per-project files win - hashed.append(legacy) - return hashed - - @staticmethod - def _try_probe_unity_mcp(port: int) -> bool: - """Quickly check if a MCP for Unity listener is on this port. - Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. - """ - try: - with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: - s.settimeout(PortDiscovery.CONNECT_TIMEOUT) - try: - s.sendall(b"ping") - data = s.recv(512) - # Minimal validation: look for a success pong response - if data and b'"message":"pong"' in data: - return True - except Exception: - return False - except Exception: - return False - return False - - @staticmethod - def _read_latest_status() -> Optional[dict]: - try: - base = PortDiscovery.get_registry_dir() - status_files = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if not status_files: - return None - with status_files[0].open('r') as f: - return json.load(f) - except Exception: - return None - - @staticmethod - def discover_unity_port() -> int: - """ - Discover Unity port by scanning per-project and legacy registry files. - Prefer the newest file whose port responds; fall back to first parsed - value; finally default to 6400. - - Returns: - Port number to connect to - """ - # Prefer the latest heartbeat status if it points to a responsive port - status = PortDiscovery._read_latest_status() - if status: - port = status.get('unity_port') - if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): - logger.info(f"Using Unity port from status: {port}") - return port - - candidates = PortDiscovery.list_candidate_files() - - first_seen_port: Optional[int] = None - - for path in candidates: - try: - with open(path, 'r') as f: - cfg = json.load(f) - unity_port = cfg.get('unity_port') - if isinstance(unity_port, int): - if first_seen_port is None: - first_seen_port = unity_port - if PortDiscovery._try_probe_unity_mcp(unity_port): - logger.info(f"Using Unity port from {path.name}: {unity_port}") - return unity_port - except Exception as e: - logger.warning(f"Could not read port registry {path}: {e}") - - if first_seen_port is not None: - logger.info(f"No responsive port found; using first seen value {first_seen_port}") - return first_seen_port - - # Fallback to default port - logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") - return PortDiscovery.DEFAULT_PORT - - @staticmethod - def get_port_config() -> Optional[dict]: - """ - Get the most relevant port configuration from registry. - Returns the most recent hashed file's config if present, - otherwise the legacy file's config. Returns None if nothing exists. - - Returns: - Port configuration dict or None if not found - """ - candidates = PortDiscovery.list_candidate_files() - if not candidates: - return None - for path in candidates: - try: - with open(path, 'r') as f: - return json.load(f) - except Exception as e: - logger.warning(f"Could not read port configuration {path}: {e}") - return None \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml deleted file mode 100644 index 067f320e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "MCPForUnityServer" -version = "3.4.0" -description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." -readme = "README.md" -requires-python = ">=3.10" -dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] - -[build-system] -requires = ["setuptools>=64.0.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools] -py-modules = ["config", "server", "unity_connection"] -packages = ["tools"] diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json deleted file mode 100644 index 4fdeb465..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "typeCheckingMode": "basic", - "reportMissingImports": "none", - "pythonVersion": "3.11", - "executionEnvironments": [ - { - "root": ".", - "pythonVersion": "3.11" - } - ] -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py deleted file mode 100644 index e224844b..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -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/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server.py deleted file mode 100644 index db64e12f..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server.py +++ /dev/null @@ -1,190 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context, Image -import logging -from logging.handlers import RotatingFileHandler -import os -from dataclasses import dataclass -from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List -from config import config -from tools import register_all_tools -from unity_connection import get_unity_connection, UnityConnection -import time - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format, - stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio - force=True # Ensure our handler replaces any prior stdout handlers -) -logger = logging.getLogger("mcp-for-unity-server") - -# Also write logs to a rotating file so logs are available when launched via stdio -try: - import os as _os - _log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs") - _os.makedirs(_log_dir, exist_ok=True) - _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") - _fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") - _fh.setFormatter(logging.Formatter(config.log_format)) - _fh.setLevel(getattr(logging, config.log_level)) - logger.addHandler(_fh) - # Also route telemetry logger to the same rotating file and normal level - try: - tlog = logging.getLogger("unity-mcp-telemetry") - tlog.setLevel(getattr(logging, config.log_level)) - tlog.addHandler(_fh) - except Exception: - # Never let logging setup break startup - pass -except Exception: - # Never let logging setup break startup - pass -# Quieten noisy third-party loggers to avoid clutter during stdio handshake -for noisy in ("httpx", "urllib3"): - try: - logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level))) - except Exception: - pass - -# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels -# Ensure a slightly higher telemetry timeout unless explicitly overridden by env -try: - - - # Ensure generous timeout unless explicitly overridden by env - if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): - os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" -except Exception: - pass -from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType - -# Global connection state -_unity_connection: UnityConnection = None - - -@asynccontextmanager -async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: - """Handle server startup and shutdown.""" - global _unity_connection - logger.info("MCP for Unity Server starting up") - - # Record server startup telemetry - start_time = time.time() - start_clk = time.perf_counter() - try: - from pathlib import Path - ver_path = Path(__file__).parent / "server_version.txt" - server_version = ver_path.read_text(encoding="utf-8").strip() - except Exception: - server_version = "unknown" - # Defer initial telemetry by 1s to avoid stdio handshake interference - import threading - def _emit_startup(): - try: - record_telemetry(RecordType.STARTUP, { - "server_version": server_version, - "startup_time": start_time, - }) - record_milestone(MilestoneType.FIRST_STARTUP) - except Exception: - logger.debug("Deferred startup telemetry failed", exc_info=True) - threading.Timer(1.0, _emit_startup).start() - - try: - skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") - if skip_connect: - logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") - else: - _unity_connection = get_unity_connection() - logger.info("Connected to Unity on startup") - - # Record successful Unity connection (deferred) - import threading as _t - _t.Timer(1.0, lambda: record_telemetry( - RecordType.UNITY_CONNECTION, - { - "status": "connected", - "connection_time_ms": (time.perf_counter() - start_clk) * 1000, - } - )).start() - - except ConnectionError as e: - logger.warning("Could not connect to Unity on startup: %s", e) - _unity_connection = None - - # Record connection failure (deferred) - import threading as _t - _err_msg = str(e)[:200] - _t.Timer(1.0, lambda: record_telemetry( - RecordType.UNITY_CONNECTION, - { - "status": "failed", - "error": _err_msg, - "connection_time_ms": (time.perf_counter() - start_clk) * 1000, - } - )).start() - except Exception as e: - logger.warning("Unexpected error connecting to Unity on startup: %s", e) - _unity_connection = None - import threading as _t - _err_msg = str(e)[:200] - _t.Timer(1.0, lambda: record_telemetry( - RecordType.UNITY_CONNECTION, - { - "status": "failed", - "error": _err_msg, - "connection_time_ms": (time.perf_counter() - start_clk) * 1000, - } - )).start() - - try: - # Yield the connection object so it can be attached to the context - # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) - yield {"bridge": _unity_connection} - finally: - if _unity_connection: - _unity_connection.disconnect() - _unity_connection = None - logger.info("MCP for Unity Server shut down") - -# Initialize MCP server -mcp = FastMCP( - "mcp-for-unity-server", - description="Unity Editor integration via Model Context Protocol", - lifespan=server_lifespan -) - -# Register all tools -register_all_tools(mcp) - -# Asset Creation Strategy - - -@mcp.prompt() -def asset_creation_strategy() -> str: - """Guide for discovering and using MCP for Unity tools effectively.""" - return ( - "Available MCP for Unity Server Tools:\n\n" - "- `manage_editor`: Controls editor state and queries info.\n" - "- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" - "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" - "- `manage_scene`: Manages scenes.\n" - "- `manage_gameobject`: Manages GameObjects in the scene.\n" - "- `manage_script`: Manages C# script files.\n" - "- `manage_asset`: Manages prefabs and assets.\n" - "- `manage_shader`: Manages shaders.\n\n" - "Tips:\n" - "- Create prefabs for reusable GameObjects.\n" - "- Always include a camera and main light in your scenes.\n" - "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" - "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" - "- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n" - "- List menu items before using them if you are unsure of the menu path.\n" - "- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n" - ) - - -# Run the server -if __name__ == "__main__": - mcp.run(transport='stdio') diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server_version.txt deleted file mode 100644 index 18091983..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ /dev/null @@ -1 +0,0 @@ -3.4.0 diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry.py deleted file mode 100644 index f95a9b3e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry.py +++ /dev/null @@ -1,431 +0,0 @@ -""" -Privacy-focused, anonymous telemetry system for Unity MCP -Inspired by Onyx's telemetry implementation with Unity-specific adaptations -""" - -import uuid -import threading -""" -Fire-and-forget telemetry sender with a single background worker. -- No context/thread-local propagation to avoid re-entrancy into tool resolution. -- Small network timeouts to prevent stalls. -""" -import json -import time -import os -import sys -import platform -import logging -from enum import Enum -from urllib.parse import urlparse -from dataclasses import dataclass, asdict -from typing import Optional, Dict, Any, List -from pathlib import Path -import importlib -import queue -import contextlib - -try: - import httpx - HAS_HTTPX = True -except ImportError: - httpx = None # type: ignore - HAS_HTTPX = False - -logger = logging.getLogger("unity-mcp-telemetry") - -class RecordType(str, Enum): - """Types of telemetry records we collect""" - VERSION = "version" - STARTUP = "startup" - USAGE = "usage" - LATENCY = "latency" - FAILURE = "failure" - TOOL_EXECUTION = "tool_execution" - UNITY_CONNECTION = "unity_connection" - CLIENT_CONNECTION = "client_connection" - -class MilestoneType(str, Enum): - """Major user journey milestones""" - FIRST_STARTUP = "first_startup" - FIRST_TOOL_USAGE = "first_tool_usage" - FIRST_SCRIPT_CREATION = "first_script_creation" - FIRST_SCENE_MODIFICATION = "first_scene_modification" - MULTIPLE_SESSIONS = "multiple_sessions" - DAILY_ACTIVE_USER = "daily_active_user" - WEEKLY_ACTIVE_USER = "weekly_active_user" - -@dataclass -class TelemetryRecord: - """Structure for telemetry data""" - record_type: RecordType - timestamp: float - customer_uuid: str - session_id: str - data: Dict[str, Any] - milestone: Optional[MilestoneType] = None - -class TelemetryConfig: - """Telemetry configuration""" - def __init__(self): - # Prefer config file, then allow env overrides - server_config = None - for modname in ( - "UnityMcpBridge.UnityMcpServer~.src.config", - "UnityMcpBridge.UnityMcpServer.src.config", - "src.config", - "config", - ): - try: - mod = importlib.import_module(modname) - server_config = getattr(mod, "config", None) - if server_config is not None: - break - except Exception: - continue - - # Determine enabled flag: config -> env DISABLE_* opt-out - cfg_enabled = True if server_config is None else bool(getattr(server_config, "telemetry_enabled", True)) - self.enabled = cfg_enabled and not self._is_disabled() - - # Telemetry endpoint (Cloud Run default; override via env) - cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None) - default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" - self.default_endpoint = default_ep - self.endpoint = self._validated_endpoint( - os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), - default_ep, - ) - try: - logger.info( - "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", - self.endpoint, - default_ep, - os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "" - ) - except Exception: - pass - - # Local storage for UUID and milestones - self.data_dir = self._get_data_directory() - self.uuid_file = self.data_dir / "customer_uuid.txt" - self.milestones_file = self.data_dir / "milestones.json" - - # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT - try: - self.timeout = float(os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) - except Exception: - self.timeout = 1.5 - try: - logger.info("Telemetry timeout=%.2fs", self.timeout) - except Exception: - pass - - # Session tracking - self.session_id = str(uuid.uuid4()) - - def _is_disabled(self) -> bool: - """Check if telemetry is disabled via environment variables""" - disable_vars = [ - "DISABLE_TELEMETRY", - "UNITY_MCP_DISABLE_TELEMETRY", - "MCP_DISABLE_TELEMETRY" - ] - - for var in disable_vars: - if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): - return True - return False - - def _get_data_directory(self) -> Path: - """Get directory for storing telemetry data""" - if os.name == 'nt': # Windows - base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) - elif os.name == 'posix': # macOS/Linux - if 'darwin' in os.uname().sysname.lower(): # macOS - base_dir = Path.home() / 'Library' / 'Application Support' - else: # Linux - base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share')) - else: - base_dir = Path.home() / '.unity-mcp' - - data_dir = base_dir / 'UnityMCP' - data_dir.mkdir(parents=True, exist_ok=True) - return data_dir - - def _validated_endpoint(self, candidate: str, fallback: str) -> str: - """Validate telemetry endpoint URL scheme; allow only http/https. - Falls back to the provided default on error. - """ - try: - parsed = urlparse(candidate) - if parsed.scheme not in ("https", "http"): - raise ValueError(f"Unsupported scheme: {parsed.scheme}") - # Basic sanity: require network location and path - if not parsed.netloc: - raise ValueError("Missing netloc in endpoint") - # Reject localhost/loopback endpoints in production to avoid accidental local overrides - host = parsed.hostname or "" - if host in ("localhost", "127.0.0.1", "::1"): - raise ValueError("Localhost endpoints are not allowed for telemetry") - return candidate - except Exception as e: - logger.debug( - f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}", - exc_info=True, - ) - return fallback - -class TelemetryCollector: - """Main telemetry collection class""" - - def __init__(self): - self.config = TelemetryConfig() - self._customer_uuid: Optional[str] = None - self._milestones: Dict[str, Dict[str, Any]] = {} - self._lock: threading.Lock = threading.Lock() - # Bounded queue with single background worker (records only; no context propagation) - self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) - # Load persistent data before starting worker so first events have UUID - self._load_persistent_data() - self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True) - self._worker.start() - - def _load_persistent_data(self): - """Load UUID and milestones from disk""" - # Load customer UUID - try: - if self.config.uuid_file.exists(): - self._customer_uuid = self.config.uuid_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4()) - else: - self._customer_uuid = str(uuid.uuid4()) - try: - self.config.uuid_file.write_text(self._customer_uuid, encoding="utf-8") - if os.name == "posix": - os.chmod(self.config.uuid_file, 0o600) - except OSError as e: - logger.debug(f"Failed to persist customer UUID: {e}", exc_info=True) - except OSError as e: - logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) - self._customer_uuid = str(uuid.uuid4()) - - # Load milestones (failure here must not affect UUID) - try: - if self.config.milestones_file.exists(): - content = self.config.milestones_file.read_text(encoding="utf-8") - self._milestones = json.loads(content) or {} - if not isinstance(self._milestones, dict): - self._milestones = {} - except (OSError, json.JSONDecodeError, ValueError) as e: - logger.debug(f"Failed to load milestones: {e}", exc_info=True) - self._milestones = {} - - def _save_milestones(self): - """Save milestones to disk. Caller must hold self._lock.""" - try: - self.config.milestones_file.write_text( - json.dumps(self._milestones, indent=2), - encoding="utf-8", - ) - except OSError as e: - logger.warning(f"Failed to save milestones: {e}", exc_info=True) - - def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: - """Record a milestone event, returns True if this is the first occurrence""" - if not self.config.enabled: - return False - milestone_key = milestone.value - with self._lock: - if milestone_key in self._milestones: - return False # Already recorded - milestone_data = { - "timestamp": time.time(), - "data": data or {}, - } - self._milestones[milestone_key] = milestone_data - self._save_milestones() - - # Also send as telemetry record - self.record( - record_type=RecordType.USAGE, - data={"milestone": milestone_key, **(data or {})}, - milestone=milestone - ) - - return True - - def record(self, - record_type: RecordType, - data: Dict[str, Any], - milestone: Optional[MilestoneType] = None): - """Record a telemetry event (async, non-blocking)""" - if not self.config.enabled: - return - - # Allow fallback sender when httpx is unavailable (no early return) - - record = TelemetryRecord( - record_type=record_type, - timestamp=time.time(), - customer_uuid=self._customer_uuid or "unknown", - session_id=self.config.session_id, - data=data, - milestone=milestone - ) - # Enqueue for background worker (non-blocking). Drop on backpressure. - try: - self._queue.put_nowait(record) - except queue.Full: - logger.debug("Telemetry queue full; dropping %s", record.record_type) - - def _worker_loop(self): - """Background worker that serializes telemetry sends.""" - while True: - rec = self._queue.get() - try: - # Run sender directly; do not reuse caller context/thread-locals - self._send_telemetry(rec) - except Exception: - logger.debug("Telemetry worker send failed", exc_info=True) - finally: - with contextlib.suppress(Exception): - self._queue.task_done() - - def _send_telemetry(self, record: TelemetryRecord): - """Send telemetry data to endpoint""" - try: - # System fingerprint (top-level remains concise; details stored in data JSON) - _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows' - _source = sys.platform # 'darwin' | 'linux' | 'win32' - _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})" - _python_version = platform.python_version() - - # Enrich data JSON so BigQuery stores detailed fields without schema change - enriched_data = dict(record.data or {}) - enriched_data.setdefault("platform_detail", _platform_detail) - enriched_data.setdefault("python_version", _python_version) - - payload = { - "record": record.record_type.value, - "timestamp": record.timestamp, - "customer_uuid": record.customer_uuid, - "session_id": record.session_id, - "data": enriched_data, - "version": "3.0.2", # Unity MCP version - "platform": _platform, - "source": _source, - } - - if record.milestone: - payload["milestone"] = record.milestone.value - - # Prefer httpx when available; otherwise fall back to urllib - if httpx: - with httpx.Client(timeout=self.config.timeout) as client: - # Re-validate endpoint at send time to handle dynamic changes - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) - response = client.post(endpoint, json=payload) - if 200 <= response.status_code < 300: - logger.debug(f"Telemetry sent: {record.record_type}") - else: - logger.warning(f"Telemetry failed: HTTP {response.status_code}") - else: - import urllib.request - import urllib.error - data_bytes = json.dumps(payload).encode("utf-8") - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) - req = urllib.request.Request( - endpoint, - data=data_bytes, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: - if 200 <= resp.getcode() < 300: - logger.debug(f"Telemetry sent (urllib): {record.record_type}") - else: - logger.warning(f"Telemetry failed (urllib): HTTP {resp.getcode()}") - except urllib.error.URLError as ue: - logger.warning(f"Telemetry send failed (urllib): {ue}") - - except Exception as e: - # Never let telemetry errors interfere with app functionality - logger.debug(f"Telemetry send failed: {e}") - - -# Global telemetry instance -_telemetry_collector: Optional[TelemetryCollector] = None - -def get_telemetry() -> TelemetryCollector: - """Get the global telemetry collector instance""" - global _telemetry_collector - if _telemetry_collector is None: - _telemetry_collector = TelemetryCollector() - return _telemetry_collector - -def record_telemetry(record_type: RecordType, - data: Dict[str, Any], - milestone: Optional[MilestoneType] = None): - """Convenience function to record telemetry""" - get_telemetry().record(record_type, data, milestone) - -def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: - """Convenience function to record a milestone""" - return get_telemetry().record_milestone(milestone, data) - -def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None): - """Record tool usage telemetry - - Args: - tool_name: Name of the tool invoked (e.g., 'manage_scene'). - success: Whether the tool completed successfully. - duration_ms: Execution duration in milliseconds. - error: Optional error message (truncated if present). - sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy'). - """ - data = { - "tool_name": tool_name, - "success": success, - "duration_ms": round(duration_ms, 2) - } - - if sub_action is not None: - try: - data["sub_action"] = str(sub_action) - except Exception: - # Ensure telemetry is never disruptive - data["sub_action"] = "unknown" - - if error: - data["error"] = str(error)[:200] # Limit error message length - - record_telemetry(RecordType.TOOL_EXECUTION, data) - -def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): - """Record latency telemetry""" - data = { - "operation": operation, - "duration_ms": round(duration_ms, 2) - } - - if metadata: - data.update(metadata) - - record_telemetry(RecordType.LATENCY, data) - -def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): - """Record failure telemetry""" - data = { - "component": component, - "error": str(error)[:500] # Limit error message length - } - - if metadata: - data.update(metadata) - - record_telemetry(RecordType.FAILURE, data) - -def is_telemetry_enabled() -> bool: - """Check if telemetry is enabled""" - return get_telemetry().config.enabled \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py deleted file mode 100644 index de94fb26..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Telemetry decorator for Unity MCP tools -""" - -import functools -import time -import inspect -import logging -from typing import Callable, Any -from telemetry import record_tool_usage, record_milestone, MilestoneType - -_log = logging.getLogger("unity-mcp-telemetry") -_decorator_log_count = 0 - -def telemetry_tool(tool_name: str): - """Decorator to add telemetry tracking to MCP tools""" - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - def _sync_wrapper(*args, **kwargs) -> Any: - start_time = time.time() - success = False - error = None - # Extract sub-action (e.g., 'get_hierarchy') from bound args when available - sub_action = None - try: - sig = inspect.signature(func) - bound = sig.bind_partial(*args, **kwargs) - bound.apply_defaults() - sub_action = bound.arguments.get("action") - except Exception: - sub_action = None - try: - global _decorator_log_count - if _decorator_log_count < 10: - _log.info(f"telemetry_decorator sync: tool={tool_name}") - _decorator_log_count += 1 - result = func(*args, **kwargs) - success = True - action_val = sub_action or kwargs.get("action") - try: - if tool_name == "manage_script" and action_val == "create": - record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) - elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) - record_milestone(MilestoneType.FIRST_TOOL_USAGE) - except Exception: - _log.debug("milestone emit failed", exc_info=True) - return result - except Exception as e: - error = str(e) - raise - finally: - duration_ms = (time.time() - start_time) * 1000 - try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) - except Exception: - _log.debug("record_tool_usage failed", exc_info=True) - - @functools.wraps(func) - async def _async_wrapper(*args, **kwargs) -> Any: - start_time = time.time() - success = False - error = None - # Extract sub-action (e.g., 'get_hierarchy') from bound args when available - sub_action = None - try: - sig = inspect.signature(func) - bound = sig.bind_partial(*args, **kwargs) - bound.apply_defaults() - sub_action = bound.arguments.get("action") - except Exception: - sub_action = None - try: - global _decorator_log_count - if _decorator_log_count < 10: - _log.info(f"telemetry_decorator async: tool={tool_name}") - _decorator_log_count += 1 - result = await func(*args, **kwargs) - success = True - action_val = sub_action or kwargs.get("action") - try: - if tool_name == "manage_script" and action_val == "create": - record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) - elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) - record_milestone(MilestoneType.FIRST_TOOL_USAGE) - except Exception: - _log.debug("milestone emit failed", exc_info=True) - return result - except Exception as e: - error = str(e) - raise - finally: - duration_ms = (time.time() - start_time) * 1000 - try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) - except Exception: - _log.debug("record_tool_usage failed", exc_info=True) - - return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper - return decorator \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py deleted file mode 100644 index c9e3013a..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for Unity MCP Telemetry System -Run this to verify telemetry is working correctly -""" - -import os -import time -import sys -from pathlib import Path - -# Add src to Python path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -def test_telemetry_basic(): - """Test basic telemetry functionality""" - # Avoid stdout noise in tests - - try: - from telemetry import ( - get_telemetry, record_telemetry, record_milestone, - RecordType, MilestoneType, is_telemetry_enabled - ) - pass - except ImportError as e: - # Silent failure path for tests - return False - - # Test telemetry enabled status - _ = is_telemetry_enabled() - - # Test basic record - try: - record_telemetry(RecordType.VERSION, { - "version": "3.0.2", - "test_run": True - }) - pass - except Exception as e: - # Silent failure path for tests - return False - - # Test milestone recording - try: - is_first = record_milestone(MilestoneType.FIRST_STARTUP, { - "test_mode": True - }) - _ = is_first - except Exception as e: - # Silent failure path for tests - return False - - # Test telemetry collector - try: - collector = get_telemetry() - _ = collector - except Exception as e: - # Silent failure path for tests - return False - - return True - -def test_telemetry_disabled(): - """Test telemetry with disabled state""" - # Silent for tests - - # Set environment variable to disable telemetry - os.environ["DISABLE_TELEMETRY"] = "true" - - # Re-import to get fresh config - import importlib - import telemetry - importlib.reload(telemetry) - - from telemetry import is_telemetry_enabled, record_telemetry, RecordType - - _ = is_telemetry_enabled() - - if not is_telemetry_enabled(): - pass - - # Test that records are ignored when disabled - record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) - pass - - return True - else: - pass - return False - -def test_data_storage(): - """Test data storage functionality""" - # Silent for tests - - try: - from telemetry import get_telemetry - - collector = get_telemetry() - data_dir = collector.config.data_dir - - _ = (data_dir, collector.config.uuid_file, collector.config.milestones_file) - - # Check if files exist - if collector.config.uuid_file.exists(): - pass - else: - pass - - if collector.config.milestones_file.exists(): - pass - else: - pass - - return True - - except Exception as e: - # Silent failure path for tests - return False - -def main(): - """Run all telemetry tests""" - # Silent runner for CI - - tests = [ - test_telemetry_basic, - test_data_storage, - test_telemetry_disabled, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - if test(): - passed += 1 - pass - else: - failed += 1 - pass - except Exception as e: - failed += 1 - pass - - _ = (passed, failed) - - if failed == 0: - pass - return True - else: - pass - return False - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py deleted file mode 100644 index c14f6ceb..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -from .manage_script_edits import register_manage_script_edits_tools -from .manage_script import register_manage_script_tools -from .manage_scene import register_manage_scene_tools -from .manage_editor import register_manage_editor_tools -from .manage_gameobject import register_manage_gameobject_tools -from .manage_asset import register_manage_asset_tools -from .manage_shader import register_manage_shader_tools -from .read_console import register_read_console_tools -from .manage_menu_item import register_manage_menu_item_tools -from .resource_tools import register_resource_tools - -logger = logging.getLogger("mcp-for-unity-server") - -def register_all_tools(mcp): - """Register all refactored tools with the MCP server.""" - # Prefer the surgical edits tool so LLMs discover it first - logger.info("Registering MCP for Unity Server refactored tools...") - register_manage_script_edits_tools(mcp) - register_manage_script_tools(mcp) - register_manage_scene_tools(mcp) - register_manage_editor_tools(mcp) - register_manage_gameobject_tools(mcp) - register_manage_asset_tools(mcp) - register_manage_shader_tools(mcp) - register_read_console_tools(mcp) - register_manage_menu_item_tools(mcp) - register_resource_tools(mcp) - logger.info("MCP for Unity Server tool registration complete.") diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py deleted file mode 100644 index 49a8aeee..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Defines the manage_asset tool for interacting with Unity assets. -""" -import asyncio # Added: Import asyncio for running sync code in async -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context -# from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper -from config import config -import time - -from telemetry_decorator import telemetry_tool - -def register_manage_asset_tools(mcp: FastMCP): - """Registers the manage_asset tool with the MCP server.""" - - @mcp.tool() - @telemetry_tool("manage_asset") - async def manage_asset( - ctx: Any, - action: str, - path: str, - asset_type: str = None, - properties: Dict[str, Any] = None, - destination: str = None, - generate_preview: bool = False, - search_pattern: str = None, - filter_type: str = None, - filter_date_after: str = None, - page_size: Any = None, - page_number: Any = None - ) -> Dict[str, Any]: - """Performs asset operations (import, create, modify, delete, etc.) in Unity. - - Args: - ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). - path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. - asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. - properties: Dictionary of properties for 'create'/'modify'. - example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. - example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. - example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. - destination: Target path for 'duplicate'/'move'. - search_pattern: Search pattern (e.g., '*.prefab'). - filter_*: Filters for search (type, date). - page_*: Pagination for search. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ - # Ensure properties is a dict if None - if properties is None: - properties = {} - - # Coerce numeric inputs defensively - def _coerce_int(value, default=None): - if value is None: - return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: - return default - - page_size = _coerce_int(page_size) - page_number = _coerce_int(page_number) - - # Prepare parameters for the C# handler - params_dict = { - "action": action.lower(), - "path": path, - "assetType": asset_type, - "properties": properties, - "destination": destination, - "generatePreview": generate_preview, - "searchPattern": search_pattern, - "filterType": filter_type, - "filterDateAfter": filter_date_after, - "pageSize": page_size, - "pageNumber": page_number - } - - # Remove None values to avoid sending unnecessary nulls - params_dict = {k: v for k, v in params_dict.items() if v is not None} - - # Get the current asyncio event loop - loop = asyncio.get_running_loop() - # Get the Unity connection instance - connection = get_unity_connection() - - # Use centralized async retry helper to avoid blocking the event loop - result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) - # Return the result obtained from Unity - return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py deleted file mode 100644 index f5508a4e..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ /dev/null @@ -1,66 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -import time -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config - -from telemetry_decorator import telemetry_tool -from telemetry import is_telemetry_enabled, record_tool_usage - -def register_manage_editor_tools(mcp: FastMCP): - """Register all editor management tools with the MCP server.""" - - @mcp.tool(description=( - "Controls and queries the Unity editor's state and settings.\n\n" - "Args:\n" - "- ctx: Context object (required)\n" - "- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n" - "- wait_for_completion: Optional. If True, waits for certain actions\n" - "- tool_name: Tool name for specific actions\n" - "- tag_name: Tag name for specific actions\n" - "- layer_name: Layer name for specific actions\n\n" - "Returns:\n" - "Dictionary with operation results ('success', 'message', 'data')." - )) - @telemetry_tool("manage_editor") - def manage_editor( - ctx: Context, - action: str, - wait_for_completion: bool = None, - # --- Parameters for specific actions --- - tool_name: str = None, - tag_name: str = None, - layer_name: str = None, - ) -> Dict[str, Any]: - try: - # Diagnostics: quick telemetry checks - if action == "telemetry_status": - return {"success": True, "telemetry_enabled": is_telemetry_enabled()} - - if action == "telemetry_ping": - record_tool_usage("diagnostic_ping", True, 1.0, None) - return {"success": True, "message": "telemetry ping queued"} - # Prepare parameters, removing None values - params = { - "action": action, - "waitForCompletion": wait_for_completion, - "toolName": tool_name, # Corrected parameter name to match C# - "tagName": tag_name, # Pass tag name - "layerName": layer_name, # Pass layer name - # Add other parameters based on the action being performed - # "width": width, - # "height": height, - # etc. - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command using centralized retry helper - response = send_command_with_retry("manage_editor", params) - - # Preserve structured failure data; unwrap success into a friendlier shape - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py deleted file mode 100644 index a2ffe0ea..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ /dev/null @@ -1,140 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time - -from telemetry_decorator import telemetry_tool - -def register_manage_gameobject_tools(mcp: FastMCP): - """Register all GameObject management tools with the MCP server.""" - - @mcp.tool() - @telemetry_tool("manage_gameobject") - def manage_gameobject( - ctx: Any, - action: str, - target: str = None, # GameObject identifier by name or path - search_method: str = None, - # --- Combined Parameters for Create/Modify --- - name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) - tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) - parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) - position: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - components_to_add: List[str] = None, # List of component names to add - primitive_type: str = None, - save_as_prefab: bool = False, - prefab_path: str = None, - prefab_folder: str = "Assets/Prefabs", - # --- Parameters for 'modify' --- - set_active: bool = None, - layer: str = None, # Layer name - components_to_remove: List[str] = None, - component_properties: Dict[str, Dict[str, Any]] = None, - # --- Parameters for 'find' --- - search_term: str = None, - find_all: bool = False, - search_in_children: bool = False, - search_inactive: bool = False, - # -- Component Management Arguments -- - component_name: str = None, - includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields - ) -> Dict[str, Any]: - """Manages GameObjects: create, modify, delete, find, and component operations. - - Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). - target: GameObject identifier (name or path string) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. - name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). - tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). - parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). - layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). - component_properties: Dict mapping Component names to their properties to set. - Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, - To set references: - - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} - - Use a dict for scene objects/components, e.g.: - {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) - {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - Example set nested property: - - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} - components_to_add: List of component names to add. - Action-specific arguments (e.g., position, rotation, scale for create/modify; - component_name for component actions; - search_term, find_all for 'find'). - includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. - - Action-specific details: - - For 'get_components': - Required: target, search_method - Optional: includeNonPublicSerialized (defaults to True) - Returns all components on the target GameObject with their serialized data. - The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. - """ - try: - # --- Early check for attempting to modify a prefab asset --- - # ---------------------------------------------------------- - - # Prepare parameters, removing None values - params = { - "action": action, - "target": target, - "searchMethod": search_method, - "name": name, - "tag": tag, - "parent": parent, - "position": position, - "rotation": rotation, - "scale": scale, - "componentsToAdd": components_to_add, - "primitiveType": primitive_type, - "saveAsPrefab": save_as_prefab, - "prefabPath": prefab_path, - "prefabFolder": prefab_folder, - "setActive": set_active, - "layer": layer, - "componentsToRemove": components_to_remove, - "componentProperties": component_properties, - "searchTerm": search_term, - "findAll": find_all, - "searchInChildren": search_in_children, - "searchInactive": search_inactive, - "componentName": component_name, - "includeNonPublicSerialized": includeNonPublicSerialized - } - params = {k: v for k, v in params.items() if v is not None} - - # --- Handle Prefab Path Logic --- - if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params - if "prefabPath" not in params: - if "name" not in params or not params["name"]: - return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} - # Use the provided prefab_folder (which has a default) and the name to construct the path - constructed_path = f"{prefab_folder}/{params['name']}.prefab" - # Ensure clean path separators (Unity prefers '/') - params["prefabPath"] = constructed_path.replace("\\", "/") - elif not params["prefabPath"].lower().endswith(".prefab"): - return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} - # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided - # The C# side only needs the final prefabPath - params.pop("prefabFolder", None) - # -------------------------------- - - # Use centralized retry helper - response = send_command_with_retry("manage_gameobject", params) - - # Check if the response indicates success - # If the response is not successful, raise an exception with the error message - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py deleted file mode 100644 index ba5601de..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Defines the manage_menu_item tool for executing and reading Unity Editor menu items. -""" -import asyncio -from typing import Annotated, Any, Literal - -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - -from unity_connection import get_unity_connection, async_send_command_with_retry - - -def register_manage_menu_item_tools(mcp: FastMCP): - """Registers the manage_menu_item tool with the MCP server.""" - - @mcp.tool() - @telemetry_tool("manage_menu_item") - async def manage_menu_item( - ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"], - menu_path: Annotated[str | None, - "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, - search: Annotated[str | None, - "Optional filter string for 'list' (e.g., 'Save')"] = None, - refresh: Annotated[bool | None, - "Optional flag to force refresh of the menu cache when listing"] = None, - ) -> dict[str, Any]: - """Manage Unity menu items (execute/list/exists). - - Args: - ctx: The MCP context. - action: One of 'execute', 'list', 'exists'. - menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project"). - search: Optional filter string for 'list'. - refresh: Optional flag to force refresh of the menu cache when listing. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ - # Prepare parameters for the C# handler - params_dict: dict[str, Any] = { - "action": action, - "menuPath": menu_path, - "search": search, - "refresh": refresh, - } - # Remove None values - params_dict = {k: v for k, v in params_dict.items() if v is not None} - - # Get the current asyncio event loop - loop = asyncio.get_running_loop() - # Touch the connection to ensure availability (mirrors other tools' pattern) - _ = get_unity_connection() - - # Use centralized async retry helper - result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) - return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py deleted file mode 100644 index 9435f039..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ /dev/null @@ -1,69 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time - -from telemetry_decorator import telemetry_tool - -def register_manage_scene_tools(mcp: FastMCP): - """Register all scene management tools with the MCP server.""" - - @mcp.tool() - @telemetry_tool("manage_scene") - def manage_scene( - ctx: Context, - action: str, - name: str = "", - path: str = "", - build_index: Any = None, - ) -> Dict[str, Any]: - """Manages Unity scenes (load, save, create, get hierarchy, etc.). - - Args: - action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). - name: Scene name (no extension) for create/load/save. - path: Asset path for scene operations (default: "Assets/"). - build_index: Build index for load/build settings actions. - # Add other action-specific args as needed (e.g., for hierarchy depth) - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Coerce numeric inputs defensively - def _coerce_int(value, default=None): - if value is None: - return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: - return default - - coerced_build_index = _coerce_int(build_index, default=None) - - params = {"action": action} - if name: - params["name"] = name - if path: - params["path"] = path - if coerced_build_index is not None: - params["buildIndex"] = coerced_build_index - - # Use centralized retry helper - response = send_command_with_retry("manage_scene", params) - - # Preserve structured failure data; unwrap success into a friendlier shape - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py deleted file mode 100644 index 0c27eb08..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ /dev/null @@ -1,622 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import send_command_with_retry -import base64 -import os -from urllib.parse import urlparse, unquote - -try: - from telemetry_decorator import telemetry_tool - from telemetry import record_milestone, MilestoneType - HAS_TELEMETRY = True -except ImportError: - HAS_TELEMETRY = False - def telemetry_tool(tool_name: str): - def decorator(func): - return func - return decorator - -def register_manage_script_tools(mcp: FastMCP): - """Register all script management tools with the MCP server.""" - - def _split_uri(uri: str) -> tuple[str, str]: - """Split an incoming URI or path into (name, directory) suitable for Unity. - - Rules: - - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) - - file://... → percent-decode, normalize, strip host and leading slashes, - then, if any 'Assets' segment exists, return path relative to that 'Assets' root. - Otherwise, fall back to original name/dir behavior. - - plain paths → decode/normalize separators; if they contain an 'Assets' segment, - return relative to 'Assets'. - """ - raw_path: str - if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/") :] - elif uri.startswith("file://"): - parsed = urlparse(uri) - host = (parsed.netloc or "").strip() - p = parsed.path or "" - # UNC: file://server/share/... -> //server/share/... - if host and host.lower() != "localhost": - p = f"//{host}{p}" - # Use percent-decoded path, preserving leading slashes - raw_path = unquote(p) - else: - raw_path = uri - - # Percent-decode any residual encodings and normalize separators - raw_path = unquote(raw_path).replace("\\", "/") - # Strip leading slash only for Windows drive-letter forms like "/C:/..." - if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": - raw_path = raw_path[1:] - - # Normalize path (collapse ../, ./) - norm = os.path.normpath(raw_path).replace("\\", "/") - - # If an 'Assets' segment exists, compute path relative to it (case-insensitive) - parts = [p for p in norm.split("/") if p not in ("", ".")] - idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) - assets_rel = "/".join(parts[idx:]) if idx is not None else None - - effective_path = assets_rel if assets_rel else norm - # For POSIX absolute paths outside Assets, drop the leading '/' - # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). - if effective_path.startswith("/"): - effective_path = effective_path[1:] - - name = os.path.splitext(os.path.basename(effective_path))[0] - directory = os.path.dirname(effective_path) - return name, directory - - @mcp.tool(description=( - "Apply small text edits to a C# script identified by URI.\n\n" - "⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" - "Common mistakes:\n" - "- Assuming what's on a line without checking\n" - "- Using wrong line numbers (they're 1-indexed)\n" - "- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n" - "RECOMMENDED WORKFLOW:\n" - "1) First call resources/read with start_line/line_count to verify exact content\n" - "2) Count columns carefully (or use find_in_file to locate patterns)\n" - "3) Apply your edit with precise coordinates\n" - "4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" - "Args:\n" - "- uri: unity://path/Assets/... or file://... or Assets/...\n" - "- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n" - "- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n" - "Notes:\n" - "- Path must resolve under Assets/\n" - "- For method/class operations, use script_apply_edits (safer, structured edits)\n" - "- For pattern-based replacements, consider anchor operations in script_apply_edits\n" - )) - @telemetry_tool("apply_text_edits") - def apply_text_edits( - ctx: Context, - uri: str, - edits: List[Dict[str, Any]], - precondition_sha256: str | None = None, - strict: bool | None = None, - options: Dict[str, Any] | None = None, - ) -> Dict[str, Any]: - """Apply small text edits to a C# script identified by URI.""" - name, directory = _split_uri(uri) - - # Normalize common aliases/misuses for resilience: - # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} - # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} - # If normalization is required, read current contents to map indices -> 1-based line/col. - def _needs_normalization(arr: List[Dict[str, Any]]) -> bool: - for e in arr or []: - if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): - return True - return False - - normalized_edits: List[Dict[str, Any]] = [] - warnings: List[str] = [] - if _needs_normalization(edits): - # Read file to support index->line/col conversion when needed - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": directory, - }) - if not (isinstance(read_resp, dict) and read_resp.get("success")): - return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - data = read_resp.get("data", {}) - contents = data.get("contents") - if not contents and data.get("contentsEncoded"): - try: - contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") - except Exception: - contents = contents or "" - - # Helper to map 0-based character index to 1-based line/col - def line_col_from_index(idx: int) -> tuple[int, int]: - if idx <= 0: - return 1, 1 - # Count lines up to idx and position within line - nl_count = contents.count("\n", 0, idx) - line = nl_count + 1 - last_nl = contents.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 - return line, col - - for e in edits or []: - e2 = dict(e) - # Map text->newText if needed - if "newText" not in e2 and "text" in e2: - e2["newText"] = e2.pop("text") - - if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: - # Guard: explicit fields must be 1-based. - zero_based = False - for k in ("startLine","startCol","endLine","endCol"): - try: - if int(e2.get(k, 1)) < 1: - zero_based = True - except Exception: - pass - if zero_based: - if strict: - return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} - # Normalize by clamping to 1 and warn - for k in ("startLine","startCol","endLine","endCol"): - try: - if int(e2.get(k, 1)) < 1: - e2[k] = 1 - except Exception: - pass - warnings.append("zero_based_explicit_fields_normalized") - normalized_edits.append(e2) - continue - - rng = e2.get("range") - if isinstance(rng, dict): - # LSP style: 0-based - s = rng.get("start", {}) - t = rng.get("end", {}) - e2["startLine"] = int(s.get("line", 0)) + 1 - e2["startCol"] = int(s.get("character", 0)) + 1 - e2["endLine"] = int(t.get("line", 0)) + 1 - e2["endCol"] = int(t.get("character", 0)) + 1 - e2.pop("range", None) - normalized_edits.append(e2) - continue - if isinstance(rng, (list, tuple)) and len(rng) == 2: - try: - a = int(rng[0]) - b = int(rng[1]) - if b < a: - a, b = b, a - sl, sc = line_col_from_index(a) - el, ec = line_col_from_index(b) - e2["startLine"] = sl - e2["startCol"] = sc - e2["endLine"] = el - e2["endCol"] = ec - e2.pop("range", None) - normalized_edits.append(e2) - continue - except Exception: - pass - # Could not normalize this edit - return { - "success": False, - "code": "missing_field", - "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", - "data": {"expected": ["startLine","startCol","endLine","endCol","newText"], "got": e} - } - else: - # Even when edits appear already in explicit form, validate 1-based coordinates. - normalized_edits = [] - for e in edits or []: - e2 = dict(e) - has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol")) - if has_all: - zero_based = False - for k in ("startLine","startCol","endLine","endCol"): - try: - if int(e2.get(k, 1)) < 1: - zero_based = True - except Exception: - pass - if zero_based: - if strict: - return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} - for k in ("startLine","startCol","endLine","endCol"): - try: - if int(e2.get(k, 1)) < 1: - e2[k] = 1 - except Exception: - pass - if "zero_based_explicit_fields_normalized" not in warnings: - warnings.append("zero_based_explicit_fields_normalized") - normalized_edits.append(e2) - - # Preflight: detect overlapping ranges among normalized line/col spans - def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]: - return ( - int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)), - int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)), - ) - - def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: - return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) - - # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. - spans = [] - for e in normalized_edits or []: - try: - s = _pos_tuple(e, True) - t = _pos_tuple(e, False) - if s != t: - spans.append((s, t)) - except Exception: - # If coordinates missing or invalid, let the server validate later - pass - - if spans: - spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) - for i in range(1, len(spans_sorted)): - prev_end = spans_sorted[i-1][1] - curr_start = spans_sorted[i][0] - # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start - if not _le(prev_end, curr_start): - conflicts = [{ - "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, - "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, - "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, - "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, - }] - return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} - - # Note: Do not auto-compute precondition if missing; callers should supply it - # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and - # preserves existing call-count expectations in clients/tests. - - # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance - opts: Dict[str, Any] = dict(options or {}) - try: - if len(normalized_edits) > 1 and "applyMode" not in opts: - opts["applyMode"] = "atomic" - except Exception: - pass - # Support optional debug preview for span-by-span simulation without write - if opts.get("debug_preview"): - try: - import difflib - # Apply locally to preview final result - lines = [] - # Build an indexable original from a read if we normalized from read; otherwise skip - prev = "" - # We cannot guarantee file contents here without a read; return normalized spans only - return { - "success": True, - "message": "Preview only (no write)", - "data": { - "normalizedEdits": normalized_edits, - "preview": True - } - } - except Exception as e: - return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} - - params = { - "action": "apply_text_edits", - "name": name, - "path": directory, - "edits": normalized_edits, - "precondition_sha256": precondition_sha256, - "options": opts, - } - params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict): - data = resp.setdefault("data", {}) - data.setdefault("normalizedEdits", normalized_edits) - if warnings: - data.setdefault("warnings", warnings) - 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: - 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: - time.sleep(0.1) - st = _latest_status() - if st and st.get("reloading"): - return - 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)} - - @mcp.tool(description=( - "Create a new C# script at the given project path.\n\n" - "Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n" - "Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n" - )) - @telemetry_tool("create_script") - def create_script( - ctx: Context, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Create a new C# script at the given path.""" - name = os.path.splitext(os.path.basename(path))[0] - directory = os.path.dirname(path) - # Local validation to avoid round-trips on obviously bad input - norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/") - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} - if ".." in norm_path.split("/") or norm_path.startswith("/"): - return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} - if not name: - return {"success": False, "code": "bad_path", "message": "path must include a script file name."} - if not norm_path.lower().endswith(".cs"): - return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} - params: Dict[str, Any] = { - "action": "create", - "name": name, - "path": directory, - "namespace": namespace, - "scriptType": script_type, - } - if contents: - params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") - params["contentsEncoded"] = True - params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - - @mcp.tool(description=( - "Delete a C# script by URI or Assets-relative path.\n\n" - "Args: uri (unity://path/... or file://... or Assets/...).\n" - "Rules: Target must resolve under Assets/.\n" - )) - @telemetry_tool("delete_script") - def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: - """Delete a C# script by URI.""" - name, directory = _split_uri(uri) - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} - params = {"action": "delete", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - - @mcp.tool(description=( - "Validate a C# script and return diagnostics.\n\n" - "Args: uri, level=('basic'|'standard'), include_diagnostics (bool, optional).\n" - "- basic: quick syntax checks.\n" - "- standard: deeper checks (performance hints, common pitfalls).\n" - "- include_diagnostics: when true, returns full diagnostics and summary; default returns counts only.\n" - )) - @telemetry_tool("validate_script") - def validate_script( - ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False - ) -> Dict[str, Any]: - """Validate a C# script and return diagnostics.""" - name, directory = _split_uri(uri) - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} - if level not in ("basic", "standard"): - return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} - params = { - "action": "validate", - "name": name, - "path": directory, - "level": level, - } - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict) and resp.get("success"): - diags = resp.get("data", {}).get("diagnostics", []) or [] - warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning") - errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal")) - if include_diagnostics: - return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} - return {"success": True, "data": {"warnings": warnings, "errors": errors}} - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - - @mcp.tool(description=( - "Compatibility router for legacy script operations.\n\n" - "Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n" - "Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n" - "Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n" - )) - @telemetry_tool("manage_script") - def manage_script( - ctx: Context, - action: str, - name: str, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Compatibility router for legacy script operations. - - IMPORTANT: - - Direct file reads should use resources/read. - - Edits should use apply_text_edits. - - Args: - action: Operation ('create', 'read', 'delete'). - name: Script name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour'). - namespace: Script namespace. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) - if action == 'update': - try: - # 1) Read current contents to compute end range and precondition - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - }) - if not (isinstance(read_resp, dict) and read_resp.get("success")): - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."} - data = read_resp.get("data", {}) - current = data.get("contents") - if not current and data.get("contentsEncoded"): - current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") - if current is None: - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} - - # 2) Compute whole-file range (1-based, end exclusive) and SHA - import hashlib as _hashlib - old_lines = current.splitlines(keepends=True) - end_line = len(old_lines) + 1 - sha = _hashlib.sha256(current.encode("utf-8")).hexdigest() - - # 3) Apply single whole-file text edit with provided 'contents' - edits = [{ - "startLine": 1, - "startCol": 1, - "endLine": end_line, - "endCol": 1, - "newText": contents or "", - }] - route_params = { - "action": "apply_text_edits", - "name": name, - "path": path, - "edits": edits, - "precondition_sha256": sha, - "options": {"refresh": "debounced", "validate": "standard"}, - } - # Preflight size vs. default cap (256 KiB) to avoid opaque server errors - try: - import json as _json - payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8")) - if payload_bytes > 256 * 1024: - return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."} - except Exception: - pass - routed = send_command_with_retry("manage_script", route_params) - if isinstance(routed, dict): - routed.setdefault("message", "Routed legacy update to apply_text_edits") - return routed - return {"success": False, "message": str(routed)} - except Exception as e: - return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"} - - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents: - if action == 'create': - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - params = {k: v for k, v in params.items() if v is not None} - - response = send_command_with_retry("manage_script", params) - - if isinstance(response, dict): - if response.get("success"): - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return { - "success": True, - "message": response.get("message", "Operation successful."), - "data": response.get("data"), - } - return response - - return {"success": False, "message": str(response)} - - except Exception as e: - return { - "success": False, - "message": f"Python error managing script: {str(e)}", - } - - @mcp.tool(description=( - "Get manage_script capabilities (supported ops, limits, and guards).\n\n" - "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" - )) - @telemetry_tool("manage_script_capabilities") - def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: - try: - # Keep in sync with server/Editor ManageScript implementation - ops = [ - "replace_class","delete_class","replace_method","delete_method", - "insert_method","anchor_insert","anchor_delete","anchor_replace" - ] - text_ops = ["replace_range","regex_replace","prepend","append"] - # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback - max_edit_payload_bytes = 256 * 1024 - guards = {"using_guard": True} - extras = {"get_sha": True} - return {"success": True, "data": { - "ops": ops, - "text_ops": text_ops, - "max_edit_payload_bytes": max_edit_payload_bytes, - "guards": guards, - "extras": extras, - }} - except Exception as e: - return {"success": False, "error": f"capabilities error: {e}"} - - @mcp.tool(description=( - "Get SHA256 and basic metadata for a Unity C# script without returning file contents.\n\n" - "Args: uri (unity://path/Assets/... or file://... or Assets/...).\n" - "Returns: {sha256, lengthBytes}." - )) - @telemetry_tool("get_sha") - def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: - """Return SHA256 and basic metadata for a script.""" - try: - name, directory = _split_uri(uri) - params = {"action": "get_sha", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict) and resp.get("success"): - data = resp.get("data", {}) - minimal = {"sha256": data.get("sha256"), "lengthBytes": data.get("lengthBytes")} - return {"success": True, "data": minimal} - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - except Exception as e: - return {"success": False, "message": f"get_sha error: {e}"} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py deleted file mode 100644 index 3d66da00..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ /dev/null @@ -1,924 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List, Tuple, Optional -import base64 -import re -import os -from unity_connection import send_command_with_retry - -from telemetry_decorator import telemetry_tool - - -def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: - text = original_text - for edit in edits or []: - op = ( - (edit.get("op") - or edit.get("operation") - or edit.get("type") - or edit.get("mode") - or "") - .strip() - .lower() - ) - - if not op: - allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError( - f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." - ) - - if op == "prepend": - prepend_text = edit.get("text", "") - text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text - elif op == "append": - append_text = edit.get("text", "") - if not text.endswith("\n"): - text += "\n" - text += append_text - if not text.endswith("\n"): - text += "\n" - elif op == "anchor_insert": - anchor = edit.get("anchor", "") - position = (edit.get("position") or "before").lower() - insert_text = edit.get("text", "") - flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0) - - # Find the best match using improved heuristics - match = _find_best_anchor_match(anchor, text, flags, bool(edit.get("prefer_last", True))) - if not match: - if edit.get("allow_noop", True): - continue - raise RuntimeError(f"anchor not found: {anchor}") - idx = match.start() if position == "before" else match.end() - text = text[:idx] + insert_text + text[idx:] - elif op == "replace_range": - start_line = int(edit.get("startLine", 1)) - start_col = int(edit.get("startCol", 1)) - end_line = int(edit.get("endLine", start_line)) - end_col = int(edit.get("endCol", 1)) - replacement = edit.get("text", "") - lines = text.splitlines(keepends=True) - max_line = len(lines) + 1 # 1-based, exclusive end - if (start_line < 1 or end_line < start_line or end_line > max_line - or start_col < 1 or end_col < 1): - raise RuntimeError("replace_range out of bounds") - def index_of(line: int, col: int) -> int: - if line <= len(lines): - return sum(len(l) for l in lines[: line - 1]) + (col - 1) - return sum(len(l) for l in lines) - a = index_of(start_line, start_col) - b = index_of(end_line, end_col) - text = text[:a] + replacement + text[b:] - elif op == "regex_replace": - pattern = edit.get("pattern", "") - repl = edit.get("replacement", "") - # Translate $n backrefs (our input) to Python \g - repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) - count = int(edit.get("count", 0)) # 0 = replace all - flags = re.MULTILINE - if edit.get("ignore_case"): - flags |= re.IGNORECASE - text = re.sub(pattern, repl_py, text, count=count, flags=flags) - else: - allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") - return text - - -def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): - """ - Find the best anchor match using improved heuristics. - - For patterns like \\s*}\\s*$ that are meant to find class-ending braces, - this function uses heuristics to choose the most semantically appropriate match: - - 1. If prefer_last=True, prefer the last match (common for class-end insertions) - 2. Use indentation levels to distinguish class vs method braces - 3. Consider context to avoid matches inside strings/comments - - Args: - pattern: Regex pattern to search for - text: Text to search in - flags: Regex flags - prefer_last: If True, prefer the last match over the first - - Returns: - Match object of the best match, or None if no match found - """ - import re - - # Find all matches - matches = list(re.finditer(pattern, text, flags)) - if not matches: - return None - - # If only one match, return it - if len(matches) == 1: - return matches[0] - - # For patterns that look like they're trying to match closing braces at end of lines - is_closing_brace_pattern = '}' in pattern and ('$' in pattern or pattern.endswith(r'\s*')) - - if is_closing_brace_pattern and prefer_last: - # Use heuristics to find the best closing brace match - return _find_best_closing_brace_match(matches, text) - - # Default behavior: use last match if prefer_last, otherwise first match - return matches[-1] if prefer_last else matches[0] - - -def _find_best_closing_brace_match(matches, text: str): - """ - Find the best closing brace match using C# structure heuristics. - - Enhanced heuristics for scope-aware matching: - 1. Prefer matches with lower indentation (likely class-level) - 2. Prefer matches closer to end of file - 3. Avoid matches that seem to be inside method bodies - 4. For #endregion patterns, ensure class-level context - 5. Validate insertion point is at appropriate scope - - Args: - matches: List of regex match objects - text: The full text being searched - - Returns: - The best match object - """ - if not matches: - return None - - scored_matches = [] - lines = text.splitlines() - - for match in matches: - score = 0 - start_pos = match.start() - - # Find which line this match is on - lines_before = text[:start_pos].count('\n') - line_num = lines_before - - if line_num < len(lines): - line_content = lines[line_num] - - # Calculate indentation level (lower is better for class braces) - indentation = len(line_content) - len(line_content.lstrip()) - - # Prefer lower indentation (class braces are typically less indented than method braces) - score += max(0, 20 - indentation) # Max 20 points for indentation=0 - - # Prefer matches closer to end of file (class closing braces are typically at the end) - distance_from_end = len(lines) - line_num - score += max(0, 10 - distance_from_end) # More points for being closer to end - - # Look at surrounding context to avoid method braces - context_start = max(0, line_num - 3) - context_end = min(len(lines), line_num + 2) - context_lines = lines[context_start:context_end] - - # Penalize if this looks like it's inside a method (has method-like patterns above) - for context_line in context_lines: - if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): - score -= 5 # Penalty for being near method signatures - - # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) - if indentation <= 4 and distance_from_end <= 3: - score += 15 # Bonus for likely class-ending brace - - scored_matches.append((score, match)) - - # Return the match with the highest score - scored_matches.sort(key=lambda x: x[0], reverse=True) - best_match = scored_matches[0][1] - - return best_match - - -def _infer_class_name(script_name: str) -> str: - # Default to script name as class name (common Unity pattern) - return (script_name or "").strip() - - -def _extract_code_after(keyword: str, request: str) -> str: - # Deprecated with NL removal; retained as no-op for compatibility - idx = request.lower().find(keyword) - if idx >= 0: - return request[idx + len(keyword):].strip() - return "" -# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services - - - -def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: - """Best-effort normalization of script "name" and "path". - - Accepts any of: - - name = "SmartReach", path = "Assets/Scripts/Interaction" - - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" - - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" - - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) - - name or path using uri prefixes: unity://path/..., file://... - - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" - - Returns (name_without_extension, directory_path_under_Assets). - """ - n = (name or "").strip() - p = (path or "").strip() - - def strip_prefix(s: str) -> str: - if s.startswith("unity://path/"): - return s[len("unity://path/"):] - if s.startswith("file://"): - return s[len("file://"):] - return s - - def collapse_duplicate_tail(s: str) -> str: - # Collapse trailing "/X.cs/X.cs" to "/X.cs" - parts = s.split("/") - if len(parts) >= 2 and parts[-1] == parts[-2]: - parts = parts[:-1] - return "/".join(parts) - - # Prefer a full path if provided in either field - candidate = "" - for v in (n, p): - v2 = strip_prefix(v) - if v2.endswith(".cs") or v2.startswith("Assets/"): - candidate = v2 - break - - if candidate: - candidate = collapse_duplicate_tail(candidate) - # If a directory was passed in path and file in name, join them - if not candidate.endswith(".cs") and n.endswith(".cs"): - v2 = strip_prefix(n) - candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) - if candidate.endswith(".cs"): - parts = candidate.split("/") - file_name = parts[-1] - dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" - base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name - return base, dir_path - - # Fall back: remove extension from name if present and return given path - base_name = n[:-3] if n.lower().endswith(".cs") else n - return base_name, (p or "Assets") - - -def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any: - if not isinstance(resp, dict): - return resp - data = resp.setdefault("data", {}) - data.setdefault("normalizedEdits", edits) - if routing: - data["routing"] = routing - return resp - - -def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, - normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: - payload: Dict[str, Any] = {"success": False, "code": code, "message": message} - data: Dict[str, Any] = {} - if expected: - data["expected"] = expected - if rewrite: - data["rewrite_suggestion"] = rewrite - if normalized is not None: - data["normalizedEdits"] = normalized - if routing: - data["routing"] = routing - if extra: - data.update(extra) - if data: - payload["data"] = data - return payload - -# Natural-language parsing removed; clients should send structured edits. - - -def register_manage_script_edits_tools(mcp: FastMCP): - @mcp.tool(description=( - "Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" - "Best practices:\n" - "- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" - "- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n" - "- Avoid whole-file regex deletes; validators will guard unbalanced braces\n" - "- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n" - "- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n" - "Canonical fields (use these exact keys):\n" - "- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" - "- className: string (defaults to 'name' if omitted on method/class ops)\n" - "- methodName: string (required for replace_method, delete_method)\n" - "- replacement: string (required for replace_method, insert_method)\n" - "- position: start | end | after | before (insert_method only)\n" - "- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" - "- anchor: regex string (for anchor_* ops)\n" - "- text: string (for anchor_insert/anchor_replace)\n\n" - "Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" - "Examples:\n" - "1) Replace a method:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"replace_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"methodName\": \"HasTarget\",\n" - " \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n" - " }\n" - " ],\n" - " \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n" - "}\n\n" - "2) Insert a method after another:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"insert_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n" - " \"position\": \"after\",\n" - " \"afterMethodName\": \"GetCurrentTarget\"\n" - " }\n" - " ]\n" - "}\n\n" - "Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n" - )) - @telemetry_tool("script_apply_edits") - def script_apply_edits( - ctx: Context, - name: str, - path: str, - edits: List[Dict[str, Any]], - options: Optional[Dict[str, Any]] = None, - script_type: str = "MonoBehaviour", - namespace: str = "", - ) -> Dict[str, Any]: - # Normalize locator first so downstream calls target the correct script file. - name, path = _normalize_script_locator(name, path) - - # No NL path: clients must provide structured edits in 'edits'. - - # Normalize unsupported or aliased ops to known structured/text paths - def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: - # Unwrap single-key wrappers like {"replace_method": {...}} - for wrapper_key in ( - "replace_method","insert_method","delete_method", - "replace_class","delete_class", - "anchor_insert","anchor_replace","anchor_delete", - ): - if wrapper_key in edit and isinstance(edit[wrapper_key], dict): - inner = dict(edit[wrapper_key]) - inner["op"] = wrapper_key - edit = inner - break - - e = dict(edit) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - if op: - e["op"] = op - - # Common field aliases - if "class_name" in e and "className" not in e: - e["className"] = e.pop("class_name") - if "class" in e and "className" not in e: - e["className"] = e.pop("class") - if "method_name" in e and "methodName" not in e: - e["methodName"] = e.pop("method_name") - # Some clients use a generic 'target' for method name - if "target" in e and "methodName" not in e: - e["methodName"] = e.pop("target") - if "method" in e and "methodName" not in e: - e["methodName"] = e.pop("method") - if "new_content" in e and "replacement" not in e: - e["replacement"] = e.pop("new_content") - if "newMethod" in e and "replacement" not in e: - e["replacement"] = e.pop("newMethod") - if "new_method" in e and "replacement" not in e: - e["replacement"] = e.pop("new_method") - if "content" in e and "replacement" not in e: - e["replacement"] = e.pop("content") - if "after" in e and "afterMethodName" not in e: - e["afterMethodName"] = e.pop("after") - if "after_method" in e and "afterMethodName" not in e: - e["afterMethodName"] = e.pop("after_method") - if "before" in e and "beforeMethodName" not in e: - e["beforeMethodName"] = e.pop("before") - if "before_method" in e and "beforeMethodName" not in e: - e["beforeMethodName"] = e.pop("before_method") - # anchor_method → before/after based on position (default after) - if "anchor_method" in e: - anchor = e.pop("anchor_method") - pos = (e.get("position") or "after").strip().lower() - if pos == "before" and "beforeMethodName" not in e: - e["beforeMethodName"] = anchor - elif "afterMethodName" not in e: - e["afterMethodName"] = anchor - if "anchorText" in e and "anchor" not in e: - e["anchor"] = e.pop("anchorText") - if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): - e["anchor"] = e.pop("pattern") - if "newText" in e and "text" not in e: - e["text"] = e.pop("newText") - - # CI compatibility (T‑A/T‑E): - # Accept method-anchored anchor_insert and upgrade to insert_method - # Example incoming shape: - # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} - if ( - e.get("op") == "anchor_insert" - and not e.get("anchor") - and (e.get("afterMethodName") or e.get("beforeMethodName")) - ): - e["op"] = "insert_method" - if "replacement" not in e: - e["replacement"] = e.get("text", "") - - # LSP-like range edit -> replace_range - if "range" in e and isinstance(e["range"], dict): - rng = e.pop("range") - start = rng.get("start", {}) - end = rng.get("end", {}) - # Convert 0-based to 1-based line/col - e["op"] = "replace_range" - e["startLine"] = int(start.get("line", 0)) + 1 - e["startCol"] = int(start.get("character", 0)) + 1 - e["endLine"] = int(end.get("line", 0)) + 1 - e["endCol"] = int(end.get("character", 0)) + 1 - if "newText" in edit and "text" not in e: - e["text"] = edit.get("newText", "") - return e - - normalized_edits: List[Dict[str, Any]] = [] - for raw in edits or []: - e = _unwrap_and_alias(raw) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - - # Default className to script name if missing on structured method/class ops - if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"): - e["className"] = name - - # Map common aliases for text ops - if op in ("text_replace",): - e["op"] = "replace_range" - normalized_edits.append(e) - continue - if op in ("regex_delete",): - e["op"] = "regex_replace" - e.setdefault("text", "") - normalized_edits.append(e) - continue - if op == "regex_replace" and ("replacement" not in e): - if "text" in e: - e["replacement"] = e.get("text", "") - elif "insert" in e or "content" in e: - e["replacement"] = e.get("insert") or e.get("content") or "" - if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): - e["op"] = "anchor_delete" - normalized_edits.append(e) - continue - normalized_edits.append(e) - - edits = normalized_edits - normalized_for_echo = edits - - # Validate required fields and produce machine-parsable hints - def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: - return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) - - for e in edits or []: - op = e.get("op", "") - if op == "replace_method": - if not e.get("methodName"): - return error_with_hint( - "replace_method requires 'methodName'.", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, - {"edits[0].methodName": "HasTarget"} - ) - if not (e.get("replacement") or e.get("text")): - return error_with_hint( - "replace_method requires 'replacement' (inline or base64).", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, - {"edits[0].replacement": "public bool X(){ return true; }"} - ) - elif op == "insert_method": - if not (e.get("replacement") or e.get("text")): - return error_with_hint( - "insert_method requires a non-empty 'replacement'.", - {"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, - {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} - ) - pos = (e.get("position") or "").lower() - if pos == "after" and not e.get("afterMethodName"): - return error_with_hint( - "insert_method with position='after' requires 'afterMethodName'.", - {"op": "insert_method", "position": {"after_requires": "afterMethodName"}}, - {"edits[0].afterMethodName": "GetCurrentTarget"} - ) - if pos == "before" and not e.get("beforeMethodName"): - return error_with_hint( - "insert_method with position='before' requires 'beforeMethodName'.", - {"op": "insert_method", "position": {"before_requires": "beforeMethodName"}}, - {"edits[0].beforeMethodName": "GetCurrentTarget"} - ) - elif op == "delete_method": - if not e.get("methodName"): - return error_with_hint( - "delete_method requires 'methodName'.", - {"op": "delete_method", "required": ["className", "methodName"]}, - {"edits[0].methodName": "PrintSeries"} - ) - elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): - if not e.get("anchor"): - return error_with_hint( - f"{op} requires 'anchor' (regex).", - {"op": op, "required": ["anchor"]}, - {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} - ) - if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): - return error_with_hint( - f"{op} requires 'text'.", - {"op": op, "required": ["anchor", "text"]}, - {"edits[0].text": "/* comment */\n"} - ) - - # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"} - TEXT = {"prepend","append","replace_range","regex_replace"} - ops_set = { (e.get("op") or "").lower() for e in edits or [] } - all_struct = ops_set.issubset(STRUCT) - all_text = ops_set.issubset(TEXT) - mixed = not (all_struct or all_text) - - # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. - if all_struct: - opts2 = dict(options or {}) - # 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", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": edits, - "options": opts2, - } - resp_struct = send_command_with_retry("manage_script", params_struct) - if isinstance(resp_struct, dict) and resp_struct.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") - - # 1) read from Unity - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - }) - if not isinstance(read_resp, dict) or not read_resp.get("success"): - return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - - data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} - contents = data.get("contents") - if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): - contents = base64.b64decode(data["encodedContents"]).decode("utf-8") - if contents is None: - return {"success": False, "message": "No contents returned from Unity read."} - - # Optional preview/dry-run: apply locally and return diff without writing - preview = bool((options or {}).get("preview")) - - # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured - if mixed: - text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] - struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT] - try: - base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: - line = base_text.count("\n", 0, idx) + 1 - last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 - return line, col - - at_edits: List[Dict[str, Any]] = [] - import re as _re - for e in text_edits: - opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" - if opx == "anchor_insert": - anchor = e.get("anchor") or "" - position = (e.get("position") or "after").lower() - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - try: - # Use improved anchor matching logic - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") - if not m: - return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") - idx = m.start() if position == "before" else m.end() - # Normalize insertion to avoid jammed methods - text_field_norm = text_field - if not text_field_norm.startswith("\n"): - text_field_norm = "\n" + text_field_norm - if not text_field_norm.endswith("\n"): - text_field_norm = text_field_norm + "\n" - sl, sc = line_col_from_index(idx) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) - # do not mutate base_text when building atomic spans - elif opx == "replace_range": - if all(k in e for k in ("startLine","startCol","endLine","endCol")): - at_edits.append({ - "startLine": int(e.get("startLine", 1)), - "startCol": int(e.get("startCol", 1)), - "endLine": int(e.get("endLine", 1)), - "endCol": int(e.get("endCol", 1)), - "newText": text_field - }) - else: - return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - elif opx == "regex_replace": - pattern = e.get("pattern") or "" - try: - regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") - m = regex_obj.search(base_text) - if not m: - continue - # Expand $1, $2... in replacement using this match - def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) - repl = _expand_dollars(text_field) - sl, sc = line_col_from_index(m.start()) - el, ec = line_col_from_index(m.end()) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) - # do not mutate base_text when building atomic spans - elif opx in ("prepend","append"): - if opx == "prepend": - sl, sc = 1, 1 - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) - # prepend can be applied atomically without local mutation - else: - # Insert at true EOF position (handles both \n and \r\n correctly) - eof_idx = len(base_text) - sl, sc = line_col_from_index(eof_idx) - new_text = ("\n" if not base_text.endswith("\n") else "") + text_field - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) - # do not mutate base_text when building atomic spans - else: - return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - - import hashlib - sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - if at_edits: - params_text: Dict[str, Any] = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": at_edits, - "precondition_sha256": sha, - "options": {"refresh": (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") - # Optional sentinel reload removed (deprecated) - 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 {}) - # Prefer debounced background refresh unless explicitly overridden - opts2.setdefault("refresh", "debounced") - params_struct: Dict[str, Any] = { - "action": "edit", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": struct_edits, - "options": opts2 - } - resp_struct = send_command_with_retry("manage_script", params_struct) - if isinstance(resp_struct, dict) and resp_struct.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") - - return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") - - # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition - # so header guards and validation run on the C# side. - # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). - text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } - structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} - if not text_ops.issubset(structured_kinds): - # Convert to apply_text_edits payload - try: - base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: - # 1-based line/col against base buffer - line = base_text.count("\n", 0, idx) + 1 - last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 - return line, col - - at_edits: List[Dict[str, Any]] = [] - import re as _re - for e in edits or []: - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - # aliasing for text field - text_field = e.get("text") or e.get("insert") or e.get("content") or "" - if op == "anchor_insert": - anchor = e.get("anchor") or "" - position = (e.get("position") or "after").lower() - # Use improved anchor matching logic with helpful errors, honoring ignore_case - try: - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) - 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") - if not m: - return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") - idx = m.start() if position == "before" else m.end() - # Normalize insertion newlines - if text_field and not text_field.startswith("\n"): - text_field = "\n" + text_field - if text_field and not text_field.endswith("\n"): - text_field = text_field + "\n" - sl, sc = line_col_from_index(idx) - at_edits.append({ - "startLine": sl, - "startCol": sc, - "endLine": sl, - "endCol": sc, - "newText": text_field or "" - }) - # Do not mutate base buffer when building an atomic batch - elif op == "replace_range": - # Directly forward if already in line/col form - if "startLine" in e: - at_edits.append({ - "startLine": int(e.get("startLine", 1)), - "startCol": int(e.get("startCol", 1)), - "endLine": int(e.get("endLine", 1)), - "endCol": int(e.get("endCol", 1)), - "newText": text_field - }) - else: - # If only indices provided, skip (we don't support index-based here) - return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") - elif op == "regex_replace": - pattern = e.get("pattern") or "" - repl = text_field - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - # Early compile for clearer error messages - try: - regex_obj = _re.compile(pattern, flags) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") - # Use smart anchor matching for consistent behavior with anchor_insert - m = _find_best_anchor_match(pattern, base_text, flags, prefer_last=True) - if not m: - continue - # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) - def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) - repl_expanded = _expand_dollars(repl) - # Let C# side handle validation using Unity's built-in compiler services - sl, sc = line_col_from_index(m.start()) - el, ec = line_col_from_index(m.end()) - at_edits.append({ - "startLine": sl, - "startCol": sc, - "endLine": el, - "endCol": ec, - "newText": repl_expanded - }) - # Do not mutate base buffer when building an atomic batch - else: - return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") - - if not at_edits: - return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") - - # Send to Unity with precondition SHA to enforce guards and immediate refresh - import hashlib - sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - params: Dict[str, Any] = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": at_edits, - "precondition_sha256": sha, - "options": { - "refresh": (options or {}).get("refresh", "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"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm( - resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, - normalized_for_echo, - routing="text" - ) - except Exception as e: - return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") - - # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. - # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. - if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): - try: - preview_text = _apply_edits_locally(contents, edits) - import difflib - diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) - if len(diff) > 800: - diff = diff[:800] + ["... (diff truncated) ..."] - if preview: - return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} - return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") - except Exception as e: - return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") - # 2) apply edits locally (only if not text-ops) - try: - new_contents = _apply_edits_locally(contents, edits) - except Exception as e: - return {"success": False, "message": f"Edit application failed: {e}"} - - # Short-circuit no-op edits to avoid false "applied" reports downstream - if new_contents == contents: - return _with_norm({ - "success": True, - "message": "No-op: contents unchanged", - "data": {"no_op": True, "evidence": {"reason": "identical_content"}} - }, normalized_for_echo, routing="text") - - if preview: - # Produce a compact unified diff limited to small context - import difflib - a = contents.splitlines() - b = new_contents.splitlines() - diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3)) - # Limit diff size to keep responses small - if len(diff) > 2000: - diff = diff[:2000] + ["... (diff truncated) ..."] - return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} - - # 3) update to Unity - # Default refresh/validate for natural usage on text path as well - options = dict(options or {}) - options.setdefault("validate", "standard") - options.setdefault("refresh", "debounced") - - import hashlib - # Compute the SHA of the current file contents for the precondition - old_lines = contents.splitlines(keepends=True) - end_line = len(old_lines) + 1 # 1-based exclusive end - sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() - - # Apply a whole-file text edit rather than the deprecated 'update' action - params = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": [ - { - "startLine": 1, - "startCol": 1, - "endLine": end_line, - "endCol": 1, - "newText": new_contents, - } - ], - "precondition_sha256": sha, - "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"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm( - write_resp if isinstance(write_resp, dict) - else {"success": False, "message": str(write_resp)}, - normalized_for_echo, - routing="text", - ) - - - - - # safe_script_edit removed to simplify API; clients should call script_apply_edits directly diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py deleted file mode 100644 index abf1d702..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ /dev/null @@ -1,71 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time -import os -import base64 - -from telemetry_decorator import telemetry_tool - -def register_manage_shader_tools(mcp: FastMCP): - """Register all shader script management tools with the MCP server.""" - - @mcp.tool() - @telemetry_tool("manage_shader") - def manage_shader( - ctx: Any, - action: str, - name: str, - path: str, - contents: str, - ) -> Dict[str, Any]: - """Manages shader scripts in Unity (create, read, update, delete). - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Shader name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: Shader code for 'create'/'update'. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command via centralized retry helper - response = send_command_with_retry("manage_shader", params) - - # Process response from Unity - if isinstance(response, dict) and response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py deleted file mode 100644 index 9b398451..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Defines the read_console tool for accessing Unity Editor console messages. -""" -from typing import List, Dict, Any -import time -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -from telemetry_decorator import telemetry_tool - -def register_read_console_tools(mcp: FastMCP): - """Registers the read_console tool with the MCP server.""" - - @mcp.tool() - @telemetry_tool("read_console") - def read_console( - ctx: Context, - action: str = None, - types: List[str] = None, - count: Any = None, - filter_text: str = None, - since_timestamp: str = None, - format: str = None, - include_stacktrace: bool = None - ) -> Dict[str, Any]: - """Gets messages from or clears the Unity Editor console. - - Args: - ctx: The MCP context. - action: Operation ('get' or 'clear'). - types: Message types to get ('error', 'warning', 'log', 'all'). - count: Max messages to return. - filter_text: Text filter for messages. - since_timestamp: Get messages after this timestamp (ISO 8601). - format: Output format ('plain', 'detailed', 'json'). - include_stacktrace: Include stack traces in output. - - Returns: - Dictionary with results. For 'get', includes 'data' (messages). - """ - - # Get the connection instance - bridge = get_unity_connection() - - # Set defaults if values are None - action = action if action is not None else 'get' - types = types if types is not None else ['error', 'warning', 'log'] - format = format if format is not None else 'detailed' - include_stacktrace = include_stacktrace if include_stacktrace is not None else True - - # Normalize action if it's a string - if isinstance(action, str): - action = action.lower() - - # Coerce count defensively (string/float -> int) - def _coerce_int(value, default=None): - if value is None: - return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: - return default - - count = _coerce_int(count) - - # Prepare parameters for the C# handler - params_dict = { - "action": action, - "types": types, - "count": count, - "filterText": filter_text, - "sinceTimestamp": since_timestamp, - "format": format.lower() if isinstance(format, str) else format, - "includeStacktrace": include_stacktrace - } - - # Remove None values unless it's 'count' (as None might mean 'all') - params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} - - # Add count back if it was None, explicitly sending null might be important for C# logic - if 'count' not in params_dict: - params_dict['count'] = None - - # Use centralized retry helper - resp = send_command_with_retry("read_console", params_dict) - if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: - # Strip stacktrace fields from returned lines if present - try: - lines = resp.get("data", {}).get("lines", []) - for line in lines: - if isinstance(line, dict) and "stacktrace" in line: - line.pop("stacktrace", None) - except Exception: - pass - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py deleted file mode 100644 index 5024fd4a..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ /dev/null @@ -1,408 +0,0 @@ -""" -Resource wrapper tools so clients that do not expose MCP resources primitives -can still list and read files via normal tools. These call into the same -safe path logic (re-implemented here to avoid importing server.py). -""" - -from typing import Dict, Any, List, Optional -import re -from pathlib import Path -from urllib.parse import urlparse, unquote -import fnmatch -import hashlib -import os - -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool -from unity_connection import send_command_with_retry - - -def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]: - """Safely coerce various inputs (str/float/etc.) to an int. - Returns default on failure; clamps to minimum when provided. - """ - if value is None: - return default - try: - # Avoid treating booleans as ints implicitly - if isinstance(value, bool): - return default - if isinstance(value, int): - result = int(value) - else: - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - # Allow "10.0" or similar inputs - result = int(float(s)) - if minimum is not None and result < minimum: - return minimum - return result - except Exception: - return default - -def _resolve_project_root(override: str | None) -> Path: - # 1) Explicit override - if override: - pr = Path(override).expanduser().resolve() - if (pr / "Assets").exists(): - return pr - # 2) Environment - env = os.environ.get("UNITY_PROJECT_ROOT") - if env: - env_path = Path(env).expanduser() - # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir - pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() - if (pr / "Assets").exists(): - return pr - # 3) Ask Unity via manage_editor.get_project_root - try: - resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) - if isinstance(resp, dict) and resp.get("success"): - pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve() - if pr and (pr / "Assets").exists(): - return pr - except Exception: - pass - - # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) - cur = Path.cwd().resolve() - for _ in range(6): - if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): - return cur - if cur.parent == cur: - break - cur = cur.parent - # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings - try: - import os as _os - root = Path.cwd().resolve() - max_depth = 3 - for dirpath, dirnames, _ in _os.walk(root): - rel = Path(dirpath).resolve() - try: - depth = len(rel.relative_to(root).parts) - except Exception: - # Unrelated mount/permission edge; skip deeper traversal - dirnames[:] = [] - continue - if depth > max_depth: - # Prune deeper traversal - dirnames[:] = [] - continue - if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): - return rel - except Exception: - pass - # 6) Fallback: CWD - return Path.cwd().resolve() - - -def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: - raw: str | None = None - if uri.startswith("unity://path/"): - raw = uri[len("unity://path/"):] - elif uri.startswith("file://"): - parsed = urlparse(uri) - raw = unquote(parsed.path or "") - # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. - try: - import os as _os - if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): - raw = raw[1:] - # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share - if _os.name == "nt" and parsed.netloc: - raw = f"//{parsed.netloc}{raw}" - except Exception: - pass - elif uri.startswith("Assets/"): - raw = uri - if raw is None: - return None - # Normalize separators early - raw = raw.replace("\\", "/") - p = (project / raw).resolve() - try: - p.relative_to(project) - except ValueError: - return None - return p - - -def register_resource_tools(mcp: FastMCP) -> None: - """Registers list_resources and read_resource wrapper tools.""" - - @mcp.tool(description=( - "List project URIs (unity://path/...) under a folder (default: Assets).\n\n" - "Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n" - "Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n" - "Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n" - )) - @telemetry_tool("list_resources") - async def list_resources( - ctx: Optional[Context] = None, - pattern: Optional[str] = "*.cs", - under: str = "Assets", - limit: Any = 200, - project_root: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Lists project URIs (unity://path/...) under a folder (default: Assets). - - pattern: glob like *.cs or *.shader (None to list all files) - - under: relative folder under project root - - limit: max results - """ - try: - project = _resolve_project_root(project_root) - base = (project / under).resolve() - try: - base.relative_to(project) - except ValueError: - return {"success": False, "error": "Base path must be under project root"} - # Enforce listing only under Assets - try: - base.relative_to(project / "Assets") - except ValueError: - return {"success": False, "error": "Listing is restricted to Assets/"} - - matches: List[str] = [] - limit_int = _coerce_int(limit, default=200, minimum=1) - for p in base.rglob("*"): - if not p.is_file(): - continue - # Resolve symlinks and ensure the real path stays under project/Assets - try: - rp = p.resolve() - rp.relative_to(project / "Assets") - except Exception: - continue - # Enforce .cs extension regardless of provided pattern - if p.suffix.lower() != ".cs": - continue - if pattern and not fnmatch.fnmatch(p.name, pattern): - continue - rel = p.relative_to(project).as_posix() - matches.append(f"unity://path/{rel}") - if len(matches) >= max(1, limit_int): - break - - # Always include the canonical spec resource so NL clients can discover it - if "unity://spec/script-edits" not in matches: - matches.append("unity://spec/script-edits") - - return {"success": True, "data": {"uris": matches, "count": len(matches)}} - except Exception as e: - return {"success": False, "error": str(e)} - - @mcp.tool(description=( - "Read a resource by unity://path/... URI with optional slicing.\n\n" - "Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n" - "Security: uri must resolve under Assets/.\n" - "Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n" - )) - @telemetry_tool("read_resource") - async def read_resource( - uri: str, - ctx: Optional[Context] = None, - start_line: Any = None, - line_count: Any = None, - head_bytes: Any = None, - tail_lines: Any = None, - project_root: Optional[str] = None, - request: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Reads a resource by unity://path/... URI with optional slicing. - One of line window (start_line/line_count) or head_bytes can be used to limit size. - """ - try: - # Serve the canonical spec directly when requested (allow bare or with scheme) - if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): - spec_json = ( - '{\n' - ' "name": "Unity MCP — Script Edits v1",\n' - ' "target_tool": "script_apply_edits",\n' - ' "canonical_rules": {\n' - ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' - ' "never_use": ["new_method","anchor_method","content","newText"],\n' - ' "defaults": {\n' - ' "className": "\u2190 server will default to \'name\' when omitted",\n' - ' "position": "end"\n' - ' }\n' - ' },\n' - ' "ops": [\n' - ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' - ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' - ' {"op":"delete_method","required":["className","methodName"]},\n' - ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' - ' ],\n' - ' "apply_text_edits_recipe": {\n' - ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' - ' "step2_apply": {\n' - ' "tool": "manage_script",\n' - ' "args": {\n' - ' "action": "apply_text_edits",\n' - ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' - ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' - ' "precondition_sha256": "",\n' - ' "options": {"refresh": "immediate", "validate": "standard"}\n' - ' }\n' - ' },\n' - ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' - ' },\n' - ' "examples": [\n' - ' {\n' - ' "title": "Replace a method",\n' - ' "args": {\n' - ' "name": "SmartReach",\n' - ' "path": "Assets/Scripts/Interaction",\n' - ' "edits": [\n' - ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' - ' ],\n' - ' "options": { "validate": "standard", "refresh": "immediate" }\n' - ' }\n' - ' },\n' - ' {\n' - ' "title": "Insert a method after another",\n' - ' "args": {\n' - ' "name": "SmartReach",\n' - ' "path": "Assets/Scripts/Interaction",\n' - ' "edits": [\n' - ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' - ' ]\n' - ' }\n' - ' }\n' - ' ]\n' - '}\n' - ) - sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() - return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} - - project = _resolve_project_root(project_root) - p = _resolve_safe_path_from_uri(uri, project) - if not p or not p.exists() or not p.is_file(): - return {"success": False, "error": f"Resource not found: {uri}"} - try: - p.relative_to(project / "Assets") - except ValueError: - return {"success": False, "error": "Read restricted to Assets/"} - # Natural-language convenience: request like "last 120 lines", "first 200 lines", - # "show 40 lines around MethodName", etc. - if request: - req = request.strip().lower() - m = re.search(r"last\s+(\d+)\s+lines", req) - if m: - tail_lines = int(m.group(1)) - m = re.search(r"first\s+(\d+)\s+lines", req) - if m: - start_line = 1 - line_count = int(m.group(1)) - m = re.search(r"first\s+(\d+)\s*bytes", req) - if m: - head_bytes = int(m.group(1)) - m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) - if m: - window = int(m.group(1)) - method = m.group(2) - # naive search for method header to get a line number - text_all = p.read_text(encoding="utf-8") - lines_all = text_all.splitlines() - pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) - hit_line = None - for i, line in enumerate(lines_all, start=1): - if pat.search(line): - hit_line = i - break - if hit_line: - half = max(1, window // 2) - start_line = max(1, hit_line - half) - line_count = window - - # Coerce numeric inputs defensively (string/float -> int) - start_line = _coerce_int(start_line) - line_count = _coerce_int(line_count) - head_bytes = _coerce_int(head_bytes, minimum=1) - tail_lines = _coerce_int(tail_lines, minimum=1) - - # Compute SHA over full file contents (metadata-only default) - full_bytes = p.read_bytes() - full_sha = hashlib.sha256(full_bytes).hexdigest() - - # Selection only when explicitly requested via windowing args or request text hints - selection_requested = bool(head_bytes or tail_lines or (start_line is not None and line_count is not None) or request) - if selection_requested: - # Mutually exclusive windowing options precedence: - # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text - if head_bytes and head_bytes > 0: - raw = full_bytes[: head_bytes] - text = raw.decode("utf-8", errors="replace") - else: - text = full_bytes.decode("utf-8", errors="replace") - if tail_lines is not None and tail_lines > 0: - lines = text.splitlines() - n = max(0, tail_lines) - text = "\n".join(lines[-n:]) - elif start_line is not None and line_count is not None and line_count >= 0: - lines = text.splitlines() - s = max(0, start_line - 1) - e = min(len(lines), s + line_count) - text = "\n".join(lines[s:e]) - return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} - else: - # Default: metadata only - return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} - except Exception as e: - return {"success": False, "error": str(e)} - - @mcp.tool() - @telemetry_tool("find_in_file") - async def find_in_file( - uri: str, - pattern: str, - ctx: Optional[Context] = None, - ignore_case: Optional[bool] = True, - project_root: Optional[str] = None, - max_results: Any = 200, - ) -> Dict[str, Any]: - """ - Searches a file with a regex pattern and returns line numbers and excerpts. - - uri: unity://path/Assets/... or file path form supported by read_resource - - pattern: regular expression (Python re) - - ignore_case: case-insensitive by default - - max_results: cap results to avoid huge payloads - """ - # re is already imported at module level - try: - project = _resolve_project_root(project_root) - p = _resolve_safe_path_from_uri(uri, project) - if not p or not p.exists() or not p.is_file(): - return {"success": False, "error": f"Resource not found: {uri}"} - - text = p.read_text(encoding="utf-8") - flags = re.MULTILINE - if ignore_case: - flags |= re.IGNORECASE - rx = re.compile(pattern, flags) - - results = [] - max_results_int = _coerce_int(max_results, default=200, minimum=1) - lines = text.splitlines() - for i, line in enumerate(lines, start=1): - m = rx.search(line) - if m: - start_col = m.start() + 1 # 1-based - end_col = m.end() + 1 # 1-based, end exclusive - results.append({ - "startLine": i, - "startCol": start_col, - "endLine": i, - "endCol": end_col, - }) - if max_results_int and len(results) >= max_results_int: - break - - return {"success": True, "data": {"matches": results, "count": len(results)}} - except Exception as e: - return {"success": False, "error": str(e)} - - diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py deleted file mode 100644 index f41b7a25..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ /dev/null @@ -1,422 +0,0 @@ -import contextlib -import errno -import json -import logging -import random -import socket -import struct -import threading -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict -from config import config -from port_discovery import PortDiscovery - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("mcp-for-unity-server") - -# Module-level lock to guard global connection initialization -_connection_lock = threading.Lock() - -# Maximum allowed framed payload size (64 MiB) -FRAMED_MAX = 64 * 1024 * 1024 - -@dataclass -class UnityConnection: - """Manages the socket connection to the Unity Editor.""" - host: str = config.unity_host - port: int = None # Will be set dynamically - sock: socket.socket = None # Socket for Unity communication - use_framing: bool = False # Negotiated per-connection - - def __post_init__(self): - """Set port from discovery if not explicitly provided""" - if self.port is None: - self.port = PortDiscovery.discover_unity_port() - self._io_lock = threading.Lock() - self._conn_lock = threading.Lock() - - def connect(self) -> bool: - """Establish a connection to the Unity Editor.""" - if self.sock: - return True - with self._conn_lock: - if self.sock: - return True - try: - # Bounded connect to avoid indefinite blocking - connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) - self.sock = socket.create_connection((self.host, self.port), connect_timeout) - # Disable Nagle's algorithm to reduce small RPC latency - with contextlib.suppress(Exception): - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - logger.debug(f"Connected to Unity at {self.host}:{self.port}") - - # Strict handshake: require FRAMING=1 - try: - require_framing = getattr(config, "require_framing", True) - timeout = float(getattr(config, "handshake_timeout", 1.0)) - self.sock.settimeout(timeout) - buf = bytearray() - deadline = time.monotonic() + timeout - while time.monotonic() < deadline and len(buf) < 512: - try: - chunk = self.sock.recv(256) - if not chunk: - break - buf.extend(chunk) - if b"\n" in buf: - break - except socket.timeout: - break - text = bytes(buf).decode('ascii', errors='ignore').strip() - - if 'FRAMING=1' in text: - self.use_framing = True - logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') - else: - if require_framing: - # Best-effort plain-text advisory for legacy peers - with contextlib.suppress(Exception): - self.sock.sendall(b'Unity MCP requires FRAMING=1\n') - raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') - else: - self.use_framing = False - logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') - finally: - self.sock.settimeout(config.connection_timeout) - return True - except Exception as e: - logger.error(f"Failed to connect to Unity: {str(e)}") - try: - if self.sock: - self.sock.close() - except Exception: - pass - self.sock = None - return False - - def disconnect(self): - """Close the connection to the Unity Editor.""" - if self.sock: - try: - self.sock.close() - except Exception as e: - logger.error(f"Error disconnecting from Unity: {str(e)}") - finally: - self.sock = None - - def _read_exact(self, sock: socket.socket, count: int) -> bytes: - data = bytearray() - while len(data) < count: - chunk = sock.recv(count - len(data)) - if not chunk: - raise ConnectionError("Connection closed before reading expected bytes") - data.extend(chunk) - return bytes(data) - - def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: - """Receive a complete response from Unity, handling chunked data.""" - if self.use_framing: - try: - # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive - heartbeat_count = 0 - deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) - while True: - header = self._read_exact(sock, 8) - payload_len = struct.unpack('>Q', header)[0] - if payload_len == 0: - # Heartbeat/no-op frame: consume and continue waiting for a data frame - logger.debug("Received heartbeat frame (length=0)") - heartbeat_count += 1 - if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: - # Treat as empty successful response to match C# server behavior - logger.debug("Heartbeat threshold reached; returning empty response") - return b"" - continue - if payload_len > FRAMED_MAX: - raise ValueError(f"Invalid framed length: {payload_len}") - payload = self._read_exact(sock, payload_len) - logger.debug(f"Received framed response ({len(payload)} bytes)") - return payload - except socket.timeout as e: - logger.warning("Socket timeout during framed receive") - raise TimeoutError("Timeout receiving Unity response") from e - except Exception as e: - logger.error(f"Error during framed receive: {str(e)}") - raise - - chunks = [] - # Respect the socket's currently configured timeout - try: - while True: - chunk = sock.recv(buffer_size) - if not chunk: - if not chunks: - raise Exception("Connection closed before receiving data") - break - chunks.append(chunk) - - # Process the data received so far - data = b''.join(chunks) - decoded_data = data.decode('utf-8') - - # Check if we've received a complete response - try: - # Special case for ping-pong - if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): - logger.debug("Received ping response") - return data - - # Handle escaped quotes in the content - if '"content":' in decoded_data: - # Find the content field and its value - content_start = decoded_data.find('"content":') + 9 - content_end = decoded_data.rfind('"', content_start) - if content_end > content_start: - # Replace escaped quotes in content with regular quotes - content = decoded_data[content_start:content_end] - content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - - # Validate JSON format - json.loads(decoded_data) - - # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") - return data - except json.JSONDecodeError: - # We haven't received a complete valid JSON response yet - continue - except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") - # Continue reading more chunks as this might not be the complete response - continue - except socket.timeout: - logger.warning("Socket timeout during receive") - raise Exception("Timeout receiving Unity response") - except Exception as e: - logger.error(f"Error during receive: {str(e)}") - raise - - def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" - # Defensive guard: catch empty/placeholder invocations early - if not command_type: - raise ValueError("MCP call missing command_type") - if params is None: - # Return a fast, structured error that clients can display without hanging - return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"} - attempts = max(config.max_retries, 5) - base_backoff = max(0.5, config.retry_delay) - - def read_status_file() -> dict | None: - try: - status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) - if not status_files: - return None - latest = status_files[0] - with latest.open('r') as f: - return json.load(f) - except Exception: - return None - - last_short_timeout = None - - # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely - try: - status = read_status_file() - if status and (status.get('reloading') or status.get('reason') == 'reloading'): - return { - "success": False, - "state": "reloading", - "retry_after_ms": int(config.reload_retry_ms), - "error": "Unity domain reload in progress", - "message": "Unity is reloading scripts; please retry shortly" - } - except Exception: - pass - - for attempt in range(attempts + 1): - try: - # Ensure connected (handshake occurs within connect()) - if not self.sock and not self.connect(): - raise Exception("Could not connect to Unity") - - # Build payload - if command_type == 'ping': - payload = b'ping' - else: - command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - - # Send/receive are serialized to protect the shared socket - with self._io_lock: - mode = 'framed' if self.use_framing else 'legacy' - with contextlib.suppress(Exception): - logger.debug( - "send %d bytes; mode=%s; head=%s", - len(payload), - mode, - (payload[:32]).decode('utf-8', 'ignore'), - ) - if self.use_framing: - header = struct.pack('>Q', len(payload)) - self.sock.sendall(header) - self.sock.sendall(payload) - else: - self.sock.sendall(payload) - - # During retry bursts use a short receive timeout and ensure restoration - restore_timeout = None - if attempt > 0 and last_short_timeout is None: - restore_timeout = self.sock.gettimeout() - self.sock.settimeout(1.0) - try: - response_data = self.receive_full_response(self.sock) - with contextlib.suppress(Exception): - logger.debug("recv %d bytes; mode=%s", len(response_data), mode) - finally: - if restore_timeout is not None: - self.sock.settimeout(restore_timeout) - last_short_timeout = None - - # Parse - if command_type == 'ping': - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': - return {"message": "pong"} - raise Exception("Ping unsuccessful") - - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'error': - err = resp.get('error') or resp.get('message', 'Unknown Unity error') - raise Exception(err) - return resp.get('result', {}) - except Exception as e: - logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") - try: - if self.sock: - self.sock.close() - finally: - self.sock = None - - # Re-discover port each time - try: - new_port = PortDiscovery.discover_unity_port() - if new_port != self.port: - logger.info(f"Unity port changed {self.port} -> {new_port}") - self.port = new_port - except Exception as de: - logger.debug(f"Port discovery failed: {de}") - - if attempt < attempts: - # Heartbeat-aware, jittered backoff - status = read_status_file() - # Base exponential backoff - backoff = base_backoff * (2 ** attempt) - # Decorrelated jitter multiplier - jitter = random.uniform(0.1, 0.3) - - # Fast‑retry for transient socket failures - fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) - if not fast_error: - try: - err_no = getattr(e, 'errno', None) - fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) - except Exception: - pass - - # Cap backoff depending on state - if status and status.get('reloading'): - cap = 0.8 - elif fast_error: - cap = 0.25 - else: - cap = 3.0 - - sleep_s = min(cap, jitter * (2 ** attempt)) - time.sleep(sleep_s) - continue - raise - -# Global Unity connection -_unity_connection = None - -def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection. - - Note: Do NOT ping on every retrieval to avoid connection storms. Rely on - send_command() exceptions to detect broken sockets and reconnect there. - """ - global _unity_connection - if _unity_connection is not None: - return _unity_connection - - # Double-checked locking to avoid concurrent socket creation - with _connection_lock: - if _unity_connection is not None: - return _unity_connection - logger.info("Creating new Unity connection") - _unity_connection = UnityConnection() - if not _unity_connection.connect(): - _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - logger.info("Connected to Unity on startup") - return _unity_connection - - -# ----------------------------- -# Centralized retry helpers -# ----------------------------- - -def _is_reloading_response(resp: dict) -> bool: - """Return True if the Unity response indicates the editor is reloading.""" - if not isinstance(resp, dict): - return False - if resp.get("state") == "reloading": - return True - message_text = (resp.get("message") or resp.get("error") or "").lower() - return "reload" in message_text - - -def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: - """Send a command via the shared connection, waiting politely through Unity reloads. - - Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the - structured failure if retries are exhausted. - """ - conn = get_unity_connection() - if max_retries is None: - max_retries = getattr(config, "reload_max_retries", 40) - if retry_ms is None: - retry_ms = getattr(config, "reload_retry_ms", 250) - - response = conn.send_command(command_type, params) - retries = 0 - while _is_reloading_response(response) and retries < max_retries: - delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms - time.sleep(max(0.0, delay_ms / 1000.0)) - retries += 1 - response = conn.send_command(command_type, params) - return response - - -async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: - """Async wrapper that runs the blocking retry helper in a thread pool.""" - try: - import asyncio # local import to avoid mandatory asyncio dependency for sync callers - if loop is None: - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - None, - lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms), - ) - except Exception as e: - # Return a structured error dict for consistency with other responses - return {"success": False, "error": f"Python async retry helper failed: {str(e)}"} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/uv.lock deleted file mode 100644 index f5cac0f5..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ /dev/null @@ -1,400 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.10" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, -] - -[[package]] -name = "mcp" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, -] - -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - -[[package]] -name = "mcpforunityserver" -version = "3.1.0" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "pydantic" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, -] - -[[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, -] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, -] - -[[package]] -name = "rich" -version = "13.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - -[[package]] -name = "starlette" -version = "0.46.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, -] - -[[package]] -name = "typer" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, -] diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json deleted file mode 100644 index c36089ab..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "com.coplaydev.unity-mcp", - "version": "3.4.0", - "displayName": "MCP for Unity", - "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nRequires Python 3.10+ and UV package manager to be installed separately.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", - "unity": "2021.3", - "documentationUrl": "https://github.com/CoplayDev/unity-mcp", - "licensesUrl": "https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE", - "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.0.2" - }, - "keywords": [ - "unity", - "ai", - "llm", - "mcp", - "model-context-protocol", - "mcp-server", - "mcp-client", - "setup-wizard", - "dependency-management" - ], - "author": { - "name": "Coplay", - "email": "support@coplay.dev", - "url": "https://coplay.dev" - } -} diff --git a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json.meta b/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json.meta deleted file mode 100644 index 5a96937c..00000000 --- a/ava-worktrees/feature/ava-asset-store-compliance/UnityMcpBridge/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a2f7ae0675bf4fb478a0a1df7a3f6c64 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From b6fb5a71741c0b3ff7e7ede8289d442d6ee325a6 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 21:18:34 -0400 Subject: [PATCH 06/30] chore: upgrade mcp[cli] dependency from 1.4.1 to 1.15.0 --- UnityMcpBridge/UnityMcpServer~/src/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml index ec121606..236726ed 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml +++ b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml @@ -4,7 +4,7 @@ version = "4.0.0" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.10" -dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] +dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.15.0"] [build-system] requires = ["setuptools>=64.0.0", "wheel"] From 5b8d1e3e9923a30314780d79895d35ba9aaa2b5a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 21:43:14 -0400 Subject: [PATCH 07/30] style: fix formatting and whitespace in Python server files --- UnityMcpBridge/UnityMcpServer~/src/Dockerfile | 2 +- .../UnityMcpServer~/src/__init__.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/config.py | 23 +- .../UnityMcpServer~/src/port_discovery.py | 39 +-- .../UnityMcpServer~/src/reload_sentinel.py | 1 + UnityMcpBridge/UnityMcpServer~/src/server.py | 7 +- .../UnityMcpServer~/src/telemetry.py | 157 ++++++----- .../src/telemetry_decorator.py | 18 +- .../UnityMcpServer~/src/test_telemetry.py | 65 ++--- .../UnityMcpServer~/src/tools/__init__.py | 2 + .../UnityMcpServer~/src/tools/manage_asset.py | 13 +- .../src/tools/manage_editor.py | 16 +- .../src/tools/manage_gameobject.py | 29 ++- .../src/tools/manage_prefabs.py | 3 +- .../UnityMcpServer~/src/tools/manage_scene.py | 10 +- .../src/tools/manage_script.py | 93 ++++--- .../src/tools/manage_script_edits.py | 243 +++++++++++------- .../src/tools/manage_shader.py | 24 +- .../UnityMcpServer~/src/tools/read_console.py | 18 +- .../src/tools/resource_tools.py | 10 +- .../UnityMcpServer~/src/unity_connection.py | 102 +++++--- 21 files changed, 530 insertions(+), 347 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/Dockerfile b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile index 3f884f37..5fcbc4eb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/Dockerfile +++ b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile @@ -24,4 +24,4 @@ RUN uv pip install --system -e . # Command to run the server -CMD ["uv", "run", "server.py"] \ No newline at end of file +CMD ["uv", "run", "server.py"] diff --git a/UnityMcpBridge/UnityMcpServer~/src/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/__init__.py index bf3404d1..ad59ec7c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/__init__.py @@ -1,3 +1,3 @@ """ MCP for Unity Server package. -""" \ No newline at end of file +""" diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index 4c7d8049..526522da 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -5,26 +5,30 @@ from dataclasses import dataclass + @dataclass class ServerConfig: """Main configuration class for the MCP server.""" - + # Network settings unity_host: str = "localhost" unity_port: int = 6400 mcp_port: int = 6500 - + # Connection settings - connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts + # short initial timeout; retries use shorter timeouts + connection_timeout: float = 1.0 buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Framed receive behavior - framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only - max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up - + # max seconds to wait while consuming heartbeats only + framed_receive_timeout: float = 2.0 + # cap heartbeat frames consumed before giving up + max_heartbeat_frames: int = 16 + # Logging settings log_level: str = "INFO" log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - + # Server settings max_retries: int = 10 retry_delay: float = 0.25 @@ -33,11 +37,12 @@ class ServerConfig: # Number of polite retries when Unity reports reloading # 40 × 250ms ≈ 10s default window reload_max_retries: int = 40 - + # Telemetry settings telemetry_enabled: bool = True # Align with telemetry.py default Cloud Run endpoint telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" + # Create a global config instance -config = ServerConfig() \ No newline at end of file +config = ServerConfig() diff --git a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py index 6f154159..b936f967 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py +++ b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py @@ -11,31 +11,31 @@ (quick socket connect + ping) before choosing it. """ +import glob import json -import os import logging from pathlib import Path -from typing import Optional, List -import glob import socket +from typing import Optional, List logger = logging.getLogger("mcp-for-unity-server") + class PortDiscovery: """Handles port discovery from Unity Bridge registry""" REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file DEFAULT_PORT = 6400 CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery - + @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - + @staticmethod def get_registry_dir() -> Path: return Path.home() / ".unity-mcp" - + @staticmethod def list_candidate_files() -> List[Path]: """Return candidate registry files, newest first. @@ -52,7 +52,7 @@ def list_candidate_files() -> List[Path]: # Put legacy at the end so hashed, per-project files win hashed.append(legacy) return hashed - + @staticmethod def _try_probe_unity_mcp(port: int) -> bool: """Quickly check if a MCP for Unity listener is on this port. @@ -78,7 +78,8 @@ def _read_latest_status() -> Optional[dict]: try: base = PortDiscovery.get_registry_dir() status_files = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + (Path(p) + for p in glob.glob(str(base / "unity-mcp-status-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) @@ -88,14 +89,14 @@ def _read_latest_status() -> Optional[dict]: return json.load(f) except Exception: return None - + @staticmethod def discover_unity_port() -> int: """ Discover Unity port by scanning per-project and legacy registry files. Prefer the newest file whose port responds; fall back to first parsed value; finally default to 6400. - + Returns: Port number to connect to """ @@ -120,26 +121,29 @@ def discover_unity_port() -> int: if first_seen_port is None: first_seen_port = unity_port if PortDiscovery._try_probe_unity_mcp(unity_port): - logger.info(f"Using Unity port from {path.name}: {unity_port}") + logger.info( + f"Using Unity port from {path.name}: {unity_port}") return unity_port except Exception as e: logger.warning(f"Could not read port registry {path}: {e}") if first_seen_port is not None: - logger.info(f"No responsive port found; using first seen value {first_seen_port}") + logger.info( + f"No responsive port found; using first seen value {first_seen_port}") return first_seen_port # Fallback to default port - logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + logger.info( + f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") return PortDiscovery.DEFAULT_PORT - + @staticmethod def get_port_config() -> Optional[dict]: """ Get the most relevant port configuration from registry. Returns the most recent hashed file's config if present, otherwise the legacy file's config. Returns None if nothing exists. - + Returns: Port configuration dict or None if not found """ @@ -151,5 +155,6 @@ def get_port_config() -> Optional[dict]: with open(path, 'r') as f: return json.load(f) except Exception as e: - logger.warning(f"Could not read port configuration {path}: {e}") - return None \ No newline at end of file + logger.warning( + f"Could not read port configuration {path}: {e}") + return None diff --git a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py index e224844b..71e5f623 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py +++ b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py @@ -4,5 +4,6 @@ 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/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index db64e12f..f7c11f69 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,10 +1,9 @@ -from mcp.server.fastmcp import FastMCP, Context, Image +from mcp.server.fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler import os -from dataclasses import dataclass from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List +from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection @@ -150,7 +149,7 @@ def _emit_startup(): # Initialize MCP server mcp = FastMCP( - "mcp-for-unity-server", + name="mcp-for-unity-server", description="Unity Editor integration via Model Context Protocol", lifespan=server_lifespan ) diff --git a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py index f95a9b3e..7efc469f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py +++ b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py @@ -1,29 +1,28 @@ """ Privacy-focused, anonymous telemetry system for Unity MCP Inspired by Onyx's telemetry implementation with Unity-specific adaptations -""" -import uuid -import threading -""" Fire-and-forget telemetry sender with a single background worker. - No context/thread-local propagation to avoid re-entrancy into tool resolution. - Small network timeouts to prevent stalls. """ + +import contextlib +from dataclasses import dataclass +from enum import Enum +import importlib import json -import time -import os -import sys -import platform import logging -from enum import Enum -from urllib.parse import urlparse -from dataclasses import dataclass, asdict -from typing import Optional, Dict, Any, List +import os from pathlib import Path -import importlib +import platform import queue -import contextlib +import sys +import threading +import time +from typing import Optional, Dict, Any +from urllib.parse import urlparse +import uuid try: import httpx @@ -34,10 +33,11 @@ logger = logging.getLogger("unity-mcp-telemetry") + class RecordType(str, Enum): """Types of telemetry records we collect""" VERSION = "version" - STARTUP = "startup" + STARTUP = "startup" USAGE = "usage" LATENCY = "latency" FAILURE = "failure" @@ -45,6 +45,7 @@ class RecordType(str, Enum): UNITY_CONNECTION = "unity_connection" CLIENT_CONNECTION = "client_connection" + class MilestoneType(str, Enum): """Major user journey milestones""" FIRST_STARTUP = "first_startup" @@ -55,6 +56,7 @@ class MilestoneType(str, Enum): DAILY_ACTIVE_USER = "daily_active_user" WEEKLY_ACTIVE_USER = "weekly_active_user" + @dataclass class TelemetryRecord: """Structure for telemetry data""" @@ -65,8 +67,10 @@ class TelemetryRecord: data: Dict[str, Any] milestone: Optional[MilestoneType] = None + class TelemetryConfig: """Telemetry configuration""" + def __init__(self): # Prefer config file, then allow env overrides server_config = None @@ -85,11 +89,13 @@ def __init__(self): continue # Determine enabled flag: config -> env DISABLE_* opt-out - cfg_enabled = True if server_config is None else bool(getattr(server_config, "telemetry_enabled", True)) + cfg_enabled = True if server_config is None else bool( + getattr(server_config, "telemetry_enabled", True)) self.enabled = cfg_enabled and not self._is_disabled() - + # Telemetry endpoint (Cloud Run default; override via env) - cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None) + cfg_default = None if server_config is None else getattr( + server_config, "telemetry_endpoint", None) default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" self.default_endpoint = default_ep self.endpoint = self._validated_endpoint( @@ -105,50 +111,53 @@ def __init__(self): ) except Exception: pass - + # Local storage for UUID and milestones self.data_dir = self._get_data_directory() self.uuid_file = self.data_dir / "customer_uuid.txt" self.milestones_file = self.data_dir / "milestones.json" - + # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT try: - self.timeout = float(os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) + self.timeout = float(os.environ.get( + "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) except Exception: self.timeout = 1.5 try: logger.info("Telemetry timeout=%.2fs", self.timeout) except Exception: pass - + # Session tracking self.session_id = str(uuid.uuid4()) - + def _is_disabled(self) -> bool: """Check if telemetry is disabled via environment variables""" disable_vars = [ "DISABLE_TELEMETRY", - "UNITY_MCP_DISABLE_TELEMETRY", + "UNITY_MCP_DISABLE_TELEMETRY", "MCP_DISABLE_TELEMETRY" ] - + for var in disable_vars: if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): return True return False - + def _get_data_directory(self) -> Path: """Get directory for storing telemetry data""" if os.name == 'nt': # Windows - base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) + base_dir = Path(os.environ.get( + 'APPDATA', Path.home() / 'AppData' / 'Roaming')) elif os.name == 'posix': # macOS/Linux if 'darwin' in os.uname().sysname.lower(): # macOS base_dir = Path.home() / 'Library' / 'Application Support' else: # Linux - base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share')) + base_dir = Path(os.environ.get('XDG_DATA_HOME', + Path.home() / '.local' / 'share')) else: base_dir = Path.home() / '.unity-mcp' - + data_dir = base_dir / 'UnityMCP' data_dir.mkdir(parents=True, exist_ok=True) return data_dir @@ -167,7 +176,8 @@ def _validated_endpoint(self, candidate: str, fallback: str) -> str: # Reject localhost/loopback endpoints in production to avoid accidental local overrides host = parsed.hostname or "" if host in ("localhost", "127.0.0.1", "::1"): - raise ValueError("Localhost endpoints are not allowed for telemetry") + raise ValueError( + "Localhost endpoints are not allowed for telemetry") return candidate except Exception as e: logger.debug( @@ -176,9 +186,10 @@ def _validated_endpoint(self, candidate: str, fallback: str) -> str: ) return fallback + class TelemetryCollector: """Main telemetry collection class""" - + def __init__(self): self.config = TelemetryConfig() self._customer_uuid: Optional[str] = None @@ -188,23 +199,27 @@ def __init__(self): self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) # Load persistent data before starting worker so first events have UUID self._load_persistent_data() - self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True) + self._worker: threading.Thread = threading.Thread( + target=self._worker_loop, daemon=True) self._worker.start() - + def _load_persistent_data(self): """Load UUID and milestones from disk""" # Load customer UUID try: if self.config.uuid_file.exists(): - self._customer_uuid = self.config.uuid_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4()) + self._customer_uuid = self.config.uuid_file.read_text( + encoding="utf-8").strip() or str(uuid.uuid4()) else: self._customer_uuid = str(uuid.uuid4()) try: - self.config.uuid_file.write_text(self._customer_uuid, encoding="utf-8") + self.config.uuid_file.write_text( + self._customer_uuid, encoding="utf-8") if os.name == "posix": os.chmod(self.config.uuid_file, 0o600) except OSError as e: - logger.debug(f"Failed to persist customer UUID: {e}", exc_info=True) + logger.debug( + f"Failed to persist customer UUID: {e}", exc_info=True) except OSError as e: logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) self._customer_uuid = str(uuid.uuid4()) @@ -212,14 +227,15 @@ def _load_persistent_data(self): # Load milestones (failure here must not affect UUID) try: if self.config.milestones_file.exists(): - content = self.config.milestones_file.read_text(encoding="utf-8") + content = self.config.milestones_file.read_text( + encoding="utf-8") self._milestones = json.loads(content) or {} if not isinstance(self._milestones, dict): self._milestones = {} except (OSError, json.JSONDecodeError, ValueError) as e: logger.debug(f"Failed to load milestones: {e}", exc_info=True) self._milestones = {} - + def _save_milestones(self): """Save milestones to disk. Caller must hold self._lock.""" try: @@ -229,7 +245,7 @@ def _save_milestones(self): ) except OSError as e: logger.warning(f"Failed to save milestones: {e}", exc_info=True) - + def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: """Record a milestone event, returns True if this is the first occurrence""" if not self.config.enabled: @@ -244,26 +260,26 @@ def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, An } self._milestones[milestone_key] = milestone_data self._save_milestones() - + # Also send as telemetry record self.record( record_type=RecordType.USAGE, data={"milestone": milestone_key, **(data or {})}, milestone=milestone ) - + return True - - def record(self, - record_type: RecordType, - data: Dict[str, Any], + + def record(self, + record_type: RecordType, + data: Dict[str, Any], milestone: Optional[MilestoneType] = None): """Record a telemetry event (async, non-blocking)""" if not self.config.enabled: return - + # Allow fallback sender when httpx is unavailable (no early return) - + record = TelemetryRecord( record_type=record_type, timestamp=time.time(), @@ -276,7 +292,8 @@ def record(self, try: self._queue.put_nowait(record) except queue.Full: - logger.debug("Telemetry queue full; dropping %s", record.record_type) + logger.debug("Telemetry queue full; dropping %s", + record.record_type) def _worker_loop(self): """Background worker that serializes telemetry sends.""" @@ -290,7 +307,7 @@ def _worker_loop(self): finally: with contextlib.suppress(Exception): self._queue.task_done() - + def _send_telemetry(self, record: TelemetryRecord): """Send telemetry data to endpoint""" try: @@ -323,17 +340,20 @@ def _send_telemetry(self, record: TelemetryRecord): if httpx: with httpx.Client(timeout=self.config.timeout) as client: # Re-validate endpoint at send time to handle dynamic changes - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) response = client.post(endpoint, json=payload) if 200 <= response.status_code < 300: logger.debug(f"Telemetry sent: {record.record_type}") else: - logger.warning(f"Telemetry failed: HTTP {response.status_code}") + logger.warning( + f"Telemetry failed: HTTP {response.status_code}") else: import urllib.request import urllib.error data_bytes = json.dumps(payload).encode("utf-8") - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) req = urllib.request.Request( endpoint, data=data_bytes, @@ -343,9 +363,11 @@ def _send_telemetry(self, record: TelemetryRecord): try: with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: if 200 <= resp.getcode() < 300: - logger.debug(f"Telemetry sent (urllib): {record.record_type}") + logger.debug( + f"Telemetry sent (urllib): {record.record_type}") else: - logger.warning(f"Telemetry failed (urllib): HTTP {resp.getcode()}") + logger.warning( + f"Telemetry failed (urllib): HTTP {resp.getcode()}") except urllib.error.URLError as ue: logger.warning(f"Telemetry send failed (urllib): {ue}") @@ -357,6 +379,7 @@ def _send_telemetry(self, record: TelemetryRecord): # Global telemetry instance _telemetry_collector: Optional[TelemetryCollector] = None + def get_telemetry() -> TelemetryCollector: """Get the global telemetry collector instance""" global _telemetry_collector @@ -364,16 +387,19 @@ def get_telemetry() -> TelemetryCollector: _telemetry_collector = TelemetryCollector() return _telemetry_collector -def record_telemetry(record_type: RecordType, - data: Dict[str, Any], - milestone: Optional[MilestoneType] = None): + +def record_telemetry(record_type: RecordType, + data: Dict[str, Any], + milestone: Optional[MilestoneType] = None): """Convenience function to record telemetry""" get_telemetry().record(record_type, data, milestone) + def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: """Convenience function to record a milestone""" return get_telemetry().record_milestone(milestone, data) + def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None): """Record tool usage telemetry @@ -396,36 +422,39 @@ def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: except Exception: # Ensure telemetry is never disruptive data["sub_action"] = "unknown" - + if error: data["error"] = str(error)[:200] # Limit error message length - + record_telemetry(RecordType.TOOL_EXECUTION, data) + def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): """Record latency telemetry""" data = { "operation": operation, "duration_ms": round(duration_ms, 2) } - + if metadata: data.update(metadata) - + record_telemetry(RecordType.LATENCY, data) + def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): """Record failure telemetry""" data = { "component": component, "error": str(error)[:500] # Limit error message length } - + if metadata: data.update(metadata) - + record_telemetry(RecordType.FAILURE, data) + def is_telemetry_enabled() -> bool: """Check if telemetry is enabled""" - return get_telemetry().config.enabled \ No newline at end of file + return get_telemetry().config.enabled diff --git a/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py b/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py index de94fb26..7e892809 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py +++ b/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py @@ -3,15 +3,17 @@ """ import functools -import time import inspect import logging +import time from typing import Callable, Any + from telemetry import record_tool_usage, record_milestone, MilestoneType _log = logging.getLogger("unity-mcp-telemetry") _decorator_log_count = 0 + def telemetry_tool(tool_name: str): """Decorator to add telemetry tracking to MCP tools""" def decorator(func: Callable) -> Callable: @@ -41,7 +43,8 @@ def _sync_wrapper(*args, **kwargs) -> Any: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) @@ -52,7 +55,8 @@ def _sync_wrapper(*args, **kwargs) -> Any: finally: duration_ms = (time.time() - start_time) * 1000 try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) @@ -82,7 +86,8 @@ async def _async_wrapper(*args, **kwargs) -> Any: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) @@ -93,9 +98,10 @@ async def _async_wrapper(*args, **kwargs) -> Any: finally: duration_ms = (time.time() - start_time) * 1000 try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper - return decorator \ No newline at end of file + return decorator diff --git a/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py b/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py index c9e3013a..145f14e1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py +++ b/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py @@ -5,30 +5,30 @@ """ import os -import time -import sys from pathlib import Path +import sys # Add src to Python path for imports sys.path.insert(0, str(Path(__file__).parent)) + def test_telemetry_basic(): """Test basic telemetry functionality""" # Avoid stdout noise in tests - + try: from telemetry import ( - get_telemetry, record_telemetry, record_milestone, + get_telemetry, record_telemetry, record_milestone, RecordType, MilestoneType, is_telemetry_enabled ) pass except ImportError as e: # Silent failure path for tests return False - + # Test telemetry enabled status _ = is_telemetry_enabled() - + # Test basic record try: record_telemetry(RecordType.VERSION, { @@ -39,7 +39,7 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + # Test milestone recording try: is_first = record_milestone(MilestoneType.FIRST_STARTUP, { @@ -49,7 +49,7 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + # Test telemetry collector try: collector = get_telemetry() @@ -57,79 +57,83 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + return True + def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests - + # Set environment variable to disable telemetry os.environ["DISABLE_TELEMETRY"] = "true" - + # Re-import to get fresh config import importlib import telemetry importlib.reload(telemetry) - + from telemetry import is_telemetry_enabled, record_telemetry, RecordType - + _ = is_telemetry_enabled() - + if not is_telemetry_enabled(): pass - + # Test that records are ignored when disabled record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) pass - + return True else: pass return False + def test_data_storage(): """Test data storage functionality""" # Silent for tests - + try: from telemetry import get_telemetry - + collector = get_telemetry() data_dir = collector.config.data_dir - - _ = (data_dir, collector.config.uuid_file, collector.config.milestones_file) - + + _ = (data_dir, collector.config.uuid_file, + collector.config.milestones_file) + # Check if files exist if collector.config.uuid_file.exists(): pass else: pass - + if collector.config.milestones_file.exists(): pass else: pass - + return True - + except Exception as e: # Silent failure path for tests return False + def main(): """Run all telemetry tests""" # Silent runner for CI - + tests = [ test_telemetry_basic, test_data_storage, test_telemetry_disabled, ] - + passed = 0 failed = 0 - + for test in tests: try: if test(): @@ -141,9 +145,9 @@ def main(): except Exception as e: failed += 1 pass - + _ = (passed, failed) - + if failed == 0: pass return True @@ -151,6 +155,7 @@ def main(): pass return False + if __name__ == "__main__": success = main() - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 6845bcf7..6eeaccc2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,4 +1,5 @@ import logging + from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools @@ -13,6 +14,7 @@ logger = logging.getLogger("mcp-for-unity-server") + def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" # Prefer the surgical edits tool so LLMs discover it first diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 4aeb56b4..a604d65b 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -1,12 +1,15 @@ """ Defines the manage_asset tool for interacting with Unity assets. """ -import asyncio # Added: Import asyncio for running sync code in async +import asyncio from typing import Dict, Any + from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper + +from unity_connection import get_unity_connection, async_send_command_with_retry from telemetry_decorator import telemetry_tool + def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" @@ -48,7 +51,7 @@ async def manage_asset( # Ensure properties is a dict if None if properties is None: properties = {} - + # Coerce numeric inputs defensively def _coerce_int(value, default=None): if value is None: @@ -82,7 +85,7 @@ def _coerce_int(value, default=None): "pageSize": page_size, "pageNumber": page_number } - + # Remove None values to avoid sending unnecessary nulls params_dict = {k: v for k, v in params_dict.items() if v is not None} @@ -90,7 +93,7 @@ def _coerce_int(value, default=None): loop = asyncio.get_running_loop() # Get the Unity connection instance connection = get_unity_connection() - + # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index d8e9365e..004c2bd9 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,10 +1,12 @@ -from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import send_command_with_retry +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool from telemetry import is_telemetry_enabled, record_tool_usage +from unity_connection import send_command_with_retry + + def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" @@ -26,7 +28,7 @@ def manage_editor( action: str, wait_for_completion: bool = None, # --- Parameters for specific actions --- - tool_name: str = None, + tool_name: str = None, tag_name: str = None, layer_name: str = None, ) -> Dict[str, Any]: @@ -42,16 +44,16 @@ def manage_editor( params = { "action": action, "waitForCompletion": wait_for_completion, - "toolName": tool_name, # Corrected parameter name to match C# + "toolName": tool_name, # Corrected parameter name to match C# "tagName": tag_name, # Pass tag name - "layerName": layer_name, # Pass layer name + "layerName": layer_name, # Pass layer name # Add other parameters based on the action being performed # "width": width, # "height": height, # etc. } params = {k: v for k, v in params.items() if v is not None} - + # Send command using centralized retry helper response = send_command_with_retry("manage_editor", params) @@ -61,4 +63,4 @@ def manage_editor( return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing editor: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 3cc639e3..930cb9d8 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,9 +1,11 @@ -from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List -from unity_connection import send_command_with_retry +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_gameobject_tools(mcp: FastMCP): """Register all GameObject management tools with the MCP server.""" @@ -15,9 +17,12 @@ def manage_gameobject( target: str = None, # GameObject identifier by name or path search_method: str = None, # --- Combined Parameters for Create/Modify --- - name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) - tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) - parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) + # Used for both 'create' (new object name) and 'modify' (rename) + name: str = None, + # Used for both 'create' (initial tag) and 'modify' (change tag) + tag: str = None, + # Used for both 'create' (initial parent) and 'modify' (change parent) + parent: str = None, position: List[float] = None, rotation: List[float] = None, scale: List[float] = None, @@ -38,7 +43,8 @@ def manage_gameobject( search_inactive: bool = False, # -- Component Management Arguments -- component_name: str = None, - includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields + # Controls serialization of private [SerializeField] fields + includeNonPublicSerialized: bool = None, ) -> Dict[str, Any]: """Manages GameObjects: create, modify, delete, find, and component operations. @@ -108,9 +114,10 @@ def manage_gameobject( "includeNonPublicSerialized": includeNonPublicSerialized } params = {k: v for k, v in params.items() if v is not None} - + # --- Handle Prefab Path Logic --- - if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params + # Check if 'saveAsPrefab' is explicitly True in params + if action == "create" and params.get("saveAsPrefab"): if "prefabPath" not in params: if "name" not in params or not params["name"]: return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} @@ -122,9 +129,9 @@ def manage_gameobject( return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided # The C# side only needs the final prefabPath - params.pop("prefabFolder", None) + params.pop("prefabFolder", None) # -------------------------------- - + # Use centralized retry helper response = send_command_with_retry("manage_gameobject", params) @@ -135,4 +142,4 @@ def manage_gameobject( return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index c9321b98..da7b913c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -1,7 +1,8 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool + from unity_connection import send_command_with_retry diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 346cab1c..fea92a7f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,9 +1,11 @@ -from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import send_command_with_retry +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_scene_tools(mcp: FastMCP): """Register all scene management tools with the MCP server.""" @@ -54,7 +56,7 @@ def _coerce_int(value, default=None): params["path"] = path if coerced_build_index is not None: params["buildIndex"] = coerced_build_index - + # Use centralized retry helper response = send_command_with_retry("manage_scene", params) @@ -64,4 +66,4 @@ def _coerce_int(value, default=None): return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing scene: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 0c27eb08..bd568b5f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,21 +1,24 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import send_command_with_retry import base64 import os +from typing import Dict, Any, List from urllib.parse import urlparse, unquote +from mcp.server.fastmcp import FastMCP, Context + +from unity_connection import send_command_with_retry + try: from telemetry_decorator import telemetry_tool - from telemetry import record_milestone, MilestoneType HAS_TELEMETRY = True except ImportError: HAS_TELEMETRY = False + def telemetry_tool(tool_name: str): def decorator(func): return func return decorator + def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" @@ -32,7 +35,7 @@ def _split_uri(uri: str) -> tuple[str, str]: """ raw_path: str if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/") :] + raw_path = uri[len("unity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) host = (parsed.netloc or "").strip() @@ -56,7 +59,8 @@ def _split_uri(uri: str) -> tuple[str, str]: # If an 'Assets' segment exists, compute path relative to it (case-insensitive) parts = [p for p in norm.split("/") if p not in ("", ".")] - idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm @@ -127,7 +131,8 @@ def _needs_normalization(arr: List[Dict[str, Any]]) -> bool: contents = data.get("contents") if not contents and data.get("contentsEncoded"): try: - contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") except Exception: contents = contents or "" @@ -151,7 +156,7 @@ def line_col_from_index(idx: int) -> tuple[int, int]: if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: # Guard: explicit fields must be 1-based. zero_based = False - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True @@ -161,13 +166,14 @@ def line_col_from_index(idx: int) -> tuple[int, int]: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} # Normalize by clamping to 1 and warn - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass - warnings.append("zero_based_explicit_fields_normalized") + warnings.append( + "zero_based_explicit_fields_normalized") normalized_edits.append(e2) continue @@ -205,17 +211,18 @@ def line_col_from_index(idx: int) -> tuple[int, int]: "success": False, "code": "missing_field", "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", - "data": {"expected": ["startLine","startCol","endLine","endCol","newText"], "got": e} + "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} } else: # Even when edits appear already in explicit form, validate 1-based coordinates. normalized_edits = [] for e in edits or []: e2 = dict(e) - has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol")) + has_all = all(k in e2 for k in ( + "startLine", "startCol", "endLine", "endCol")) if has_all: zero_based = False - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True @@ -224,21 +231,24 @@ def line_col_from_index(idx: int) -> tuple[int, int]: if zero_based: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass if "zero_based_explicit_fields_normalized" not in warnings: - warnings.append("zero_based_explicit_fields_normalized") + warnings.append( + "zero_based_explicit_fields_normalized") normalized_edits.append(e2) # Preflight: detect overlapping ranges among normalized line/col spans def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]: return ( - int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)), - int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)), + int(e.get("startLine", 1)) if key_start else int( + e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int( + e.get("endCol", 1)), ) def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: @@ -320,10 +330,16 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: 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 + import threading + import time + import json + import glob + import 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) + 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: @@ -369,7 +385,8 @@ def create_script( name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input - norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/") + norm_path = os.path.normpath( + (path or "").replace("\\", "/")).replace("\\", "/") if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} if ".." in norm_path.split("/") or norm_path.startswith("/"): @@ -386,7 +403,8 @@ def create_script( "scriptType": script_type, } if contents: - params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") + params["encodedContents"] = base64.b64encode( + contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) @@ -433,8 +451,10 @@ def validate_script( resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] - warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning") - errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal")) + warnings = sum(1 for d in diags if str( + d.get("severity", "")).lower() == "warning") + errors = sum(1 for d in diags if str( + d.get("severity", "")).lower() in ("error", "fatal")) if include_diagnostics: return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} return {"success": True, "data": {"warnings": warnings, "errors": errors}} @@ -488,7 +508,8 @@ def manage_script( data = read_resp.get("data", {}) current = data.get("contents") if not current and data.get("contentsEncoded"): - current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + current = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") if current is None: return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} @@ -517,14 +538,17 @@ def manage_script( # Preflight size vs. default cap (256 KiB) to avoid opaque server errors try: import json as _json - payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8")) + payload_bytes = len(_json.dumps( + {"edits": edits}, ensure_ascii=False).encode("utf-8")) if payload_bytes > 256 * 1024: return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."} except Exception: pass - routed = send_command_with_retry("manage_script", route_params) + routed = send_command_with_retry( + "manage_script", route_params) if isinstance(routed, dict): - routed.setdefault("message", "Routed legacy update to apply_text_edits") + routed.setdefault( + "message", "Routed legacy update to apply_text_edits") return routed return {"success": False, "message": str(routed)} except Exception as e: @@ -542,7 +566,8 @@ def manage_script( # Base64 encode the contents if they exist to avoid JSON escaping issues if contents: if action == 'create': - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents @@ -554,7 +579,8 @@ def manage_script( if isinstance(response, dict): if response.get("success"): if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] @@ -583,10 +609,10 @@ def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: try: # Keep in sync with server/Editor ManageScript implementation ops = [ - "replace_class","delete_class","replace_method","delete_method", - "insert_method","anchor_insert","anchor_delete","anchor_replace" + "replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" ] - text_ops = ["replace_range","regex_replace","prepend","append"] + text_ops = ["replace_range", "regex_replace", "prepend", "append"] # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback max_edit_payload_bytes = 256 * 1024 guards = {"using_guard": True} @@ -615,7 +641,8 @@ def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) - minimal = {"sha256": data.get("sha256"), "lengthBytes": data.get("lengthBytes")} + minimal = {"sha256": data.get( + "sha256"), "lengthBytes": data.get("lengthBytes")} return {"success": True, "data": minimal} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 88e941fd..ed7c42f6 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -1,11 +1,12 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List, Tuple, Optional import base64 import re -from unity_connection import send_command_with_retry +from typing import Dict, Any, List, Tuple, Optional +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: text = original_text @@ -28,7 +29,8 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str if op == "prepend": prepend_text = edit.get("text", "") - text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text + text = (prepend_text if prepend_text.endswith( + "\n") else prepend_text + "\n") + text elif op == "append": append_text = edit.get("text", "") if not text.endswith("\n"): @@ -40,10 +42,12 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str anchor = edit.get("anchor", "") position = (edit.get("position") or "before").lower() insert_text = edit.get("text", "") - flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0) - + flags = re.MULTILINE | ( + re.IGNORECASE if edit.get("ignore_case") else 0) + # Find the best match using improved heuristics - match = _find_best_anchor_match(anchor, text, flags, bool(edit.get("prefer_last", True))) + match = _find_best_anchor_match( + anchor, text, flags, bool(edit.get("prefer_last", True))) if not match: if edit.get("allow_noop", True): continue @@ -52,15 +56,16 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str text = text[:idx] + insert_text + text[idx:] elif op == "replace_range": start_line = int(edit.get("startLine", 1)) - start_col = int(edit.get("startCol", 1)) - end_line = int(edit.get("endLine", start_line)) - end_col = int(edit.get("endCol", 1)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) replacement = edit.get("text", "") lines = text.splitlines(keepends=True) max_line = len(lines) + 1 # 1-based, exclusive end if (start_line < 1 or end_line < start_line or end_line > max_line or start_col < 1 or end_col < 1): raise RuntimeError("replace_range out of bounds") + def index_of(line: int, col: int) -> int: if line <= len(lines): return sum(len(l) for l in lines[: line - 1]) + (col - 1) @@ -80,48 +85,50 @@ def index_of(line: int, col: int) -> int: text = re.sub(pattern, repl_py, text, count=count, flags=flags) else: allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + raise RuntimeError( + f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") return text def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): """ Find the best anchor match using improved heuristics. - + For patterns like \\s*}\\s*$ that are meant to find class-ending braces, this function uses heuristics to choose the most semantically appropriate match: - + 1. If prefer_last=True, prefer the last match (common for class-end insertions) 2. Use indentation levels to distinguish class vs method braces 3. Consider context to avoid matches inside strings/comments - + Args: pattern: Regex pattern to search for text: Text to search in flags: Regex flags prefer_last: If True, prefer the last match over the first - + Returns: Match object of the best match, or None if no match found """ import re - + # Find all matches matches = list(re.finditer(pattern, text, flags)) if not matches: return None - + # If only one match, return it if len(matches) == 1: return matches[0] - + # For patterns that look like they're trying to match closing braces at end of lines - is_closing_brace_pattern = '}' in pattern and ('$' in pattern or pattern.endswith(r'\s*')) - + is_closing_brace_pattern = '}' in pattern and ( + '$' in pattern or pattern.endswith(r'\s*')) + if is_closing_brace_pattern and prefer_last: # Use heuristics to find the best closing brace match return _find_best_closing_brace_match(matches, text) - + # Default behavior: use last match if prefer_last, otherwise first match return matches[-1] if prefer_last else matches[0] @@ -129,68 +136,70 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo def _find_best_closing_brace_match(matches, text: str): """ Find the best closing brace match using C# structure heuristics. - + Enhanced heuristics for scope-aware matching: 1. Prefer matches with lower indentation (likely class-level) 2. Prefer matches closer to end of file 3. Avoid matches that seem to be inside method bodies 4. For #endregion patterns, ensure class-level context 5. Validate insertion point is at appropriate scope - + Args: matches: List of regex match objects text: The full text being searched - + Returns: The best match object """ if not matches: return None - + scored_matches = [] lines = text.splitlines() - + for match in matches: score = 0 start_pos = match.start() - + # Find which line this match is on lines_before = text[:start_pos].count('\n') line_num = lines_before - + if line_num < len(lines): line_content = lines[line_num] - + # Calculate indentation level (lower is better for class braces) indentation = len(line_content) - len(line_content.lstrip()) - + # Prefer lower indentation (class braces are typically less indented than method braces) - score += max(0, 20 - indentation) # Max 20 points for indentation=0 - + # Max 20 points for indentation=0 + score += max(0, 20 - indentation) + # Prefer matches closer to end of file (class closing braces are typically at the end) distance_from_end = len(lines) - line_num - score += max(0, 10 - distance_from_end) # More points for being closer to end - + # More points for being closer to end + score += max(0, 10 - distance_from_end) + # Look at surrounding context to avoid method braces context_start = max(0, line_num - 3) - context_end = min(len(lines), line_num + 2) + context_end = min(len(lines), line_num + 2) context_lines = lines[context_start:context_end] - + # Penalize if this looks like it's inside a method (has method-like patterns above) for context_line in context_lines: if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): score -= 5 # Penalty for being near method signatures - + # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) if indentation <= 4 and distance_from_end <= 3: score += 15 # Bonus for likely class-ending brace - + scored_matches.append((score, match)) - + # Return the match with the highest score scored_matches.sort(key=lambda x: x[0], reverse=True) best_match = scored_matches[0][1] - + return best_match @@ -208,7 +217,6 @@ def _extract_code_after(keyword: str, request: str) -> str: # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services - def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: """Best-effort normalization of script "name" and "path". @@ -257,7 +265,8 @@ def collapse_duplicate_tail(s: str) -> str: parts = candidate.split("/") file_name = parts[-1] dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" - base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name + base = file_name[:- + 3] if file_name.lower().endswith(".cs") else file_name return base, dir_path # Fall back: remove extension from name if present and return given path @@ -277,7 +286,8 @@ def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: - payload: Dict[str, Any] = {"success": False, "code": code, "message": message} + payload: Dict[str, Any] = {"success": False, + "code": code, "message": message} data: Dict[str, Any] = {} if expected: data["expected"] = expected @@ -365,9 +375,9 @@ def script_apply_edits( def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: # Unwrap single-key wrappers like {"replace_method": {...}} for wrapper_key in ( - "replace_method","insert_method","delete_method", - "replace_class","delete_class", - "anchor_insert","anchor_replace","anchor_delete", + "replace_method", "insert_method", "delete_method", + "replace_class", "delete_class", + "anchor_insert", "anchor_replace", "anchor_delete", ): if wrapper_key in edit and isinstance(edit[wrapper_key], dict): inner = dict(edit[wrapper_key]) @@ -376,7 +386,8 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: break e = dict(edit) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() if op: e["op"] = op @@ -454,10 +465,11 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: normalized_edits: List[Dict[str, Any]] = [] for raw in edits or []: e = _unwrap_and_alias(raw) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() # Default className to script name if missing on structured method/class ops - if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"): + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): e["className"] = name # Map common aliases for text ops @@ -474,7 +486,8 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: if "text" in e: e["replacement"] = e.get("text", "") elif "insert" in e or "content" in e: - e["replacement"] = e.get("insert") or e.get("content") or "" + e["replacement"] = e.get( + "insert") or e.get("content") or "" if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): e["op"] = "anchor_delete" normalized_edits.append(e) @@ -494,40 +507,46 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str if not e.get("methodName"): return error_with_hint( "replace_method requires 'methodName'.", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, {"edits[0].methodName": "HasTarget"} ) if not (e.get("replacement") or e.get("text")): return error_with_hint( "replace_method requires 'replacement' (inline or base64).", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, {"edits[0].replacement": "public bool X(){ return true; }"} ) elif op == "insert_method": if not (e.get("replacement") or e.get("text")): return error_with_hint( "insert_method requires a non-empty 'replacement'.", - {"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"op": "insert_method", "required": ["className", "replacement"], "position": { + "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} ) pos = (e.get("position") or "").lower() if pos == "after" and not e.get("afterMethodName"): return error_with_hint( "insert_method with position='after' requires 'afterMethodName'.", - {"op": "insert_method", "position": {"after_requires": "afterMethodName"}}, + {"op": "insert_method", "position": { + "after_requires": "afterMethodName"}}, {"edits[0].afterMethodName": "GetCurrentTarget"} ) if pos == "before" and not e.get("beforeMethodName"): return error_with_hint( "insert_method with position='before' requires 'beforeMethodName'.", - {"op": "insert_method", "position": {"before_requires": "beforeMethodName"}}, + {"op": "insert_method", "position": { + "before_requires": "beforeMethodName"}}, {"edits[0].beforeMethodName": "GetCurrentTarget"} ) elif op == "delete_method": if not e.get("methodName"): return error_with_hint( "delete_method requires 'methodName'.", - {"op": "delete_method", "required": ["className", "methodName"]}, + {"op": "delete_method", "required": [ + "className", "methodName"]}, {"edits[0].methodName": "PrintSeries"} ) elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): @@ -545,9 +564,10 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str ) # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"} - TEXT = {"prepend","append","replace_range","regex_replace"} - ops_set = { (e.get("op") or "").lower() for e in edits or [] } + STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} + TEXT = {"prepend", "append", "replace_range", "regex_replace"} + ops_set = {(e.get("op") or "").lower() for e in edits or []} all_struct = ops_set.issubset(STRUCT) all_text = ops_set.issubset(TEXT) mixed = not (all_struct or all_text) @@ -566,7 +586,8 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str "edits": edits, "options": opts2, } - resp_struct = send_command_with_retry("manage_script", params_struct) + resp_struct = send_command_with_retry( + "manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") @@ -582,10 +603,12 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str if not isinstance(read_resp, dict) or not read_resp.get("success"): return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} + data = read_resp.get("data") or read_resp.get( + "result", {}).get("data") or {} contents = data.get("contents") if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): - contents = base64.b64decode(data["encodedContents"]).decode("utf-8") + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8") if contents is None: return {"success": False, "message": "No contents returned from Unity read."} @@ -594,28 +617,36 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured if mixed: - text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] - struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT] + text_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in STRUCT] try: base_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 return line, col at_edits: List[Dict[str, Any]] = [] import re as _re for e in text_edits: - opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" + opx = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get( + "content") or e.get("replacement") or "" if opx == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + flags = _re.MULTILINE | ( + _re.IGNORECASE if e.get("ignore_case") else 0) try: # Use improved anchor matching logic - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") if not m: @@ -628,10 +659,11 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: if not text_field_norm.endswith("\n"): text_field_norm = text_field_norm + "\n" sl, sc = line_col_from_index(idx) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) # do not mutate base_text when building atomic spans elif opx == "replace_range": - if all(k in e for k in ("startLine","startCol","endLine","endCol")): + if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): at_edits.append({ "startLine": int(e.get("startLine", 1)), "startCol": int(e.get("startCol", 1)), @@ -644,31 +676,37 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: elif opx == "regex_replace": pattern = e.get("pattern") or "" try: - regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)) + regex_obj = _re.compile(pattern, _re.MULTILINE | ( + _re.IGNORECASE if e.get("ignore_case") else 0)) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") m = regex_obj.search(base_text) if not m: continue # Expand $1, $2... in replacement using this match + def _expand_dollars(rep: str, _m=m) -> str: return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl = _expand_dollars(text_field) sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) # do not mutate base_text when building atomic spans - elif opx in ("prepend","append"): + elif opx in ("prepend", "append"): if opx == "prepend": sl, sc = 1, 1 - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) # prepend can be applied atomically without local mutation else: # Insert at true EOF position (handles both \n and \r\n correctly) eof_idx = len(base_text) sl, sc = line_col_from_index(eof_idx) - new_text = ("\n" if not base_text.endswith("\n") else "") + text_field - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + new_text = ("\n" if not base_text.endswith( + "\n") else "") + text_field + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) # do not mutate base_text when building atomic spans else: return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") @@ -686,7 +724,8 @@ def _expand_dollars(rep: str, _m=m) -> str: "precondition_sha256": sha, "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) + 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") # Optional sentinel reload removed (deprecated) @@ -706,7 +745,8 @@ def _expand_dollars(rep: str, _m=m) -> str: "edits": struct_edits, "options": opts2 } - resp_struct = send_command_with_retry("manage_script", params_struct) + resp_struct = send_command_with_retry( + "manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") @@ -716,32 +756,40 @@ def _expand_dollars(rep: str, _m=m) -> str: # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition # so header guards and validation run on the C# side. # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). - text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } - structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} + text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( + "mode") or "").strip().lower() for e in (edits or [])} + structured_kinds = {"replace_class", "delete_class", + "replace_method", "delete_method", "insert_method", "anchor_insert"} if not text_ops.issubset(structured_kinds): # Convert to apply_text_edits payload try: base_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: # 1-based line/col against base buffer line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 return line, col at_edits: List[Dict[str, Any]] = [] import re as _re for e in edits or []: - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() # aliasing for text field - text_field = e.get("text") or e.get("insert") or e.get("content") or "" + text_field = e.get("text") or e.get( + "insert") or e.get("content") or "" if op == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() # Use improved anchor matching logic with helpful errors, honoring ignore_case try: - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) + flags = _re.MULTILINE | ( + _re.IGNORECASE if e.get("ignore_case") else 0) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) 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") if not m: @@ -777,17 +825,20 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: elif op == "regex_replace": pattern = e.get("pattern") or "" repl = text_field - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + flags = _re.MULTILINE | ( + _re.IGNORECASE if e.get("ignore_case") else 0) # Early compile for clearer error messages try: regex_obj = _re.compile(pattern, flags) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") # Use smart anchor matching for consistent behavior with anchor_insert - m = _find_best_anchor_match(pattern, base_text, flags, prefer_last=True) + m = _find_best_anchor_match( + pattern, base_text, flags, prefer_last=True) if not m: continue # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + def _expand_dollars(rep: str, _m=m) -> str: return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl_expanded = _expand_dollars(repl) @@ -829,7 +880,8 @@ def _expand_dollars(rep: str, _m=m) -> str: if isinstance(resp, dict) and resp.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm( - resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, + resp if isinstance(resp, dict) else { + "success": False, "message": str(resp)}, normalized_for_echo, routing="text" ) @@ -842,7 +894,8 @@ def _expand_dollars(rep: str, _m=m) -> str: try: preview_text = _apply_edits_locally(contents, edits) import difflib - diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + diff = list(difflib.unified_diff(contents.splitlines( + ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) if len(diff) > 800: diff = diff[:800] + ["... (diff truncated) ..."] if preview: @@ -869,7 +922,8 @@ def _expand_dollars(rep: str, _m=m) -> str: import difflib a = contents.splitlines() b = new_contents.splitlines() - diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3)) + diff = list(difflib.unified_diff( + a, b, fromfile="before", tofile="after", n=3)) # Limit diff size to keep responses small if len(diff) > 2000: diff = diff[:2000] + ["... (diff truncated) ..."] @@ -911,13 +965,10 @@ def _expand_dollars(rep: str, _m=m) -> str: if isinstance(write_resp, dict) and write_resp.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm( - write_resp if isinstance(write_resp, dict) - else {"success": False, "message": str(write_resp)}, + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, normalized_for_echo, routing="text", ) - - - # safe_script_edit removed to simplify API; clients should call script_apply_edits directly diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 21a61c30..854f69d3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,10 +1,12 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import send_command_with_retry import base64 +from typing import Dict, Any +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_shader_tools(mcp: FastMCP): """Register all shader script management tools with the MCP server.""" @@ -35,34 +37,36 @@ def manage_shader( "name": name, "path": path, } - + # Base64 encode the contents if they exist to avoid JSON escaping issues if contents is not None: if action in ['create', 'update']: # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents - + # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} # Send command via centralized retry helper response = send_command_with_retry("manage_shader", params) - + # Process response from Unity if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] - + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing shader: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index dcfe0c08..c0d7c93a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -2,10 +2,13 @@ Defines the read_console tool for accessing Unity Editor console messages. """ from typing import List, Dict, Any + from mcp.server.fastmcp import FastMCP, Context -from unity_connection import send_command_with_retry from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" @@ -36,7 +39,7 @@ def read_console( Returns: Dictionary with results. For 'get', includes 'data' (messages). """ - + # Get the connection instance bridge = get_unity_connection() @@ -49,7 +52,7 @@ def read_console( # Normalize action if it's a string if isinstance(action, str): action = action.lower() - + # Coerce count defensively (string/float -> int) def _coerce_int(value, default=None): if value is None: @@ -80,11 +83,12 @@ def _coerce_int(value, default=None): } # Remove None values unless it's 'count' (as None might mean 'all') - params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} - + params_dict = {k: v for k, v in params_dict.items() + if v is not None or k == 'count'} + # Add count back if it was None, explicitly sending null might be important for C# logic if 'count' not in params_dict: - params_dict['count'] = None + params_dict['count'] = None # Use centralized retry helper resp = send_command_with_retry("read_console", params_dict) @@ -97,4 +101,4 @@ def _coerce_int(value, default=None): line.pop("stacktrace", None) except Exception: pass - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 14999eb4..833ad123 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -3,17 +3,17 @@ can still list and read files via normal tools. These call into the same safe path logic (re-implemented here to avoid importing server.py). """ - -from typing import Any -import re -from pathlib import Path -from urllib.parse import urlparse, unquote import fnmatch import hashlib import os +from pathlib import Path +import re +from typing import Any +from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool + from unity_connection import send_command_with_retry diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index f41b7a25..f688da34 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,17 +1,18 @@ +from config import config import contextlib +from dataclasses import dataclass import errno import json import logging +from pathlib import Path +from port_discovery import PortDiscovery import random import socket import struct import threading import time -from dataclasses import dataclass -from pathlib import Path from typing import Any, Dict -from config import config -from port_discovery import PortDiscovery + # Configure logging using settings from config logging.basicConfig( @@ -26,6 +27,7 @@ # Maximum allowed framed payload size (64 MiB) FRAMED_MAX = 64 * 1024 * 1024 + @dataclass class UnityConnection: """Manages the socket connection to the Unity Editor.""" @@ -33,7 +35,7 @@ class UnityConnection: port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication use_framing: bool = False # Negotiated per-connection - + def __post_init__(self): """Set port from discovery if not explicitly provided""" if self.port is None: @@ -50,11 +52,14 @@ def connect(self) -> bool: return True try: # Bounded connect to avoid indefinite blocking - connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) - self.sock = socket.create_connection((self.host, self.port), connect_timeout) + connect_timeout = float( + getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) + self.sock = socket.create_connection( + (self.host, self.port), connect_timeout) # Disable Nagle's algorithm to reduce small RPC latency with contextlib.suppress(Exception): - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.sock.setsockopt( + socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) logger.debug(f"Connected to Unity at {self.host}:{self.port}") # Strict handshake: require FRAMING=1 @@ -78,16 +83,20 @@ def connect(self) -> bool: if 'FRAMING=1' in text: self.use_framing = True - logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') + logger.debug( + 'Unity MCP handshake received: FRAMING=1 (strict)') else: if require_framing: # Best-effort plain-text advisory for legacy peers with contextlib.suppress(Exception): - self.sock.sendall(b'Unity MCP requires FRAMING=1\n') - raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + self.sock.sendall( + b'Unity MCP requires FRAMING=1\n') + raise ConnectionError( + f'Unity MCP requires FRAMING=1, got: {text!r}') else: self.use_framing = False - logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') + logger.warning( + 'Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') finally: self.sock.settimeout(config.connection_timeout) return True @@ -116,7 +125,8 @@ def _read_exact(self, sock: socket.socket, count: int) -> bytes: while len(data) < count: chunk = sock.recv(count - len(data)) if not chunk: - raise ConnectionError("Connection closed before reading expected bytes") + raise ConnectionError( + "Connection closed before reading expected bytes") data.extend(chunk) return bytes(data) @@ -136,13 +146,16 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: heartbeat_count += 1 if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: # Treat as empty successful response to match C# server behavior - logger.debug("Heartbeat threshold reached; returning empty response") + logger.debug( + "Heartbeat threshold reached; returning empty response") return b"" continue if payload_len > FRAMED_MAX: - raise ValueError(f"Invalid framed length: {payload_len}") + raise ValueError( + f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) - logger.debug(f"Received framed response ({len(payload)} bytes)") + logger.debug( + f"Received framed response ({len(payload)} bytes)") return payload except socket.timeout as e: logger.warning("Socket timeout during framed receive") @@ -158,21 +171,22 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: chunk = sock.recv(buffer_size) if not chunk: if not chunks: - raise Exception("Connection closed before receiving data") + raise Exception( + "Connection closed before receiving data") break chunks.append(chunk) - + # Process the data received so far data = b''.join(chunks) decoded_data = data.decode('utf-8') - + # Check if we've received a complete response try: # Special case for ping-pong if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): logger.debug("Received ping response") return data - + # Handle escaped quotes in the content if '"content":' in decoded_data: # Find the content field and its value @@ -182,19 +196,22 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: # Replace escaped quotes in content with regular quotes content = decoded_data[content_start:content_end] content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - + decoded_data = decoded_data[:content_start] + \ + content + decoded_data[content_end:] + # Validate JSON format json.loads(decoded_data) - + # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") + logger.info( + f"Received complete response ({len(data)} bytes)") return data except json.JSONDecodeError: # We haven't received a complete valid JSON response yet continue except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") + logger.warning( + f"Error processing response chunk: {str(e)}") # Continue reading more chunks as this might not be the complete response continue except socket.timeout: @@ -217,7 +234,8 @@ def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict def read_status_file() -> dict | None: try: - status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + status_files = sorted(Path.home().joinpath( + '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) if not status_files: return None latest = status_files[0] @@ -253,7 +271,8 @@ def read_status_file() -> dict | None: payload = b'ping' else: command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + payload = json.dumps( + command, ensure_ascii=False).encode('utf-8') # Send/receive are serialized to protect the shared socket with self._io_lock: @@ -280,7 +299,8 @@ def read_status_file() -> dict | None: try: response_data = self.receive_full_response(self.sock) with contextlib.suppress(Exception): - logger.debug("recv %d bytes; mode=%s", len(response_data), mode) + logger.debug("recv %d bytes; mode=%s", + len(response_data), mode) finally: if restore_timeout is not None: self.sock.settimeout(restore_timeout) @@ -295,11 +315,13 @@ def read_status_file() -> dict | None: resp = json.loads(response_data.decode('utf-8')) if resp.get('status') == 'error': - err = resp.get('error') or resp.get('message', 'Unknown Unity error') + err = resp.get('error') or resp.get( + 'message', 'Unknown Unity error') raise Exception(err) return resp.get('result', {}) except Exception as e: - logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + logger.warning( + f"Unity communication attempt {attempt+1} failed: {e}") try: if self.sock: self.sock.close() @@ -310,7 +332,8 @@ def read_status_file() -> dict | None: try: new_port = PortDiscovery.discover_unity_port() if new_port != self.port: - logger.info(f"Unity port changed {self.port} -> {new_port}") + logger.info( + f"Unity port changed {self.port} -> {new_port}") self.port = new_port except Exception as de: logger.debug(f"Port discovery failed: {de}") @@ -324,11 +347,13 @@ def read_status_file() -> dict | None: jitter = random.uniform(0.1, 0.3) # Fast‑retry for transient socket failures - fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + fast_error = isinstance( + e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) if not fast_error: try: err_no = getattr(e, 'errno', None) - fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + fast_error = err_no in ( + errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) except Exception: pass @@ -345,9 +370,11 @@ def read_status_file() -> dict | None: continue raise + # Global Unity connection _unity_connection = None + def get_unity_connection() -> UnityConnection: """Retrieve or establish a persistent Unity connection. @@ -366,7 +393,8 @@ def get_unity_connection() -> UnityConnection: _unity_connection = UnityConnection() if not _unity_connection.connect(): _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + raise ConnectionError( + "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") logger.info("Connected to Unity on startup") return _unity_connection @@ -400,7 +428,8 @@ def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_re response = conn.send_command(command_type, params) retries = 0 while _is_reloading_response(response) and retries < max_retries: - delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms + delay_ms = int(response.get("retry_after_ms", retry_ms) + ) if isinstance(response, dict) else retry_ms time.sleep(max(0.0, delay_ms / 1000.0)) retries += 1 response = conn.send_command(command_type, params) @@ -415,7 +444,8 @@ async def async_send_command_with_retry(command_type: str, params: Dict[str, Any loop = asyncio.get_running_loop() return await loop.run_in_executor( None, - lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms), + lambda: send_command_with_retry( + command_type, params, max_retries=max_retries, retry_ms=retry_ms), ) except Exception as e: # Return a structured error dict for consistency with other responses From 749fea447b7dc81765e9eb1b4663d3c33d158b85 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 21:43:37 -0400 Subject: [PATCH 08/30] Remove description, probably a Python versionn change --- UnityMcpBridge/UnityMcpServer~/src/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index f7c11f69..a2765cc2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -150,7 +150,6 @@ def _emit_startup(): # Initialize MCP server mcp = FastMCP( name="mcp-for-unity-server", - description="Unity Editor integration via Model Context Protocol", lifespan=server_lifespan ) From b94dcca32d2986838f39bffba5c5f94132c01015 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 22:23:41 -0400 Subject: [PATCH 09/30] feat: add type hints and parameter descriptions to Unity MCP tools --- .../UnityMcpServer~/src/tools/read_console.py | 26 ++++--- .../src/tools/resource_tools.py | 72 ++++++++----------- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index c0d7c93a..379c7ec9 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -1,7 +1,7 @@ """ Defines the read_console tool for accessing Unity Editor console messages. """ -from typing import List, Dict, Any +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -12,19 +12,23 @@ def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Gets messages from or clears the Unity Editor console.") @telemetry_tool("read_console") def read_console( ctx: Context, - action: str = None, - types: List[str] = None, - count: Any = None, - filter_text: str = None, - since_timestamp: str = None, - format: str = None, - include_stacktrace: bool = None - ) -> Dict[str, Any]: - """Gets messages from or clears the Unity Editor console. + action: Annotated[Literal['get', 'clear'], "Operation"], + types: Annotated[list[Literal['error', 'warning', + 'log', 'all']], "Message types to get"] | None = None, + count: Annotated[int, "Max messages to return"] | None = None, + filter_text: Annotated[str, "Text filter for messages"] | None = None, + since_timestamp: Annotated[str, + "Get messages after this timestamp (ISO 8601)"] | None = None, + format: Annotated[Literal['plain', 'detailed', + 'json'], "Output format"] | None = None, + include_stacktrace: Annotated[bool, + "Include stack traces in output"] | None = None + ) -> dict[str, Any]: + """ Args: ctx: The MCP context. diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 833ad123..1b17d0d7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -8,7 +8,7 @@ import os from pathlib import Path import re -from typing import Any +from typing import Annotated, Any from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context @@ -136,26 +136,16 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: def register_resource_tools(mcp: FastMCP) -> None: """Registers list_resources and read_resource wrapper tools.""" - @mcp.tool(description=( - "List project URIs (unity://path/...) under a folder (default: Assets).\n\n" - "Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n" - "Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n" - "Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n" - )) + @mcp.tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) @telemetry_tool("list_resources") async def list_resources( - ctx: Context | None = None, - pattern: str | None = "*.cs", - under: str = "Assets", - limit: Any = 200, - project_root: str | None = None, + ctx: Context, + pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", + under: Annotated[str, + "Folder under project root, default is Assets"] = "Assets", + limit: Annotated[int, "Page limit"] = 200, + project_root: Annotated[str, "Project path"] | None = None, ) -> dict[str, Any]: - """ - Lists project URIs (unity://path/...) under a folder (default: Assets). - - pattern: glob like *.cs or *.shader (None to list all files) - - under: relative folder under project root - - limit: max results - """ try: project = _resolve_project_root(project_root) base = (project / under).resolve() @@ -198,27 +188,23 @@ async def list_resources( except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool(description=( - "Read a resource by unity://path/... URI with optional slicing.\n\n" - "Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n" - "Security: uri must resolve under Assets/.\n" - "Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n" - )) + @mcp.tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) @telemetry_tool("read_resource") async def read_resource( - uri: str, - ctx: Context | None = None, - start_line: Any = None, - line_count: Any = None, - head_bytes: Any = None, - tail_lines: Any = None, - project_root: str | None = None, - request: str | None = None, + ctx: Context, + uri: Annotated[str, "The resource URI to read under Assets/"], + start_line: Annotated[int, + "The starting line number (0-based)"] | None = None, + line_count: Annotated[int, + "The number of lines to read"] | None = None, + head_bytes: Annotated[int, + "The number of bytes to read from the start of the file"] | None = None, + tail_lines: Annotated[int, + "The number of lines to read from the end of the file"] | None = None, + project_root: Annotated[str, + "The project root directory"] | None = None, + request: Annotated[str, "The request ID"] | None = None, ) -> dict[str, Any]: - """ - Reads a resource by unity://path/... URI with optional slicing. - One of line window (start_line/line_count) or head_bytes can be used to limit size. - """ try: # Serve the canonical spec directly when requested (allow bare or with scheme) if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): @@ -364,12 +350,14 @@ async def read_resource( @mcp.tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") @telemetry_tool("find_in_file") async def find_in_file( - uri: str, - pattern: str, - ctx: Context | None = None, - ignore_case: bool | None = True, - project_root: str | None = None, - max_results: Any = 200, + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, + project_root: Annotated[str, + "The project root directory"] | None = None, + max_results: Annotated[int, + "Cap results to avoid huge payloads"] = 200, ) -> dict[str, Any]: """ Searches a file with a regex pattern and returns line numbers and excerpts. From b31ffd1d4e58d4dcb11b476018d21667a6f8819b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 22:30:55 -0400 Subject: [PATCH 10/30] docs: improve shader management tool parameter descriptions and types --- .../src/tools/manage_menu_item.py | 2 +- .../src/tools/manage_prefabs.py | 2 +- .../src/tools/manage_shader.py | 26 ++++++------------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 6ded5d02..768b1d89 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -17,7 +17,7 @@ def register_manage_menu_item_tools(mcp: FastMCP): @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"], + action: Annotated[Literal["execute", "list", "exists"], "Operation"], menu_path: Annotated[str | None, "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, search: Annotated[str | None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index da7b913c..451cff4b 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -18,7 +18,7 @@ def manage_prefabs( "close_stage", "save_open_stage", "create_from_gameobject", - ], "One of open_stage, close_stage, save_open_stage, create_from_gameobject"], + ], "Operation"], prefab_path: Annotated[str | None, "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] = None, mode: Annotated[str | None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 854f69d3..8dcf7167 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,5 +1,5 @@ import base64 -from typing import Dict, Any +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -10,26 +10,16 @@ def register_manage_shader_tools(mcp: FastMCP): """Register all shader script management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Manages shader scripts in Unity (create, read, update, delete).") @telemetry_tool("manage_shader") def manage_shader( ctx: Context, - action: str, - name: str, - path: str, - contents: str, - ) -> Dict[str, Any]: - """Manages shader scripts in Unity (create, read, update, delete). - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Shader name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: Shader code for 'create'/'update'. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Operation"], + name: Annotated[str, "Shader name (no .cs extension)"], + path: Annotated[str, "Asset path (default: \"Assets/\")"], + contents: Annotated[str, + "Shader code for 'create'/'update'"] | None = None, + ) -> dict[str, Any]: try: # Prepare parameters for Unity params = { From 4dfb16703ec0be8e0dfdc393a52730d591f45163 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 22:58:47 -0400 Subject: [PATCH 11/30] refactor: add type annotations and improve documentation for script management tools --- .../src/tools/manage_script.py | 143 +++++++----------- 1 file changed, 55 insertions(+), 88 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index bd568b5f..bf7873b1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,6 +1,6 @@ import base64 import os -from typing import Dict, Any, List +from typing import Annotated, Any, Literal from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context @@ -75,34 +75,30 @@ def _split_uri(uri: str) -> tuple[str, str]: @mcp.tool(description=( "Apply small text edits to a C# script identified by URI.\n\n" - "⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" - "Common mistakes:\n" - "- Assuming what's on a line without checking\n" - "- Using wrong line numbers (they're 1-indexed)\n" - "- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n" + "IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" "RECOMMENDED WORKFLOW:\n" "1) First call resources/read with start_line/line_count to verify exact content\n" "2) Count columns carefully (or use find_in_file to locate patterns)\n" "3) Apply your edit with precise coordinates\n" "4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" - "Args:\n" - "- uri: unity://path/Assets/... or file://... or Assets/...\n" - "- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n" - "- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n" "Notes:\n" - "- Path must resolve under Assets/\n" "- For method/class operations, use script_apply_edits (safer, structured edits)\n" "- For pattern-based replacements, consider anchor operations in script_apply_edits\n" + "- Lines, columns are 1-indexed\n" + "- Tabs count as 1 column\n" )) @telemetry_tool("apply_text_edits") def apply_text_edits( ctx: Context, - uri: str, - edits: List[Dict[str, Any]], - precondition_sha256: str | None = None, - strict: bool | None = None, - options: Dict[str, Any] | None = None, - ) -> Dict[str, Any]: + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], + precondition_sha256: Annotated[str | None, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] = None, + strict: Annotated[bool | None, + "Optional strict flag, used to enforce strict mode"] = None, + options: Annotated[dict[str, Any] | None, + "Optional options, used to pass additional options to the script editor"] = None, + ) -> dict[str, Any]: """Apply small text edits to a C# script identified by URI.""" name, directory = _split_uri(uri) @@ -110,14 +106,14 @@ def apply_text_edits( # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} # If normalization is required, read current contents to map indices -> 1-based line/col. - def _needs_normalization(arr: List[Dict[str, Any]]) -> bool: + def _needs_normalization(arr: list[dict[str, Any]]) -> bool: for e in arr or []: if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): return True return False - normalized_edits: List[Dict[str, Any]] = [] - warnings: List[str] = [] + normalized_edits: list[dict[str, Any]] = [] + warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed read_resp = send_command_with_retry("manage_script", { @@ -243,7 +239,7 @@ def line_col_from_index(idx: int) -> tuple[int, int]: normalized_edits.append(e2) # Preflight: detect overlapping ranges among normalized line/col spans - def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]: + def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: return ( int(e.get("startLine", 1)) if key_start else int( e.get("endLine", 1)), @@ -286,7 +282,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: # preserves existing call-count expectations in clients/tests. # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance - opts: Dict[str, Any] = dict(options or {}) + opts: dict[str, Any] = dict(options or {}) try: if len(normalized_edits) > 1 and "applyMode" not in opts: opts["applyMode"] = "atomic" @@ -368,20 +364,15 @@ def _flip_async(): return resp return {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Create a new C# script at the given project path.\n\n" - "Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n" - "Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n" - )) + @mcp.tool(description=("Create a new C# script at the given project path.")) @telemetry_tool("create_script") def create_script( ctx: Context, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Create a new C# script at the given path.""" + path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + script_type: Annotated[str | None, "Script type (e.g., 'C#')"] = None, + namespace: Annotated[str | None, "Namespace for the script"] = None, + ) -> dict[str, Any]: name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input @@ -395,7 +386,7 @@ def create_script( return {"success": False, "code": "bad_path", "message": "path must include a script file name."} if not norm_path.lower().endswith(".cs"): return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} - params: Dict[str, Any] = { + params: dict[str, Any] = { "action": "create", "name": name, "path": directory, @@ -410,13 +401,12 @@ def create_script( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Delete a C# script by URI or Assets-relative path.\n\n" - "Args: uri (unity://path/... or file://... or Assets/...).\n" - "Rules: Target must resolve under Assets/.\n" - )) + @mcp.tool(description=("Delete a C# script by URI or Assets-relative path.")) @telemetry_tool("delete_script") - def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: + def delete_script( + ctx: Context, + uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] + ) -> dict[str, Any]: """Delete a C# script by URI.""" name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": @@ -425,18 +415,16 @@ def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Validate a C# script and return diagnostics.\n\n" - "Args: uri, level=('basic'|'standard'), include_diagnostics (bool, optional).\n" - "- basic: quick syntax checks.\n" - "- standard: deeper checks (performance hints, common pitfalls).\n" - "- include_diagnostics: when true, returns full diagnostics and summary; default returns counts only.\n" - )) + @mcp.tool(description=("Validate a C# script and return diagnostics.")) @telemetry_tool("validate_script") def validate_script( - ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False - ) -> Dict[str, Any]: - """Validate a C# script and return diagnostics.""" + ctx: Context, + uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + level: Annotated[Literal['basic', 'standard'], + "Validation level"] = "basic", + include_diagnostics: Annotated[bool, + "Include full diagnostics and summary"] = False + ) -> dict[str, Any]: name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} @@ -460,39 +448,20 @@ def validate_script( return {"success": True, "data": {"warnings": warnings, "errors": errors}} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Compatibility router for legacy script operations.\n\n" - "Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n" - "Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n" - "Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n" - )) + @mcp.tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) @telemetry_tool("manage_script") def manage_script( ctx: Context, - action: str, - name: str, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Compatibility router for legacy script operations. - - IMPORTANT: - - Direct file reads should use resources/read. - - Edits should use apply_text_edits. - - Args: - action: Operation ('create', 'read', 'delete'). - name: Script name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour'). - namespace: Script namespace. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + action: Annotated[Literal['create', 'read', 'delete'], "Operation"], + name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], + path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create", + "C# code for 'create'/'update'"] | None = None, + script_type: Annotated[str | None, + "Script type (e.g., 'C#')", "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str | None, "Namespace for the script", + "Script namespace"] | None = None, + ) -> dict[str, Any]: try: # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) if action == 'update': @@ -605,7 +574,7 @@ def manage_script( "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" )) @telemetry_tool("manage_script_capabilities") - def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: + def manage_script_capabilities(ctx: Context) -> dict[str, Any]: try: # Keep in sync with server/Editor ManageScript implementation ops = [ @@ -627,14 +596,12 @@ def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} - @mcp.tool(description=( - "Get SHA256 and basic metadata for a Unity C# script without returning file contents.\n\n" - "Args: uri (unity://path/Assets/... or file://... or Assets/...).\n" - "Returns: {sha256, lengthBytes}." - )) + @mcp.tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") @telemetry_tool("get_sha") - def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: - """Return SHA256 and basic metadata for a script.""" + def get_sha( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] + ) -> dict[str, Any]: try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} From 966f9575d24f510b370a0e18ba062f509222787d Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 23:03:07 -0400 Subject: [PATCH 12/30] refactor: improve type annotations and documentation in manage_scene tool --- .../UnityMcpServer~/src/tools/manage_scene.py | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index fea92a7f..8eb40b07 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing import Annotated, Literal, Any from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -9,27 +9,16 @@ def register_manage_scene_tools(mcp: FastMCP): """Register all scene management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Manage Unity scenes") @telemetry_tool("manage_scene") def manage_scene( ctx: Context, - action: str, - name: str = "", - path: str = "", - build_index: Any = None, - ) -> Dict[str, Any]: - """Manages Unity scenes (load, save, create, get hierarchy, etc.). - - Args: - action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). - name: Scene name (no extension) for create/load/save. - path: Asset path for scene operations (default: "Assets/"). - build_index: Build index for load/build settings actions. - # Add other action-specific args as needed (e.g., for hierarchy depth) - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Operation"], + name: Annotated[str, "Scene name (no extension) for create/load/save"], + path: Annotated[str, "Asset path for scene operations (default: 'Assets/')"], + build_index: Annotated[int | None, + "Build index for load/build settings actions"] = None, + ) -> dict[str, Any]: try: # Coerce numeric inputs defensively def _coerce_int(value, default=None): From 01b2a33500e758edf5544482de904131778fbaa2 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 01:00:41 -0400 Subject: [PATCH 13/30] refactor: add type annotations and improve parameter descriptions across MCP tools --- .../UnityMcpServer~/src/tools/manage_asset.py | 36 ++--- .../src/tools/manage_editor.py | 29 ++--- .../src/tools/manage_gameobject.py | 123 ++++++++---------- .../src/tools/manage_menu_item.py | 2 +- .../UnityMcpServer~/src/tools/manage_scene.py | 2 +- .../src/tools/manage_script.py | 2 +- .../UnityMcpServer~/src/tools/read_console.py | 2 +- 7 files changed, 87 insertions(+), 109 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index a604d65b..2bf00e0f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -2,7 +2,7 @@ Defines the manage_asset tool for interacting with Unity assets. """ import asyncio -from typing import Dict, Any +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context @@ -13,23 +13,29 @@ def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Performs asset operations (import, create, modify, delete, etc.) in Unity.") @telemetry_tool("manage_asset") async def manage_asset( ctx: Context, - action: str, - path: str, - asset_type: str = None, - properties: Dict[str, Any] = None, - destination: str = None, - generate_preview: bool = False, - search_pattern: str = None, - filter_type: str = None, - filter_date_after: str = None, - page_size: Any = None, - page_number: Any = None - ) -> Dict[str, Any]: - """Performs asset operations (import, create, modify, delete, etc.) in Unity. + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Operations"], + path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], + asset_type: Annotated[str, + "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, + properties: Annotated[dict[str, Any], + "Dictionary of properties for 'create'/'modify'."] | None = None, + destination: Annotated[str, + "Target path for 'duplicate'/'move'."] | None = None, + generate_preview: Annotated[bool, + "When true, `close_stage` will save the prefab before exiting the stage."] = False, + search_pattern: Annotated[str, + "Search pattern (e.g., '*.prefab')."] | None = None, + filter_type: Annotated[str, "Filter type for search"] | None = None, + filter_date_after: Annotated[str, + "Date after which to filter"] | None = None, + page_size: Annotated[int, "Page size for pagination"] | None = None, + page_number: Annotated[int, "Page number for pagination"] | None = None + ) -> dict[str, Any]: + """ (import, create, modify, delete, etc.) in Unity. Args: ctx: The MCP context. diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 004c2bd9..b123d0e7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -10,28 +10,19 @@ def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" - @mcp.tool(description=( - "Controls and queries the Unity editor's state and settings.\n\n" - "Args:\n" - "- ctx: Context object (required)\n" - "- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n" - "- wait_for_completion: Optional. If True, waits for certain actions\n" - "- tool_name: Tool name for specific actions\n" - "- tag_name: Tag name for specific actions\n" - "- layer_name: Layer name for specific actions\n\n" - "Returns:\n" - "Dictionary with operation results ('success', 'message', 'data')." - )) + @mcp.tool(description="Controls and queries the Unity editor's state and settings") @telemetry_tool("manage_editor") def manage_editor( ctx: Context, - action: str, - wait_for_completion: bool = None, + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Operations"] = None, + wait_for_completion: Annotated[bool, + "Optional. If True, waits for certain actions"] = None, # --- Parameters for specific actions --- - tool_name: str = None, - tag_name: str = None, - layer_name: str = None, - ) -> Dict[str, Any]: + tool_name: Annotated[str, "Tool name for specific actions"] = None, + tag_name: Annotated[str, "Tag name for specific actions"] = None, + layer_name: Annotated[str, "Layer name for specific actions"] = None, + ) -> dict[str, Any]: try: # Diagnostics: quick telemetry checks if action == "telemetry_status": diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 930cb9d8..cb8b4f06 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, List +from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -9,83 +9,64 @@ def register_manage_gameobject_tools(mcp: FastMCP): """Register all GameObject management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties.") @telemetry_tool("manage_gameobject") def manage_gameobject( ctx: Context, - action: str, - target: str = None, # GameObject identifier by name or path - search_method: str = None, - # --- Combined Parameters for Create/Modify --- - # Used for both 'create' (new object name) and 'modify' (rename) - name: str = None, - # Used for both 'create' (initial tag) and 'modify' (change tag) - tag: str = None, - # Used for both 'create' (initial parent) and 'modify' (change parent) - parent: str = None, - position: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - components_to_add: List[str] = None, # List of component names to add - primitive_type: str = None, - save_as_prefab: bool = False, - prefab_path: str = None, - prefab_folder: str = "Assets/Prefabs", + action: Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], + target: Annotated[str, + "GameObject identifier by name or path for modify/delete/component actions"] = None, + search_method: Annotated[str, + "How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] = None, + name: Annotated[str, + "GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] = None, + tag: Annotated[str, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] = None, + parent: Annotated[str, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] = None, + position: Annotated[list[float], + "Position - used for both 'create' (initial position) and 'modify' (change position)"] = None, + rotation: Annotated[list[float], + "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] = None, + scale: Annotated[list[float], + "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] = None, + components_to_add: Annotated[list[str], + "List of component names to add"] = None, + primitive_type: Annotated[str, + "Primitive type for 'create' action"] = None, + save_as_prefab: Annotated[bool, + "If True, saves the created GameObject as a prefab"] = False, + prefab_path: Annotated[str, "Path for prefab creation"] = None, + prefab_folder: Annotated[str, + "Folder for prefab creation"] = "Assets/Prefabs", # --- Parameters for 'modify' --- - set_active: bool = None, - layer: str = None, # Layer name - components_to_remove: List[str] = None, - component_properties: Dict[str, Dict[str, Any]] = None, + set_active: Annotated[bool, + "If True, sets the GameObject active"] = None, + layer: Annotated[str, "Layer name"] = None, + components_to_remove: Annotated[list[str], + "List of component names to remove"] = None, + component_properties: Annotated[dict[str, dict[str, Any]], + """Dictionary of component names to their properties to set. For example: + `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject + `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component + Example set nested property: + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] = None, # --- Parameters for 'find' --- - search_term: str = None, - find_all: bool = False, - search_in_children: bool = False, - search_inactive: bool = False, + search_term: Annotated[str, "Search term for 'find' action"] = None, + find_all: Annotated[bool, + "If True, finds all GameObjects matching the search term"] = False, + search_in_children: Annotated[bool, + "If True, searches in children of the GameObject"] = False, + search_inactive: Annotated[bool, + "If True, searches inactive GameObjects"] = False, # -- Component Management Arguments -- - component_name: str = None, - # Controls serialization of private [SerializeField] fields - includeNonPublicSerialized: bool = None, - ) -> Dict[str, Any]: - """Manages GameObjects: create, modify, delete, find, and component operations. - - Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). - target: GameObject identifier (name or path string) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. - name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). - tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). - parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). - layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). - component_properties: Dict mapping Component names to their properties to set. - Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, - To set references: - - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} - - Use a dict for scene objects/components, e.g.: - {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) - {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - Example set nested property: - - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} - components_to_add: List of component names to add. - Action-specific arguments (e.g., position, rotation, scale for create/modify; - component_name for component actions; - search_term, find_all for 'find'). - includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. - - Action-specific details: - - For 'get_components': - Required: target, search_method - Optional: includeNonPublicSerialized (defaults to True) - Returns all components on the target GameObject with their serialized data. - The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. - """ + component_name: Annotated[str, + "Component name for 'add_component' and 'remove_component' actions"] = None, + # Controls whether serialization of private [SerializeField] fields is included + includeNonPublicSerialized: Annotated[bool, + "Controls whether serialization of private [SerializeField] fields is included"] = None, + ) -> dict[str, Any]: try: - # --- Early check for attempting to modify a prefab asset --- - # ---------------------------------------------------------- - # Prepare parameters, removing None values params = { "action": action, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 768b1d89..f4830207 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -17,7 +17,7 @@ def register_manage_menu_item_tools(mcp: FastMCP): @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "Operation"], + action: Annotated[Literal["execute", "list", "exists"], "Operations"], menu_path: Annotated[str | None, "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, search: Annotated[str | None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 8eb40b07..083bb84b 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -13,7 +13,7 @@ def register_manage_scene_tools(mcp: FastMCP): @telemetry_tool("manage_scene") def manage_scene( ctx: Context, - action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Operation"], + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Operations"], name: Annotated[str, "Scene name (no extension) for create/load/save"], path: Annotated[str, "Asset path for scene operations (default: 'Assets/')"], build_index: Annotated[int | None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index bf7873b1..38cd2080 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -452,7 +452,7 @@ def validate_script( @telemetry_tool("manage_script") def manage_script( ctx: Context, - action: Annotated[Literal['create', 'read', 'delete'], "Operation"], + action: Annotated[Literal['create', 'read', 'delete'], "Operations"], name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create", diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 379c7ec9..8d87d13e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -16,7 +16,7 @@ def register_read_console_tools(mcp: FastMCP): @telemetry_tool("read_console") def read_console( ctx: Context, - action: Annotated[Literal['get', 'clear'], "Operation"], + action: Annotated[Literal['get', 'clear'], "Operations"], types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int, "Max messages to return"] | None = None, From 4ca2529eec9323b94534bdc4690537d5abe62bd6 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 01:17:22 -0400 Subject: [PATCH 14/30] feat: add explicit name parameters to all MCP tool decorators --- .../UnityMcpServer~/src/tools/manage_asset.py | 2 +- .../UnityMcpServer~/src/tools/manage_editor.py | 2 +- .../UnityMcpServer~/src/tools/manage_gameobject.py | 2 +- .../UnityMcpServer~/src/tools/manage_menu_item.py | 2 +- .../UnityMcpServer~/src/tools/manage_prefabs.py | 2 +- .../UnityMcpServer~/src/tools/manage_scene.py | 2 +- .../UnityMcpServer~/src/tools/manage_script.py | 14 +++++++------- .../src/tools/manage_script_edits.py | 2 +- .../UnityMcpServer~/src/tools/manage_shader.py | 2 +- .../UnityMcpServer~/src/tools/read_console.py | 2 +- .../UnityMcpServer~/src/tools/resource_tools.py | 6 +++--- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 2bf00e0f..66c1029c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -13,7 +13,7 @@ def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" - @mcp.tool(description="Performs asset operations (import, create, modify, delete, etc.) in Unity.") + @mcp.tool(name="manage_asset", description="Performs asset operations (import, create, modify, delete, etc.) in Unity.") @telemetry_tool("manage_asset") async def manage_asset( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index b123d0e7..6c0744af 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -10,7 +10,7 @@ def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" - @mcp.tool(description="Controls and queries the Unity editor's state and settings") + @mcp.tool(name="manage_editor", description="Controls and queries the Unity editor's state and settings") @telemetry_tool("manage_editor") def manage_editor( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index cb8b4f06..334e2ef6 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -9,7 +9,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): """Register all GameObject management tools with the MCP server.""" - @mcp.tool(description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties.") + @mcp.tool(name="manage_gameobject", description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties.") @telemetry_tool("manage_gameobject") def manage_gameobject( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index f4830207..465ce5e1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -13,7 +13,7 @@ def register_manage_menu_item_tools(mcp: FastMCP): """Registers the manage_menu_item tool with the MCP server.""" - @mcp.tool(description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.") + @mcp.tool(name="manage_menu_item", description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.") @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 451cff4b..7f2c77bb 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -9,7 +9,7 @@ def register_manage_prefabs_tools(mcp: FastMCP) -> None: """Register prefab management tools with the MCP server.""" - @mcp.tool(description="Bridge for prefab management commands (stage control and creation).") + @mcp.tool(name="manage_prefabs", description="Bridge for prefab management commands (stage control and creation).") @telemetry_tool("manage_prefabs") def manage_prefabs( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 083bb84b..285a586a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -9,7 +9,7 @@ def register_manage_scene_tools(mcp: FastMCP): """Register all scene management tools with the MCP server.""" - @mcp.tool(description="Manage Unity scenes") + @mcp.tool(name="manage_scene", description="Manage Unity scenes") @telemetry_tool("manage_scene") def manage_scene( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 38cd2080..61ecd582 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -73,7 +73,7 @@ def _split_uri(uri: str) -> tuple[str, str]: directory = os.path.dirname(effective_path) return name, directory - @mcp.tool(description=( + @mcp.tool(name="apply_text_edits", description=( "Apply small text edits to a C# script identified by URI.\n\n" "IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" "RECOMMENDED WORKFLOW:\n" @@ -364,7 +364,7 @@ def _flip_async(): return resp return {"success": False, "message": str(resp)} - @mcp.tool(description=("Create a new C# script at the given project path.")) + @mcp.tool(name="create_script", description=("Create a new C# script at the given project path.")) @telemetry_tool("create_script") def create_script( ctx: Context, @@ -401,7 +401,7 @@ def create_script( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=("Delete a C# script by URI or Assets-relative path.")) + @mcp.tool(name="delete_script", description=("Delete a C# script by URI or Assets-relative path.")) @telemetry_tool("delete_script") def delete_script( ctx: Context, @@ -415,7 +415,7 @@ def delete_script( resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=("Validate a C# script and return diagnostics.")) + @mcp.tool(name="validate_script", description=("Validate a C# script and return diagnostics.")) @telemetry_tool("validate_script") def validate_script( ctx: Context, @@ -448,7 +448,7 @@ def validate_script( return {"success": True, "data": {"warnings": warnings, "errors": errors}} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) + @mcp.tool(name="manage_script", description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) @telemetry_tool("manage_script") def manage_script( ctx: Context, @@ -569,7 +569,7 @@ def manage_script( "message": f"Python error managing script: {str(e)}", } - @mcp.tool(description=( + @mcp.tool(name="manage_script_capabilities", description=( "Get manage_script capabilities (supported ops, limits, and guards).\n\n" "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" )) @@ -596,7 +596,7 @@ def manage_script_capabilities(ctx: Context) -> dict[str, Any]: except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} - @mcp.tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") + @mcp.tool(name="get_sha", description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") @telemetry_tool("get_sha") def get_sha( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index ed7c42f6..afb49520 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -307,7 +307,7 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew def register_manage_script_edits_tools(mcp: FastMCP): - @mcp.tool(description=( + @mcp.tool(name="script_apply_edits", description=( "Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" "Best practices:\n" "- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 8dcf7167..7252d377 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -10,7 +10,7 @@ def register_manage_shader_tools(mcp: FastMCP): """Register all shader script management tools with the MCP server.""" - @mcp.tool(description="Manages shader scripts in Unity (create, read, update, delete).") + @mcp.tool(name="manage_shader", description="Manages shader scripts in Unity (create, read, update, delete).") @telemetry_tool("manage_shader") def manage_shader( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 8d87d13e..152ad7f0 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -12,7 +12,7 @@ def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" - @mcp.tool(description="Gets messages from or clears the Unity Editor console.") + @mcp.tool(name="read_console", description="Gets messages from or clears the Unity Editor console.") @telemetry_tool("read_console") def read_console( ctx: Context, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 1b17d0d7..e12b4089 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -136,7 +136,7 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: def register_resource_tools(mcp: FastMCP) -> None: """Registers list_resources and read_resource wrapper tools.""" - @mcp.tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) + @mcp.tool(name="list_resources", description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) @telemetry_tool("list_resources") async def list_resources( ctx: Context, @@ -188,7 +188,7 @@ async def list_resources( except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) + @mcp.tool(name="read_resource", description=("Reads a resource by unity://path/... URI with optional slicing.")) @telemetry_tool("read_resource") async def read_resource( ctx: Context, @@ -347,7 +347,7 @@ async def read_resource( except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") + @mcp.tool(name="find_in_file", description="Searches a file with a regex pattern and returns line numbers and excerpts.") @telemetry_tool("find_in_file") async def find_in_file( ctx: Context, From fe69e619e9ddcd6dc036427092f2a4bae795137a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 07:56:08 -0400 Subject: [PATCH 15/30] refactor: remove unused Unity connection instance in manage_asset_tools --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 66c1029c..9174b4c1 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -97,8 +97,6 @@ def _coerce_int(value, default=None): # Get the current asyncio event loop loop = asyncio.get_running_loop() - # Get the Unity connection instance - connection = get_unity_connection() # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) From ae4d4014348c6af1110ffbb241052c99cf21e7ad Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 08:01:02 -0400 Subject: [PATCH 16/30] chore: update type hints in manage_editor function parameters for better clarity --- .../UnityMcpServer~/src/tools/manage_editor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 6c0744af..43b4f54c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -15,13 +15,15 @@ def register_manage_editor_tools(mcp: FastMCP): def manage_editor( ctx: Context, action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", - "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Operations"] = None, + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Operations"], wait_for_completion: Annotated[bool, - "Optional. If True, waits for certain actions"] = None, - # --- Parameters for specific actions --- - tool_name: Annotated[str, "Tool name for specific actions"] = None, - tag_name: Annotated[str, "Tag name for specific actions"] = None, - layer_name: Annotated[str, "Layer name for specific actions"] = None, + "Optional. If True, waits for certain actions"] | None = None, + tool_name: Annotated[str, + "Tool name when setting active tool"] | None = None, + tag_name: Annotated[str, + "Tag name when adding and removing tags"] | None = None, + layer_name: Annotated[str, + "Layer name when adding and removing layers"] | None = None, ) -> dict[str, Any]: try: # Diagnostics: quick telemetry checks From 83756488d09dd46097656d209b7a024360db2dca Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 08:09:44 -0400 Subject: [PATCH 17/30] feat: make name and path parameters optional for scene management operations --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 285a586a..252bab4f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -14,10 +14,12 @@ def register_manage_scene_tools(mcp: FastMCP): def manage_scene( ctx: Context, action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Operations"], - name: Annotated[str, "Scene name (no extension) for create/load/save"], - path: Annotated[str, "Asset path for scene operations (default: 'Assets/')"], + name: Annotated[str, + "Scene name. Not required get_active/get_build_settings"] | None = None, + path: Annotated[str, + "Asset path for scene operations (default: 'Assets/')"] | None = None, build_index: Annotated[int | None, - "Build index for load/build settings actions"] = None, + "Build index for load/build settings actions"] | None = None, ) -> dict[str, Any]: try: # Coerce numeric inputs defensively From 32243de8cfb1c18c0b057074fafdfc359b2f4d44 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 09:40:45 -0400 Subject: [PATCH 18/30] refactor: remove unused get_unity_connection import from manage_asset.py --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 9174b4c1..fd993a1c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -6,7 +6,7 @@ from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, async_send_command_with_retry +from unity_connection import async_send_command_with_retry from telemetry_decorator import telemetry_tool From a7af7b471d4aee83d032dc5a9d268801e4d3af77 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 10:23:20 -0400 Subject: [PATCH 19/30] chore: rename Operation parameter annotation to Operations for consistency --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 7f2c77bb..c7b30e0d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -18,7 +18,7 @@ def manage_prefabs( "close_stage", "save_open_stage", "create_from_gameobject", - ], "Operation"], + ], "Operations"], prefab_path: Annotated[str | None, "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] = None, mode: Annotated[str | None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 7252d377..ba03124e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -14,7 +14,7 @@ def register_manage_shader_tools(mcp: FastMCP): @telemetry_tool("manage_shader") def manage_shader( ctx: Context, - action: Annotated[Literal['create', 'read', 'update', 'delete'], "Operation"], + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Operations"], name: Annotated[str, "Shader name (no .cs extension)"], path: Annotated[str, "Asset path (default: \"Assets/\")"], contents: Annotated[str, From 4395ec5eeec36642adbbbffe464aee9378c0bfdf Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 11:17:10 -0400 Subject: [PATCH 20/30] feat: add logging to MCP clients for tool actions across MCP server components --- .../UnityMcpServer~/src/tools/manage_asset.py | 20 +------------------ .../src/tools/manage_editor.py | 1 + .../src/tools/manage_gameobject.py | 1 + .../src/tools/manage_menu_item.py | 1 + .../src/tools/manage_prefabs.py | 1 + .../UnityMcpServer~/src/tools/manage_scene.py | 1 + .../src/tools/manage_script.py | 8 +++++++- .../src/tools/manage_script_edits.py | 1 + .../src/tools/manage_shader.py | 1 + .../UnityMcpServer~/src/tools/read_console.py | 17 +--------------- .../src/tools/resource_tools.py | 11 +++------- 11 files changed, 19 insertions(+), 44 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index fd993a1c..e5599e28 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -35,25 +35,7 @@ async def manage_asset( page_size: Annotated[int, "Page size for pagination"] | None = None, page_number: Annotated[int, "Page number for pagination"] | None = None ) -> dict[str, Any]: - """ (import, create, modify, delete, etc.) in Unity. - - Args: - ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). - path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. - asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. - properties: Dictionary of properties for 'create'/'modify'. - example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. - example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. - example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. - destination: Target path for 'duplicate'/'move'. - search_pattern: Search pattern (e.g., '*.prefab'). - filter_*: Filters for search (type, date). - page_*: Pagination for search. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ + ctx.info(f"Processing manage_asset: {action}") # Ensure properties is a dict if None if properties is None: properties = {} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 43b4f54c..0ad600a5 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -25,6 +25,7 @@ def manage_editor( layer_name: Annotated[str, "Layer name when adding and removing layers"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_editor: {action}") try: # Diagnostics: quick telemetry checks if action == "telemetry_status": diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 334e2ef6..d639851e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -66,6 +66,7 @@ def manage_gameobject( includeNonPublicSerialized: Annotated[bool, "Controls whether serialization of private [SerializeField] fields is included"] = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_gameobject: {action}") try: # Prepare parameters, removing None values params = { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 465ce5e1..d05ce892 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -25,6 +25,7 @@ async def manage_menu_item( refresh: Annotated[bool | None, "Optional flag to force refresh of the menu cache when listing"] = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_menu_item: {action}") # Prepare parameters for the C# handler params_dict: dict[str, Any] = { "action": action, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index c7b30e0d..254f4663 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -32,6 +32,7 @@ def manage_prefabs( search_inactive: Annotated[bool | None, "Include inactive objects when resolving the target name"] = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_prefabs: {action}") try: params: dict[str, Any] = {"action": action} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 252bab4f..186e8891 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -21,6 +21,7 @@ def manage_scene( build_index: Annotated[int | None, "Build index for load/build settings actions"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_scene: {action}") try: # Coerce numeric inputs defensively def _coerce_int(value, default=None): diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 61ecd582..dfe7c716 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -99,7 +99,7 @@ def apply_text_edits( options: Annotated[dict[str, Any] | None, "Optional options, used to pass additional options to the script editor"] = None, ) -> dict[str, Any]: - """Apply small text edits to a C# script identified by URI.""" + ctx.info(f"Processing apply_text_edits: {uri}") name, directory = _split_uri(uri) # Normalize common aliases/misuses for resilience: @@ -373,6 +373,7 @@ def create_script( script_type: Annotated[str | None, "Script type (e.g., 'C#')"] = None, namespace: Annotated[str | None, "Namespace for the script"] = None, ) -> dict[str, Any]: + ctx.info(f"Processing create_script: {path}") name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input @@ -408,6 +409,7 @@ def delete_script( uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: """Delete a C# script by URI.""" + ctx.info(f"Processing delete_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} @@ -425,6 +427,7 @@ def validate_script( include_diagnostics: Annotated[bool, "Include full diagnostics and summary"] = False ) -> dict[str, Any]: + ctx.info(f"Processing validate_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} @@ -462,6 +465,7 @@ def manage_script( namespace: Annotated[str | None, "Namespace for the script", "Script namespace"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_script: {action}") try: # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) if action == 'update': @@ -575,6 +579,7 @@ def manage_script( )) @telemetry_tool("manage_script_capabilities") def manage_script_capabilities(ctx: Context) -> dict[str, Any]: + ctx.info("Processing manage_script_capabilities") try: # Keep in sync with server/Editor ManageScript implementation ops = [ @@ -602,6 +607,7 @@ def get_sha( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: + ctx.info(f"Processing get_sha: {uri}") try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index afb49520..cb8d440c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -366,6 +366,7 @@ def script_apply_edits( script_type: str = "MonoBehaviour", namespace: str = "", ) -> Dict[str, Any]: + ctx.info(f"Processing script_apply_edits: {name}") # Normalize locator first so downstream calls target the correct script file. name, path = _normalize_script_locator(name, path) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index ba03124e..5121e135 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -20,6 +20,7 @@ def manage_shader( contents: Annotated[str, "Shader code for 'create'/'update'"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing manage_shader: {action}") try: # Prepare parameters for Unity params = { diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 152ad7f0..c1d3f3a2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -28,22 +28,7 @@ def read_console( include_stacktrace: Annotated[bool, "Include stack traces in output"] | None = None ) -> dict[str, Any]: - """ - - Args: - ctx: The MCP context. - action: Operation ('get' or 'clear'). - types: Message types to get ('error', 'warning', 'log', 'all'). - count: Max messages to return. - filter_text: Text filter for messages. - since_timestamp: Get messages after this timestamp (ISO 8601). - format: Output format ('plain', 'detailed', 'json'). - include_stacktrace: Include stack traces in output. - - Returns: - Dictionary with results. For 'get', includes 'data' (messages). - """ - + ctx.info(f"Processing read_console: {action}") # Get the connection instance bridge = get_unity_connection() diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index e12b4089..0a8853d4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -146,6 +146,7 @@ async def list_resources( limit: Annotated[int, "Page limit"] = 200, project_root: Annotated[str, "Project path"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing list_resources: {pattern}") try: project = _resolve_project_root(project_root) base = (project / under).resolve() @@ -205,6 +206,7 @@ async def read_resource( "The project root directory"] | None = None, request: Annotated[str, "The request ID"] | None = None, ) -> dict[str, Any]: + ctx.info(f"Processing read_resource: {uri}") try: # Serve the canonical spec directly when requested (allow bare or with scheme) if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): @@ -359,14 +361,7 @@ async def find_in_file( max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200, ) -> dict[str, Any]: - """ - Searches a file with a regex pattern and returns line numbers and excerpts. - - uri: unity://path/Assets/... or file path form supported by read_resource - - pattern: regular expression (Python re) - - ignore_case: case-insensitive by default - - max_results: cap results to avoid huge payloads - """ - # re is already imported at module level + ctx.info(f"Processing find_in_file: {uri}") try: project = _resolve_project_root(project_root) p = _resolve_safe_path_from_uri(uri, project) From 226221e2d602dbae1d02ad714b40391329d48dbc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 11:17:16 -0400 Subject: [PATCH 21/30] chore: add FastMCP type hint to register_all_tools parameter --- UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 6eeaccc2..5bf45f2e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,5 +1,7 @@ import logging +from mcp.server.fastmcp import FastMCP + from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools @@ -15,7 +17,7 @@ logger = logging.getLogger("mcp-for-unity-server") -def register_all_tools(mcp): +def register_all_tools(mcp: FastMCP): """Register all refactored tools with the MCP server.""" # Prefer the surgical edits tool so LLMs discover it first logger.info("Registering MCP for Unity Server refactored tools...") From f66e1fe7878d65e138eab7a601c229d56c8563a8 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 11:29:06 -0400 Subject: [PATCH 22/30] style: reformat docstring in apply_text_edits tool to use multiline string syntax --- .../src/tools/manage_script.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index dfe7c716..357089aa 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -74,18 +74,18 @@ def _split_uri(uri: str) -> tuple[str, str]: return name, directory @mcp.tool(name="apply_text_edits", description=( - "Apply small text edits to a C# script identified by URI.\n\n" - "IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" - "RECOMMENDED WORKFLOW:\n" - "1) First call resources/read with start_line/line_count to verify exact content\n" - "2) Count columns carefully (or use find_in_file to locate patterns)\n" - "3) Apply your edit with precise coordinates\n" - "4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" - "Notes:\n" - "- For method/class operations, use script_apply_edits (safer, structured edits)\n" - "- For pattern-based replacements, consider anchor operations in script_apply_edits\n" - "- Lines, columns are 1-indexed\n" - "- Tabs count as 1 column\n" + """Apply small text edits to a C# script identified by URI. + IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! + RECOMMENDED WORKFLOW: + 1. First call resources/read with start_line/line_count to verify exact content + 2. Count columns carefully (or use find_in_file to locate patterns) + 3. Apply your edit with precise coordinates + 4. Consider script_apply_edits with anchors for safer pattern-based replacements + Notes: + - For method/class operations, use script_apply_edits (safer, structured edits) + - For pattern-based replacements, consider anchor operations in script_apply_edits + - Lines, columns are 1-indexed + - Tabs count as 1 column""" )) @telemetry_tool("apply_text_edits") def apply_text_edits( From 6addf3a3b31de648fb559c3c24719c30c34823cb Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 11:37:38 -0400 Subject: [PATCH 23/30] refactor: update type hints from Dict/List/Tuple/Optional to modern Python syntax --- .../src/tools/manage_script_edits.py | 140 +++++++++--------- 1 file changed, 69 insertions(+), 71 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index cb8d440c..2a9db1b7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -1,6 +1,6 @@ import base64 import re -from typing import Dict, Any, List, Tuple, Optional +from typing import Any from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -8,7 +8,7 @@ from unity_connection import send_command_with_retry -def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: +def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: text = original_text for edit in edits or []: op = ( @@ -217,7 +217,7 @@ def _extract_code_after(keyword: str, request: str) -> str: # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services -def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: +def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: """Best-effort normalization of script "name" and "path". Accepts any of: @@ -274,7 +274,7 @@ def collapse_duplicate_tail(s: str) -> str: return base_name, (p or "Assets") -def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any: +def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: if not isinstance(resp, dict): return resp data = resp.setdefault("data", {}) @@ -284,11 +284,11 @@ def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: return resp -def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, - normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: - payload: Dict[str, Any] = {"success": False, +def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, + normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": False, "code": code, "message": message} - data: Dict[str, Any] = {} + data: dict[str, Any] = {} if expected: data["expected"] = expected if rewrite: @@ -308,64 +308,64 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(name="script_apply_edits", description=( - "Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" - "Best practices:\n" - "- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" - "- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n" - "- Avoid whole-file regex deletes; validators will guard unbalanced braces\n" - "- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n" - "- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n" - "Canonical fields (use these exact keys):\n" - "- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" - "- className: string (defaults to 'name' if omitted on method/class ops)\n" - "- methodName: string (required for replace_method, delete_method)\n" - "- replacement: string (required for replace_method, insert_method)\n" - "- position: start | end | after | before (insert_method only)\n" - "- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" - "- anchor: regex string (for anchor_* ops)\n" - "- text: string (for anchor_insert/anchor_replace)\n\n" - "Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" - "Examples:\n" - "1) Replace a method:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"replace_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"methodName\": \"HasTarget\",\n" - " \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n" - " }\n" - " ],\n" - " \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n" - "}\n\n" - "2) Insert a method after another:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"insert_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n" - " \"position\": \"after\",\n" - " \"afterMethodName\": \"GetCurrentTarget\"\n" - " }\n" - " ]\n" - "}\n\n" - "Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n" + """Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text. + Best practices: + - Prefer anchor_* ops for pattern-based insert/replace near stable markers + - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) + - Avoid whole-file regex deletes; validators will guard unbalanced braces + - For tail insertions, prefer anchor/regex_replace on final brace (class closing) + - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits + Canonical fields (use these exact keys): + - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace + - className: string (defaults to 'name' if omitted on method/class ops) + - methodName: string (required for replace_method, delete_method) + - replacement: string (required for replace_method, insert_method) + - position: start | end | after | before (insert_method only) + - afterMethodName / beforeMethodName: string (required when position='after'/'before') + - anchor: regex string (for anchor_* ops) + - text: string (for anchor_insert/anchor_replace) + Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized). + Examples: + 1) Replace a method: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "replace_method", + "className": "SmartReach", + "methodName": "HasTarget", + "replacement": "public bool HasTarget(){ return currentTarget!=null; }" + } + ], + "options": {"validate": "standard", "refresh": "immediate"} + } + "2) Insert a method after another: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "insert_method", + "className": "SmartReach", + "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", + "position": "after", + "afterMethodName": "GetCurrentTarget" + } + ], + } + ]""" )) @telemetry_tool("script_apply_edits") def script_apply_edits( ctx: Context, name: str, path: str, - edits: List[Dict[str, Any]], - options: Optional[Dict[str, Any]] = None, + edits: list[dict[str, Any]], + options: dict[str, Any] | None = None, script_type: str = "MonoBehaviour", namespace: str = "", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: ctx.info(f"Processing script_apply_edits: {name}") # Normalize locator first so downstream calls target the correct script file. name, path = _normalize_script_locator(name, path) @@ -373,7 +373,7 @@ def script_apply_edits( # No NL path: clients must provide structured edits in 'edits'. # Normalize unsupported or aliased ops to known structured/text paths - def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: # Unwrap single-key wrappers like {"replace_method": {...}} for wrapper_key in ( "replace_method", "insert_method", "delete_method", @@ -463,7 +463,7 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: e["text"] = edit.get("newText", "") return e - normalized_edits: List[Dict[str, Any]] = [] + normalized_edits: list[dict[str, Any]] = [] for raw in edits or []: e = _unwrap_and_alias(raw) op = (e.get("op") or e.get("operation") or e.get( @@ -499,7 +499,7 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: normalized_for_echo = edits # Validate required fields and produce machine-parsable hints - def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: + def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) for e in edits or []: @@ -578,7 +578,7 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str opts2 = dict(options or {}) # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused opts2.setdefault("refresh", "immediate") - params_struct: Dict[str, Any] = { + params_struct: dict[str, Any] = { "action": "edit", "name": name, "path": path, @@ -625,14 +625,14 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str try: base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: + def line_col_from_index(idx: int) -> tuple[int, int]: line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) col = (idx - (last_nl + 1)) + \ 1 if last_nl >= 0 else idx + 1 return line, col - at_edits: List[Dict[str, Any]] = [] + at_edits: list[dict[str, Any]] = [] import re as _re for e in text_edits: opx = (e.get("op") or e.get("operation") or e.get( @@ -715,7 +715,7 @@ def _expand_dollars(rep: str, _m=m) -> str: import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() if at_edits: - params_text: Dict[str, Any] = { + params_text: dict[str, Any] = { "action": "apply_text_edits", "name": name, "path": path, @@ -737,7 +737,7 @@ def _expand_dollars(rep: str, _m=m) -> str: opts2 = dict(options or {}) # Prefer debounced background refresh unless explicitly overridden opts2.setdefault("refresh", "debounced") - params_struct: Dict[str, Any] = { + params_struct: dict[str, Any] = { "action": "edit", "name": name, "path": path, @@ -766,7 +766,7 @@ def _expand_dollars(rep: str, _m=m) -> str: try: base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: + def line_col_from_index(idx: int) -> tuple[int, int]: # 1-based line/col against base buffer line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) @@ -774,7 +774,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]: 1 if last_nl >= 0 else idx + 1 return line, col - at_edits: List[Dict[str, Any]] = [] + at_edits: list[dict[str, Any]] = [] import re as _re for e in edits or []: op = (e.get("op") or e.get("operation") or e.get( @@ -863,7 +863,7 @@ def _expand_dollars(rep: str, _m=m) -> str: # Send to Unity with precondition SHA to enforce guards and immediate refresh import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - params: Dict[str, Any] = { + params: dict[str, Any] = { "action": "apply_text_edits", "name": name, "path": path, @@ -971,5 +971,3 @@ def _expand_dollars(rep: str, _m=m) -> str: normalized_for_echo, routing="text", ) - - # safe_script_edit removed to simplify API; clients should call script_apply_edits directly From 29ad9d5dc0ba203103f96bf52b2a47c503206a77 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 11:48:15 -0400 Subject: [PATCH 24/30] refactor: clean up imports and add type annotations to script editing tools --- .../src/tools/manage_script_edits.py | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 2a9db1b7..6cd1f529 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -1,6 +1,7 @@ import base64 +import hashlib import re -from typing import Any +from typing import Annotated, Any from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool @@ -110,7 +111,6 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo Returns: Match object of the best match, or None if no match found """ - import re # Find all matches matches = list(re.finditer(pattern, text, flags)) @@ -324,7 +324,6 @@ def register_manage_script_edits_tools(mcp: FastMCP): - afterMethodName / beforeMethodName: string (required when position='after'/'before') - anchor: regex string (for anchor_* ops) - text: string (for anchor_insert/anchor_replace) - Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized). Examples: 1) Replace a method: { @@ -359,20 +358,21 @@ def register_manage_script_edits_tools(mcp: FastMCP): @telemetry_tool("script_apply_edits") def script_apply_edits( ctx: Context, - name: str, - path: str, - edits: list[dict[str, Any]], - options: dict[str, Any] | None = None, - script_type: str = "MonoBehaviour", - namespace: str = "", + name: Annotated[str, "Name of the script to edit"], + path: Annotated[str, "Path to the script to edit under Assets/ directory"], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + options: Annotated[dict[str, Any], + "Options for the script edit"] | None = None, + script_type: Annotated[str, + "Type of the script to edit"] = "MonoBehaviour", + namespace: Annotated[str, + "Namespace of the script to edit"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing script_apply_edits: {name}") # Normalize locator first so downstream calls target the correct script file. name, path = _normalize_script_locator(name, path) - - # No NL path: clients must provide structured edits in 'edits'. - # Normalize unsupported or aliased ops to known structured/text paths + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: # Unwrap single-key wrappers like {"replace_method": {...}} for wrapper_key in ( @@ -633,7 +633,6 @@ def line_col_from_index(idx: int) -> tuple[int, int]: return line, col at_edits: list[dict[str, Any]] = [] - import re as _re for e in text_edits: opx = (e.get("op") or e.get("operation") or e.get( "type") or e.get("mode") or "").strip().lower() @@ -642,8 +641,8 @@ def line_col_from_index(idx: int) -> tuple[int, int]: if opx == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - flags = _re.MULTILINE | ( - _re.IGNORECASE if e.get("ignore_case") else 0) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) try: # Use improved anchor matching logic m = _find_best_anchor_match( @@ -677,8 +676,8 @@ def line_col_from_index(idx: int) -> tuple[int, int]: elif opx == "regex_replace": pattern = e.get("pattern") or "" try: - regex_obj = _re.compile(pattern, _re.MULTILINE | ( - _re.IGNORECASE if e.get("ignore_case") else 0)) + regex_obj = re.compile(pattern, re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0)) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") m = regex_obj.search(base_text) @@ -687,7 +686,7 @@ def line_col_from_index(idx: int) -> tuple[int, int]: # Expand $1, $2... in replacement using this match def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl = _expand_dollars(text_field) sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) @@ -712,7 +711,6 @@ def _expand_dollars(rep: str, _m=m) -> str: else: return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() if at_edits: params_text: dict[str, Any] = { @@ -787,8 +785,8 @@ def line_col_from_index(idx: int) -> tuple[int, int]: position = (e.get("position") or "after").lower() # Use improved anchor matching logic with helpful errors, honoring ignore_case try: - flags = _re.MULTILINE | ( - _re.IGNORECASE if e.get("ignore_case") else 0) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) m = _find_best_anchor_match( anchor, base_text, flags, prefer_last=True) except Exception as ex: @@ -826,11 +824,11 @@ def line_col_from_index(idx: int) -> tuple[int, int]: elif op == "regex_replace": pattern = e.get("pattern") or "" repl = text_field - flags = _re.MULTILINE | ( - _re.IGNORECASE if e.get("ignore_case") else 0) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) # Early compile for clearer error messages try: - regex_obj = _re.compile(pattern, flags) + regex_obj = re.compile(pattern, flags) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") # Use smart anchor matching for consistent behavior with anchor_insert @@ -841,7 +839,7 @@ def line_col_from_index(idx: int) -> tuple[int, int]: # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl_expanded = _expand_dollars(repl) # Let C# side handle validation using Unity's built-in compiler services sl, sc = line_col_from_index(m.start()) @@ -860,8 +858,6 @@ def _expand_dollars(rep: str, _m=m) -> str: if not at_edits: return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") - # Send to Unity with precondition SHA to enforce guards and immediate refresh - import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() params: dict[str, Any] = { "action": "apply_text_edits", @@ -936,7 +932,6 @@ def _expand_dollars(rep: str, _m=m) -> str: options.setdefault("validate", "standard") options.setdefault("refresh", "debounced") - import hashlib # Compute the SHA of the current file contents for the precondition old_lines = contents.splitlines(keepends=True) end_line = len(old_lines) + 1 # 1-based exclusive end From b7d669f9db1f85dab5d17841fc5be9140a3ccf95 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 12:03:36 -0400 Subject: [PATCH 25/30] refactor: update type hints to use | None syntax for optional parameters --- .../src/tools/manage_gameobject.py | 47 ++++++++++--------- .../src/tools/manage_menu_item.py | 12 ++--- .../src/tools/manage_prefabs.py | 24 +++++----- .../UnityMcpServer~/src/tools/manage_scene.py | 2 +- .../src/tools/manage_script.py | 23 +++++---- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index d639851e..d206c9f4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -15,56 +15,57 @@ def manage_gameobject( ctx: Context, action: Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], target: Annotated[str, - "GameObject identifier by name or path for modify/delete/component actions"] = None, + "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[str, - "How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] = None, + "How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] | None = None, name: Annotated[str, - "GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] = None, + "GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] | None = None, tag: Annotated[str, - "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] = None, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, parent: Annotated[str, - "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] = None, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, position: Annotated[list[float], - "Position - used for both 'create' (initial position) and 'modify' (change position)"] = None, + "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, rotation: Annotated[list[float], - "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] = None, + "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, scale: Annotated[list[float], - "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] = None, + "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, components_to_add: Annotated[list[str], - "List of component names to add"] = None, + "List of component names to add"] | None = None, primitive_type: Annotated[str, - "Primitive type for 'create' action"] = None, + "Primitive type for 'create' action"] | None = None, save_as_prefab: Annotated[bool, - "If True, saves the created GameObject as a prefab"] = False, - prefab_path: Annotated[str, "Path for prefab creation"] = None, + "If True, saves the created GameObject as a prefab"] | None = None, + prefab_path: Annotated[str, "Path for prefab creation"] | None = None, prefab_folder: Annotated[str, - "Folder for prefab creation"] = "Assets/Prefabs", + "Folder for prefab creation"] | None = None, # --- Parameters for 'modify' --- set_active: Annotated[bool, - "If True, sets the GameObject active"] = None, - layer: Annotated[str, "Layer name"] = None, + "If True, sets the GameObject active"] | None = None, + layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], - "List of component names to remove"] = None, + "List of component names to remove"] | None = None, component_properties: Annotated[dict[str, dict[str, Any]], """Dictionary of component names to their properties to set. For example: `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component Example set nested property: - - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] = None, + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, # --- Parameters for 'find' --- - search_term: Annotated[str, "Search term for 'find' action"] = None, + search_term: Annotated[str, + "Search term for 'find' action"] | None = None, find_all: Annotated[bool, - "If True, finds all GameObjects matching the search term"] = False, + "If True, finds all GameObjects matching the search term"] | None = None, search_in_children: Annotated[bool, - "If True, searches in children of the GameObject"] = False, + "If True, searches in children of the GameObject"] | None = None, search_inactive: Annotated[bool, - "If True, searches inactive GameObjects"] = False, + "If True, searches inactive GameObjects"] | None = None, # -- Component Management Arguments -- component_name: Annotated[str, - "Component name for 'add_component' and 'remove_component' actions"] = None, + "Component name for 'add_component' and 'remove_component' actions"] | None = None, # Controls whether serialization of private [SerializeField] fields is included includeNonPublicSerialized: Annotated[bool, - "Controls whether serialization of private [SerializeField] fields is included"] = None, + "Controls whether serialization of private [SerializeField] fields is included"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_gameobject: {action}") try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index d05ce892..2ae14be5 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -18,12 +18,12 @@ def register_manage_menu_item_tools(mcp: FastMCP): async def manage_menu_item( ctx: Context, action: Annotated[Literal["execute", "list", "exists"], "Operations"], - menu_path: Annotated[str | None, - "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, - search: Annotated[str | None, - "Optional filter string for 'list' (e.g., 'Save')"] = None, - refresh: Annotated[bool | None, - "Optional flag to force refresh of the menu cache when listing"] = None, + menu_path: Annotated[str, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, + search: Annotated[str, + "Optional filter string for 'list' (e.g., 'Save')"] | None = None, + refresh: Annotated[bool, + "Optional flag to force refresh of the menu cache when listing"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_menu_item: {action}") # Prepare parameters for the C# handler diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 254f4663..a37c601c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -19,18 +19,18 @@ def manage_prefabs( "save_open_stage", "create_from_gameobject", ], "Operations"], - prefab_path: Annotated[str | None, - "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] = None, - mode: Annotated[str | None, - "Optional prefab stage mode (only 'InIsolation' is currently supported)"] = None, - save_before_close: Annotated[bool | None, - "When true, `close_stage` will save the prefab before exiting the stage."] = None, - target: Annotated[str | None, - "Scene GameObject name required for create_from_gameobject"] = None, - allow_overwrite: Annotated[bool | None, - "Allow replacing an existing prefab at the same path"] = None, - search_inactive: Annotated[bool | None, - "Include inactive objects when resolving the target name"] = None, + prefab_path: Annotated[str, + "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, + mode: Annotated[str, + "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, + save_before_close: Annotated[bool, + "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, + target: Annotated[str, + "Scene GameObject name required for create_from_gameobject"] | None = None, + allow_overwrite: Annotated[bool, + "Allow replacing an existing prefab at the same path"] | None = None, + search_inactive: Annotated[bool, + "Include inactive objects when resolving the target name"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_prefabs: {action}") try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 186e8891..d4e834cf 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -18,7 +18,7 @@ def manage_scene( "Scene name. Not required get_active/get_build_settings"] | None = None, path: Annotated[str, "Asset path for scene operations (default: 'Assets/')"] | None = None, - build_index: Annotated[int | None, + build_index: Annotated[int, "Build index for load/build settings actions"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_scene: {action}") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 357089aa..9bc69752 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -92,12 +92,12 @@ def apply_text_edits( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], - precondition_sha256: Annotated[str | None, - "Optional SHA256 of the script to edit, used to prevent concurrent edits"] = None, - strict: Annotated[bool | None, - "Optional strict flag, used to enforce strict mode"] = None, - options: Annotated[dict[str, Any] | None, - "Optional options, used to pass additional options to the script editor"] = None, + precondition_sha256: Annotated[str, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, + strict: Annotated[bool, + "Optional strict flag, used to enforce strict mode"] | None = None, + options: Annotated[dict[str, Any], + "Optional options, used to pass additional options to the script editor"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing apply_text_edits: {uri}") name, directory = _split_uri(uri) @@ -370,8 +370,8 @@ def create_script( ctx: Context, path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], - script_type: Annotated[str | None, "Script type (e.g., 'C#')"] = None, - namespace: Annotated[str | None, "Namespace for the script"] = None, + script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing create_script: {path}") name = os.path.splitext(os.path.basename(path))[0] @@ -460,10 +460,9 @@ def manage_script( path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create", "C# code for 'create'/'update'"] | None = None, - script_type: Annotated[str | None, - "Script type (e.g., 'C#')", "Type hint (e.g., 'MonoBehaviour')"] | None = None, - namespace: Annotated[str | None, "Namespace for the script", - "Script namespace"] | None = None, + script_type: Annotated[str, "Script type (e.g., 'C#')", + "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_script: {action}") try: From 8cd7fba1540e8b7fd50f696fbbe4709e47cf1090 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 12:12:49 -0400 Subject: [PATCH 26/30] Minor fixes --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py | 4 +--- UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py | 3 --- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index e5599e28..b4974f26 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -26,7 +26,7 @@ async def manage_asset( destination: Annotated[str, "Target path for 'duplicate'/'move'."] | None = None, generate_preview: Annotated[bool, - "When true, `close_stage` will save the prefab before exiting the stage."] = False, + "Generate a preview/thumbnail for the asset when supported."] = False, search_pattern: Annotated[str, "Search pattern (e.g., '*.prefab')."] | None = None, filter_type: Annotated[str, "Filter type for search"] | None = None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 2ae14be5..ebd4b720 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -7,7 +7,7 @@ from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool -from unity_connection import get_unity_connection, async_send_command_with_retry +from unity_connection import async_send_command_with_retry def register_manage_menu_item_tools(mcp: FastMCP): @@ -38,8 +38,6 @@ async def manage_menu_item( # Get the current asyncio event loop loop = asyncio.get_running_loop() - # Touch the connection to ensure availability (mirrors other tools' pattern) - _ = get_unity_connection() # Use centralized async retry helper result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index c1d3f3a2..087bf8ec 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -29,9 +29,6 @@ def read_console( "Include stack traces in output"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing read_console: {action}") - # Get the connection instance - bridge = get_unity_connection() - # Set defaults if values are None action = action if action is not None else 'get' types = types if types is not None else ['error', 'warning', 'log'] From d1691a74acb7701dd2aed762370c93004376880f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 12:22:42 -0400 Subject: [PATCH 27/30] docs: improve tool descriptions with clearer action explanations --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index b4974f26..a442b422 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -17,7 +17,7 @@ def register_manage_asset_tools(mcp: FastMCP): @telemetry_tool("manage_asset") async def manage_asset( ctx: Context, - action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Operations"], + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], asset_type: Annotated[str, "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 0ad600a5..644209f7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -15,7 +15,7 @@ def register_manage_editor_tools(mcp: FastMCP): def manage_editor( ctx: Context, action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", - "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Operations"], + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], wait_for_completion: Annotated[bool, "Optional. If True, waits for certain actions"] | None = None, tool_name: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index d206c9f4..285b0461 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -13,7 +13,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): @telemetry_tool("manage_gameobject") def manage_gameobject( ctx: Context, - action: Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."], target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index ebd4b720..3e7620a6 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -17,7 +17,7 @@ def register_manage_menu_item_tools(mcp: FastMCP): @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "Operations"], + action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."], menu_path: Annotated[str, "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, search: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index a37c601c..7c65f28f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -18,7 +18,7 @@ def manage_prefabs( "close_stage", "save_open_stage", "create_from_gameobject", - ], "Operations"], + ], "Manage prefabs (stage control and creation)."], prefab_path: Annotated[str, "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, mode: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index d4e834cf..fb5a1bca 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -13,7 +13,7 @@ def register_manage_scene_tools(mcp: FastMCP): @telemetry_tool("manage_scene") def manage_scene( ctx: Context, - action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Operations"], + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], name: Annotated[str, "Scene name. Not required get_active/get_build_settings"] | None = None, path: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 9bc69752..15ae234b 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -455,7 +455,7 @@ def validate_script( @telemetry_tool("manage_script") def manage_script( ctx: Context, - action: Annotated[Literal['create', 'read', 'delete'], "Operations"], + action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create", diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 5121e135..e9ccc14a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -14,7 +14,7 @@ def register_manage_shader_tools(mcp: FastMCP): @telemetry_tool("manage_shader") def manage_shader( ctx: Context, - action: Annotated[Literal['create', 'read', 'update', 'delete'], "Operations"], + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], name: Annotated[str, "Shader name (no .cs extension)"], path: Annotated[str, "Asset path (default: \"Assets/\")"], contents: Annotated[str, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 087bf8ec..c647cf8f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -16,7 +16,7 @@ def register_read_console_tools(mcp: FastMCP): @telemetry_tool("read_console") def read_console( ctx: Context, - action: Annotated[Literal['get', 'clear'], "Operations"], + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int, "Max messages to return"] | None = None, From 679da41bf318e951ca33103101074127b74c5466 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 12:40:22 -0400 Subject: [PATCH 28/30] refactor: remove legacy update action migration code from manage_script.py --- .../src/tools/manage_script.py | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 15ae234b..a6ea56d0 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -466,66 +466,6 @@ def manage_script( ) -> dict[str, Any]: ctx.info(f"Processing manage_script: {action}") try: - # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) - if action == 'update': - try: - # 1) Read current contents to compute end range and precondition - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - }) - if not (isinstance(read_resp, dict) and read_resp.get("success")): - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."} - data = read_resp.get("data", {}) - current = data.get("contents") - if not current and data.get("contentsEncoded"): - current = base64.b64decode(data.get("encodedContents", "").encode( - "utf-8")).decode("utf-8", "replace") - if current is None: - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} - - # 2) Compute whole-file range (1-based, end exclusive) and SHA - import hashlib as _hashlib - old_lines = current.splitlines(keepends=True) - end_line = len(old_lines) + 1 - sha = _hashlib.sha256(current.encode("utf-8")).hexdigest() - - # 3) Apply single whole-file text edit with provided 'contents' - edits = [{ - "startLine": 1, - "startCol": 1, - "endLine": end_line, - "endCol": 1, - "newText": contents or "", - }] - route_params = { - "action": "apply_text_edits", - "name": name, - "path": path, - "edits": edits, - "precondition_sha256": sha, - "options": {"refresh": "debounced", "validate": "standard"}, - } - # Preflight size vs. default cap (256 KiB) to avoid opaque server errors - try: - import json as _json - payload_bytes = len(_json.dumps( - {"edits": edits}, ensure_ascii=False).encode("utf-8")) - if payload_bytes > 256 * 1024: - return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."} - except Exception: - pass - routed = send_command_with_retry( - "manage_script", route_params) - if isinstance(routed, dict): - routed.setdefault( - "message", "Routed legacy update to apply_text_edits") - return routed - return {"success": False, "message": str(routed)} - except Exception as e: - return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"} - # Prepare parameters for Unity params = { "action": action, From e338c7a508d24dbadb3b7c3195426308235faca7 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 13:28:05 -0400 Subject: [PATCH 29/30] style: replace em dashes with regular hyphens in tool descriptions [skip ci] --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 6cd1f529..261eb502 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -308,7 +308,7 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew def register_manage_script_edits_tools(mcp: FastMCP): @mcp.tool(name="script_apply_edits", description=( - """Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text. + """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. Best practices: - Prefer anchor_* ops for pattern-based insert/replace near stable markers - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 0a8853d4..2ae06e85 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -212,7 +212,7 @@ async def read_resource( if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): spec_json = ( '{\n' - ' "name": "Unity MCP — Script Edits v1",\n' + ' "name": "Unity MCP - Script Edits v1",\n' ' "target_tool": "script_apply_edits",\n' ' "canonical_rules": {\n' ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' From 4d5058455c2cb932319b3bdbfcb30dd5f9378348 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 27 Sep 2025 13:41:48 -0400 Subject: [PATCH 30/30] refactor: convert manage_script_capabilities docstring to multiline format [skip ci] --- UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index a6ea56d0..fef1e92d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -513,8 +513,12 @@ def manage_script( } @mcp.tool(name="manage_script_capabilities", description=( - "Get manage_script capabilities (supported ops, limits, and guards).\n\n" - "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" + """Get manage_script capabilities (supported ops, limits, and guards). + Returns: + - ops: list of supported structured ops + - text_ops: list of supported text ops + - max_edit_payload_bytes: server edit payload cap + - guards: header/using guard enabled flag""" )) @telemetry_tool("manage_script_capabilities") def manage_script_capabilities(ctx: Context) -> dict[str, Any]: