diff --git a/README.md b/README.md index 60439a59..2d8f82e3 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,27 @@ Unity MCP connects your tools using two components: * [Cursor](https://www.cursor.com/en/downloads) * [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview) * *(Others may work with manual config)* + *
[Optional] Roslyn for Advanced Script Validation + + For **Strict** validation level that catches undefined namespaces, types, and methods: + + **Method 1: NuGet for Unity (Recommended)** + 1. Install [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity) + 2. Go to `Window > NuGet Package Manager` + 3. Search for `Microsoft.CodeAnalysis.CSharp` and install the package + 5. Go to `Player Settings > Scripting Define Symbols` + 6. Add `USE_ROSLYN` + 7. Restart Unity + + **Method 2: Manual DLL Installation** + 1. Download Microsoft.CodeAnalysis.CSharp.dll and dependencies from [NuGet](https://www.nuget.org/packages/Microsoft.CodeAnalysis.CSharp/) + 2. Place DLLs in `Assets/Plugins/` folder + 3. Ensure .NET compatibility settings are correct + 4. Add `USE_ROSLYN` to Scripting Define Symbols + 5. Restart Unity + + **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting.
+ ### Step 1: Install the Unity Package (Bridge) @@ -192,7 +213,7 @@ If Auto-Configure fails or you use a different client: ### 🔴 High Priority - [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization -- [ ] **Code Generation Enhancements** - Improved generated code quality, validation, and error handling +- [ ] **Code Generation Enhancements** - Improved generated code quality and error handling - [ ] **Robust Error Handling** - Comprehensive error messages, recovery mechanisms, and graceful degradation - [ ] **Remote Connection Support** - Enable seamless remote connection between Unity host and MCP server - [ ] **Documentation Expansion** - Complete tutorials for custom tool creation and API reference @@ -210,6 +231,7 @@ If Auto-Configure fails or you use a different client: ✅ Completed Features - [x] **Shader Generation** - Generate shaders using CGProgram template + - [x] **Advanced Script Validation** - Multi-level validation with semantic analysis, namespace/type checking, and Unity best practices (Will need Roslyn Installed, see [Prerequisite](#prerequisites)). ### 🔬 Research & Exploration @@ -295,4 +317,4 @@ Thanks to the contributors and the Unity team. ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=unity-mcp/unity-mcp,justinpbarnett/unity-mcp&type=Date)](https://www.star-history.com/#unity-mcp/unity-mcp&justinpbarnett/unity-mcp&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=justinpbarnett/unity-mcp&type=Date)](https://www.star-history.com/#justinpbarnett/unity-mcp&Date) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 70c9f71b..d79e17a6 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -7,10 +7,43 @@ using UnityEngine; using UnityMcpBridge.Editor.Helpers; +#if USE_ROSLYN +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +#endif + +#if UNITY_EDITOR +using UnityEditor.Compilation; +#endif + + namespace UnityMcpBridge.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 { @@ -168,12 +201,18 @@ string namespaceName contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); } - // Validate syntax (basic check) - if (!ValidateScriptSyntax(contents)) + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); + return Response.Error(errorMessage); + } + else if (validationErrors != null && validationErrors.Length > 0) { - // Optionally return a specific error or warning about syntax - // return Response.Error("Provided script content has potential syntax errors."); - Debug.LogWarning($"Potential syntax error in script being created: {name}"); + // Log warnings but don't block creation + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); } try @@ -243,11 +282,18 @@ string contents return Response.Error("Content is required for the 'update' action."); } - // Validate syntax (basic check) - if (!ValidateScriptSyntax(contents)) + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); + return Response.Error(errorMessage); + } + else if (validationErrors != null && validationErrors.Length > 0) { - Debug.LogWarning($"Potential syntax error in script being updated: {name}"); - // Consider if this should be a hard error or just a warning + // Log warnings but don't block update + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); } try @@ -361,27 +407,624 @@ string namespaceName } /// - /// Performs a very basic syntax validation (checks for balanced braces). - /// TODO: Implement more robust syntax checking if possible. + /// Gets the validation level from the GUI settings + /// + private static ValidationLevel GetValidationLevelFromGUI() + { + string savedLevel = EditorPrefs.GetString("UnityMCP_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 is technically valid? + { + 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 + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return level != ValidationLevel.Standard; //TODO: Allow standard to run roslyn right now, might formalize it in the future + } +#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; - foreach (char c in contents) + 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) { - if (c == '{') - braceBalance++; - else if (c == '}') - braceBalance--; + 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 braceBalance == 0; - // This is extremely basic. A real C# parser/compiler check would be ideal - // but is complex to implement directly here. + 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*[;})\]])"); + 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*{"); + 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); + // } + // } } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index bf11f1fd..3b1e69d5 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using Newtonsoft.Json; using UnityEditor; @@ -19,6 +20,19 @@ public class UnityMcpEditorWindow : EditorWindow private const int unityPort = 6400; // Hardcoded Unity port private const int mcpPort = 6500; // Hardcoded MCP port private readonly McpClients mcpClients = new(); + + // 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/Unity MCP")] public static void ShowWindow() @@ -35,6 +49,9 @@ private void OnEnable() { CheckMcpConfiguration(mcpClient); } + + // Load validation level setting + LoadValidationLevelSetting(); } private Color GetStatusColor(McpStatus status) @@ -79,89 +96,253 @@ private void UpdatePythonServerInstallationStatus() } } - private void ConfigurationSection(McpClient mcpClient) + + private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) { - // Calculate if we should use half-width layout - // Minimum width for half-width layout is 400 pixels - bool useHalfWidth = position.width >= 800; - float sectionWidth = useHalfWidth ? (position.width / 2) - 15 : position.width - 20; + 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; - // Begin horizontal layout if using half-width - if (useHalfWidth && mcpClients.clients.IndexOf(mcpClient) % 2 == 0) - { - EditorGUILayout.BeginHorizontal(); - } + // Draw the main dot + Handles.color = statusColor; + Handles.DrawSolidDisc(center, Vector3.forward, radius); - // Begin section with fixed width - EditorGUILayout.BeginVertical(EditorStyles.helpBox, GUILayout.Width(sectionWidth)); + // 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); + } - // Header with improved styling + private void OnGUI() + { + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + // Header + DrawHeader(); + + // Main sections in a more compact layout + EditorGUILayout.BeginHorizontal(); + + // Left column - Status and Bridge + EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.5f)); + DrawServerStatusSection(); EditorGUILayout.Space(5); - Rect headerRect = EditorGUILayout.GetControlRect(false, 24); + DrawBridgeSection(); + EditorGUILayout.EndVertical(); + + // Right column - Validation Settings + EditorGUILayout.BeginVertical(); + DrawValidationSection(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // Unified MCP Client Configuration + DrawUnifiedClientConfiguration(); + + 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( - headerRect.x + 8, - headerRect.y + 4, - headerRect.width - 16, - headerRect.height - ), - mcpClient.name + " Configuration", - EditorStyles.boldLabel + new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), + "Unity MCP Editor", + titleStyle ); - EditorGUILayout.Space(5); + EditorGUILayout.Space(15); + } - // Status indicator with colored dot - Rect statusRect = EditorGUILayout.BeginHorizontal(GUILayout.Height(20)); - Color statusColor = GetStatusColor(mcpClient.status); + 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(); - // Draw status dot - DrawStatusDot(statusRect, statusColor); + EditorGUILayout.Space(5); + GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 11 + }; + EditorGUILayout.LabelField($"Ports: Unity {unityPort}, MCP {mcpPort}", portStyle); + EditorGUILayout.Space(5); + EditorGUILayout.EndVertical(); + } - // Status text with some padding - EditorGUILayout.LabelField( - new GUIContent(" " + mcpClient.configStatus), - GUILayout.Height(20), - GUILayout.MinWidth(100) - ); + private void DrawBridgeSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + 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(); + } - // Configure button with improved styling - GUIStyle buttonStyle = new(GUI.skin.button) + private void DrawValidationSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { - padding = new RectOffset(15, 15, 5, 5), - margin = new RectOffset(10, 10, 5, 5), + 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(5); + EditorGUILayout.EndVertical(); + } - // Create muted button style for Manual Setup - GUIStyle mutedButtonStyle = new(buttonStyle); + private void DrawUnifiedClientConfiguration() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14 + }; + EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); + EditorGUILayout.Space(10); + + // 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 DrawClientConfigurationCompact(McpClient mcpClient) + { + // 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(); + + EditorGUILayout.Space(10); + + // Action buttons in horizontal layout + EditorGUILayout.BeginHorizontal(); + if (mcpClient.mcpType == McpTypes.VSCode) { - // Special handling for VSCode GitHub Copilot - if ( - GUILayout.Button( - "Auto Configure VSCode with GitHub Copilot", - buttonStyle, - GUILayout.Height(28) - ) - ) + if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) { ConfigureMcpClient(mcpClient); } - - if (GUILayout.Button("Manual Setup", mutedButtonStyle, GUILayout.Height(28))) + } + else + { + if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) { - // Show VSCode specific manual setup window - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? mcpClient.windowsConfigPath - : mcpClient.linuxConfigPath; - - // Get the Python directory path - string pythonDir = FindPackagePythonDirectory(); + ConfigureMcpClient(mcpClient); + } + } + + if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? mcpClient.windowsConfigPath + : mcpClient.linuxConfigPath; - // Create VSCode-specific configuration + if (mcpClient.mcpType == McpTypes.VSCode) + { + string pythonDir = FindPackagePythonDirectory(); var vscodeConfig = new { mcp = new @@ -176,139 +357,25 @@ private void ConfigurationSection(McpClient mcpClient) } } }; - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); - - // Use the VSCodeManualSetupWindow directly since we're in the same namespace VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); } - } - else - { - // Standard client buttons - if ( - GUILayout.Button( - $"Auto Configure {mcpClient.name}", - buttonStyle, - GUILayout.Height(28) - ) - ) - { - ConfigureMcpClient(mcpClient); - } - - if (GUILayout.Button("Manual Setup", mutedButtonStyle, GUILayout.Height(28))) + else { - // Get the appropriate config path based on OS - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? mcpClient.windowsConfigPath - : mcpClient.linuxConfigPath; ShowManualInstructionsWindow(configPath, mcpClient); } } - EditorGUILayout.Space(5); - - EditorGUILayout.EndVertical(); - - // End horizontal layout if using half-width and at the end of a row - if (useHalfWidth && mcpClients.clients.IndexOf(mcpClient) % 2 == 1) - { - EditorGUILayout.EndHorizontal(); - EditorGUILayout.Space(5); - } - // Add space and end the horizontal layout if last item is odd - else if ( - useHalfWidth - && mcpClients.clients.IndexOf(mcpClient) == mcpClients.clients.Count - 1 - ) - { - EditorGUILayout.EndHorizontal(); - EditorGUILayout.Space(5); - } - } - - private void DrawStatusDot(Rect statusRect, Color statusColor) - { - Rect dotRect = new(statusRect.x + 6, statusRect.y + 4, 12, 12); - Vector3 center = new( - dotRect.x + (dotRect.width / 2), - dotRect.y + (dotRect.height / 2), - 0 - ); - float radius = dotRect.width / 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); - - EditorGUILayout.Space(10); - // Title with improved styling - 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), - "MCP Editor", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(10); - - // Python Server Installation Status Section - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Python Server Status", EditorStyles.boldLabel); - - // Status indicator with colored dot - Rect installStatusRect = EditorGUILayout.BeginHorizontal(GUILayout.Height(20)); - DrawStatusDot(installStatusRect, pythonServerInstallationStatusColor); - EditorGUILayout.LabelField(" " + pythonServerInstallationStatus); + EditorGUILayout.EndHorizontal(); - - EditorGUILayout.LabelField($"Unity Port: {unityPort}"); - EditorGUILayout.LabelField($"MCP Port: {mcpPort}"); - EditorGUILayout.HelpBox( - "Your MCP client (e.g. Cursor or Claude Desktop) will start the server automatically when you start it.", - MessageType.Info - ); - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(10); - - // Unity Bridge Section - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Unity MCP Bridge", EditorStyles.boldLabel); - EditorGUILayout.LabelField($"Status: {(isUnityBridgeRunning ? "Running" : "Stopped")}"); - EditorGUILayout.LabelField($"Port: {unityPort}"); - - if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge")) - { - ToggleUnityBridge(); - } - EditorGUILayout.EndVertical(); - - foreach (McpClient mcpClient in mcpClients.clients) + + EditorGUILayout.Space(8); + // Quick info + GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) { - EditorGUILayout.Space(10); - ConfigurationSection(mcpClient); - } - - EditorGUILayout.EndScrollView(); + fontSize = 10 + }; + EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); } private void ToggleUnityBridge() @@ -355,6 +422,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingConfig ??= new Newtonsoft.Json.Linq.JObject(); // Handle different client types with a switch statement + //Comments: Interestingly, VSCode has mcp.servers.unityMCP while others have mcpServers.unityMCP, which is why we need to prevent this switch (mcpClient?.mcpType) { case McpTypes.VSCode: @@ -370,6 +438,12 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC { existingConfig.mcp.servers = new Newtonsoft.Json.Linq.JObject(); } + + // Add/update UnityMCP server in VSCode settings + existingConfig.mcp.servers.unityMCP = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig) + ); break; default: @@ -379,15 +453,15 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC { existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); } + + // Add/update UnityMCP server in standard MCP settings + existingConfig.mcpServers.unityMCP = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig) + ); break; } - // Add/update UnityMCP server in VSCode settings - existingConfig.mcp.servers.unityMCP = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig) - ); - // Write the merged configuration back to file string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); File.WriteAllText(configPath, mergedJson); @@ -613,6 +687,50 @@ McpClient mcpClient ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } + private void LoadValidationLevelSetting() + { + string savedLevel = EditorPrefs.GetString("UnityMCP_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("UnityMCP_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("UnityMCP_ScriptValidationLevel", "standard"); + return savedLevel; + } + private void CheckMcpConfiguration(McpClient mcpClient) { try