Merge branch 'justinpbarnett:master' into master

main
dsarno 2025-07-29 10:09:52 -07:00 committed by GitHub
commit a57752f883
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3063 additions and 301 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ CONTRIBUTING.md.meta
.idea/ .idea/
.vscode/ .vscode/
.aider* .aider*
.DS_Store*

122
README.md
View File

@ -1,12 +1,22 @@
# Unity MCP ✨ # 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. 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.
--- ---
## <picture><source media="(prefers-color-scheme: dark)" srcset="https://github.com/justinpbarnett/unity-mcp/assets/11047284/c279675a-dd58-406b-9613-5b16b5c6bb63"><source media="(prefers-color-scheme: light)" srcset="https://github.com/justinpbarnett/unity-mcp/assets/11047284/b54f891d-961b-4048-a9c4-3af46e2a52fc"><img alt="UnityMCP Workflow" width="100%" style="max-width: 600px; display: block; margin-left: auto; margin-right: auto;"></picture>
## Key Features 🚀 ## 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. * **🤖 Automation:** Automate repetitive Unity workflows.
* **🧩 Extensible:** Designed to work with various MCP Clients. * **🧩 Extensible:** Designed to work with various MCP Clients.
<details> <details open>
<summary><strong>Expand for Available Tools...</strong></summary> <summary><strong> Available Tools </strong></summary>
Your LLM can use functions like: 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_editor`: Controls and queries the editor's state and settings.
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
* `manage_asset`: Performs asset operations (import, create, modify, delete, 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. * `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"). * `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
</details> </details>
@ -48,8 +59,6 @@ Unity MCP connects your tools using two components:
### Prerequisites ### Prerequisites
<details>
<summary><strong>Click to view required software...</strong></summary>
* **Git CLI:** For cloning the server code. [Download Git](https://git-scm.com/downloads) * **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/) * **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:** * **An MCP Client:**
* [Claude Desktop](https://claude.ai/download) * [Claude Desktop](https://claude.ai/download)
* [Claude Code](https://github.com/anthropics/claude-code)
* [Cursor](https://www.cursor.com/en/downloads) * [Cursor](https://www.cursor.com/en/downloads)
* [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview)
* *(Others may work with manual config)* * *(Others may work with manual config)*
</details> * <details> <summary><strong>[Optional] Roslyn for Advanced Script Validation</strong></summary>
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.</details>
### Step 1: Install the Unity Package (Bridge) ### 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. Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1.
**Option A: Auto-Configure (Recommended for Claude/Cursor)** <img width="609" alt="image" src="https://github.com/user-attachments/assets/cef3a639-4677-4fd8-84e7-2d82a04d55bb" />
**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)**
1. In Unity, go to `Window > Unity MCP`. 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)*. 3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client's config file automatically)*.
**Option B: Manual Configuration** **Option B: Manual Configuration**
@ -163,6 +196,20 @@ If Auto-Configure fails or you use a different client:
</details> </details>
**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 ▶️ ## 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. 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
<details open>
<summary><strong>✅ Completed Features<strong></summary>
- [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)).
</details>
### 🔬 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!
</details> </details>
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. 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 🙏 ## Acknowledgments 🙏
Thanks to the contributors and the Unity team. 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)

View File

@ -28,6 +28,20 @@ namespace UnityMcpBridge.Editor.Data
configStatus = "Not Configured", configStatus = "Not Configured",
}, },
new() 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", name = "Cursor",
windowsConfigPath = Path.Combine( windowsConfigPath = Path.Combine(
@ -43,6 +57,26 @@ namespace UnityMcpBridge.Editor.Data
mcpType = McpTypes.Cursor, mcpType = McpTypes.Cursor,
configStatus = "Not Configured", 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 // Initialize status enums after construction

View File

@ -13,7 +13,7 @@ namespace UnityMcpBridge.Editor.Helpers
/// <summary> /// <summary>
/// Handles serialization of GameObjects and Components for MCP responses. /// Handles serialization of GameObjects and Components for MCP responses.
/// Includes reflection helpers and caching for performance. /// Includes reflection helpers and caching for performance.
/// </summary> tew /// </summary>
public static class GameObjectSerializer public static class GameObjectSerializer
{ {
// --- Data Serialization --- // --- Data Serialization ---
@ -422,7 +422,7 @@ namespace UnityMcpBridge.Editor.Helpers
catch (Exception e) catch (Exception e)
{ {
// Catch potential errors during JToken conversion or addition to dictionary // 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 // Helper to create JToken using the output serializer
private static JToken CreateTokenFromValue(object value, Type type) private static JToken CreateTokenFromValue(object value, Type type)
{ {
if (value == null) return JValue.CreateNull(); if (value == null) return JValue.CreateNull();
try try
{ {
@ -514,12 +514,12 @@ namespace UnityMcpBridge.Editor.Helpers
} }
catch (JsonSerializationException e) catch (JsonSerializationException e)
{ {
// Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field.");
return null; // Indicate serialization failure return null; // Indicate serialization failure
} }
catch (Exception e) // Catch other unexpected errors 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 return null; // Indicate serialization failure
} }
} }

View File

@ -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
{
/// <summary>
/// Manages dynamic port allocation and persistent storage for Unity MCP Bridge
/// </summary>
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;
}
/// <summary>
/// Get the port to use - either from storage or discover a new one
/// Will try stored port first, then fallback to discovering new port
/// </summary>
/// <returns>Port number to use</returns>
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;
}
/// <summary>
/// Discover and save a new available port (used by Auto-Connect button)
/// </summary>
/// <returns>New available port</returns>
public static int DiscoverNewPort()
{
int newPort = FindAvailablePort();
SavePort(newPort);
Debug.Log($"Discovered and saved new port: {newPort}");
return newPort;
}
/// <summary>
/// Find an available port starting from the default port
/// </summary>
/// <returns>Available port number</returns>
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}");
}
/// <summary>
/// Check if a specific port is available
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port is available</returns>
public static bool IsPortAvailable(int port)
{
try
{
var testListener = new TcpListener(IPAddress.Loopback, port);
testListener.Start();
testListener.Stop();
return true;
}
catch (SocketException)
{
return false;
}
}
/// <summary>
/// Save port to persistent storage
/// </summary>
/// <param name="port">Port to save</param>
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}");
}
}
/// <summary>
/// Load port from persistent storage
/// </summary>
/// <returns>Stored port number, or 0 if not found</returns>
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<PortConfig>(json);
return portConfig?.unity_port ?? 0;
}
catch (Exception ex)
{
Debug.LogWarning($"Could not load port from storage: {ex.Message}");
return 0;
}
}
/// <summary>
/// Get the current stored port configuration
/// </summary>
/// <returns>Port configuration if exists, null otherwise</returns>
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<PortConfig>(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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a1b2c3d4e5f6789012345678901234ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -4,6 +4,8 @@ namespace UnityMcpBridge.Editor.Models
{ {
ClaudeDesktop, ClaudeDesktop,
Cursor, Cursor,
VSCode,
ClaudeCode,
} }
} }

View File

@ -20,6 +20,7 @@ namespace UnityMcpBridge.Editor.Tools
{ "HandleManageAsset", ManageAsset.HandleCommand }, { "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand }, { "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand},
}; };
/// <summary> /// <summary>

View File

@ -66,13 +66,15 @@ namespace UnityMcpBridge.Editor.Tools
/// </summary> /// </summary>
private static object ExecuteItem(JObject @params) 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. // 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). // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
if (string.IsNullOrWhiteSpace(menuPath)) 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 // Validate against blacklist

View File

@ -8,6 +8,14 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
using UnityMcpBridge.Editor.Helpers; // For Response class 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 namespace UnityMcpBridge.Editor.Tools
{ {
/// <summary> /// <summary>
@ -177,6 +185,14 @@ namespace UnityMcpBridge.Editor.Tools
AssetDatabase.CreateAsset(mat, fullPath); AssetDatabase.CreateAsset(mat, fullPath);
newAsset = mat; 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") else if (lowerAssetType == "scriptableobject")
{ {
string scriptClassName = properties?["scriptClass"]?.ToString(); 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<float>(),
colorArr[1].ToObject<float>(),
colorArr[2].ToObject<float>(),
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 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 // Example: Set float property
if (properties["float"] is JObject floatProps) if (properties["float"] is JObject floatProps)
@ -948,6 +988,77 @@ namespace UnityMcpBridge.Editor.Tools
return modified; return modified;
} }
/// <summary>
/// Applies properties from JObject to a PhysicsMaterial.
/// </summary>
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<float>();
pmat.dynamicFriction = dynamicFriction;
modified = true;
}
// Example: Set static friction
if (properties["staticFriction"]?.Type == JTokenType.Float)
{
float staticFriction = properties["staticFriction"].ToObject<float>();
pmat.staticFriction = staticFriction;
modified = true;
}
// Example: Set bounciness
if (properties["bounciness"]?.Type == JTokenType.Float)
{
float bounciness = properties["bounciness"].ToObject<float>();
pmat.bounciness = bounciness;
modified = true;
}
List<String> averageList = new List<String> { "ave", "Ave", "average", "Average" };
List<String> multiplyList = new List<String> { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" };
List<String> minimumList = new List<String> { "min", "Min", "minimum", "Minimum" };
List<String> maximumList = new List<String> { "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;
}
/// <summary> /// <summary>
/// Generic helper to set properties on any UnityEngine.Object using reflection. /// Generic helper to set properties on any UnityEngine.Object using reflection.
/// </summary> /// </summary>

View File

@ -9,8 +9,8 @@ using UnityEditor.SceneManagement;
using UnityEditorInternal; using UnityEditorInternal;
using UnityEngine; using UnityEngine;
using UnityEngine.SceneManagement; using UnityEngine.SceneManagement;
using UnityMcpBridge.Editor.Helpers; // For Response class AND GameObjectSerializer using UnityMcpBridge.Editor.Helpers; // For Response class
using UnityMcpBridge.Runtime.Serialization; // <<< Keep for Converters access? Might not be needed here directly using UnityMcpBridge.Runtime.Serialization;
namespace UnityMcpBridge.Editor.Tools namespace UnityMcpBridge.Editor.Tools
{ {
@ -23,10 +23,6 @@ namespace UnityMcpBridge.Editor.Tools
public static object HandleCommand(JObject @params) 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(); string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action)) 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." $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending."
); );
prefabPath += ".prefab"; 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<GameObject>(prefabPath); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefabAsset != null) if (prefabAsset != null)
{ {
try try
{ {
// Instantiate the prefab, initially place it at the root
// Parent will be set later if specified
newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject;
if (newGo == null) if (newGo == null)
{ {
// This might happen if the asset exists but isn't a valid GameObject prefab somehow
Debug.LogError( Debug.LogError(
$"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." $"[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}'." $"Failed to instantiate prefab at '{prefabPath}'."
); );
} }
// Name the instance based on the 'name' parameter, not the prefab's default name
if (!string.IsNullOrEmpty(name)) if (!string.IsNullOrEmpty(name))
{ {
newGo.name = name; newGo.name = name;
} }
// Register Undo for prefab instantiation
Undo.RegisterCreatedObjectUndo( Undo.RegisterCreatedObjectUndo(
newGo, newGo,
$"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'"
@ -260,9 +261,12 @@ namespace UnityMcpBridge.Editor.Tools
} }
else else
{ {
// Only return error if prefabPath was specified but not found.
// If prefabPath was empty/null, we proceed to create primitive/empty.
Debug.LogWarning( Debug.LogWarning(
$"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." $"[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) PrimitiveType type = (PrimitiveType)
Enum.Parse(typeof(PrimitiveType), primitiveType, true); Enum.Parse(typeof(PrimitiveType), primitiveType, true);
newGo = GameObject.CreatePrimitive(type); newGo = GameObject.CreatePrimitive(type);
// Set name *after* creation for primitives
if (!string.IsNullOrEmpty(name)) if (!string.IsNullOrEmpty(name))
newGo.name = name; newGo.name = name;
else else
@ -309,18 +314,21 @@ namespace UnityMcpBridge.Editor.Tools
newGo = new GameObject(name); newGo = new GameObject(name);
createdNewObject = true; createdNewObject = true;
} }
// Record creation for Undo *only* if we created a new object
if (createdNewObject) if (createdNewObject)
{ {
Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'");
} }
} }
// --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists ---
if (newGo == null) 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."); 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.transform, "Set GameObject Transform");
Undo.RecordObject(newGo, "Set GameObject Properties"); Undo.RecordObject(newGo, "Set GameObject Properties");
@ -352,6 +360,7 @@ namespace UnityMcpBridge.Editor.Tools
// Set Tag (added for create action) // Set Tag (added for create action)
if (!string.IsNullOrEmpty(tag)) if (!string.IsNullOrEmpty(tag))
{ {
// Similar logic as in ModifyGameObject for setting/creating tags
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
try try
{ {
@ -448,13 +457,16 @@ namespace UnityMcpBridge.Editor.Tools
if (createdNewObject && saveAsPrefab) if (createdNewObject && saveAsPrefab)
{ {
string finalPrefabPath = prefabPath; // Use a separate variable for saving path string finalPrefabPath = prefabPath; // Use a separate variable for saving path
// This check should now happen *before* attempting to save
if (string.IsNullOrEmpty(finalPrefabPath)) if (string.IsNullOrEmpty(finalPrefabPath))
{ {
// Clean up the created object before returning error
UnityEngine.Object.DestroyImmediate(newGo); UnityEngine.Object.DestroyImmediate(newGo);
return Response.Error( return Response.Error(
"'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." "'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)) if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{ {
Debug.Log( Debug.Log(
@ -465,6 +477,7 @@ namespace UnityMcpBridge.Editor.Tools
try try
{ {
// Ensure directory exists using the final saving path
string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath);
if ( if (
!string.IsNullOrEmpty(directoryPath) !string.IsNullOrEmpty(directoryPath)
@ -477,7 +490,7 @@ namespace UnityMcpBridge.Editor.Tools
$"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"
); );
} }
// Use SaveAsPrefabAssetAndConnect with the final saving path
finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(
newGo, newGo,
finalPrefabPath, finalPrefabPath,
@ -486,6 +499,7 @@ namespace UnityMcpBridge.Editor.Tools
if (finalInstance == null) if (finalInstance == null)
{ {
// Destroy the original if saving failed somehow (shouldn't usually happen if path is valid)
UnityEngine.Object.DestroyImmediate(newGo); UnityEngine.Object.DestroyImmediate(newGo);
return Response.Error( return Response.Error(
$"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions."
@ -494,16 +508,21 @@ namespace UnityMcpBridge.Editor.Tools
Debug.Log( Debug.Log(
$"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." $"[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) catch (Exception e)
{ {
// Clean up the instance if prefab saving fails
UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt
return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); 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; Selection.activeGameObject = finalInstance;
// Determine appropriate success message using the potentially updated or original path
string messagePrefabPath = string messagePrefabPath =
finalInstance == null finalInstance == null
? originalPrefabPath ? originalPrefabPath
@ -529,6 +548,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
// Use the new serializer helper // Use the new serializer helper
//return Response.Success(successMessage, GetGameObjectData(finalInstance));
return Response.Success(successMessage, Helpers.GameObjectSerializer.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.transform, "Modify GameObject Transform");
Undo.RecordObject(targetGo, "Modify GameObject Properties"); Undo.RecordObject(targetGo, "Modify GameObject Properties");
@ -564,6 +585,7 @@ namespace UnityMcpBridge.Editor.Tools
if (parentToken != null) if (parentToken != null)
{ {
GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path");
// Check for hierarchy loops
if ( if (
newParentGo == null newParentGo == null
&& !( && !(
@ -600,8 +622,11 @@ namespace UnityMcpBridge.Editor.Tools
// Change Tag (using consolidated 'tag' parameter) // Change Tag (using consolidated 'tag' parameter)
string tag = @params["tag"]?.ToString(); 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) 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; string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
try try
{ {
@ -610,6 +635,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
catch (UnityException ex) catch (UnityException ex)
{ {
// Check if the error is specifically because the tag doesn't exist
if (ex.Message.Contains("is not defined")) if (ex.Message.Contains("is not defined"))
{ {
Debug.LogWarning( Debug.LogWarning(
@ -617,7 +643,12 @@ namespace UnityMcpBridge.Editor.Tools
); );
try try
{ {
// Attempt to create the tag using internal utility
InternalEditorUtility.AddTag(tagToSet); 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; targetGo.tag = tagToSet;
modified = true; modified = true;
Debug.Log( Debug.Log(
@ -626,6 +657,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
catch (Exception innerEx) catch (Exception innerEx)
{ {
// Handle failure during tag creation or the second assignment attempt
Debug.LogError( Debug.LogError(
$"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}"
); );
@ -636,6 +668,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
else else
{ {
// If the exception was for a different reason, return the original error
return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}.");
} }
} }
@ -681,12 +714,14 @@ namespace UnityMcpBridge.Editor.Tools
} }
// --- Component Modifications --- // --- Component Modifications ---
// Note: These might need more specific Undo recording per component
// Remove Components // Remove Components
if (@params["componentsToRemove"] is JArray componentsToRemoveArray) if (@params["componentsToRemove"] is JArray componentsToRemoveArray)
{ {
foreach (var compToken in componentsToRemoveArray) foreach (var compToken in componentsToRemoveArray)
{ {
// ... (parsing logic as in CreateGameObject) ...
string typeName = compToken.ToString(); string typeName = compToken.ToString();
if (!string.IsNullOrEmpty(typeName)) if (!string.IsNullOrEmpty(typeName))
{ {
@ -746,7 +781,11 @@ namespace UnityMcpBridge.Editor.Tools
if (!modified) 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( return Response.Success(
$"No modifications applied to GameObject '{targetGo.name}'.", $"No modifications applied to GameObject '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo) Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
@ -754,11 +793,15 @@ namespace UnityMcpBridge.Editor.Tools
} }
EditorUtility.SetDirty(targetGo); // Mark scene as dirty EditorUtility.SetDirty(targetGo); // Mark scene as dirty
// Use the new serializer helper // Use the new serializer helper
return Response.Success( return Response.Success(
$"GameObject '{targetGo.name}' modified successfully.", $"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo) Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
); );
// return Response.Success(
// $"GameObject '{targetGo.name}' modified successfully.",
// GetGameObjectData(targetGo));
} }
private static object DeleteGameObject(JToken targetToken, string searchMethod) private static object DeleteGameObject(JToken targetToken, string searchMethod)
@ -821,11 +864,12 @@ namespace UnityMcpBridge.Editor.Tools
} }
// Use the new serializer helper // Use the new serializer helper
//var results = foundObjects.Select(go => GetGameObjectData(go)).ToList();
var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList();
return Response.Success($"Found {results.Count} GameObject(s).", results); 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); GameObject targetGo = FindObjectInternal(target, searchMethod);
if (targetGo == null) if (targetGo == null)
@ -1443,6 +1487,8 @@ namespace UnityMcpBridge.Editor.Tools
Debug.LogWarning( Debug.LogWarning(
$"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}'). Property might not exist, be read-only, or type mismatch." $"[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) catch (Exception e)
@ -1450,6 +1496,8 @@ namespace UnityMcpBridge.Editor.Tools
Debug.LogError( Debug.LogError(
$"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" $"[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); EditorUtility.SetDirty(targetComponent);
@ -1488,6 +1536,7 @@ namespace UnityMcpBridge.Editor.Tools
try try
{ {
// Handle special case for materials with dot notation (material.property) // Handle special case for materials with dot notation (material.property)
// Examples: material.color, sharedMaterial.color, materials[0].color
if (memberName.Contains('.') || memberName.Contains('[')) if (memberName.Contains('.') || memberName.Contains('['))
{ {
// Pass the inputSerializer down for nested conversions // Pass the inputSerializer down for nested conversions
@ -1538,7 +1587,8 @@ namespace UnityMcpBridge.Editor.Tools
/// <summary> /// <summary>
/// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]")
/// </summary> /// </summary>
// 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) private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer)
{ {
try try
@ -1560,6 +1610,7 @@ namespace UnityMcpBridge.Editor.Tools
bool isArray = false; bool isArray = false;
int arrayIndex = -1; int arrayIndex = -1;
// Check if this part contains array indexing
if (part.Contains("[")) if (part.Contains("["))
{ {
int startBracket = part.IndexOf('['); int startBracket = part.IndexOf('[');
@ -1577,7 +1628,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
} }
} }
// Get the property/field
PropertyInfo propInfo = currentType.GetProperty(part, flags); PropertyInfo propInfo = currentType.GetProperty(part, flags);
FieldInfo fieldInfo = null; FieldInfo fieldInfo = null;
if (propInfo == null) if (propInfo == null)
@ -1592,11 +1643,12 @@ namespace UnityMcpBridge.Editor.Tools
} }
} }
// Get the value
currentObject = currentObject =
propInfo != null propInfo != null
? propInfo.GetValue(currentObject) ? propInfo.GetValue(currentObject)
: fieldInfo.GetValue(currentObject); : fieldInfo.GetValue(currentObject);
//Need to stop if current property is null
if (currentObject == null) if (currentObject == null)
{ {
Debug.LogWarning( Debug.LogWarning(
@ -1604,7 +1656,7 @@ namespace UnityMcpBridge.Editor.Tools
); );
return false; return false;
} }
// If this part was an array or list, access the specific index
if (isArray) if (isArray)
{ {
if (currentObject is Material[]) if (currentObject is Material[])
@ -1653,32 +1705,32 @@ namespace UnityMcpBridge.Editor.Tools
{ {
// Try converting to known types that SetColor/SetVector accept // Try converting to known types that SetColor/SetVector accept
if (jArray.Count == 4) { if (jArray.Count == 4) {
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch {} try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { }
try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch {} try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
} else if (jArray.Count == 3) { } else if (jArray.Count == 3) {
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch {} // ToObject handles conversion to Color try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color
} else if (jArray.Count == 2) { } else if (jArray.Count == 2) {
try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch {} try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
} }
} }
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
{ {
try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch {} try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch { }
} }
else if (value.Type == JTokenType.Boolean) else if (value.Type == JTokenType.Boolean)
{ {
try { material.SetFloat(finalPart, value.ToObject<bool>(inputSerializer) ? 1f : 0f); return true; } catch {} try { material.SetFloat(finalPart, value.ToObject<bool>(inputSerializer) ? 1f : 0f); return true; } catch { }
} }
else if (value.Type == JTokenType.String) else if (value.Type == JTokenType.String)
{ {
// Try converting to Texture using the serializer/converter // Try converting to Texture using the serializer/converter
try { try {
Texture texture = value.ToObject<Texture>(inputSerializer); Texture texture = value.ToObject<Texture>(inputSerializer);
if (texture != null) { if (texture != null) {
material.SetTexture(finalPart, texture); material.SetTexture(finalPart, texture);
return true; return true;
} }
} catch {} } catch { }
} }
Debug.LogWarning( Debug.LogWarning(
@ -1698,7 +1750,7 @@ namespace UnityMcpBridge.Editor.Tools
finalPropInfo.SetValue(currentObject, convertedValue); finalPropInfo.SetValue(currentObject, convertedValue);
return true; return true;
} }
else { else {
Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); 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); FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags);
if (finalFieldInfo != null) if (finalFieldInfo != null)
{ {
// Use the inputSerializer for conversion // Use the inputSerializer for conversion
object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null) if (convertedValue != null || value.Type == JTokenType.Null)
{ {
finalFieldInfo.SetValue(currentObject, convertedValue); finalFieldInfo.SetValue(currentObject, convertedValue);
return true; return true;
} }
else { else {
Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
} }
} }
else else
{ {
@ -1742,6 +1794,7 @@ namespace UnityMcpBridge.Editor.Tools
/// </summary> /// </summary>
private static string[] SplitPropertyPath(string path) private static string[] SplitPropertyPath(string path)
{ {
// Handle complex paths with both dots and array indexers
List<string> parts = new List<string>(); List<string> parts = new List<string>();
int startIndex = 0; int startIndex = 0;
bool inBrackets = false; bool inBrackets = false;
@ -1760,6 +1813,7 @@ namespace UnityMcpBridge.Editor.Tools
} }
else if (c == '.' && !inBrackets) else if (c == '.' && !inBrackets)
{ {
// Found a dot separator outside of brackets
parts.Add(path.Substring(startIndex, i - startIndex)); parts.Add(path.Substring(startIndex, i - startIndex));
startIndex = i + 1; startIndex = i + 1;
} }

View File

@ -7,10 +7,43 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
using UnityMcpBridge.Editor.Helpers; 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 namespace UnityMcpBridge.Editor.Tools
{ {
/// <summary> /// <summary>
/// Handles CRUD operations for C# scripts within the Unity project. /// 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.
/// </summary> /// </summary>
public static class ManageScript public static class ManageScript
{ {
@ -168,12 +201,18 @@ namespace UnityMcpBridge.Editor.Tools
contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); contents = GenerateDefaultScriptContent(name, scriptType, namespaceName);
} }
// Validate syntax (basic check) // Validate syntax with detailed error reporting using GUI setting
if (!ValidateScriptSyntax(contents)) ValidationLevel validationLevel = GetValidationLevelFromGUI();
bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors);
if (!isValid)
{ {
// Optionally return a specific error or warning about syntax string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors);
// return Response.Error("Provided script content has potential syntax errors."); return Response.Error(errorMessage);
Debug.LogWarning($"Potential syntax error in script being created: {name}"); }
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 try
@ -243,11 +282,18 @@ namespace UnityMcpBridge.Editor.Tools
return Response.Error("Content is required for the 'update' action."); return Response.Error("Content is required for the 'update' action.");
} }
// Validate syntax (basic check) // Validate syntax with detailed error reporting using GUI setting
if (!ValidateScriptSyntax(contents)) ValidationLevel validationLevel = GetValidationLevelFromGUI();
bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors);
if (!isValid)
{ {
Debug.LogWarning($"Potential syntax error in script being updated: {name}"); string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors);
// Consider if this should be a hard error or just a warning 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 try
@ -361,27 +407,624 @@ namespace UnityMcpBridge.Editor.Tools
} }
/// <summary> /// <summary>
/// Performs a very basic syntax validation (checks for balanced braces). /// Gets the validation level from the GUI settings
/// TODO: Implement more robust syntax checking if possible. /// </summary>
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
};
}
/// <summary>
/// Validates C# script syntax using multiple validation layers.
/// </summary> /// </summary>
private static bool ValidateScriptSyntax(string contents) private static bool ValidateScriptSyntax(string contents)
{ {
if (string.IsNullOrEmpty(contents)) return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _);
return true; // Empty is technically valid? }
int braceBalance = 0; /// <summary>
foreach (char c in contents) /// Advanced syntax validation with detailed diagnostics and configurable strictness.
/// </summary>
private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors)
{
var errorList = new System.Collections.Generic.List<string>();
errors = null;
if (string.IsNullOrEmpty(contents))
{ {
if (c == '{') return true; // Empty content is valid
braceBalance++;
else if (c == '}')
braceBalance--;
} }
return braceBalance == 0; // Basic structural validation
// This is extremely basic. A real C# parser/compiler check would be ideal if (!ValidateBasicStructure(contents, errorList))
// but is complex to implement directly here. {
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:")));
} }
/// <summary>
/// Validation strictness levels
/// </summary>
private enum ValidationLevel
{
Basic, // Only syntax errors
Standard, // Syntax + Unity best practices
Comprehensive, // All checks + semantic analysis
Strict // Treat all issues as errors
}
/// <summary>
/// Validates basic code structure (braces, quotes, comments)
/// </summary>
private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List<string> 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
/// <summary>
/// Cached compilation references for performance
/// </summary>
private static System.Collections.Generic.List<MetadataReference> _cachedReferences = null;
private static DateTime _cacheTime = DateTime.MinValue;
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5);
/// <summary>
/// Validates syntax using Roslyn compiler services
/// </summary>
private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> 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;
}
}
/// <summary>
/// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors
/// </summary>
private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List<string> 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;
}
}
/// <summary>
/// Gets compilation references with caching for performance
/// </summary>
private static System.Collections.Generic.List<MetadataReference> GetCompilationReferences()
{
// Check cache validity
if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry)
{
return _cachedReferences;
}
try
{
var references = new System.Collections.Generic.List<MetadataReference>();
// 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<MetadataReference>();
}
}
#else
private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> errors)
{
// Fallback when Roslyn is not available
return true;
}
#endif
/// <summary>
/// Validates Unity-specific coding rules and best practices
/// //TODO: Naive Unity Checks and not really yield any results, need to be improved
/// </summary>
private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List<string> 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");
}
}
/// <summary>
/// Validates semantic rules and common coding issues
/// </summary>
private static void ValidateSemanticRules(string contents, System.Collections.Generic.List<string> 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)
/// <summary>
/// Public method to validate script syntax with configurable validation level
/// Returns detailed validation results including errors and warnings
/// </summary>
// 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);
// }
// }
} }
} }

View File

@ -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
{
/// <summary>
/// Handles CRUD operations for shader files within the Unity project.
/// </summary>
public static class ManageShader
{
/// <summary>
/// Main handler for shader management actions.
/// </summary>
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<bool>() ?? 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."
);
}
}
/// <summary>
/// Decode base64 string to normal text
/// </summary>
private static string DecodeBase64(string encoded)
{
byte[] data = Convert.FromBase64String(encoded);
return System.Text.Encoding.UTF8.GetString(data);
}
/// <summary>
/// Encode text to base64 string
/// </summary>
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
}
}
}";
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bcf4f1f3110494344b2af9324cf5c571
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -25,9 +25,40 @@ namespace UnityMcpBridge.Editor
string, string,
(string commandJson, TaskCompletionSource<string> tcs) (string commandJson, TaskCompletionSource<string> tcs)
> commandQueue = new(); > 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 bool IsRunning => isRunning;
public static int GetCurrentPort() => currentUnityPort;
public static bool IsAutoConnectMode() => isAutoConnectMode;
/// <summary>
/// Start with Auto-Connect mode - discovers new port and saves it
/// </summary>
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) public static bool FolderExists(string path)
{ {
@ -74,10 +105,14 @@ namespace UnityMcpBridge.Editor
try 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(); listener.Start();
isRunning = true; 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 // Assuming ListenerLoop and ProcessCommands are defined elsewhere
Task.Run(ListenerLoop); Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands; EditorApplication.update += ProcessCommands;
@ -87,7 +122,7 @@ namespace UnityMcpBridge.Editor
if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
{ {
Debug.LogError( 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 else
@ -379,6 +414,7 @@ namespace UnityMcpBridge.Editor
"manage_editor" => ManageEditor.HandleCommand(paramsObject), "manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject), "manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject), "read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), "execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException( _ => throw new ArgumentException(

View File

@ -8,13 +8,13 @@ namespace UnityMcpBridge.Editor.Windows
// Editor window to display manual configuration instructions // Editor window to display manual configuration instructions
public class ManualConfigEditorWindow : EditorWindow public class ManualConfigEditorWindow : EditorWindow
{ {
private string configPath; protected string configPath;
private string configJson; protected string configJson;
private Vector2 scrollPos; protected Vector2 scrollPos;
private bool pathCopied = false; protected bool pathCopied = false;
private bool jsonCopied = false; protected bool jsonCopied = false;
private float copyFeedbackTimer = 0; protected float copyFeedbackTimer = 0;
private McpClient mcpClient; protected McpClient mcpClient;
public static void ShowWindow(string configPath, string configJson, McpClient mcpClient) public static void ShowWindow(string configPath, string configJson, McpClient mcpClient)
{ {
@ -26,7 +26,7 @@ namespace UnityMcpBridge.Editor.Windows
window.Show(); window.Show();
} }
private void OnGUI() protected virtual void OnGUI()
{ {
scrollPos = EditorGUILayout.BeginScrollView(scrollPos); scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
@ -39,7 +39,7 @@ namespace UnityMcpBridge.Editor.Windows
); );
GUI.Label( GUI.Label(
new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height),
mcpClient.name + " Manual Configuration", (mcpClient?.name ?? "Unknown") + " Manual Configuration",
EditorStyles.boldLabel EditorStyles.boldLabel
); );
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
@ -70,17 +70,17 @@ namespace UnityMcpBridge.Editor.Windows
}; };
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
"1. Open " + mcpClient.name + " config file by either:", "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:",
instructionStyle instructionStyle
); );
if (mcpClient.mcpType == McpTypes.ClaudeDesktop) if (mcpClient?.mcpType == McpTypes.ClaudeDesktop)
{ {
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
" a) Going to Settings > Developer > Edit Config", " a) Going to Settings > Developer > Edit Config",
instructionStyle instructionStyle
); );
} }
else if (mcpClient.mcpType == McpTypes.Cursor) else if (mcpClient?.mcpType == McpTypes.Cursor)
{ {
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
" a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", " 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 // Path section with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.BeginVertical(EditorStyles.helpBox);
string displayPath; string displayPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (mcpClient != null)
{ {
displayPath = mcpClient.windowsConfigPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
} {
else if ( displayPath = mcpClient.windowsConfigPath;
RuntimeInformation.IsOSPlatform(OSPlatform.OSX) }
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux) else if (
) RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
{ || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
displayPath = mcpClient.linuxConfigPath; )
{
displayPath = mcpClient.linuxConfigPath;
}
else
{
displayPath = configPath;
}
} }
else else
{ {
@ -224,7 +231,7 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
"3. Save the file and restart " + mcpClient.name, "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"),
instructionStyle instructionStyle
); );
@ -245,7 +252,7 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.EndScrollView(); EditorGUILayout.EndScrollView();
} }
private void Update() protected virtual void Update()
{ {
// Handle the feedback message timer // Handle the feedback message timer
if (copyFeedbackTimer > 0) if (copyFeedbackTimer > 0)

File diff suppressed because it is too large Load Diff

View File

@ -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<VSCodeManualSetupWindow>("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();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 377fe73d52cf0435fabead5f50a0d204

View File

@ -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"]

View File

@ -15,7 +15,7 @@ class ServerConfig:
mcp_port: int = 6500 mcp_port: int = 6500
# Connection settings # 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 buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Logging settings # Logging settings

View File

@ -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

View File

@ -61,7 +61,8 @@ def asset_creation_strategy() -> str:
"- `manage_scene`: Manages scenes.\\n" "- `manage_scene`: Manages scenes.\\n"
"- `manage_gameobject`: Manages GameObjects in the scene.\\n" "- `manage_gameobject`: Manages GameObjects in the scene.\\n"
"- `manage_script`: Manages C# script files.\\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" "Tips:\\n"
"- Create prefabs for reusable GameObjects.\\n" "- Create prefabs for reusable GameObjects.\\n"
"- Always include a camera and main light in your scenes.\\n" "- Always include a camera and main light in your scenes.\\n"

View File

@ -3,6 +3,7 @@ from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_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 .read_console import register_read_console_tools
from .execute_menu_item import register_execute_menu_item_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_editor_tools(mcp)
register_manage_gameobject_tools(mcp) register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp) register_manage_asset_tools(mcp)
register_manage_shader_tools(mcp)
register_read_console_tools(mcp) register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp) register_execute_menu_item_tools(mcp)
print("Unity MCP Server tool registration complete.") print("Unity MCP Server tool registration complete.")

View File

@ -33,6 +33,9 @@ def register_manage_asset_tools(mcp: FastMCP):
path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope.
asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'.
properties: Dictionary of properties for 'create'/'modify'. 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'. destination: Target path for 'duplicate'/'move'.
search_pattern: Search pattern (e.g., '*.prefab'). search_pattern: Search pattern (e.g., '*.prefab').
filter_*: Filters for search (type, date). filter_*: Filters for search (type, date).

View File

@ -40,7 +40,7 @@ def register_manage_gameobject_tools(mcp: FastMCP):
"""Manages GameObjects: create, modify, delete, find, and component operations. """Manages GameObjects: create, modify, delete, find, and component operations.
Args: 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. 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. 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). 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'). search_term, find_all for 'find').
includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. 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: Returns:
Dictionary with operation results ('success', 'message', 'data'). Dictionary with operation results ('success', 'message', 'data').
For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties.
""" """
try: try:
# --- Early check for attempting to modify a prefab asset --- # --- Early check for attempting to modify a prefab asset ---

View File

@ -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)}"}

View File

@ -4,6 +4,7 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Any from typing import Dict, Any
from config import config from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config # Configure logging using settings from config
logging.basicConfig( logging.basicConfig(
@ -16,8 +17,13 @@ logger = logging.getLogger("unity-mcp-server")
class UnityConnection: class UnityConnection:
"""Manages the socket connection to the Unity Editor.""" """Manages the socket connection to the Unity Editor."""
host: str = config.unity_host 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 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: def connect(self) -> bool:
"""Establish a connection to the Unity Editor.""" """Establish a connection to the Unity Editor."""