diff --git a/.gitignore b/.gitignore index 75612dc..f1d2443 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ CONTRIBUTING.md.meta .idea/ .vscode/ .aider* +.DS_Store* \ No newline at end of file diff --git a/README.md b/README.md index e422540..19e81d4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,22 @@ # Unity MCP ✨ -**Connect your Unity Editor to LLMs using the Model Context Protocol.** + +[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) +[![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/justinpbarnett/unity-mcp) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/justinpbarnett/unity-mcp) +[![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) + + + + +**Create your Unity apps with LLMs!** Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity. --- -## UnityMCP Workflow ## Key Features 🚀 @@ -15,8 +25,8 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte * **🤖 Automation:** Automate repetitive Unity workflows. * **🧩 Extensible:** Designed to work with various MCP Clients. -
- Expand for Available Tools... +
+ Available Tools Your LLM can use functions like: @@ -25,6 +35,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte * `manage_editor`: Controls and queries the editor's state and settings. * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). + * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
@@ -48,8 +59,6 @@ Unity MCP connects your tools using two components: ### Prerequisites -
- Click to view required software... * **Git CLI:** For cloning the server code. [Download Git](https://git-scm.com/downloads) * **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/) @@ -61,9 +70,31 @@ Unity MCP connects your tools using two components: ``` * **An MCP Client:** * [Claude Desktop](https://claude.ai/download) + * [Claude Code](https://github.com/anthropics/claude-code) * [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) @@ -81,10 +112,12 @@ Unity MCP connects your tools using two components: Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1. -**Option A: Auto-Configure (Recommended for Claude/Cursor)** +image + +**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)** 1. In Unity, go to `Window > Unity MCP`. -2. Click `Auto Configure Claude` or `Auto Configure Cursor`. +2. Click `Auto Configure` on the IDE you uses. 3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client's config file automatically)*. **Option B: Manual Configuration** @@ -163,6 +196,20 @@ If Auto-Configure fails or you use a different client:
+**Option C: Claude Code Registration** + +If you're using Claude Code, you can register the MCP server using these commands: + +**macOS:** +```bash +claude mcp add UnityMCP -- uv --directory /[PATH_TO]/UnityMCP/UnityMcpServer/src run server.py +``` + +**Windows:** +```bash +claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/Scripts/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/Programs/UnityMCP/UnityMcpServer/src" run server.py +``` + --- ## Usage ▶️ @@ -173,8 +220,41 @@ If Auto-Configure fails or you use a different client: 3. **Interact!** Unity tools should now be available in your MCP Client. - Example Prompt: `Create a 3D player controller.` + Example Prompt: `Create a 3D player controller`, `Create a yellow and bridge sun`, `Create a cool shader and apply it on a cube`. +--- + +## Future Dev Plans (Besides PR) 📝 + +### 🔴 High Priority +- [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization +- [ ] **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 + +### 🟡 Medium Priority +- [ ] **Custom Tool Creation GUI** - Visual interface for users to create and configure their own MCP tools +- [ ] **Advanced Logging System** - Logging with filtering, export, and debugging capabilities + +### 🟢 Low Priority +- [ ] **Mobile Platform Support** - Extended toolset for mobile development workflows and platform-specific features +- [ ] **Easier Tool Setup** +- [ ] **Plugin Marketplace** - Community-driven tool sharing and distribution platform + +
+ ✅ 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 +- [ ] **AI-Powered Asset Generation** - Integration with AI tools for automatic 3D models, textures, and animations +- [ ] **Real-time Collaboration** - Live editing sessions between multiple developers *(Currently in progress)* +- [ ] **Analytics Dashboard** - Usage analytics, project insights, and performance metrics +- [ ] **Voice Commands** - Voice-controlled Unity operations for accessibility +- [ ] **AR/VR Tool Integration** - Extended support for immersive development workflows --- @@ -227,7 +307,15 @@ Help make Unity MCP better! -Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues). +Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues) or [Join the Discord](https://discord.gg/vhTUxXaqYr)! + +--- + +## Contact 👋 + +- **justinpbarnett:** [X/Twitter](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett) +- **scriptwonder**: [Email](mailto:swu85@ur.rochester.edu), [LinkedIn](https://www.linkedin.com/in/shutong-wu-214043172/) + --- @@ -235,15 +323,13 @@ Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgit MIT License. See [LICENSE](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fblob%2Fmaster%2FLICENSE) file. ---- - -## Contact 👋 - -- **X/Twitter:** [@justinpbarnett](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett) - - --- ## Acknowledgments 🙏 Thanks to the contributors and the Unity team. + + +## Star History + +[![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/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index d370df8..dec53c8 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -28,6 +28,20 @@ namespace UnityMcpBridge.Editor.Data configStatus = "Not Configured", }, new() + { + name = "Claude Code", + windowsConfigPath = 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", + }, + new() { name = "Cursor", windowsConfigPath = Path.Combine( @@ -43,6 +57,26 @@ namespace UnityMcpBridge.Editor.Data mcpType = McpTypes.Cursor, configStatus = "Not Configured", }, + new() + { + name = "VSCode GitHub Copilot", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Code", + "User", + "settings.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "settings.json" + ), + mcpType = McpTypes.VSCode, + configStatus = "Not Configured", + }, }; // Initialize status enums after construction diff --git a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs index 51f6d97..5fc3fce 100644 --- a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs +++ b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs @@ -13,7 +13,7 @@ namespace UnityMcpBridge.Editor.Helpers /// /// Handles serialization of GameObjects and Components for MCP responses. /// Includes reflection helpers and caching for performance. - /// tew + /// public static class GameObjectSerializer { // --- Data Serialization --- @@ -422,7 +422,7 @@ namespace UnityMcpBridge.Editor.Helpers 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."); + Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); } } @@ -505,7 +505,7 @@ namespace UnityMcpBridge.Editor.Helpers // Helper to create JToken using the output serializer private static JToken CreateTokenFromValue(object value, Type type) { - if (value == null) return JValue.CreateNull(); + if (value == null) return JValue.CreateNull(); try { @@ -514,12 +514,12 @@ namespace UnityMcpBridge.Editor.Helpers } 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 + 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."); + Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); return null; // Indicate serialization failure } } diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs new file mode 100644 index 0000000..8e368a6 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using Newtonsoft.Json; +using UnityEngine; + +namespace UnityMcpBridge.Editor.Helpers +{ + /// + /// Manages dynamic port allocation and persistent storage for Unity MCP Bridge + /// + public static class PortManager + { + 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 + int storedPort = LoadStoredPort(); + if (storedPort > 0 && IsPortAvailable(storedPort)) + { + Debug.Log($"Using stored port {storedPort}"); + return storedPort; + } + + // If no stored port or stored port is unavailable, find a new one + 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); + Debug.Log($"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)) + { + Debug.Log($"Using default port {DefaultPort}"); + return DefaultPort; + } + + Debug.Log($"Default port {DefaultPort} is in use, searching for alternative..."); + + // Search for alternatives + for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) + { + if (IsPortAvailable(port)) + { + Debug.Log($"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 + /// + /// 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; + } + } + + /// + /// 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 = Path.Combine(registryDir, RegistryFileName); + string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + File.WriteAllText(registryFile, json); + + Debug.Log($"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 = Path.Combine(GetRegistryDirectory(), RegistryFileName); + + if (!File.Exists(registryFile)) + { + return 0; + } + + 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 = Path.Combine(GetRegistryDirectory(), RegistryFileName); + + if (!File.Exists(registryFile)) + { + return null; + } + + 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"); + } + } +} \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta b/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta new file mode 100644 index 0000000..ee3f667 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs.meta @@ -0,0 +1,11 @@ +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/UnityMcpBridge/Editor/Models/McpTypes.cs b/UnityMcpBridge/Editor/Models/McpTypes.cs index 913ed47..cb691a2 100644 --- a/UnityMcpBridge/Editor/Models/McpTypes.cs +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs @@ -4,6 +4,8 @@ namespace UnityMcpBridge.Editor.Models { ClaudeDesktop, Cursor, + VSCode, + ClaudeCode, } } diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index 18e2de9..ce502b4 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -20,6 +20,7 @@ namespace UnityMcpBridge.Editor.Tools { "HandleManageAsset", ManageAsset.HandleCommand }, { "HandleReadConsole", ReadConsole.HandleCommand }, { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, + { "HandleManageShader", ManageShader.HandleCommand}, }; /// diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs index b5de165..18b4f04 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs @@ -66,13 +66,15 @@ namespace UnityMcpBridge.Editor.Tools /// private static object ExecuteItem(JObject @params) { - string menuPath = @params["menu_path"]?.ToString(); + // Try both naming conventions: snake_case and camelCase + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). if (string.IsNullOrWhiteSpace(menuPath)) { - return Response.Error("Required parameter 'menu_path' is missing or empty."); + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); } // Validate against blacklist diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs index 7a0dad7..432b234 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -8,6 +8,14 @@ using UnityEditor; using UnityEngine; using UnityMcpBridge.Editor.Helpers; // For Response class +#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 UnityMcpBridge.Editor.Tools { /// @@ -177,6 +185,14 @@ namespace UnityMcpBridge.Editor.Tools 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(); @@ -891,6 +907,30 @@ namespace UnityMcpBridge.Editor.Tools ); } } + } 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) @@ -948,6 +988,77 @@ namespace UnityMcpBridge.Editor.Tools 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. /// diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 66c64cb..36897a9 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -9,8 +9,8 @@ using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; using UnityEngine.SceneManagement; -using UnityMcpBridge.Editor.Helpers; // For Response class AND GameObjectSerializer -using UnityMcpBridge.Runtime.Serialization; // <<< Keep for Converters access? Might not be needed here directly +using UnityMcpBridge.Editor.Helpers; // For Response class +using UnityMcpBridge.Runtime.Serialization; namespace UnityMcpBridge.Editor.Tools { @@ -23,10 +23,6 @@ namespace UnityMcpBridge.Editor.Tools public static object HandleCommand(JObject @params) { - // --- DEBUG --- Log the raw parameter value --- - // JToken rawIncludeFlag = @params["includeNonPublicSerialized"]; - // Debug.Log($"[HandleCommand Debug] Raw includeNonPublicSerialized parameter: Type={rawIncludeFlag?.Type.ToString() ?? "Null"}, Value={rawIncludeFlag?.ToString() ?? "N/A"}"); - // --- END DEBUG --- string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) @@ -219,17 +215,22 @@ namespace UnityMcpBridge.Editor.Tools $"[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." ); @@ -237,12 +238,12 @@ namespace UnityMcpBridge.Editor.Tools $"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}'" @@ -260,9 +261,12 @@ namespace UnityMcpBridge.Editor.Tools } 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 } } @@ -277,6 +281,7 @@ namespace UnityMcpBridge.Editor.Tools 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 @@ -309,18 +314,21 @@ namespace UnityMcpBridge.Editor.Tools 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"); @@ -352,6 +360,7 @@ namespace UnityMcpBridge.Editor.Tools // 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 { @@ -448,13 +457,16 @@ namespace UnityMcpBridge.Editor.Tools 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( @@ -465,6 +477,7 @@ namespace UnityMcpBridge.Editor.Tools try { + // Ensure directory exists using the final saving path string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); if ( !string.IsNullOrEmpty(directoryPath) @@ -477,7 +490,7 @@ namespace UnityMcpBridge.Editor.Tools $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" ); } - + // Use SaveAsPrefabAssetAndConnect with the final saving path finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( newGo, finalPrefabPath, @@ -486,6 +499,7 @@ namespace UnityMcpBridge.Editor.Tools 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." @@ -494,16 +508,21 @@ namespace UnityMcpBridge.Editor.Tools 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 @@ -529,6 +548,7 @@ namespace UnityMcpBridge.Editor.Tools } // Use the new serializer helper + //return Response.Success(successMessage, GetGameObjectData(finalInstance)); return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } @@ -546,6 +566,7 @@ namespace UnityMcpBridge.Editor.Tools ); } + // Record state for Undo *before* modifications Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); Undo.RecordObject(targetGo, "Modify GameObject Properties"); @@ -564,6 +585,7 @@ namespace UnityMcpBridge.Editor.Tools if (parentToken != null) { GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); + // Check for hierarchy loops if ( newParentGo == null && !( @@ -600,8 +622,11 @@ namespace UnityMcpBridge.Editor.Tools // 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 { @@ -610,6 +635,7 @@ namespace UnityMcpBridge.Editor.Tools } catch (UnityException ex) { + // Check if the error is specifically because the tag doesn't exist if (ex.Message.Contains("is not defined")) { Debug.LogWarning( @@ -617,7 +643,12 @@ namespace UnityMcpBridge.Editor.Tools ); 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( @@ -626,6 +657,7 @@ namespace UnityMcpBridge.Editor.Tools } 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}" ); @@ -636,6 +668,7 @@ namespace UnityMcpBridge.Editor.Tools } else { + // If the exception was for a different reason, return the original error return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } @@ -681,12 +714,14 @@ namespace UnityMcpBridge.Editor.Tools } // --- 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)) { @@ -746,7 +781,11 @@ namespace UnityMcpBridge.Editor.Tools if (!modified) { - // Use the new serializer helper + // 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) @@ -754,11 +793,15 @@ namespace UnityMcpBridge.Editor.Tools } EditorUtility.SetDirty(targetGo); // Mark scene as dirty - // Use the new serializer helper + // 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) @@ -821,11 +864,12 @@ namespace UnityMcpBridge.Editor.Tools } // 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) + private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) @@ -1443,6 +1487,8 @@ namespace UnityMcpBridge.Editor.Tools Debug.LogWarning( $"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}'). Property might not exist, be read-only, or type mismatch." ); + // Optionally return an error here instead of just logging + // return Response.Error($"Could not set property '{propName}' on component '{compName}'."); } } catch (Exception e) @@ -1450,6 +1496,8 @@ namespace UnityMcpBridge.Editor.Tools Debug.LogError( $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" ); + // Optionally return an error here + // return Response.Error($"Error setting property '{propName}' on '{compName}': {e.Message}"); } } EditorUtility.SetDirty(targetComponent); @@ -1488,6 +1536,7 @@ namespace UnityMcpBridge.Editor.Tools 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 @@ -1538,7 +1587,8 @@ namespace UnityMcpBridge.Editor.Tools /// /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") /// - // Pass the input serializer for conversions + // Pass the input serializer for conversions + //Using the serializer helper private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) { try @@ -1560,6 +1610,7 @@ namespace UnityMcpBridge.Editor.Tools bool isArray = false; int arrayIndex = -1; + // Check if this part contains array indexing if (part.Contains("[")) { int startBracket = part.IndexOf('['); @@ -1577,7 +1628,7 @@ namespace UnityMcpBridge.Editor.Tools } } } - + // Get the property/field PropertyInfo propInfo = currentType.GetProperty(part, flags); FieldInfo fieldInfo = null; if (propInfo == null) @@ -1592,11 +1643,12 @@ namespace UnityMcpBridge.Editor.Tools } } + // 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( @@ -1604,7 +1656,7 @@ namespace UnityMcpBridge.Editor.Tools ); return false; } - + // If this part was an array or list, access the specific index if (isArray) { if (currentObject is Material[]) @@ -1653,32 +1705,32 @@ namespace UnityMcpBridge.Editor.Tools { // 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 {} + 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 + 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 {} + 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 {} + 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 {} + 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 {} + try { + Texture texture = value.ToObject(inputSerializer); + if (texture != null) { + material.SetTexture(finalPart, texture); + return true; + } + } catch { } } Debug.LogWarning( @@ -1698,7 +1750,7 @@ namespace UnityMcpBridge.Editor.Tools finalPropInfo.SetValue(currentObject, convertedValue); return true; } - else { + else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } @@ -1707,16 +1759,16 @@ namespace UnityMcpBridge.Editor.Tools FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); if (finalFieldInfo != null) { - // Use the inputSerializer for conversion + // 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] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); + } } else { @@ -1742,6 +1794,7 @@ namespace UnityMcpBridge.Editor.Tools /// 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; @@ -1760,6 +1813,7 @@ namespace UnityMcpBridge.Editor.Tools } else if (c == '.' && !inBrackets) { + // Found a dot separator outside of brackets parts.Add(path.Substring(startIndex, i - startIndex)); startIndex = i + 1; } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 70c9f71..d79e17a 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -7,10 +7,43 @@ using UnityEditor; 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 @@ namespace UnityMcpBridge.Editor.Tools 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) { - // 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}"); + string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); + return Response.Error(errorMessage); + } + 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 @@ -243,11 +282,18 @@ namespace UnityMcpBridge.Editor.Tools 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) { - Debug.LogWarning($"Potential syntax error in script being updated: {name}"); - // Consider if this should be a hard error or just a warning + string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors); + return Response.Error(errorMessage); + } + 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 @@ -361,27 +407,624 @@ namespace UnityMcpBridge.Editor.Tools } /// - /// 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) { - if (string.IsNullOrEmpty(contents)) - return true; // Empty is technically valid? + return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); + } - int braceBalance = 0; - foreach (char c in contents) + /// + /// 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)) { - if (c == '{') - braceBalance++; - else if (c == '}') - braceBalance--; + return true; // Empty content is valid } - return braceBalance == 0; - // This is extremely basic. A real C# parser/compiler check would be ideal - // but is complex to implement directly here. + // 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; + 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*[;})\]])"); + 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/Tools/ManageShader.cs b/UnityMcpBridge/Editor/Tools/ManageShader.cs new file mode 100644 index 0000000..03b28f1 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageShader.cs @@ -0,0 +1,342 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; + +namespace UnityMcpBridge.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); + 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); + 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/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta b/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta new file mode 100644 index 0000000..89d10cd --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageShader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcf4f1f3110494344b2af9324cf5c571 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index a0b112b..4f3a608 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -25,9 +25,40 @@ namespace UnityMcpBridge.Editor string, (string commandJson, TaskCompletionSource tcs) > commandQueue = new(); - private static readonly int unityPort = 6400; // Hardcoded port + private static int currentUnityPort = 6400; // Dynamic port, starts with default + private static bool isAutoConnectMode = 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 + { + // Discover new port and save it + currentUnityPort = PortManager.DiscoverNewPort(); + + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Start(); + isRunning = true; + isAutoConnectMode = true; + + Debug.Log($"UnityMcpBridge auto-connected on port {currentUnityPort}"); + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + } + catch (Exception ex) + { + Debug.LogError($"Auto-connect failed: {ex.Message}"); + throw; + } + } public static bool FolderExists(string path) { @@ -74,10 +105,14 @@ namespace UnityMcpBridge.Editor try { - listener = new TcpListener(IPAddress.Loopback, unityPort); + // Use PortManager to get available port with automatic fallback + currentUnityPort = PortManager.GetPortWithFallback(); + + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Start(); isRunning = true; - Debug.Log($"UnityMcpBridge started on port {unityPort}."); + isAutoConnectMode = false; // Normal startup mode + Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); // Assuming ListenerLoop and ProcessCommands are defined elsewhere Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; @@ -87,7 +122,7 @@ namespace UnityMcpBridge.Editor if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { Debug.LogError( - $"Port {unityPort} is already in use. Ensure no other instances are running or change the port." + $"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation." ); } else @@ -379,6 +414,7 @@ namespace UnityMcpBridge.Editor "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), "execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), _ => throw new ArgumentException( diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index 17c93e0..89f6099 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -8,13 +8,13 @@ namespace UnityMcpBridge.Editor.Windows // Editor window to display manual configuration instructions public class ManualConfigEditorWindow : EditorWindow { - private string configPath; - private string configJson; - private Vector2 scrollPos; - private bool pathCopied = false; - private bool jsonCopied = false; - private float copyFeedbackTimer = 0; - private McpClient mcpClient; + 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) { @@ -26,7 +26,7 @@ namespace UnityMcpBridge.Editor.Windows window.Show(); } - private void OnGUI() + protected virtual void OnGUI() { scrollPos = EditorGUILayout.BeginScrollView(scrollPos); @@ -39,7 +39,7 @@ namespace UnityMcpBridge.Editor.Windows ); GUI.Label( new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - mcpClient.name + " Manual Configuration", + (mcpClient?.name ?? "Unknown") + " Manual Configuration", EditorStyles.boldLabel ); EditorGUILayout.Space(10); @@ -70,17 +70,17 @@ namespace UnityMcpBridge.Editor.Windows }; EditorGUILayout.LabelField( - "1. Open " + mcpClient.name + " config file by either:", + "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:", instructionStyle ); - if (mcpClient.mcpType == McpTypes.ClaudeDesktop) + if (mcpClient?.mcpType == McpTypes.ClaudeDesktop) { EditorGUILayout.LabelField( " a) Going to Settings > Developer > Edit Config", instructionStyle ); } - else if (mcpClient.mcpType == McpTypes.Cursor) + else if (mcpClient?.mcpType == McpTypes.Cursor) { EditorGUILayout.LabelField( " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", @@ -96,16 +96,23 @@ namespace UnityMcpBridge.Editor.Windows // Path section with improved styling EditorGUILayout.BeginVertical(EditorStyles.helpBox); string displayPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (mcpClient != null) { - displayPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - displayPath = mcpClient.linuxConfigPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + displayPath = mcpClient.windowsConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + displayPath = mcpClient.linuxConfigPath; + } + else + { + displayPath = configPath; + } } else { @@ -224,7 +231,7 @@ namespace UnityMcpBridge.Editor.Windows EditorGUILayout.Space(10); EditorGUILayout.LabelField( - "3. Save the file and restart " + mcpClient.name, + "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"), instructionStyle ); @@ -245,7 +252,7 @@ namespace UnityMcpBridge.Editor.Windows EditorGUILayout.EndScrollView(); } - private void Update() + protected virtual void Update() { // Handle the feedback message timer if (copyFeedbackTimer > 0) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index c794fb7..62c919d 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using Newtonsoft.Json; using UnityEditor; @@ -16,9 +18,21 @@ namespace UnityMcpBridge.Editor.Windows private Vector2 scrollPosition; private string pythonServerInstallationStatus = "Not Installed"; private Color pythonServerInstallationStatusColor = Color.red; - private const int unityPort = 6400; // Hardcoded Unity port - private const int mcpPort = 6500; // Hardcoded MCP port + private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) 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() @@ -30,11 +44,25 @@ namespace UnityMcpBridge.Editor.Windows { UpdatePythonServerInstallationStatus(); + // Refresh bridge status isUnityBridgeRunning = UnityMcpBridge.IsRunning; foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); } + + // Load validation level setting + LoadValidationLevelSetting(); + } + + private void OnFocus() + { + // Refresh configuration status when window gains focus + foreach (McpClient mcpClient in mcpClients.clients) + { + CheckMcpConfiguration(mcpClient); + } + Repaint(); } private Color GetStatusColor(McpStatus status) @@ -79,113 +107,18 @@ namespace UnityMcpBridge.Editor.Windows } } - 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; - - // Begin horizontal layout if using half-width - if (useHalfWidth && mcpClients.clients.IndexOf(mcpClient) % 2 == 0) - { - EditorGUILayout.BeginHorizontal(); - } - - // Begin section with fixed width - EditorGUILayout.BeginVertical(EditorStyles.helpBox, GUILayout.Width(sectionWidth)); - - // Header with improved styling - EditorGUILayout.Space(5); - Rect headerRect = EditorGUILayout.GetControlRect(false, 24); - GUI.Label( - new Rect( - headerRect.x + 8, - headerRect.y + 4, - headerRect.width - 16, - headerRect.height - ), - mcpClient.name + " Configuration", - EditorStyles.boldLabel - ); - EditorGUILayout.Space(5); - - // Status indicator with colored dot - Rect statusRect = EditorGUILayout.BeginHorizontal(GUILayout.Height(20)); - Color statusColor = GetStatusColor(mcpClient.status); - - // Draw status dot - DrawStatusDot(statusRect, statusColor); - - // Status text with some padding - EditorGUILayout.LabelField( - new GUIContent(" " + mcpClient.configStatus), - GUILayout.Height(20), - GUILayout.MinWidth(100) - ); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - - // Configure button with improved styling - GUIStyle buttonStyle = new(GUI.skin.button) - { - padding = new RectOffset(15, 15, 5, 5), - margin = new RectOffset(10, 10, 5, 5), - }; - - // Create muted button style for Manual Setup - GUIStyle mutedButtonStyle = new(buttonStyle); - - if ( - GUILayout.Button( - $"Auto Configure {mcpClient.name}", - buttonStyle, - GUILayout.Height(28) - ) - ) - { - ConfigureMcpClient(mcpClient); - } - - if (GUILayout.Button("Manual Setup", mutedButtonStyle, GUILayout.Height(28))) - { - // 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); + 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 = dotRect.width / 2; + float radius = size / 2; // Draw the main dot Handles.color = statusColor; @@ -205,59 +138,313 @@ namespace UnityMcpBridge.Editor.Windows { 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); + DrawBridgeSection(); + EditorGUILayout.EndVertical(); + + // Right column - Validation Settings + EditorGUILayout.BeginVertical(); + DrawValidationSection(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + 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) - ); + + // 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(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - "MCP Editor", - EditorStyles.boldLabel + new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), + "Unity MCP Editor", + titleStyle ); - EditorGUILayout.Space(10); + EditorGUILayout.Space(15); + } - // Python Server Installation Status Section + private void DrawServerStatusSection() + { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Python Server Status", EditorStyles.boldLabel); + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14 + }; + EditorGUILayout.LabelField("Server Status", sectionTitleStyle); + EditorGUILayout.Space(8); - // Status indicator with colored dot - Rect installStatusRect = EditorGUILayout.BeginHorizontal(GUILayout.Height(20)); - DrawStatusDot(installStatusRect, pythonServerInstallationStatusColor); - EditorGUILayout.LabelField(" " + pythonServerInstallationStatus); + 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.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.Space(5); + + // Connection mode and Auto-Connect button + EditorGUILayout.BeginHorizontal(); + + bool isAutoMode = UnityMcpBridge.IsAutoConnectMode(); + GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; + EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); + + // Auto-Connect button + if (GUILayout.Button(isAutoMode ? "Connected ✓" : "Auto-Connect", GUILayout.Width(100), GUILayout.Height(24))) + { + if (!isAutoMode) + { + try + { + UnityMcpBridge.StartAutoConnect(); + // Update UI state + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + Repaint(); + } + catch (Exception ex) + { + EditorUtility.DisplayDialog("Auto-Connect Failed", ex.Message, "OK"); + } + } + } + + EditorGUILayout.EndHorizontal(); + + // Current ports display + int currentUnityPort = UnityMcpBridge.GetCurrentPort(); + GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 11 + }; + EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); + EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); + } - EditorGUILayout.Space(10); - - // Unity Bridge Section + private void DrawBridgeSection() + { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Unity MCP Bridge", EditorStyles.boldLabel); - EditorGUILayout.LabelField($"Status: {(isUnityBridgeRunning ? "Running" : "Stopped")}"); - EditorGUILayout.LabelField($"Port: {unityPort}"); + + 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(); - if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge")) + EditorGUILayout.Space(8); + if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) { ToggleUnityBridge(); } + EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); + } - foreach (McpClient mcpClient in mcpClients.clients) + private void DrawValidationSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { - EditorGUILayout.Space(10); - ConfigurationSection(mcpClient); + 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(); + } - EditorGUILayout.EndScrollView(); + 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) + { + if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) + { + ConfigureMcpClient(mcpClient); + } + } + else if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + bool isConfigured = mcpClient.status == McpStatus.Configured; + string buttonText = isConfigured ? "Unregister UnityMCP with Claude Code" : "Register with Claude Code"; + if (GUILayout.Button(buttonText, GUILayout.Height(32))) + { + if (isConfigured) + { + UnregisterWithClaudeCode(); + } + else + { + string pythonDir = FindPackagePythonDirectory(); + RegisterWithClaudeCode(pythonDir); + } + } + } + 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; + } + + var vscodeConfig = new + { + mcp = new + { + servers = new + { + unityMCP = new + { + command = uvPath, + args = new[] { "--directory", pythonDir, "run", "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 + 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() @@ -274,12 +461,18 @@ namespace UnityMcpBridge.Editor.Windows isUnityBridgeRunning = !isUnityBridgeRunning; } - private string WriteToConfig(string pythonDir, string configPath) + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { + string uvPath = FindUvPath(); + if (uvPath == null) + { + return "UV package manager not found. Please install UV first."; + } + // Create configuration object for unityMCP McpConfigServer unityMCPConfig = new() { - command = "uv", + command = uvPath, args = new[] { "--directory", pythonDir, "run", "server.py" }, }; @@ -295,7 +488,7 @@ namespace UnityMcpBridge.Editor.Windows } catch (Exception e) { - Debug.LogWarning($"Error reading existing config: {e.Message}."); + UnityEngine.Debug.LogWarning($"Error reading existing config: {e.Message}."); } } @@ -303,17 +496,46 @@ namespace UnityMcpBridge.Editor.Windows dynamic existingConfig = JsonConvert.DeserializeObject(existingJson); existingConfig ??= new Newtonsoft.Json.Linq.JObject(); - // Ensure mcpServers object exists - if (existingConfig.mcpServers == null) + // 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) { - existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); - } + case McpTypes.VSCode: + // VSCode specific configuration + // Ensure mcp object exists + if (existingConfig.mcp == null) + { + existingConfig.mcp = new Newtonsoft.Json.Linq.JObject(); + } - // Add/update unityMCP while preserving other servers - existingConfig.mcpServers.unityMCP = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig) - ); + // Ensure mcp.servers object exists + if (existingConfig.mcp.servers == null) + { + 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: + // Standard MCP configuration (Claude Desktop, Cursor, etc.) + // Ensure mcpServers object exists + if (existingConfig.mcpServers == null) + { + existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); + } + + // Add/update UnityMCP server in standard MCP settings + existingConfig.mcpServers.unityMCP = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig) + ); + break; + } // Write the merged configuration back to file string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); @@ -334,22 +556,56 @@ namespace UnityMcpBridge.Editor.Windows { // Get the Python directory path using Package Manager API string pythonDir = FindPackagePythonDirectory(); - - // Create the manual configuration message - McpConfig jsonConfig = new() - { - mcpServers = new McpConfigServers - { - unityMCP = new McpConfigServer - { - command = "uv", - args = new[] { "--directory", pythonDir, "run", "server.py" }, - }, - }, - }; - + string manualConfigJson; + + // Create common JsonSerializerSettings JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); + + // Use switch statement to handle different client types + switch (mcpClient.mcpType) + { + case McpTypes.VSCode: + // Create VSCode-specific configuration with proper format + var vscodeConfig = new + { + mcp = new + { + servers = new + { + unityMCP = new + { + command = "uv", + args = new[] { "--directory", pythonDir, "run", "server.py" } + } + } + } + }; + manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); + break; + + default: + // Create standard MCP configuration for other clients + 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[] { "--directory", pythonDir, "run", "server.py" }, + }, + }, + }; + manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); + break; + } ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } @@ -372,12 +628,17 @@ namespace UnityMcpBridge.Editor.Windows if (package.name == "com.justinpbarnett.unity-mcp") { string packagePath = package.resolvedPath; + + // Check for local package structure (UnityMcpServer/src) + string localPythonDir = Path.Combine(Path.GetDirectoryName(packagePath), "UnityMcpServer", "src"); + if (Directory.Exists(localPythonDir) && File.Exists(Path.Combine(localPythonDir, "server.py"))) + { + return localPythonDir; + } + + // Check for old structure (Python subdirectory) string potentialPythonDir = Path.Combine(packagePath, "Python"); - - if ( - Directory.Exists(potentialPythonDir) - && File.Exists(Path.Combine(potentialPythonDir, "server.py")) - ) + if (Directory.Exists(potentialPythonDir) && File.Exists(Path.Combine(potentialPythonDir, "server.py"))) { return potentialPythonDir; } @@ -386,13 +647,22 @@ namespace UnityMcpBridge.Editor.Windows } else if (request.Error != null) { - Debug.LogError("Failed to list packages: " + request.Error.message); + UnityEngine.Debug.LogError("Failed to list packages: " + request.Error.message); } // If not found via Package Manager, try manual approaches - // First check for local installation + // Check for local development structure string[] possibleDirs = { + // Check in the Unity project's Packages folder (for local package development) + Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "unity-mcp", "UnityMcpServer", "src")), + // Check relative to the Unity project (for development) + Path.GetFullPath(Path.Combine(Application.dataPath, "..", "unity-mcp", "UnityMcpServer", "src")), + // Check in user's home directory (common installation location) + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "unity-mcp", "UnityMcpServer", "src"), + // Check in Applications folder (macOS/Linux common location) + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "UnityMCP", "UnityMcpServer", "src"), + // Legacy Python folder structure Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python")), }; @@ -405,11 +675,11 @@ namespace UnityMcpBridge.Editor.Windows } // If still not found, return the placeholder path - Debug.LogWarning("Could not find Python directory, using placeholder path"); + UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); } catch (Exception e) { - Debug.LogError($"Error finding package path: {e.Message}"); + UnityEngine.Debug.LogError($"Error finding package path: {e.Message}"); } return pythonDir; @@ -450,7 +720,7 @@ namespace UnityMcpBridge.Editor.Windows return "Manual Configuration Required"; } - string result = WriteToConfig(pythonDir, configPath); + string result = WriteToConfig(pythonDir, configPath, mcpClient); // Update the client status after successful configuration if (result == "Configured successfully") @@ -477,7 +747,7 @@ namespace UnityMcpBridge.Editor.Windows } ShowManualInstructionsWindow(configPath, mcpClient); - Debug.LogError( + UnityEngine.Debug.LogError( $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); return $"Failed to configure {mcpClient.name}"; @@ -495,13 +765,20 @@ namespace UnityMcpBridge.Editor.Windows 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 = "uv", + command = uvPath, args = new[] { "--directory", pythonDir, "run", "server.py" }, }, }, @@ -513,10 +790,61 @@ namespace UnityMcpBridge.Editor.Windows 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 { + // Special handling for Claude Code + if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + CheckClaudeCodeConfiguration(mcpClient); + return; + } + string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -542,18 +870,42 @@ namespace UnityMcpBridge.Editor.Windows } string configJson = File.ReadAllText(configPath); - McpConfig config = JsonConvert.DeserializeObject(configJson); - - if (config?.mcpServers?.unityMCP != null) + string pythonDir = ServerInstaller.GetServerPath(); + + // Use switch statement to handle different client types, extracting common logic + string[] args = null; + bool configExists = false; + + switch (mcpClient.mcpType) { - string pythonDir = ServerInstaller.GetServerPath(); - if ( - pythonDir != null - && Array.Exists( - config.mcpServers.unityMCP.args, - arg => arg.Contains(pythonDir, StringComparison.Ordinal) - ) - ) + case McpTypes.VSCode: + dynamic config = JsonConvert.DeserializeObject(configJson); + + if (config?.mcp?.servers?.unityMCP != null) + { + // Extract args from VSCode config format + 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) + { + if (pythonDir != null && + Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal))) { mcpClient.SetStatus(McpStatus.Configured); } @@ -572,5 +924,399 @@ namespace UnityMcpBridge.Editor.Windows mcpClient.SetStatus(McpStatus.Error, e.Message); } } + + private void RegisterWithClaudeCode(string pythonDir) + { + string command; + string args; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + command = FindClaudeCommand(); + + if (string.IsNullOrEmpty(command)) + { + UnityEngine.Debug.LogError("Claude CLI not found. Please ensure Claude Code is installed and accessible."); + return; + } + + // Try to find uv.exe in common locations + string uvPath = FindWindowsUvPath(); + + if (string.IsNullOrEmpty(uvPath)) + { + // Fallback to expecting uv in PATH + args = $"mcp add UnityMCP -- uv --directory \"{pythonDir}\" run server.py"; + } + else + { + args = $"mcp add UnityMCP -- \"{uvPath}\" --directory \"{pythonDir}\" run server.py"; + } + } + else + { + // Use full path to claude command + command = "/usr/local/bin/claude"; + args = $"mcp add UnityMCP -- uv --directory \"{pythonDir}\" run server.py"; + } + + try + { + // Get the Unity project directory (where the Assets folder is) + string unityProjectDir = Application.dataPath; + string projectDir = Path.GetDirectoryName(unityProjectDir); + + var psi = new ProcessStartInfo + { + FileName = command, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = projectDir // Set working directory to Unity project directory + }; + + // Set PATH to include common binary locations + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + string additionalPaths = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"; + psi.EnvironmentVariables["PATH"] = $"{additionalPaths}:{currentPath}"; + + using var process = Process.Start(psi); + string output = process.StandardOutput.ReadToEnd(); + string errors = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + + + // Check for success or already exists + if (output.Contains("Added stdio MCP server") || errors.Contains("already exists")) + { + // Force refresh the configuration status + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + CheckMcpConfiguration(claudeClient); + } + Repaint(); + UnityEngine.Debug.Log("UnityMCP server successfully registered from Claude Code."); + + + } + else if (!string.IsNullOrEmpty(errors)) + { + UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); + } + } + catch (Exception e) + { + UnityEngine.Debug.LogError($"Claude CLI registration failed: {e.Message}"); + } + } + + private void UnregisterWithClaudeCode() + { + string command; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + command = FindClaudeCommand(); + + if (string.IsNullOrEmpty(command)) + { + UnityEngine.Debug.LogError("Claude CLI not found. Please ensure Claude Code is installed and accessible."); + return; + } + } + else + { + // Use full path to claude command + command = "/usr/local/bin/claude"; + } + + try + { + // Get the Unity project directory (where the Assets folder is) + string unityProjectDir = Application.dataPath; + string projectDir = Path.GetDirectoryName(unityProjectDir); + + var psi = new ProcessStartInfo + { + FileName = command, + Arguments = "mcp remove UnityMCP", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = projectDir // Set working directory to Unity project directory + }; + + // Set PATH to include common binary locations + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + string additionalPaths = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"; + psi.EnvironmentVariables["PATH"] = $"{additionalPaths}:{currentPath}"; + + using var process = Process.Start(psi); + string output = process.StandardOutput.ReadToEnd(); + string errors = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + // Check for success + if (output.Contains("Removed MCP server") || process.ExitCode == 0) + { + // Force refresh the configuration status + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + CheckMcpConfiguration(claudeClient); + } + Repaint(); + + UnityEngine.Debug.Log("UnityMCP server successfully unregistered from Claude Code."); + } + else if (!string.IsNullOrEmpty(errors)) + { + UnityEngine.Debug.LogWarning($"Claude MCP removal errors: {errors}"); + } + } + catch (Exception e) + { + UnityEngine.Debug.LogError($"Claude CLI unregistration failed: {e.Message}"); + } + } + + private string FindUvPath() + { + string uvPath = null; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + uvPath = FindWindowsUvPath(); + } + else + { + // macOS/Linux paths + string[] possiblePaths = { + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/usr/local/bin/uv", + "/opt/homebrew/bin/uv", + "/usr/bin/uv" + }; + + foreach (string path in possiblePaths) + { + if (File.Exists(path)) + { + uvPath = path; + break; + } + } + + // If not found in common locations, try to find via which command + if (uvPath == null) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + if (!string.IsNullOrEmpty(output) && File.Exists(output)) + { + uvPath = output; + } + } + catch + { + // Ignore errors + } + } + } + + if (uvPath == null) + { + UnityEngine.Debug.LogError("UV package manager not found! Please install UV first:\n" + + "• macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n" + + "• Windows: pip install uv\n" + + "• Or visit: https://docs.astral.sh/uv/getting-started/installation"); + return null; + } + + return uvPath; + } + + private string FindWindowsUvPath() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + + // Check for different Python versions + string[] pythonVersions = { "Python313", "Python312", "Python311", "Python310", "Python39", "Python38" }; + + foreach (string version in pythonVersions) + { + string uvPath = Path.Combine(appData, version, "Scripts", "uv.exe"); + if (File.Exists(uvPath)) + { + return uvPath; + } + } + + // Check Program Files locations + string[] programFilesPaths = { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Python"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python") + }; + + foreach (string basePath in programFilesPaths) + { + if (Directory.Exists(basePath)) + { + foreach (string dir in Directory.GetDirectories(basePath, "Python*")) + { + string uvPath = Path.Combine(dir, "Scripts", "uv.exe"); + if (File.Exists(uvPath)) + { + return uvPath; + } + } + } + } + + return null; // Will fallback to using 'uv' from PATH + } + + private string FindClaudeCommand() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Common locations for Claude CLI on Windows + string[] possiblePaths = { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm", "claude.cmd"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "npm", "claude.cmd"), + "claude.cmd", // Fallback to PATH + "claude" // Final fallback + }; + + foreach (string path in possiblePaths) + { + if (path.Contains("\\") && File.Exists(path)) + { + return path; + } + } + + // Try to find via where command + try + { + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = "claude", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + if (!string.IsNullOrEmpty(output)) + { + string[] lines = output.Split('\n'); + foreach (string line in lines) + { + string cleanPath = line.Trim(); + if (File.Exists(cleanPath)) + { + return cleanPath; + } + } + } + } + catch + { + // Ignore errors and fall back + } + + return "claude"; // Final fallback to PATH + } + else + { + return "/usr/local/bin/claude"; + } + } + + 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 + string configPath = mcpClient.linuxConfigPath; // ~/.claude.json + if (!File.Exists(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 UnityMCP 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; + if (projectPath == projectDir && project.Value?.mcpServers != null) + { + // Check for UnityMCP (case variations) + var servers = project.Value.mcpServers; + if (servers.UnityMCP != null || servers.unityMCP != null) + { + // Found UnityMCP 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); + } + } } } diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs new file mode 100644 index 0000000..a0b78e2 --- /dev/null +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs @@ -0,0 +1,295 @@ +using System.Runtime.InteropServices; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Models; + +namespace UnityMcpBridge.Editor.Windows +{ + public class VSCodeManualSetupWindow : ManualConfigEditorWindow + { + public static new 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 Unity MCP", + 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 VSCode Settings (File > Preferences > Settings)", + instructionStyle + ); + EditorGUILayout.LabelField( + "b) Click on the 'Open Settings (JSON)' button in the top right", + instructionStyle + ); + EditorGUILayout.LabelField( + "c) Add the MCP configuration shown below to your settings.json file", + instructionStyle + ); + EditorGUILayout.LabelField( + "d) Save the file and restart VSCode", + instructionStyle + ); + EditorGUILayout.Space(5); + + EditorGUILayout.LabelField( + "3. VSCode settings.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", + "settings.json" + ); + } + else + { + displayPath = System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "settings.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 settings.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 Unity MCP 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/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta new file mode 100644 index 0000000..437ccab --- /dev/null +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 377fe73d52cf0435fabead5f50a0d204 \ No newline at end of file diff --git a/UnityMcpServer/src/Dockerfile b/UnityMcpServer/src/Dockerfile new file mode 100644 index 0000000..3f884f3 --- /dev/null +++ b/UnityMcpServer/src/Dockerfile @@ -0,0 +1,27 @@ +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/UnityMcpServer/src/config.py b/UnityMcpServer/src/config.py index 58f6f84..c42437a 100644 --- a/UnityMcpServer/src/config.py +++ b/UnityMcpServer/src/config.py @@ -15,7 +15,7 @@ class ServerConfig: mcp_port: int = 6500 # Connection settings - connection_timeout: float = 86400.0 # 24 hours timeout + connection_timeout: float = 600.0 # 10 minutes timeout buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Logging settings diff --git a/UnityMcpServer/src/port_discovery.py b/UnityMcpServer/src/port_discovery.py new file mode 100644 index 0000000..a0dfe96 --- /dev/null +++ b/UnityMcpServer/src/port_discovery.py @@ -0,0 +1,69 @@ +""" +Port discovery utility for Unity MCP Server. +Reads port configuration saved by Unity Bridge. +""" + +import json +import os +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger("unity-mcp-server") + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + + REGISTRY_FILE = "unity-mcp-port.json" + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port from registry file with fallback to default + + Returns: + Port number to connect to + """ + registry_file = PortDiscovery.get_registry_path() + + if registry_file.exists(): + try: + with open(registry_file, 'r') as f: + port_config = json.load(f) + + unity_port = port_config.get('unity_port') + if unity_port and isinstance(unity_port, int): + logger.info(f"Discovered Unity port from registry: {unity_port}") + return unity_port + + except Exception as e: + logger.warning(f"Could not read port registry: {e}") + + # Fallback to default port + logger.info("No port registry found, using default port 6400") + return 6400 + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the full port configuration from registry + + Returns: + Port configuration dict or None if not found + """ + registry_file = PortDiscovery.get_registry_path() + + if not registry_file.exists(): + return None + + try: + with open(registry_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not read port configuration: {e}") + return None \ No newline at end of file diff --git a/UnityMcpServer/src/server.py b/UnityMcpServer/src/server.py index 90d0c72..55360b5 100644 --- a/UnityMcpServer/src/server.py +++ b/UnityMcpServer/src/server.py @@ -61,7 +61,8 @@ def asset_creation_strategy() -> str: "- `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\\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" diff --git a/UnityMcpServer/src/tools/__init__.py b/UnityMcpServer/src/tools/__init__.py index 8cfc38e..4d8d63c 100644 --- a/UnityMcpServer/src/tools/__init__.py +++ b/UnityMcpServer/src/tools/__init__.py @@ -3,6 +3,7 @@ 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 .execute_menu_item import register_execute_menu_item_tools @@ -14,6 +15,7 @@ def register_all_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_execute_menu_item_tools(mcp) print("Unity MCP Server tool registration complete.") diff --git a/UnityMcpServer/src/tools/manage_asset.py b/UnityMcpServer/src/tools/manage_asset.py index 328b85a..dada66b 100644 --- a/UnityMcpServer/src/tools/manage_asset.py +++ b/UnityMcpServer/src/tools/manage_asset.py @@ -33,6 +33,9 @@ def register_manage_asset_tools(mcp: FastMCP): 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). diff --git a/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpServer/src/tools/manage_gameobject.py index 0f4c9bf..83ab9c7 100644 --- a/UnityMcpServer/src/tools/manage_gameobject.py +++ b/UnityMcpServer/src/tools/manage_gameobject.py @@ -40,7 +40,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): """Manages GameObjects: create, modify, delete, find, and component operations. Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property'). + 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). @@ -62,8 +62,16 @@ def register_manage_gameobject_tools(mcp: FastMCP): 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 --- diff --git a/UnityMcpServer/src/tools/manage_shader.py b/UnityMcpServer/src/tools/manage_shader.py new file mode 100644 index 0000000..c447a3a --- /dev/null +++ b/UnityMcpServer/src/tools/manage_shader.py @@ -0,0 +1,67 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import os +import base64 + +def register_manage_shader_tools(mcp: FastMCP): + """Register all shader script management tools with the MCP server.""" + + @mcp.tool() + 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'). + """ + 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 to Unity + response = get_unity_connection().send_command("manage_shader", params) + + # Process response from Unity + if 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")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + 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/UnityMcpServer/src/unity_connection.py b/UnityMcpServer/src/unity_connection.py index 252b504..da88d9b 100644 --- a/UnityMcpServer/src/unity_connection.py +++ b/UnityMcpServer/src/unity_connection.py @@ -4,6 +4,7 @@ import logging from dataclasses import dataclass from typing import Dict, Any from config import config +from port_discovery import PortDiscovery # Configure logging using settings from config logging.basicConfig( @@ -16,8 +17,13 @@ logger = logging.getLogger("unity-mcp-server") class UnityConnection: """Manages the socket connection to the Unity Editor.""" host: str = config.unity_host - port: int = config.unity_port + port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() def connect(self) -> bool: """Establish a connection to the Unity Editor."""