dsarno 2025-07-29 10:20:55 -07:00
commit 44530de8da
28 changed files with 3063 additions and 301 deletions

1
.gitignore vendored
View File

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

122
README.md
View File

@ -1,12 +1,22 @@
# Unity MCP ✨
**Connect your Unity Editor to LLMs using the Model Context Protocol.**
[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive)
[![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
![GitHub commit activity](https://img.shields.io/github/commit-activity/w/justinpbarnett/unity-mcp)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/justinpbarnett/unity-mcp)
[![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT)
**Create your Unity apps with LLMs!**
Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity.
---
## <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 🚀
@ -15,8 +25,8 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte
* **🤖 Automation:** Automate repetitive Unity workflows.
* **🧩 Extensible:** Designed to work with various MCP Clients.
<details>
<summary><strong>Expand for Available Tools...</strong></summary>
<details open>
<summary><strong> Available Tools </strong></summary>
Your LLM can use functions like:
@ -25,6 +35,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte
* `manage_editor`: Controls and queries the editor's state and settings.
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
* `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
</details>
@ -48,8 +59,6 @@ Unity MCP connects your tools using two components:
### 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)
* **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/)
@ -61,9 +70,31 @@ Unity MCP connects your tools using two components:
```
* **An MCP Client:**
* [Claude Desktop](https://claude.ai/download)
* [Claude Code](https://github.com/anthropics/claude-code)
* [Cursor](https://www.cursor.com/en/downloads)
* [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview)
* *(Others may work with manual config)*
</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)
@ -81,10 +112,12 @@ Unity MCP connects your tools using two components:
Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1.
**Option A: Auto-Configure (Recommended for Claude/Cursor)**
<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`.
2. Click `Auto Configure Claude` or `Auto Configure Cursor`.
2. Click `Auto Configure` on the IDE you uses.
3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client's config file automatically)*.
**Option B: Manual Configuration**
@ -163,6 +196,20 @@ If Auto-Configure fails or you use a different client:
</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 ▶️
@ -173,8 +220,41 @@ If Auto-Configure fails or you use a different client:
3. **Interact!** Unity tools should now be available in your MCP Client.
Example Prompt: `Create a 3D player controller.`
Example Prompt: `Create a 3D player controller`, `Create a yellow and bridge sun`, `Create a cool shader and apply it on a cube`.
---
## Future Dev Plans (Besides PR) 📝
### 🔴 High Priority
- [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization
- [ ] **Code Generation Enhancements** - Improved generated code quality and error handling
- [ ] **Robust Error Handling** - Comprehensive error messages, recovery mechanisms, and graceful degradation
- [ ] **Remote Connection Support** - Enable seamless remote connection between Unity host and MCP server
- [ ] **Documentation Expansion** - Complete tutorials for custom tool creation and API reference
### 🟡 Medium Priority
- [ ] **Custom Tool Creation GUI** - Visual interface for users to create and configure their own MCP tools
- [ ] **Advanced Logging System** - Logging with filtering, export, and debugging capabilities
### 🟢 Low Priority
- [ ] **Mobile Platform Support** - Extended toolset for mobile development workflows and platform-specific features
- [ ] **Easier Tool Setup**
- [ ] **Plugin Marketplace** - Community-driven tool sharing and distribution platform
<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>
Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues).
Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues) or [Join the Discord](https://discord.gg/vhTUxXaqYr)!
---
## Contact 👋
- **justinpbarnett:** [X/Twitter](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett)
- **scriptwonder**: [Email](mailto:swu85@ur.rochester.edu), [LinkedIn](https://www.linkedin.com/in/shutong-wu-214043172/)
---
@ -235,15 +323,13 @@ Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgit
MIT License. See [LICENSE](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fblob%2Fmaster%2FLICENSE) file.
---
## Contact 👋
- **X/Twitter:** [@justinpbarnett](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett)
---
## Acknowledgments 🙏
Thanks to the contributors and the Unity team.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=justinpbarnett/unity-mcp&type=Date)](https://www.star-history.com/#justinpbarnett/unity-mcp&Date)

View File

@ -28,6 +28,20 @@ namespace UnityMcpBridge.Editor.Data
configStatus = "Not Configured",
},
new()
{
name = "Claude Code",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
mcpType = McpTypes.ClaudeCode,
configStatus = "Not Configured",
},
new()
{
name = "Cursor",
windowsConfigPath = Path.Combine(
@ -43,6 +57,26 @@ namespace UnityMcpBridge.Editor.Data
mcpType = McpTypes.Cursor,
configStatus = "Not Configured",
},
new()
{
name = "VSCode GitHub Copilot",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Code",
"User",
"settings.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
"Code",
"User",
"settings.json"
),
mcpType = McpTypes.VSCode,
configStatus = "Not Configured",
},
};
// Initialize status enums after construction

View File

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

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,
Cursor,
VSCode,
ClaudeCode,
}
}

View File

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

View File

@ -66,13 +66,15 @@ namespace UnityMcpBridge.Editor.Tools
/// </summary>
private static object ExecuteItem(JObject @params)
{
string menuPath = @params["menu_path"]?.ToString();
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
if (string.IsNullOrWhiteSpace(menuPath))
{
return Response.Error("Required parameter 'menu_path' is missing or empty.");
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
}
// Validate against blacklist

View File

@ -8,6 +8,14 @@ using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers; // For Response class
#if UNITY_6000_0_OR_NEWER
using PhysicsMaterialType = UnityEngine.PhysicsMaterial;
using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine;
#else
using PhysicsMaterialType = UnityEngine.PhysicMaterial;
using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine;
#endif
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
@ -177,6 +185,14 @@ namespace UnityMcpBridge.Editor.Tools
AssetDatabase.CreateAsset(mat, fullPath);
newAsset = mat;
}
else if (lowerAssetType == "physicsmaterial")
{
PhysicsMaterialType pmat = new PhysicsMaterialType();
if (properties != null)
ApplyPhysicsMaterialProperties(pmat, properties);
AssetDatabase.CreateAsset(pmat, fullPath);
newAsset = pmat;
}
else if (lowerAssetType == "scriptableobject")
{
string scriptClassName = properties?["scriptClass"]?.ToString();
@ -891,6 +907,30 @@ namespace UnityMcpBridge.Editor.Tools
);
}
}
} else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
string propName = "_Color";
try {
if (colorArr.Count >= 3)
{
Color newColor = new Color(
colorArr[0].ToObject<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
if (properties["float"] is JObject floatProps)
@ -948,6 +988,77 @@ namespace UnityMcpBridge.Editor.Tools
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>
/// Generic helper to set properties on any UnityEngine.Object using reflection.
/// </summary>

View File

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

View File

@ -7,10 +7,43 @@ using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers;
#if USE_ROSLYN
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
#endif
#if UNITY_EDITOR
using UnityEditor.Compilation;
#endif
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// 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>
public static class ManageScript
{
@ -168,12 +201,18 @@ namespace UnityMcpBridge.Editor.Tools
contents = GenerateDefaultScriptContent(name, scriptType, namespaceName);
}
// Validate syntax (basic check)
if (!ValidateScriptSyntax(contents))
// Validate syntax with detailed error reporting using GUI setting
ValidationLevel validationLevel = GetValidationLevelFromGUI();
bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors);
if (!isValid)
{
// Optionally return a specific error or warning about syntax
// return Response.Error("Provided script content has potential syntax errors.");
Debug.LogWarning($"Potential syntax error in script being created: {name}");
string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors);
return Response.Error(errorMessage);
}
else if (validationErrors != null && validationErrors.Length > 0)
{
// Log warnings but don't block creation
Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors));
}
try
@ -243,11 +282,18 @@ namespace UnityMcpBridge.Editor.Tools
return Response.Error("Content is required for the 'update' action.");
}
// Validate syntax (basic check)
if (!ValidateScriptSyntax(contents))
// Validate syntax with detailed error reporting using GUI setting
ValidationLevel validationLevel = GetValidationLevelFromGUI();
bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors);
if (!isValid)
{
Debug.LogWarning($"Potential syntax error in script being updated: {name}");
// Consider if this should be a hard error or just a warning
string errorMessage = "Script validation failed:\n" + string.Join("\n", validationErrors);
return Response.Error(errorMessage);
}
else if (validationErrors != null && validationErrors.Length > 0)
{
// Log warnings but don't block update
Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors));
}
try
@ -361,27 +407,624 @@ namespace UnityMcpBridge.Editor.Tools
}
/// <summary>
/// Performs a very basic syntax validation (checks for balanced braces).
/// TODO: Implement more robust syntax checking if possible.
/// Gets the validation level from the GUI settings
/// </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>
private static bool ValidateScriptSyntax(string contents)
{
if (string.IsNullOrEmpty(contents))
return true; // Empty is technically valid?
return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _);
}
int braceBalance = 0;
foreach (char c in contents)
/// <summary>
/// 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 == '{')
braceBalance++;
else if (c == '}')
braceBalance--;
return true; // Empty content is valid
}
return braceBalance == 0;
// This is extremely basic. A real C# parser/compiler check would be ideal
// but is complex to implement directly here.
// Basic structural validation
if (!ValidateBasicStructure(contents, errorList))
{
errors = errorList.ToArray();
return false;
}
#if USE_ROSLYN
// Advanced Roslyn-based validation
if (!ValidateScriptSyntaxRoslyn(contents, level, errorList))
{
errors = errorList.ToArray();
return level != ValidationLevel.Standard; //TODO: Allow standard to run roslyn right now, might formalize it in the future
}
#endif
// Unity-specific validation
if (level >= ValidationLevel.Standard)
{
ValidateScriptSyntaxUnity(contents, errorList);
}
// Semantic analysis for common issues
if (level >= ValidationLevel.Comprehensive)
{
ValidateSemanticRules(contents, errorList);
}
#if USE_ROSLYN
// Full semantic compilation validation for Strict level
if (level == ValidationLevel.Strict)
{
if (!ValidateScriptSemantics(contents, errorList))
{
errors = errorList.ToArray();
return false; // Strict level fails on any semantic errors
}
}
#endif
errors = errorList.ToArray();
return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:")));
}
/// <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 commandJson, TaskCompletionSource<string> tcs)
> commandQueue = new();
private static readonly int unityPort = 6400; // Hardcoded port
private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false;
public static bool IsRunning => isRunning;
public static int GetCurrentPort() => currentUnityPort;
public static bool IsAutoConnectMode() => isAutoConnectMode;
/// <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)
{
@ -74,10 +105,14 @@ namespace UnityMcpBridge.Editor
try
{
listener = new TcpListener(IPAddress.Loopback, unityPort);
// Use PortManager to get available port with automatic fallback
currentUnityPort = PortManager.GetPortWithFallback();
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Start();
isRunning = true;
Debug.Log($"UnityMcpBridge started on port {unityPort}.");
isAutoConnectMode = false; // Normal startup mode
Debug.Log($"UnityMcpBridge started on port {currentUnityPort}.");
// Assuming ListenerLoop and ProcessCommands are defined elsewhere
Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands;
@ -87,7 +122,7 @@ namespace UnityMcpBridge.Editor
if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
{
Debug.LogError(
$"Port {unityPort} is already in use. Ensure no other instances are running or change the port."
$"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation."
);
}
else
@ -379,6 +414,7 @@ namespace UnityMcpBridge.Editor
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException(

View File

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

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
# Connection settings
connection_timeout: float = 86400.0 # 24 hours timeout
connection_timeout: float = 600.0 # 10 minutes timeout
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Logging settings

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_gameobject`: Manages GameObjects in the scene.\\n"
"- `manage_script`: Manages C# script files.\\n"
"- `manage_asset`: Manages prefabs and assets.\\n\\n"
"- `manage_asset`: Manages prefabs and assets.\\n"
"- `manage_shader`: Manages shaders.\\n\\n"
"Tips:\\n"
"- Create prefabs for reusable GameObjects.\\n"
"- Always include a camera and main light in your scenes.\\n"

View File

@ -3,6 +3,7 @@ from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_tools
from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools
from .execute_menu_item import register_execute_menu_item_tools
@ -14,6 +15,7 @@ def register_all_tools(mcp):
register_manage_editor_tools(mcp)
register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp)
register_manage_shader_tools(mcp)
register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp)
print("Unity MCP Server tool registration complete.")

View File

@ -33,6 +33,9 @@ def register_manage_asset_tools(mcp: FastMCP):
path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope.
asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'.
properties: Dictionary of properties for 'create'/'modify'.
example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}.
example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}.
example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}.
destination: Target path for 'duplicate'/'move'.
search_pattern: Search pattern (e.g., '*.prefab').
filter_*: Filters for search (type, date).

View File

@ -40,7 +40,7 @@ def register_manage_gameobject_tools(mcp: FastMCP):
"""Manages GameObjects: create, modify, delete, find, and component operations.
Args:
action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property').
action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components').
target: GameObject identifier (name or path string) for modify/delete/component actions.
search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups.
name: GameObject name - used for both 'create' (initial name) and 'modify' (rename).
@ -62,8 +62,16 @@ def register_manage_gameobject_tools(mcp: FastMCP):
search_term, find_all for 'find').
includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data.
Action-specific details:
- For 'get_components':
Required: target, search_method
Optional: includeNonPublicSerialized (defaults to True)
Returns all components on the target GameObject with their serialized data.
The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path').
Returns:
Dictionary with operation results ('success', 'message', 'data').
For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties.
"""
try:
# --- Early check for attempting to modify a prefab asset ---

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 typing import Dict, Any
from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config
logging.basicConfig(
@ -16,9 +17,14 @@ logger = logging.getLogger("unity-mcp-server")
class UnityConnection:
"""Manages the socket connection to the Unity Editor."""
host: str = config.unity_host
port: int = config.unity_port
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
def __post_init__(self):
"""Set port from discovery if not explicitly provided"""
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
def connect(self) -> bool:
"""Establish a connection to the Unity Editor."""
if self.sock: