From cb603b7b1a54037cbabb02c836cea8c913b4de76 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 06:14:13 -0400 Subject: [PATCH 01/27] restructured project - moved Unity package and Python application into separate folders to be downloaded separately using clone .git/UnityMcpBridge --- .gitignore | 4 + Editor/Models/MCPConfigServers.cs | 12 - Editor/Models/McpStatus.cs | 17 - Editor/Tools/ManageAsset.cs | 959 ------- Editor/Tools/ManageGameObject.cs | 1726 ------------- Editor/Tools/ReadConsole.cs | 397 --- Python.meta | 8 - Python/__init__.py.meta | 7 - Python/__pycache__.meta | 8 - Python/config.py.meta | 7 - Python/pyproject.toml.meta | 7 - Python/server.py.meta | 7 - Python/tools.meta | 8 - Python/tools/__init__.py.meta | 7 - Python/tools/__pycache__.meta | 8 - Python/tools/execute_menu_item.py.meta | 7 - Python/tools/manage_asset.py.meta | 7 - Python/tools/manage_editor.py.meta | 7 - Python/tools/manage_gameobject.py.meta | 7 - Python/tools/manage_scene.py.meta | 7 - Python/tools/manage_script.py.meta | 7 - Python/tools/read_console.py.meta | 7 - Python/unity_connection.py.meta | 7 - Python/unity_mcp.egg-info.meta | 8 - Python/uv.lock.meta | 7 - Editor.meta => UnityMcpBridge/Editor.meta | 2 +- {Editor => UnityMcpBridge/Editor}/Data.meta | 0 .../Editor}/Data/DefaultServerConfig.cs | 7 +- .../Editor}/Data/DefaultServerConfig.cs.meta | 0 .../Editor}/Data/McpClients.cs | 22 +- .../Editor}/Data/McpClients.cs.meta | 0 .../Editor}/Helpers.meta | 0 .../Editor}/Helpers/Response.cs | 21 +- .../Editor}/Helpers/Response.cs.meta | 0 .../Editor}/Helpers/Vector3Helper.cs | 7 +- .../Editor}/Helpers/Vector3Helper.cs.meta | 0 {Editor => UnityMcpBridge/Editor}/Models.meta | 0 .../Editor}/Models/Command.cs | 5 +- .../Editor}/Models/Command.cs.meta | 0 .../Editor}/Models/MCPConfigServer.cs | 4 +- .../Editor}/Models/MCPConfigServer.cs.meta | 0 .../Editor/Models/MCPConfigServers.cs | 12 + .../Editor}/Models/MCPConfigServers.cs.meta | 0 .../Editor}/Models/McpClient.cs | 6 +- .../Editor}/Models/McpClient.cs.meta | 0 .../Editor/Models/McpConfig.cs | 6 +- .../Editor/Models/McpConfig.cs.meta | 0 UnityMcpBridge/Editor/Models/McpStatus.cs | 18 + .../Editor}/Models/McpStatus.cs.meta | 0 .../Editor}/Models/McpTypes.cs | 7 +- .../Editor}/Models/McpTypes.cs.meta | 0 .../Editor}/Models/ServerConfig.cs | 2 +- .../Editor}/Models/ServerConfig.cs.meta | 0 {Editor => UnityMcpBridge/Editor}/Tools.meta | 0 .../Editor}/Tools/CommandRegistry.cs | 9 +- .../Editor}/Tools/CommandRegistry.cs.meta | 0 .../Editor}/Tools/ExecuteMenuItem.cs | 69 +- .../Editor}/Tools/ExecuteMenuItem.cs.meta | 0 UnityMcpBridge/Editor/Tools/ManageAsset.cs | 1238 +++++++++ .../Editor}/Tools/ManageAsset.cs.meta | 0 .../Editor}/Tools/ManageEditor.cs | 221 +- .../Editor}/Tools/ManageEditor.cs.meta | 0 .../Editor/Tools/ManageGameObject.cs | 2220 +++++++++++++++++ .../Editor}/Tools/ManageGameObject.cs.meta | 0 .../Editor}/Tools/ManageScene.cs | 210 +- .../Editor}/Tools/ManageScene.cs.meta | 0 .../Editor}/Tools/ManageScript.cs | 135 +- .../Editor}/Tools/ManageScript.cs.meta | 0 UnityMcpBridge/Editor/Tools/ReadConsole.cs | 516 ++++ .../Editor}/Tools/ReadConsole.cs.meta | 0 .../Editor/UnityMcpBridge.cs | 108 +- .../Editor/UnityMcpBridge.cs.meta | 0 .../Editor}/Windows.meta | 0 .../Windows/ManualConfigEditorWindow.cs | 130 +- .../Windows/ManualConfigEditorWindow.cs.meta | 0 .../Windows/UnityMCPEditorWindow.cs.meta | 0 .../Editor/Windows/UnityMcpEditorWindow.cs | 272 +- UnityMcpBridge/package.json | 10 + .../src}/.python-version | 0 {Python => UnityMcpServer/src}/__init__.py | 0 {Python => UnityMcpServer/src}/config.py | 0 {Python => UnityMcpServer/src}/pyproject.toml | 4 +- {Python => UnityMcpServer/src}/server.py | 10 +- .../src}/tools/__init__.py | 4 +- .../src}/tools/execute_menu_item.py | 0 .../src}/tools/manage_asset.py | 0 .../src}/tools/manage_editor.py | 0 .../src}/tools/manage_gameobject.py | 0 .../src}/tools/manage_scene.py | 0 .../src}/tools/manage_script.py | 0 .../src}/tools/read_console.py | 0 .../src}/unity_connection.py | 4 +- {Python => UnityMcpServer/src}/uv.lock | 2 +- package.json | 10 - package.json.meta | 7 - 95 files changed, 4862 insertions(+), 3689 deletions(-) delete mode 100644 Editor/Models/MCPConfigServers.cs delete mode 100644 Editor/Models/McpStatus.cs delete mode 100644 Editor/Tools/ManageAsset.cs delete mode 100644 Editor/Tools/ManageGameObject.cs delete mode 100644 Editor/Tools/ReadConsole.cs delete mode 100644 Python.meta delete mode 100644 Python/__init__.py.meta delete mode 100644 Python/__pycache__.meta delete mode 100644 Python/config.py.meta delete mode 100644 Python/pyproject.toml.meta delete mode 100644 Python/server.py.meta delete mode 100644 Python/tools.meta delete mode 100644 Python/tools/__init__.py.meta delete mode 100644 Python/tools/__pycache__.meta delete mode 100644 Python/tools/execute_menu_item.py.meta delete mode 100644 Python/tools/manage_asset.py.meta delete mode 100644 Python/tools/manage_editor.py.meta delete mode 100644 Python/tools/manage_gameobject.py.meta delete mode 100644 Python/tools/manage_scene.py.meta delete mode 100644 Python/tools/manage_script.py.meta delete mode 100644 Python/tools/read_console.py.meta delete mode 100644 Python/unity_connection.py.meta delete mode 100644 Python/unity_mcp.egg-info.meta delete mode 100644 Python/uv.lock.meta rename Editor.meta => UnityMcpBridge/Editor.meta (77%) rename {Editor => UnityMcpBridge/Editor}/Data.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Data/DefaultServerConfig.cs (87%) rename {Editor => UnityMcpBridge/Editor}/Data/DefaultServerConfig.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Data/McpClients.cs (83%) rename {Editor => UnityMcpBridge/Editor}/Data/McpClients.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Helpers.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Helpers/Response.cs (79%) rename {Editor => UnityMcpBridge/Editor}/Helpers/Response.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Helpers/Vector3Helper.cs (94%) rename {Editor => UnityMcpBridge/Editor}/Helpers/Vector3Helper.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models/Command.cs (91%) rename {Editor => UnityMcpBridge/Editor}/Models/Command.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models/MCPConfigServer.cs (73%) rename {Editor => UnityMcpBridge/Editor}/Models/MCPConfigServer.cs.meta (100%) create mode 100644 UnityMcpBridge/Editor/Models/MCPConfigServers.cs rename {Editor => UnityMcpBridge/Editor}/Models/MCPConfigServers.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models/McpClient.cs (95%) rename {Editor => UnityMcpBridge/Editor}/Models/McpClient.cs.meta (100%) rename Editor/Models/MCPConfig.cs => UnityMcpBridge/Editor/Models/McpConfig.cs (50%) rename Editor/Models/MCPConfig.cs.meta => UnityMcpBridge/Editor/Models/McpConfig.cs.meta (100%) create mode 100644 UnityMcpBridge/Editor/Models/McpStatus.cs rename {Editor => UnityMcpBridge/Editor}/Models/McpStatus.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models/McpTypes.cs (51%) rename {Editor => UnityMcpBridge/Editor}/Models/McpTypes.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Models/ServerConfig.cs (95%) rename {Editor => UnityMcpBridge/Editor}/Models/ServerConfig.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools/CommandRegistry.cs (96%) rename {Editor => UnityMcpBridge/Editor}/Tools/CommandRegistry.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools/ExecuteMenuItem.cs (63%) rename {Editor => UnityMcpBridge/Editor}/Tools/ExecuteMenuItem.cs.meta (100%) create mode 100644 UnityMcpBridge/Editor/Tools/ManageAsset.cs rename {Editor => UnityMcpBridge/Editor}/Tools/ManageAsset.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageEditor.cs (73%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageEditor.cs.meta (100%) create mode 100644 UnityMcpBridge/Editor/Tools/ManageGameObject.cs rename {Editor => UnityMcpBridge/Editor}/Tools/ManageGameObject.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageScene.cs (60%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageScene.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageScript.cs (74%) rename {Editor => UnityMcpBridge/Editor}/Tools/ManageScript.cs.meta (100%) create mode 100644 UnityMcpBridge/Editor/Tools/ReadConsole.cs rename {Editor => UnityMcpBridge/Editor}/Tools/ReadConsole.cs.meta (100%) rename Editor/UnityMCPBridge.cs => UnityMcpBridge/Editor/UnityMcpBridge.cs (80%) rename Editor/UnityMCPBridge.cs.meta => UnityMcpBridge/Editor/UnityMcpBridge.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Windows.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Windows/ManualConfigEditorWindow.cs (60%) rename {Editor => UnityMcpBridge/Editor}/Windows/ManualConfigEditorWindow.cs.meta (100%) rename {Editor => UnityMcpBridge/Editor}/Windows/UnityMCPEditorWindow.cs.meta (100%) rename Editor/Windows/UnityMCPEditorWindow.cs => UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs (75%) create mode 100644 UnityMcpBridge/package.json rename {Python => UnityMcpServer/src}/.python-version (100%) rename {Python => UnityMcpServer/src}/__init__.py (100%) rename {Python => UnityMcpServer/src}/config.py (100%) rename {Python => UnityMcpServer/src}/pyproject.toml (90%) rename {Python => UnityMcpServer/src}/server.py (92%) rename {Python => UnityMcpServer/src}/tools/__init__.py (86%) rename {Python => UnityMcpServer/src}/tools/execute_menu_item.py (100%) rename {Python => UnityMcpServer/src}/tools/manage_asset.py (100%) rename {Python => UnityMcpServer/src}/tools/manage_editor.py (100%) rename {Python => UnityMcpServer/src}/tools/manage_gameobject.py (100%) rename {Python => UnityMcpServer/src}/tools/manage_scene.py (100%) rename {Python => UnityMcpServer/src}/tools/manage_script.py (100%) rename {Python => UnityMcpServer/src}/tools/read_console.py (100%) rename {Python => UnityMcpServer/src}/unity_connection.py (99%) rename {Python => UnityMcpServer/src}/uv.lock (99%) delete mode 100644 package.json delete mode 100644 package.json.meta diff --git a/.gitignore b/.gitignore index ddad8cbd..715f0970 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ build/ dist/ wheels/ *.egg-info +UnityMcpServer/**/*.meta +UnityMcpServer.meta # Virtual environments .venv @@ -21,6 +23,8 @@ wheels/ # Unity Editor *.unitypackage *.asset +UnityMcpBridge.meta +package.json.meta # IDE .idea/ diff --git a/Editor/Models/MCPConfigServers.cs b/Editor/Models/MCPConfigServers.cs deleted file mode 100644 index de9e8757..00000000 --- a/Editor/Models/MCPConfigServers.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace UnityMCP.Editor.Models -{ - [Serializable] - public class MCPConfigServers - { - [JsonProperty("unityMCP")] - public MCPConfigServer unityMCP; - } -} diff --git a/Editor/Models/McpStatus.cs b/Editor/Models/McpStatus.cs deleted file mode 100644 index 36308e5e..00000000 --- a/Editor/Models/McpStatus.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace UnityMCP.Editor.Models -{ - // Enum representing the various status states for MCP clients - public enum McpStatus - { - NotConfigured, // Not set up yet - Configured, // Successfully configured - Running, // Service is running - Connected, // Successfully connected - IncorrectPath, // Configuration has incorrect paths - CommunicationError, // Connected but communication issues - NoResponse, // Connected but not responding - MissingConfig, // Config file exists but missing required elements - UnsupportedOS, // OS is not supported - Error // General error state - } -} \ No newline at end of file diff --git a/Editor/Tools/ManageAsset.cs b/Editor/Tools/ManageAsset.cs deleted file mode 100644 index 138b798c..00000000 --- a/Editor/Tools/ManageAsset.cs +++ /dev/null @@ -1,959 +0,0 @@ -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; -using System; -using System.IO; -using System.Linq; -using System.Collections.Generic; -using UnityMCP.Editor.Helpers; // For Response class -using System.Globalization; - -namespace UnityMCP.Editor.Tools -{ - /// - /// Handles asset management operations within the Unity project. - /// - public static class ManageAsset - { - // --- Main Handler --- - - // Define the list of valid actions - private static readonly List ValidActions = new List - { - "import", "create", "modify", "delete", "duplicate", - "move", "rename", "search", "get_info", "create_folder", - "get_components" - }; - - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString().ToLower(); - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - // Check if the action is valid before switching - if (!ValidActions.Contains(action)) - { - string validActionsList = string.Join(", ", ValidActions); - return Response.Error($"Unknown action: '{action}'. Valid actions are: {validActionsList}"); - } - - // Common parameters - string path = @params["path"]?.ToString(); - - try - { - switch (action) - { - case "import": - // Note: Unity typically auto-imports. This might re-import or configure import settings. - return ReimportAsset(path, @params["properties"] as JObject); - case "create": - return CreateAsset(@params); - case "modify": - return ModifyAsset(path, @params["properties"] as JObject); - case "delete": - return DeleteAsset(path); - case "duplicate": - return DuplicateAsset(path, @params["destination"]?.ToString()); - case "move": // Often same as rename if within Assets/ - case "rename": - return MoveOrRenameAsset(path, @params["destination"]?.ToString()); - case "search": - return SearchAssets(@params); - case "get_info": - return GetAssetInfo(path, @params["generatePreview"]?.ToObject() ?? false); - case "create_folder": // Added specific action for clarity - return CreateFolder(path); - case "get_components": - return GetComponentsFromAsset(path); - - default: - // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. - string validActionsListDefault = string.Join(", ", ValidActions); - return Response.Error($"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}"); - } - } - catch (Exception e) - { - Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); - return Response.Error($"Internal error processing action '{action}' on '{path}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object ReimportAsset(string path, JObject properties) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - // TODO: Apply importer properties before reimporting? - // This is complex as it requires getting the AssetImporter, casting it, - // applying properties via reflection or specific methods, saving, then reimporting. - if (properties != null && properties.HasValues) - { - Debug.LogWarning("[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet."); - // AssetImporter importer = AssetImporter.GetAtPath(fullPath); - // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } - } - - AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); - // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh - return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); - } - catch (Exception e) - { - return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); - } - } - - private static object CreateAsset(JObject @params) - { - string path = @params["path"]?.ToString(); - string assetType = @params["assetType"]?.ToString(); - JObject properties = @params["properties"] as JObject; - - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create."); - if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create."); - - string fullPath = SanitizeAssetPath(path); - string directory = Path.GetDirectoryName(fullPath); - - // Ensure directory exists - if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) - { - Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); - AssetDatabase.Refresh(); // Make sure Unity knows about the new folder - } - - if (AssetExists(fullPath)) return Response.Error($"Asset already exists at path: {fullPath}"); - - try - { - UnityEngine.Object newAsset = null; - string lowerAssetType = assetType.ToLowerInvariant(); - - // Handle common asset types - if (lowerAssetType == "folder") - { - return CreateFolder(path); // Use dedicated method - } - else if (lowerAssetType == "material") - { - Material mat = new Material(Shader.Find("Standard")); // Default shader - // TODO: Apply properties from JObject (e.g., shader name, color, texture assignments) - if(properties != null) ApplyMaterialProperties(mat, properties); - AssetDatabase.CreateAsset(mat, fullPath); - newAsset = mat; - } - else if (lowerAssetType == "scriptableobject") - { - string scriptClassName = properties?["scriptClass"]?.ToString(); - if (string.IsNullOrEmpty(scriptClassName)) return Response.Error("'scriptClass' property required when creating ScriptableObject asset."); - - Type scriptType = FindType(scriptClassName); - if (scriptType == null || !typeof(ScriptableObject).IsAssignableFrom(scriptType)) - { - return Response.Error($"Script class '{scriptClassName}' not found or does not inherit from ScriptableObject."); - } - - ScriptableObject so = ScriptableObject.CreateInstance(scriptType); - // TODO: Apply properties from JObject to the ScriptableObject instance? - AssetDatabase.CreateAsset(so, fullPath); - newAsset = so; - } - else if (lowerAssetType == "prefab") - { - // Creating prefabs usually involves saving an existing GameObject hierarchy. - // A common pattern is to create an empty GameObject, configure it, and then save it. - return Response.Error("Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement."); - // Example (conceptual): - // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); - // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); - } - // TODO: Add more asset types (Animation Controller, Scene, etc.) - else - { - // Generic creation attempt (might fail or create empty files) - // For some types, just creating the file might be enough if Unity imports it. - // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); - // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it - // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); - return Response.Error($"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject."); - } - - if (newAsset == null && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath))) // Check if it wasn't a folder and asset wasn't created - { - return Response.Error($"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details."); - } - - AssetDatabase.SaveAssets(); - // AssetDatabase.Refresh(); // CreateAsset often handles refresh - return Response.Success($"Asset '{fullPath}' created successfully.", GetAssetData(fullPath)); - } - catch (Exception e) - { - return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); - } - } - - private static object CreateFolder(string path) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder."); - string fullPath = SanitizeAssetPath(path); - string parentDir = Path.GetDirectoryName(fullPath); - string folderName = Path.GetFileName(fullPath); - - if (AssetExists(fullPath)) - { - // Check if it's actually a folder already - if (AssetDatabase.IsValidFolder(fullPath)) - { - return Response.Success($"Folder already exists at path: {fullPath}", GetAssetData(fullPath)); - } - else - { - return Response.Error($"An asset (not a folder) already exists at path: {fullPath}"); - } - } - - try - { - // Ensure parent exists - if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) { - // Recursively create parent folders if needed (AssetDatabase handles this internally) - // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); - } - - string guid = AssetDatabase.CreateFolder(parentDir, folderName); - if (string.IsNullOrEmpty(guid)) { - return Response.Error($"Failed to create folder '{fullPath}'. Check logs and permissions."); - } - - // AssetDatabase.Refresh(); // CreateFolder usually handles refresh - return Response.Success($"Folder '{fullPath}' created successfully.", GetAssetData(fullPath)); - } - catch (Exception e) - { - return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); - } - } - - private static object ModifyAsset(string path, JObject properties) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for modify."); - if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify."); - - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(fullPath); - if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); - - bool modified = false; // Flag to track if any changes were made - - // --- NEW: Handle GameObject / Prefab Component Modification --- - if (asset is GameObject gameObject) - { - // Iterate through the properties JSON: keys are component names, values are properties objects for that component - foreach (var prop in properties.Properties()) - { - string componentName = prop.Name; // e.g., "Collectible" - // Check if the value associated with the component name is actually an object containing properties - if (prop.Value is JObject componentProperties && componentProperties.HasValues) // e.g., {"bobSpeed": 2.0} - { - // Find the component on the GameObject using the name from the JSON key - // Using GetComponent(string) is convenient but might require exact type name or be ambiguous. - // Consider using FindType helper if needed for more complex scenarios. - Component targetComponent = gameObject.GetComponent(componentName); - - if (targetComponent != null) - { - // Apply the nested properties (e.g., bobSpeed) to the found component instance - // Use |= to ensure 'modified' becomes true if any component is successfully modified - modified |= ApplyObjectProperties(targetComponent, componentProperties); - } - else - { - // Log a warning if a specified component couldn't be found - Debug.LogWarning($"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component."); - } - } - else - { - // Log a warning if the structure isn't {"ComponentName": {"prop": value}} - // We could potentially try to apply this property directly to the GameObject here if needed, - // but the primary goal is component modification. - Debug.LogWarning($"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping."); - } - } - // Note: 'modified' is now true if ANY component property was successfully changed. - } - // --- End NEW --- - - // --- Existing logic for other asset types (now as else-if) --- - // Example: Modifying a Material - else if (asset is Material material) - { - // Apply properties directly to the material. If this modifies, it sets modified=true. - // Use |= in case the asset was already marked modified by previous logic (though unlikely here) - modified |= ApplyMaterialProperties(material, properties); - } - // Example: Modifying a ScriptableObject - else if (asset is ScriptableObject so) - { - // Apply properties directly to the ScriptableObject. - modified |= ApplyObjectProperties(so, properties); // General helper - } - // Example: Modifying TextureImporter settings - else if (asset is Texture) { - AssetImporter importer = AssetImporter.GetAtPath(fullPath); - if (importer is TextureImporter textureImporter) - { - bool importerModified = ApplyObjectProperties(textureImporter, properties); - if (importerModified) { - // Importer settings need saving and reimporting - AssetDatabase.WriteImportSettingsIfDirty(fullPath); - AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes - modified = true; // Mark overall operation as modified - } - } - else { - Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); - } - } - // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) - else // Fallback for other asset types OR direct properties on non-GameObject assets - { - // This block handles non-GameObject/Material/ScriptableObject/Texture assets. - // Attempts to apply properties directly to the asset itself. - Debug.LogWarning($"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself."); - modified |= ApplyObjectProperties(asset, properties); - } - // --- End Existing Logic --- - - // Check if any modification happened (either component or direct asset modification) - if (modified) - { - // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. - EditorUtility.SetDirty(asset); - // Save all modified assets to disk. - AssetDatabase.SaveAssets(); - // Refresh might be needed in some edge cases, but SaveAssets usually covers it. - // AssetDatabase.Refresh(); - return Response.Success($"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath)); - } else { - // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. - return Response.Success($"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath)); - // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); - } - } - catch (Exception e) - { - // Log the detailed error internally - Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); - // Return a user-friendly error message - return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); - } - } - - private static object DeleteAsset(string path) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - bool success = AssetDatabase.DeleteAsset(fullPath); - if (success) - { - // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh - return Response.Success($"Asset '{fullPath}' deleted successfully."); - } - else - { - // This might happen if the file couldn't be deleted (e.g., locked) - return Response.Error($"Failed to delete asset '{fullPath}'. Check logs or if the file is locked."); - } - } - catch (Exception e) - { - return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); - } - } - - private static object DuplicateAsset(string path, string destinationPath) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate."); - - string sourcePath = SanitizeAssetPath(path); - if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); - - string destPath; - if (string.IsNullOrEmpty(destinationPath)) - { - // Generate a unique path if destination is not provided - destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); - } - else - { - destPath = SanitizeAssetPath(destinationPath); - if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}"); - // Ensure destination directory exists - EnsureDirectoryExists(Path.GetDirectoryName(destPath)); - } - - try - { - bool success = AssetDatabase.CopyAsset(sourcePath, destPath); - if (success) - { - // AssetDatabase.Refresh(); - return Response.Success($"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath)); - } - else - { - return Response.Error($"Failed to duplicate asset from '{sourcePath}' to '{destPath}'."); - } - } - catch (Exception e) - { - return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); - } - } - - private static object MoveOrRenameAsset(string path, string destinationPath) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for move/rename."); - if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename."); - - string sourcePath = SanitizeAssetPath(path); - string destPath = SanitizeAssetPath(destinationPath); - - if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); - if (AssetExists(destPath)) return Response.Error($"An asset already exists at the destination path: {destPath}"); - - // Ensure destination directory exists - EnsureDirectoryExists(Path.GetDirectoryName(destPath)); - - try - { - // Validate will return an error string if failed, null if successful - string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); - if (!string.IsNullOrEmpty(error)) - { - return Response.Error($"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}"); - } - - string guid = AssetDatabase.MoveAsset(sourcePath, destPath); - if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success - { - // AssetDatabase.Refresh(); // MoveAsset usually handles refresh - return Response.Success($"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath)); - } - else - { - // This case might not be reachable if ValidateMoveAsset passes, but good to have - return Response.Error($"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'."); - } - } - catch (Exception e) - { - return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); - } - } - - private static object SearchAssets(JObject @params) - { - string searchPattern = @params["searchPattern"]?.ToString(); - string filterType = @params["filterType"]?.ToString(); - string pathScope = @params["path"]?.ToString(); // Use path as folder scope - string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); - int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size - int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) - bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; - - List searchFilters = new List(); - if (!string.IsNullOrEmpty(searchPattern)) searchFilters.Add(searchPattern); - if (!string.IsNullOrEmpty(filterType)) searchFilters.Add($"t:{filterType}"); - - string[] folderScope = null; - if (!string.IsNullOrEmpty(pathScope)) - { - folderScope = new string[] { SanitizeAssetPath(pathScope) }; - if (!AssetDatabase.IsValidFolder(folderScope[0])) { - // Maybe the user provided a file path instead of a folder? - // We could search in the containing folder, or return an error. - Debug.LogWarning($"Search path '{folderScope[0]}' is not a valid folder. Searching entire project."); - folderScope = null; // Search everywhere if path isn't a folder - } - } - - DateTime? filterDateAfter = null; - if (!string.IsNullOrEmpty(filterDateAfterStr)) { - if (DateTime.TryParse(filterDateAfterStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime parsedDate)) { - filterDateAfter = parsedDate; - } else { - Debug.LogWarning($"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format."); - } - } - - try - { - string[] guids = AssetDatabase.FindAssets(string.Join(" ", searchFilters), folderScope); - List results = new List(); - int totalFound = 0; - - foreach (string guid in guids) - { - string assetPath = AssetDatabase.GUIDToAssetPath(guid); - if (string.IsNullOrEmpty(assetPath)) continue; - - // Apply date filter if present - if (filterDateAfter.HasValue) { - DateTime lastWriteTime = File.GetLastWriteTimeUtc(Path.Combine(Directory.GetCurrentDirectory(), assetPath)); - if (lastWriteTime <= filterDateAfter.Value) { - continue; // Skip assets older than or equal to the filter date - } - } - - totalFound++; // Count matching assets before pagination - results.Add(GetAssetData(assetPath, generatePreview)); - } - - // Apply pagination - int startIndex = (pageNumber - 1) * pageSize; - var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); - - return Response.Success($"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new { - totalAssets = totalFound, - pageSize = pageSize, - pageNumber = pageNumber, - assets = pagedResults - }); - } - catch (Exception e) - { - return Response.Error($"Error searching assets: {e.Message}"); - } - } - - private static object GetAssetInfo(string path, bool generatePreview) - { - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info."); - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - return Response.Success("Asset info retrieved.", GetAssetData(fullPath, generatePreview)); - } - catch (Exception e) - { - return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); - } - } - - /// - /// Retrieves components attached to a GameObject asset (like a Prefab). - /// - /// The asset path of the GameObject or Prefab. - /// A response object containing a list of component type names or an error. - private static object GetComponentsFromAsset(string path) - { - // 1. Validate input path - if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_components."); - - // 2. Sanitize and check existence - string fullPath = SanitizeAssetPath(path); - if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); - - try - { - // 3. Load the asset - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(fullPath); - if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); - - // 4. Check if it's a GameObject (Prefabs load as GameObjects) - GameObject gameObject = asset as GameObject; - if (gameObject == null) - { - // Also check if it's *directly* a Component type (less common for primary assets) - Component componentAsset = asset as Component; - if (componentAsset != null) { - // If the asset itself *is* a component, maybe return just its info? - // This is an edge case. Let's stick to GameObjects for now. - return Response.Error($"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject."); - } - return Response.Error($"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type."); - } - - // 5. Get components - Component[] components = gameObject.GetComponents(); - - // 6. Format component data - List componentList = components.Select(comp => new { - typeName = comp.GetType().FullName, - instanceID = comp.GetInstanceID(), - // TODO: Add more component-specific details here if needed in the future? - // Requires reflection or specific handling per component type. - }).ToList(); // Explicit cast for clarity if needed - - // 7. Return success response - return Response.Success($"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList); - } - catch (Exception e) - { - Debug.LogError($"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}"); - return Response.Error($"Error getting components for asset '{fullPath}': {e.Message}"); - } - } - - // --- Internal Helpers --- - - /// - /// Ensures the asset path starts with "Assets/". - /// - private static string SanitizeAssetPath(string path) - { - if (string.IsNullOrEmpty(path)) return path; - path = path.Replace('\\', '/'); // Normalize separators - if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - return "Assets/" + path.TrimStart('/'); - } - return path; - } - - /// - /// Checks if an asset exists at the given path (file or folder). - /// - private static bool AssetExists(string sanitizedPath) - { - // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. - // Check if it's a known asset GUID. - if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) - { - return true; - } - // AssetPathToGUID might not work for newly created folders not yet refreshed. - // Check directory explicitly for folders. - if(Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) { - // Check if it's considered a *valid* folder by Unity - return AssetDatabase.IsValidFolder(sanitizedPath); - } - // Check file existence for non-folder assets. - if(File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))){ - return true; // Assume if file exists, it's an asset or will be imported - } - - return false; - // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); - } - - /// - /// Ensures the directory for a given asset path exists, creating it if necessary. - /// - private static void EnsureDirectoryExists(string directoryPath) - { - if (string.IsNullOrEmpty(directoryPath)) return; - string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); - if (!Directory.Exists(fullDirPath)) - { - Directory.CreateDirectory(fullDirPath); - AssetDatabase.Refresh(); // Let Unity know about the new folder - } - } - - /// - /// Applies properties from JObject to a Material. - /// - private static bool ApplyMaterialProperties(Material mat, JObject properties) - { - if (mat == null || properties == null) return false; - bool modified = false; - - // Example: Set shader - if (properties["shader"]?.Type == JTokenType.String) { - Shader newShader = Shader.Find(properties["shader"].ToString()); - if (newShader != null && mat.shader != newShader) { - mat.shader = newShader; - modified = true; - } - } - // Example: Set color property - if (properties["color"] is JObject colorProps) { - string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color - if (colorProps["value"] is JArray colArr && colArr.Count >= 3) { - try { - Color newColor = new Color( - colArr[0].ToObject(), - colArr[1].ToObject(), - colArr[2].ToObject(), - colArr.Count > 3 ? colArr[3].ToObject() : 1.0f - ); - if(mat.HasProperty(propName) && mat.GetColor(propName) != newColor) { - mat.SetColor(propName, newColor); - modified = true; - } - } catch (Exception ex) { Debug.LogWarning($"Error parsing color property '{propName}': {ex.Message}"); } - } - } - // Example: Set float property - if (properties["float"] is JObject floatProps) { - string propName = floatProps["name"]?.ToString(); - if (!string.IsNullOrEmpty(propName) && floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) { - try { - float newVal = floatProps["value"].ToObject(); - if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) { - mat.SetFloat(propName, newVal); - modified = true; - } - } catch (Exception ex) { Debug.LogWarning($"Error parsing float property '{propName}': {ex.Message}"); } - } - } - // Example: Set texture property - if (properties["texture"] is JObject texProps) { - string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture - string texPath = texProps["path"]?.ToString(); - if (!string.IsNullOrEmpty(texPath)) { - Texture newTex = AssetDatabase.LoadAssetAtPath(SanitizeAssetPath(texPath)); - if (newTex != null && mat.HasProperty(propName) && mat.GetTexture(propName) != newTex) { - mat.SetTexture(propName, newTex); - modified = true; - } - else if(newTex == null) { - Debug.LogWarning($"Texture not found at path: {texPath}"); - } - } - } - - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) - return modified; - } - - /// - /// Generic helper to set properties on any UnityEngine.Object using reflection. - /// - private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) - { - if (target == null || properties == null) return false; - bool modified = false; - Type type = target.GetType(); - - foreach (var prop in properties.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - if (SetPropertyOrField(target, propName, propValue, type)) - { - modified = true; - } - } - return modified; - } - - /// - /// Helper to set a property or field via reflection, handling basic types and Unity objects. - /// - private static bool SetPropertyOrField(object target, string memberName, JToken value, Type type = null) - { - type = type ?? target.GetType(); - System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase; - - try - { - System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); - if (propInfo != null && propInfo.CanWrite) - { - object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); - if (convertedValue != null && !object.Equals(propInfo.GetValue(target), convertedValue)) - { - propInfo.SetValue(target, convertedValue); - return true; - } - } - else - { - System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); - if (fieldInfo != null) - { - object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); - if (convertedValue != null && !object.Equals(fieldInfo.GetValue(target), convertedValue)) - { - fieldInfo.SetValue(target, convertedValue); - return true; - } - } - } - } - catch (Exception ex) - { - Debug.LogWarning($"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}"); - } - return false; - } - - /// - /// Simple JToken to Type conversion for common Unity types and primitives. - /// - private static object ConvertJTokenToType(JToken token, Type targetType) - { - try - { - if (token == null || token.Type == JTokenType.Null) return null; - - if (targetType == typeof(string)) return token.ToObject(); - if (targetType == typeof(int)) return token.ToObject(); - if (targetType == typeof(float)) return token.ToObject(); - if (targetType == typeof(bool)) return token.ToObject(); - if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) - return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); - if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) - return new Vector3(arrV3[0].ToObject(), arrV3[1].ToObject(), arrV3[2].ToObject()); - if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) - return new Vector4(arrV4[0].ToObject(), arrV4[1].ToObject(), arrV4[2].ToObject(), arrV4[3].ToObject()); - if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) - return new Quaternion(arrQ[0].ToObject(), arrQ[1].ToObject(), arrQ[2].ToObject(), arrQ[3].ToObject()); - if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA - return new Color(arrC[0].ToObject(), arrC[1].ToObject(), arrC[2].ToObject(), arrC.Count > 3 ? arrC[3].ToObject() : 1.0f); - if (targetType.IsEnum) - return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing - - // Handle loading Unity Objects (Materials, Textures, etc.) by path - if (typeof(UnityEngine.Object).IsAssignableFrom(targetType) && token.Type == JTokenType.String) - { - string assetPath = SanitizeAssetPath(token.ToString()); - UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType); - if (loadedAsset == null) { - Debug.LogWarning($"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}"); - } - return loadedAsset; - } - - // Fallback: Try direct conversion (might work for other simple value types) - return token.ToObject(targetType); - } - catch (Exception ex) - { - Debug.LogWarning($"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}"); - return null; - } - } - - /// - /// Helper to find a Type by name, searching relevant assemblies. - /// Needed for creating ScriptableObjects or finding component types by name. - /// - private static Type FindType(string typeName) - { - if (string.IsNullOrEmpty(typeName)) return null; - - // Try direct lookup first (common Unity types often don't need assembly qualified name) - var type = Type.GetType(typeName) ?? - Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") ?? - Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") ?? - Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule"); - - if (type != null) return type; - - // If not found, search loaded assemblies (slower but more robust for user scripts) - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - // Look for non-namespaced first - type = assembly.GetType(typeName, false, true); // throwOnError=false, ignoreCase=true - if (type != null) return type; - - // Check common namespaces if simple name given - type = assembly.GetType("UnityEngine." + typeName, false, true); - if (type != null) return type; - type = assembly.GetType("UnityEditor." + typeName, false, true); - if (type != null) return type; - // Add other likely namespaces if needed (e.g., specific plugins) - } - - Debug.LogWarning($"[FindType] Type '{typeName}' not found in any loaded assembly."); - return null; // Not found - } - - // --- Data Serialization --- - - /// - /// Creates a serializable representation of an asset. - /// - private static object GetAssetData(string path, bool generatePreview = false) - { - if (string.IsNullOrEmpty(path) || !AssetExists(path)) return null; - - string guid = AssetDatabase.AssetPathToGUID(path); - Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); - string previewBase64 = null; - int previewWidth = 0; - int previewHeight = 0; - - if (generatePreview && asset != null) - { - Texture2D preview = AssetPreview.GetAssetPreview(asset); - - if (preview != null) - { - try { - // Ensure texture is readable for EncodeToPNG - // Creating a temporary readable copy is safer - RenderTexture rt = RenderTexture.GetTemporary(preview.width, preview.height); - Graphics.Blit(preview, rt); - RenderTexture previous = RenderTexture.active; - RenderTexture.active = rt; - Texture2D readablePreview = new Texture2D(preview.width, preview.height); - readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); - readablePreview.Apply(); - RenderTexture.active = previous; - RenderTexture.ReleaseTemporary(rt); - - byte[] pngData = readablePreview.EncodeToPNG(); - previewBase64 = Convert.ToBase64String(pngData); - previewWidth = readablePreview.width; - previewHeight = readablePreview.height; - UnityEngine.Object.DestroyImmediate(readablePreview); // Clean up temp texture - - } catch (Exception ex) { - Debug.LogWarning($"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable."); - // Fallback: Try getting static preview if available? - // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); - } - } - else - { - Debug.LogWarning($"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?"); - } - } - - return new - { - path = path, - guid = guid, - assetType = assetType?.FullName ?? "Unknown", - name = Path.GetFileNameWithoutExtension(path), - fileName = Path.GetFileName(path), - isFolder = AssetDatabase.IsValidFolder(path), - instanceID = asset?.GetInstanceID() ?? 0, - lastWriteTimeUtc = File.GetLastWriteTimeUtc(Path.Combine(Directory.GetCurrentDirectory(), path)).ToString("o"), // ISO 8601 - // --- Preview Data --- - previewBase64 = previewBase64, // PNG data as Base64 string - previewWidth = previewWidth, - previewHeight = previewHeight - // TODO: Add more metadata? Importer settings? Dependencies? - }; - } - } -} \ No newline at end of file diff --git a/Editor/Tools/ManageGameObject.cs b/Editor/Tools/ManageGameObject.cs deleted file mode 100644 index 9c2e7a7f..00000000 --- a/Editor/Tools/ManageGameObject.cs +++ /dev/null @@ -1,1726 +0,0 @@ -using UnityEngine; -using UnityEngine.SceneManagement; -using UnityEditor; -using UnityEditor.SceneManagement; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using UnityEditorInternal; -using UnityMCP.Editor.Helpers; // For Response class - -namespace UnityMCP.Editor.Tools -{ - /// - /// Handles GameObject manipulation within the current scene (CRUD, find, components). - /// - public static class ManageGameObject - { - // --- Main Handler --- - - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString().ToLower(); - if (string.IsNullOrEmpty(action)) - { - return Response.Error("Action parameter is required."); - } - - // Parameters used by various actions - JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) - string searchMethod = @params["searchMethod"]?.ToString().ToLower(); - - // Get common parameters (consolidated) - string name = @params["name"]?.ToString(); - string tag = @params["tag"]?.ToString(); - string layer = @params["layer"]?.ToString(); - JToken parentToken = @params["parent"]; - - // --- Prefab Redirection Check --- - string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; - if (!string.IsNullOrEmpty(targetPath) && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) - if (action == "modify" || action == "set_component_property") - { - Debug.Log($"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset."); - // Prepare params for ManageAsset.ModifyAsset - JObject assetParams = new JObject(); - assetParams["action"] = "modify"; // ManageAsset uses "modify" - assetParams["path"] = targetPath; - - // Extract properties. - // For 'set_component_property', combine componentName and componentProperties. - // For 'modify', directly use componentProperties. - JObject properties = null; - if (action == "set_component_property") - { - string compName = @params["componentName"]?.ToString(); - JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting - if (string.IsNullOrEmpty(compName)) return Response.Error("Missing 'componentName' for 'set_component_property' on prefab."); - if (compProps == null) return Response.Error($"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab."); - - properties = new JObject(); - properties[compName] = compProps; - } - else // action == "modify" - { - properties = @params["componentProperties"] as JObject; - if (properties == null) return Response.Error("Missing 'componentProperties' for 'modify' action on prefab."); - } - - assetParams["properties"] = properties; - - // Call ManageAsset handler - return ManageAsset.HandleCommand(assetParams); - } - else if (action == "delete" || action == "add_component" || action == "remove_component" || action == "get_components") // Added get_components here too - { - // Explicitly block other modifications on the prefab asset itself via manage_gameobject - return Response.Error($"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command."); - } - // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. - // No specific handling needed here, the code below will run. - } - // --- End Prefab Redirection Check --- - - try - { - switch (action) - { - case "create": - return CreateGameObject(@params); - case "modify": - return ModifyGameObject(@params, targetToken, searchMethod); - case "delete": - return DeleteGameObject(targetToken, searchMethod); - case "find": - return FindGameObjects(@params, targetToken, searchMethod); - case "get_components": - string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string - if (getCompTarget == null) return Response.Error("'target' parameter required for get_components."); - return GetComponentsFromTarget(getCompTarget, searchMethod); - case "add_component": - return AddComponentToTarget(@params, targetToken, searchMethod); - case "remove_component": - return RemoveComponentFromTarget(@params, targetToken, searchMethod); - case "set_component_property": - return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); - - default: - return Response.Error($"Unknown action: '{action}'."); - } - } - catch (Exception e) - { - Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object CreateGameObject(JObject @params) - { - string name = @params["name"]?.ToString(); - if (string.IsNullOrEmpty(name)) - { - return Response.Error("'name' parameter is required for 'create' action."); - } - - // Get prefab creation parameters - bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; - string prefabPath = @params["prefabPath"]?.ToString(); - string tag = @params["tag"]?.ToString(); // Get tag for creation - string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check - GameObject newGo = null; // Initialize as null - - // --- Try Instantiating Prefab First --- - string originalPrefabPath = prefabPath; // Keep original for messages - if (!string.IsNullOrEmpty(prefabPath)) - { - // If no extension, search for the prefab by name - if (!prefabPath.Contains("/") && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - string prefabNameOnly = prefabPath; - Debug.Log($"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'"); - string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); - if (guids.Length == 0) - { - return Response.Error($"Prefab named '{prefabNameOnly}' not found anywhere in the project."); - } - else if (guids.Length > 1) - { - string foundPaths = string.Join(", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g))); - return Response.Error($"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path."); - } - else // Exactly one found - { - prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path - Debug.Log($"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'"); - } - } - else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. - // We could also error here, but appending might be more user-friendly. - Debug.LogWarning($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending."); - prefabPath += ".prefab"; - // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. - } - - // Removed the early return error for missing .prefab ending. - // The logic above now handles finding or assuming the .prefab extension. - - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); - if (prefabAsset != null) - { - try - { - // Instantiate the prefab, initially place it at the root - // Parent will be set later if specified - newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; - - if (newGo == null) - { - // This might happen if the asset exists but isn't a valid GameObject prefab somehow - Debug.LogError($"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject."); - return Response.Error($"Failed to instantiate prefab at '{prefabPath}'."); - } - - // Name the instance based on the 'name' parameter, not the prefab's default name - if (!string.IsNullOrEmpty(name)) - { - newGo.name = name; - } - - // Register Undo for prefab instantiation - Undo.RegisterCreatedObjectUndo(newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'"); - Debug.Log($"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'."); - - } - catch (Exception e) - { - return Response.Error($"Error instantiating prefab '{prefabPath}': {e.Message}"); - } - } - else - { - // Only return error if prefabPath was specified but not found. - // If prefabPath was empty/null, we proceed to create primitive/empty. - Debug.LogWarning($"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified."); - // Do not return error here, allow fallback to primitive/empty creation - } - } - - // --- Fallback: Create Primitive or Empty GameObject --- - bool createdNewObject = false; // Flag to track if we created (not instantiated) - if (newGo == null) // Only proceed if prefab instantiation didn't happen - { - if (!string.IsNullOrEmpty(primitiveType)) - { - try - { - PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); - newGo = GameObject.CreatePrimitive(type); - // Set name *after* creation for primitives - if (!string.IsNullOrEmpty(name)) newGo.name = name; - else return Response.Error("'name' parameter is required when creating a primitive."); // Name is essential - createdNewObject = true; - } - catch (ArgumentException) - { - return Response.Error($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}"); - } - catch (Exception e) - { - return Response.Error($"Failed to create primitive '{primitiveType}': {e.Message}"); - } - } - else // Create empty GameObject - { - if (string.IsNullOrEmpty(name)) - { - return Response.Error("'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive."); - } - newGo = new GameObject(name); - createdNewObject = true; - } - - // Record creation for Undo *only* if we created a new object - if (createdNewObject) - { - Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); - } - } - - // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- - if (newGo == null) - { - // Should theoretically not happen if logic above is correct, but safety check. - return Response.Error("Failed to create or instantiate the GameObject."); - } - - // Record potential changes to the existing prefab instance or the new GO - // Record transform separately in case parent changes affect it - Undo.RecordObject(newGo.transform, "Set GameObject Transform"); - Undo.RecordObject(newGo, "Set GameObject Properties"); - - // Set Parent - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding - if (parentGo == null) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object - return Response.Error($"Parent specified ('{parentToken}') but not found."); - } - newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true - } - - // Set Transform - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue) newGo.transform.localPosition = position.Value; - if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; - if (scale.HasValue) newGo.transform.localScale = scale.Value; - - // Set Tag (added for create action) - if (!string.IsNullOrEmpty(tag)) - { - // Similar logic as in ModifyGameObject for setting/creating tags - string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - try { - newGo.tag = tagToSet; - } catch (UnityException ex) { - if (ex.Message.Contains("is not defined")) { - Debug.LogWarning($"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it."); - try { - InternalEditorUtility.AddTag(tagToSet); - newGo.tag = tagToSet; // Retry - Debug.Log($"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully."); - } catch (Exception innerEx) { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error($"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}."); - } - } else { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error($"Failed to set tag to '{tagToSet}' during creation: {ex.Message}."); - } - } - } - - // Set Layer (new for create action) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId != -1) - { - newGo.layer = layerId; - } - else - { - Debug.LogWarning($"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer."); - } - } - - // Add Components - if (@params["componentsToAdd"] is JArray componentsToAddArray) - { - foreach (var compToken in componentsToAddArray) - { - string typeName = null; - JObject properties = null; - - if (compToken.Type == JTokenType.String) - { - typeName = compToken.ToString(); - } - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(newGo, typeName, properties); - if (addResult != null) // Check if AddComponentInternal returned an error object - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return addResult; // Return the error response - } - } - else - { - Debug.LogWarning($"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}"); - } - } - } - - // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true - GameObject finalInstance = newGo; // Use this for selection and return data - if (createdNewObject && saveAsPrefab) - { - string finalPrefabPath = prefabPath; // Use a separate variable for saving path - // This check should now happen *before* attempting to save - if (string.IsNullOrEmpty(finalPrefabPath)) - { - // Clean up the created object before returning error - UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error("'prefabPath' is required when 'saveAsPrefab' is true and creating a new object."); - } - // Ensure the *saving* path ends with .prefab - if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - Debug.Log($"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'"); - finalPrefabPath += ".prefab"; - } - - // Removed the error check here as we now ensure the extension exists - // if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - // { - // UnityEngine.Object.DestroyImmediate(newGo); - // return Response.Error($"'prefabPath' must end with '.prefab'. Provided: '{prefabPath}'"); - // } - - try - { - // Ensure directory exists using the final saving path - string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); - if (!string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath)) - { - System.IO.Directory.CreateDirectory(directoryPath); - AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder - Debug.Log($"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"); - } - - // Use SaveAsPrefabAssetAndConnect with the final saving path - finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction); - - if (finalInstance == null) - { - // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) - UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error($"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions."); - } - Debug.Log($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected."); - // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. - // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect - } - catch (Exception e) - { - // Clean up the instance if prefab saving fails - UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt - return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); - } - } - - // Select the instance in the scene (either prefab instance or newly created/saved one) - Selection.activeGameObject = finalInstance; - - // Determine appropriate success message using the potentially updated or original path - string messagePrefabPath = finalInstance == null ? originalPrefabPath : AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance); - string successMessage; - if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab - { - successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; - } - else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab - { - successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; - } - else // Created new primitive or empty GO, didn't save as prefab - { - successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; - } - - // Return data for the instance in the scene - return Response.Success(successMessage, GetGameObjectData(finalInstance)); - } - - private static object ModifyGameObject(JObject @params, JToken targetToken, string searchMethod) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return Response.Error($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); - } - - // Record state for Undo *before* modifications - Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); - Undo.RecordObject(targetGo, "Modify GameObject Properties"); - - bool modified = false; - - // Rename (using consolidated 'name' parameter) - string name = @params["name"]?.ToString(); - if (!string.IsNullOrEmpty(name) && targetGo.name != name) - { - targetGo.name = name; - modified = true; - } - - // Change Parent (using consolidated 'parent' parameter) - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); - if (newParentGo == null && !(parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))) - { - return Response.Error($"New parent ('{parentToken}') not found."); - } - // Check for hierarchy loops - if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) - { - return Response.Error($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop."); - } - if (targetGo.transform.parent != (newParentGo?.transform)) - { - targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true - modified = true; - } - } - - // Set Active State - bool? setActive = @params["setActive"]?.ToObject(); - if (setActive.HasValue && targetGo.activeSelf != setActive.Value) - { - targetGo.SetActive(setActive.Value); - modified = true; - } - - // Change Tag (using consolidated 'tag' parameter) - string tag = @params["tag"]?.ToString(); - // Only attempt to change tag if a non-null tag is provided and it's different from the current one. - // Allow setting an empty string to remove the tag (Unity uses "Untagged"). - if (tag != null && targetGo.tag != tag) - { - // Ensure the tag is not empty, if empty, it means "Untagged" implicitly - string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - - try { - // First attempt to set the tag - targetGo.tag = tagToSet; - modified = true; - } - catch (UnityException ex) - { - // Check if the error is specifically because the tag doesn't exist - if (ex.Message.Contains("is not defined")) - { - Debug.LogWarning($"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it."); - try - { - // Attempt to create the tag using internal utility - InternalEditorUtility.AddTag(tagToSet); - // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. - // yield return null; // Cannot yield here, editor script limitation - - // Retry setting the tag immediately after creation - targetGo.tag = tagToSet; - modified = true; // Mark as modified on successful retry - Debug.Log($"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully."); - } - catch (Exception innerEx) - { - // Handle failure during tag creation or the second assignment attempt - Debug.LogError($"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}"); - return Response.Error($"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions."); - } - } - else - { - // If the exception was for a different reason, return the original error - return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); - } - } - } - - // Change Layer (using consolidated 'layer' parameter) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId == -1 && layerName != "Default") - { - return Response.Error($"Invalid layer specified: '{layerName}'. Use a valid layer name."); - } - if (layerId != -1 && targetGo.layer != layerId) - { - targetGo.layer = layerId; - modified = true; - } - } - - // Transform Modifications - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue && targetGo.transform.localPosition != position.Value) - { - targetGo.transform.localPosition = position.Value; - modified = true; - } - if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) - { - targetGo.transform.localEulerAngles = rotation.Value; - modified = true; - } - if (scale.HasValue && targetGo.transform.localScale != scale.Value) - { - targetGo.transform.localScale = scale.Value; - modified = true; - } - - // --- Component Modifications --- - // Note: These might need more specific Undo recording per component - - // Remove Components - if (@params["componentsToRemove"] is JArray componentsToRemoveArray) - { - foreach (var compToken in componentsToRemoveArray) - { - string typeName = compToken.ToString(); - if (!string.IsNullOrEmpty(typeName)) - { - var removeResult = RemoveComponentInternal(targetGo, typeName); - if (removeResult != null) return removeResult; // Return error if removal failed - modified = true; - } - } - } - - // Add Components (similar to create) - if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) - { - foreach (var compToken in componentsToAddArrayModify) - { - // ... (parsing logic as in CreateGameObject) ... - string typeName = null; - JObject properties = null; - if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); - else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(targetGo, typeName, properties); - if (addResult != null) return addResult; - modified = true; - } - } - } - - // Set Component Properties - if (@params["componentProperties"] is JObject componentPropertiesObj) - { - foreach (var prop in componentPropertiesObj.Properties()) - { - string compName = prop.Name; - JObject propertiesToSet = prop.Value as JObject; - if (propertiesToSet != null) - { - var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); - if (setResult != null) return setResult; - modified = true; - } - } - } - - if (!modified) - { - return Response.Success($"No modifications applied to GameObject '{targetGo.name}'.", GetGameObjectData(targetGo)); - } - - EditorUtility.SetDirty(targetGo); // Mark scene as dirty - return Response.Success($"GameObject '{targetGo.name}' modified successfully.", GetGameObjectData(targetGo)); - } - - private static object DeleteGameObject(JToken targetToken, string searchMethod) - { - // Find potentially multiple objects if name/tag search is used without find_all=false implicitly - List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety - - if (targets.Count == 0) - { - return Response.Error($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); - } - - List deletedObjects = new List(); - foreach(var targetGo in targets) - { - if (targetGo != null) - { - string goName = targetGo.name; - int goId = targetGo.GetInstanceID(); - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(targetGo); - deletedObjects.Add(new { name = goName, instanceID = goId }); - } - } - - if (deletedObjects.Count > 0) - { - string message = targets.Count == 1 - ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." - : $"{deletedObjects.Count} GameObjects deleted successfully."; - return Response.Success(message, deletedObjects); - } - else - { - // Should not happen if targets.Count > 0 initially, but defensive check - return Response.Error("Failed to delete target GameObject(s)."); - } - } - - private static object FindGameObjects(JObject @params, JToken targetToken, string searchMethod) - { - bool findAll = @params["findAll"]?.ToObject() ?? false; - List foundObjects = FindObjectsInternal(targetToken, searchMethod, findAll, @params); - - if (foundObjects.Count == 0) - { - return Response.Success("No matching GameObjects found.", new List()); - } - - var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); - return Response.Success($"Found {results.Count} GameObject(s).", results); - } - - private static object GetComponentsFromTarget(string target, string searchMethod) - { - GameObject targetGo = FindObjectInternal(target, searchMethod); - if (targetGo == null) - { - return Response.Error($"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'."); - } - - try - { - Component[] components = targetGo.GetComponents(); - var componentData = components.Select(c => GetComponentData(c)).ToList(); - return Response.Success($"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData); - } - catch (Exception e) - { - return Response.Error($"Error getting components from '{targetGo.name}': {e.Message}"); - } - } - - private static object AddComponentToTarget(JObject @params, JToken targetToken, string searchMethod) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) { - return Response.Error($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); - } - - string typeName = null; - JObject properties = null; - - // Allow adding component specified directly or via componentsToAdd array (take first) - if (@params["componentName"] != null) - { - typeName = @params["componentName"]?.ToString(); - properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name - } - else if (@params["componentsToAdd"] is JArray componentsToAddArray && componentsToAddArray.Count > 0) - { - var compToken = componentsToAddArray.First; - if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); - else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } - } - - if (string.IsNullOrEmpty(typeName)) - { - return Response.Error("Component type name ('componentName' or first element in 'componentsToAdd') is required."); - } - - var addResult = AddComponentInternal(targetGo, typeName, properties); - if (addResult != null) return addResult; // Return error - - EditorUtility.SetDirty(targetGo); - return Response.Success($"Component '{typeName}' added to '{targetGo.name}'.", GetGameObjectData(targetGo)); // Return updated GO data - } - - private static object RemoveComponentFromTarget(JObject @params, JToken targetToken, string searchMethod) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) { - return Response.Error($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); - } - - string typeName = null; - // Allow removing component specified directly or via componentsToRemove array (take first) - if (@params["componentName"] != null) - { - typeName = @params["componentName"]?.ToString(); - } - else if (@params["componentsToRemove"] is JArray componentsToRemoveArray && componentsToRemoveArray.Count > 0) - { - typeName = componentsToRemoveArray.First?.ToString(); - } - - if (string.IsNullOrEmpty(typeName)) - { - return Response.Error("Component type name ('componentName' or first element in 'componentsToRemove') is required."); - } - - var removeResult = RemoveComponentInternal(targetGo, typeName); - if (removeResult != null) return removeResult; // Return error - - EditorUtility.SetDirty(targetGo); - return Response.Success($"Component '{typeName}' removed from '{targetGo.name}'.", GetGameObjectData(targetGo)); - } - - private static object SetComponentPropertyOnTarget(JObject @params, JToken targetToken, string searchMethod) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) { - return Response.Error($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); - } - - string compName = @params["componentName"]?.ToString(); - JObject propertiesToSet = null; - - if (!string.IsNullOrEmpty(compName)) - { - // Properties might be directly under componentProperties or nested under the component name - if (@params["componentProperties"] is JObject compProps) { - propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure - } - } - else { - return Response.Error("'componentName' parameter is required."); - } - - if (propertiesToSet == null || !propertiesToSet.HasValues) - { - return Response.Error("'componentProperties' dictionary for the specified component is required and cannot be empty."); - } - - var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); - if (setResult != null) return setResult; // Return error - - EditorUtility.SetDirty(targetGo); - return Response.Success($"Properties set for component '{compName}' on '{targetGo.name}'.", GetGameObjectData(targetGo)); - } - - - // --- Internal Helpers --- - - /// - /// Finds a single GameObject based on token (ID, name, path) and search method. - /// - private static GameObject FindObjectInternal(JToken targetToken, string searchMethod, JObject findParams = null) - { - // If find_all is not explicitly false, we still want only one for most single-target operations. - bool findAll = findParams?["findAll"]?.ToObject() ?? false; - // If a specific target ID is given, always find just that one. - if (targetToken?.Type == JTokenType.Integer || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _))) - { - findAll = false; - } - List results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams); - return results.Count > 0 ? results[0] : null; - } - - /// - /// Core logic for finding GameObjects based on various criteria. - /// - private static List FindObjectsInternal(JToken targetToken, string searchMethod, bool findAll, JObject findParams = null) - { - List results = new List(); - string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself - bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; - bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; - - // Default search method if not specified - if (string.IsNullOrEmpty(searchMethod)) - { - if (targetToken?.Type == JTokenType.Integer) searchMethod = "by_id"; - else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) searchMethod = "by_path"; - else searchMethod = "by_name"; // Default fallback - } - - GameObject rootSearchObject = null; - // If searching in children, find the initial target first - if (searchInChildren && targetToken != null) - { - rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search - if (rootSearchObject == null) - { - Debug.LogWarning($"[ManageGameObject.Find] Root object '{targetToken}' for child search not found."); - return results; // Return empty if root not found - } - } - - switch (searchMethod) - { - case "by_id": - if (int.TryParse(searchTerm, out int instanceId)) - { - // EditorUtility.InstanceIDToObject is slow, iterate manually if possible - // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; - var allObjects = GetAllSceneObjects(searchInactive); // More efficient - GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceID() == instanceId); - if (obj != null) results.Add(obj); - } - break; - case "by_name": - var searchPoolName = rootSearchObject ? rootSearchObject.GetComponentsInChildren(searchInactive).Select(t => t.gameObject) : - GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); - break; - case "by_path": - // Path is relative to scene root or rootSearchObject - Transform foundTransform = rootSearchObject ? rootSearchObject.transform.Find(searchTerm) : GameObject.Find(searchTerm)?.transform; - if(foundTransform != null) results.Add(foundTransform.gameObject); - break; - case "by_tag": - var searchPoolTag = rootSearchObject ? rootSearchObject.GetComponentsInChildren(searchInactive).Select(t => t.gameObject) : - GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); - break; - case "by_layer": - var searchPoolLayer = rootSearchObject ? rootSearchObject.GetComponentsInChildren(searchInactive).Select(t => t.gameObject) : - GetAllSceneObjects(searchInactive); - if (int.TryParse(searchTerm, out int layerIndex)) - { - results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); - } - else - { - int namedLayer = LayerMask.NameToLayer(searchTerm); - if(namedLayer != -1) results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); - } - break; - case "by_component": - Type componentType = FindType(searchTerm); - if (componentType != null) - { - // Determine FindObjectsInactive based on the searchInactive flag - FindObjectsInactive findInactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; - // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state - var searchPoolComp = rootSearchObject - ? rootSearchObject.GetComponentsInChildren(componentType, searchInactive).Select(c => (c as Component).gameObject) - : UnityEngine.Object.FindObjectsByType(componentType, findInactive, FindObjectsSortMode.None).Select(c => (c as Component).gameObject); - results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid - } - else { Debug.LogWarning($"[ManageGameObject.Find] Component type not found: {searchTerm}"); } - break; - case "by_id_or_name_or_path": // Helper method used internally - if (int.TryParse(searchTerm, out int id)) { - var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup - GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceID() == id); - if (objById != null) { results.Add(objById); break; } - } - GameObject objByPath = GameObject.Find(searchTerm); - if (objByPath != null) { results.Add(objByPath); break; } - - var allObjectsName = GetAllSceneObjects(true); - results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); - break; - default: - Debug.LogWarning($"[ManageGameObject.Find] Unknown search method: {searchMethod}"); - break; - } - - // If only one result is needed, return just the first one found. - if (!findAll && results.Count > 1) - { - return new List { results[0] }; - } - - return results.Distinct().ToList(); // Ensure uniqueness - } - - // Helper to get all scene objects efficiently - private static IEnumerable GetAllSceneObjects(bool includeInactive) - { - // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() - var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); - var allObjects = new List(); - foreach(var root in rootObjects) - { - allObjects.AddRange(root.GetComponentsInChildren(includeInactive).Select(t=>t.gameObject)); - } - return allObjects; - } - - /// - /// Adds a component by type name and optionally sets properties. - /// Returns null on success, or an error response object on failure. - /// - private static object AddComponentInternal(GameObject targetGo, string typeName, JObject properties) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return Response.Error($"Component type '{typeName}' not found or is not a valid Component."); - } - if (!typeof(Component).IsAssignableFrom(componentType)) - { - return Response.Error($"Type '{typeName}' is not a Component."); - } - - // Prevent adding Transform again - if (componentType == typeof(Transform)) - { - return Response.Error("Cannot add another Transform component."); - } - - // Check for 2D/3D physics component conflicts - bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); - bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); - - if (isAdding2DPhysics) - { - // Check if the GameObject already has any 3D Rigidbody or Collider - if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) - { - return Response.Error($"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider."); - } - } - else if (isAdding3DPhysics) - { - // Check if the GameObject already has any 2D Rigidbody or Collider - if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) - { - return Response.Error($"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider."); - } - } - - // Check if component already exists (optional, depending on desired behavior) - // if (targetGo.GetComponent(componentType) != null) { - // return Response.Error($"Component '{typeName}' already exists on '{targetGo.name}'."); - // } - - try - { - // Use Undo.AddComponent for undo support - Component newComponent = Undo.AddComponent(targetGo, componentType); - if (newComponent == null) - { - return Response.Error($"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)."); - } - - // Set default values for specific component types - if (newComponent is Light light) - { - // Default newly added lights to directional - light.type = LightType.Directional; - } - - // Set properties if provided - if (properties != null) - { - var setResult = SetComponentPropertiesInternal(targetGo, typeName, properties, newComponent); // Pass the new component instance - if (setResult != null) { - // If setting properties failed, maybe remove the added component? - Undo.DestroyObjectImmediate(newComponent); - return setResult; // Return the error from setting properties - } - } - - return null; // Success - } - catch (Exception e) - { - return Response.Error($"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}"); - } - } - - /// - /// Removes a component by type name. - /// Returns null on success, or an error response object on failure. - /// - private static object RemoveComponentInternal(GameObject targetGo, string typeName) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return Response.Error($"Component type '{typeName}' not found for removal."); - } - - // Prevent removing essential components - if (componentType == typeof(Transform)) - { - return Response.Error("Cannot remove the Transform component."); - } - - Component componentToRemove = targetGo.GetComponent(componentType); - if (componentToRemove == null) - { - return Response.Error($"Component '{typeName}' not found on '{targetGo.name}' to remove."); - } - - try - { - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(componentToRemove); - return null; // Success - } - catch (Exception e) - { - return Response.Error($"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}"); - } - } - - /// - /// Sets properties on a component. - /// Returns null on success, or an error response object on failure. - /// - private static object SetComponentPropertiesInternal(GameObject targetGo, string compName, JObject propertiesToSet, Component targetComponentInstance = null) - { - Component targetComponent = targetComponentInstance ?? targetGo.GetComponent(compName); - if (targetComponent == null) - { - return Response.Error($"Component '{compName}' not found on '{targetGo.name}' to set properties."); - } - - Undo.RecordObject(targetComponent, "Set Component Properties"); - - foreach (var prop in propertiesToSet.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - - try - { - if (!SetProperty(targetComponent, propName, propValue)) - { - // Log warning if property could not be set - 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) - { - 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); - return null; // Success (or partial success if warnings were logged) - } - - /// - /// Helper to set a property or field via reflection, handling basic types. - /// - private static bool SetProperty(object target, string memberName, JToken value) - { - Type type = target.GetType(); - BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - try - { - // Handle special case for materials with dot notation (material.property) - // Examples: material.color, sharedMaterial.color, materials[0].color - if (memberName.Contains('.') || memberName.Contains('[')) - { - return SetNestedProperty(target, memberName, value); - } - - PropertyInfo propInfo = type.GetProperty(memberName, flags); - if (propInfo != null && propInfo.CanWrite) - { - object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); - if (convertedValue != null) - { - propInfo.SetValue(target, convertedValue); - return true; - } - } - else - { - FieldInfo fieldInfo = type.GetField(memberName, flags); - if (fieldInfo != null) - { - object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); - if (convertedValue != null) { - fieldInfo.SetValue(target, convertedValue); - return true; - } - } - } - } - catch (Exception ex) - { - Debug.LogError($"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}"); - } - return false; - } - - /// - /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") - /// - private static bool SetNestedProperty(object target, string path, JToken value) - { - try - { - // Split the path into parts (handling both dot notation and array indexing) - string[] pathParts = SplitPropertyPath(path); - if (pathParts.Length == 0) return false; - - object currentObject = target; - Type currentType = currentObject.GetType(); - BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - // Traverse the path until we reach the final property - for (int i = 0; i < pathParts.Length - 1; i++) - { - string part = pathParts[i]; - bool isArray = false; - int arrayIndex = -1; - - // Check if this part contains array indexing - if (part.Contains("[")) - { - int startBracket = part.IndexOf('['); - int endBracket = part.IndexOf(']'); - if (startBracket > 0 && endBracket > startBracket) - { - string indexStr = part.Substring(startBracket + 1, endBracket - startBracket - 1); - if (int.TryParse(indexStr, out arrayIndex)) - { - isArray = true; - part = part.Substring(0, startBracket); - } - } - } - - // Get the property/field - PropertyInfo propInfo = currentType.GetProperty(part, flags); - FieldInfo fieldInfo = null; - if (propInfo == null) - { - fieldInfo = currentType.GetField(part, flags); - if (fieldInfo == null) - { - Debug.LogWarning($"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'"); - return false; - } - } - - // Get the value - currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); - - // If the current property is null, we need to stop - if (currentObject == null) - { - Debug.LogWarning($"[SetNestedProperty] Property '{part}' is null, cannot access nested properties."); - return false; - } - - // If this is an array/list access, get the element at the index - if (isArray) - { - if (currentObject is Material[]) - { - var materials = currentObject as Material[]; - if (arrayIndex < 0 || arrayIndex >= materials.Length) - { - Debug.LogWarning($"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length-1})"); - return false; - } - currentObject = materials[arrayIndex]; - } - else if (currentObject is System.Collections.IList) - { - var list = currentObject as System.Collections.IList; - if (arrayIndex < 0 || arrayIndex >= list.Count) - { - Debug.LogWarning($"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count-1})"); - return false; - } - currentObject = list[arrayIndex]; - } - else - { - Debug.LogWarning($"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index."); - return false; - } - } - - // Update type for next iteration - currentType = currentObject.GetType(); - } - - // Set the final property - string finalPart = pathParts[pathParts.Length - 1]; - - // Special handling for Material properties (shader properties) - if (currentObject is Material material && finalPart.StartsWith("_")) - { - // Handle various material property types - if (value is JArray jArray) - { - if (jArray.Count == 4) // Color with alpha - { - Color color = new Color( - jArray[0].ToObject(), - jArray[1].ToObject(), - jArray[2].ToObject(), - jArray[3].ToObject() - ); - material.SetColor(finalPart, color); - return true; - } - else if (jArray.Count == 3) // Color without alpha - { - Color color = new Color( - jArray[0].ToObject(), - jArray[1].ToObject(), - jArray[2].ToObject(), - 1.0f - ); - material.SetColor(finalPart, color); - return true; - } - else if (jArray.Count == 2) // Vector2 - { - Vector2 vec = new Vector2( - jArray[0].ToObject(), - jArray[1].ToObject() - ); - material.SetVector(finalPart, vec); - return true; - } - else if (jArray.Count == 4) // Vector4 - { - Vector4 vec = new Vector4( - jArray[0].ToObject(), - jArray[1].ToObject(), - jArray[2].ToObject(), - jArray[3].ToObject() - ); - material.SetVector(finalPart, vec); - return true; - } - } - else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) - { - material.SetFloat(finalPart, value.ToObject()); - return true; - } - else if (value.Type == JTokenType.Boolean) - { - material.SetFloat(finalPart, value.ToObject() ? 1f : 0f); - return true; - } - else if (value.Type == JTokenType.String) - { - // Might be a texture path - string texturePath = value.ToString(); - if (texturePath.EndsWith(".png") || texturePath.EndsWith(".jpg") || texturePath.EndsWith(".tga")) - { - Texture2D texture = AssetDatabase.LoadAssetAtPath(texturePath); - if (texture != null) - { - material.SetTexture(finalPart, texture); - return true; - } - } - else - { - // Materials don't have SetString, use SetTextureOffset as workaround or skip - // material.SetString(finalPart, texturePath); - Debug.LogWarning($"[SetNestedProperty] String values not directly supported for material property {finalPart}"); - return false; - } - } - - Debug.LogWarning($"[SetNestedProperty] Unsupported material property value type: {value.Type} for {finalPart}"); - return false; - } - - // For standard properties (not shader specific) - PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); - if (finalPropInfo != null && finalPropInfo.CanWrite) - { - object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType); - if (convertedValue != null) - { - finalPropInfo.SetValue(currentObject, convertedValue); - return true; - } - } - else - { - FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); - if (finalFieldInfo != null) - { - object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType); - if (convertedValue != null) - { - finalFieldInfo.SetValue(currentObject, convertedValue); - return true; - } - } - else - { - Debug.LogWarning($"[SetNestedProperty] Could not find final property or field '{finalPart}' on type '{currentType.Name}'"); - } - } - } - catch (Exception ex) - { - Debug.LogError($"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}"); - } - - return false; - } - - /// - /// Split a property path into parts, handling both dot notation and array indexers - /// - private static string[] SplitPropertyPath(string path) - { - // Handle complex paths with both dots and array indexers - List parts = new List(); - int startIndex = 0; - bool inBrackets = false; - - for (int i = 0; i < path.Length; i++) - { - char c = path[i]; - - if (c == '[') - { - inBrackets = true; - } - else if (c == ']') - { - inBrackets = false; - } - else if (c == '.' && !inBrackets) - { - // Found a dot separator outside of brackets - parts.Add(path.Substring(startIndex, i - startIndex)); - startIndex = i + 1; - } - } - - // Add the final part - if (startIndex < path.Length) - { - parts.Add(path.Substring(startIndex)); - } - - return parts.ToArray(); - } - - /// - /// Simple JToken to Type conversion for common Unity types. - /// - private static object ConvertJTokenToType(JToken token, Type targetType) - { - try - { - // Unwrap nested material properties if we're assigning to a Material - if (typeof(Material).IsAssignableFrom(targetType) && token is JObject materialProps) - { - // Handle case where we're passing shader properties directly in a nested object - string materialPath = token["path"]?.ToString(); - if (!string.IsNullOrEmpty(materialPath)) - { - // Load the material by path - Material material = AssetDatabase.LoadAssetAtPath(materialPath); - if (material != null) - { - // If there are additional properties, set them - foreach (var prop in materialProps.Properties()) - { - if (prop.Name != "path") - { - SetProperty(material, prop.Name, prop.Value); - } - } - return material; - } - else - { - Debug.LogWarning($"[ConvertJTokenToType] Could not load material at path: '{materialPath}'"); - return null; - } - } - - // If no path is specified, could be a dynamic material or instance set by reference - return null; - } - - // Basic types first - if (targetType == typeof(string)) return token.ToObject(); - if (targetType == typeof(int)) return token.ToObject(); - if (targetType == typeof(float)) return token.ToObject(); - if (targetType == typeof(bool)) return token.ToObject(); - - // Vector/Quaternion/Color types - if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) - return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); - if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) - return new Vector3(arrV3[0].ToObject(), arrV3[1].ToObject(), arrV3[2].ToObject()); - if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) - return new Vector4(arrV4[0].ToObject(), arrV4[1].ToObject(), arrV4[2].ToObject(), arrV4[3].ToObject()); - if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) - return new Quaternion(arrQ[0].ToObject(), arrQ[1].ToObject(), arrQ[2].ToObject(), arrQ[3].ToObject()); - if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA - return new Color(arrC[0].ToObject(), arrC[1].ToObject(), arrC[2].ToObject(), arrC.Count > 3 ? arrC[3].ToObject() : 1.0f); - - // Enum types - if (targetType.IsEnum) - return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing - - // Handle assigning Unity Objects (Assets, Scene Objects, Components) - if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) - { - // CASE 1: Reference is a JSON Object specifying a scene object/component find criteria - if (token is JObject refObject) - { - JToken findToken = refObject["find"]; - string findMethod = refObject["method"]?.ToString() ?? "by_id_or_name_or_path"; // Default search - string componentTypeName = refObject["component"]?.ToString(); - - if (findToken == null) - { - Debug.LogWarning($"[ConvertJTokenToType] Reference object missing 'find' property: {token}"); - return null; - } - - // Find the target GameObject - // Pass 'searchInactive: true' for internal lookups to be more robust - JObject findParams = new JObject(); - findParams["searchInactive"] = true; - GameObject foundGo = FindObjectInternal(findToken, findMethod, findParams); - - if (foundGo == null) - { - Debug.LogWarning($"[ConvertJTokenToType] Could not find GameObject specified by reference object: {token}"); - return null; - } - - // If a component type is specified, try to get it - if (!string.IsNullOrEmpty(componentTypeName)) - { - Type compType = FindType(componentTypeName); - if (compType == null) - { - Debug.LogWarning($"[ConvertJTokenToType] Could not find component type '{componentTypeName}' specified in reference object: {token}"); - return null; - } - - // Ensure the targetType is assignable from the found component type - if (!targetType.IsAssignableFrom(compType)) - { - Debug.LogWarning($"[ConvertJTokenToType] Found component '{componentTypeName}' but it is not assignable to the target property type '{targetType.Name}'. Reference: {token}"); - return null; - } - - Component foundComp = foundGo.GetComponent(compType); - if (foundComp == null) - { - Debug.LogWarning($"[ConvertJTokenToType] Found GameObject '{foundGo.name}' but could not find component '{componentTypeName}' on it. Reference: {token}"); - return null; - } - return foundComp; // Return the found component - } - else - { - // Otherwise, return the GameObject itself, ensuring it's assignable - if (!targetType.IsAssignableFrom(typeof(GameObject))) - { - Debug.LogWarning($"[ConvertJTokenToType] Found GameObject '{foundGo.name}' but it is not assignable to the target property type '{targetType.Name}' (component name was not specified). Reference: {token}"); - return null; - } - return foundGo; // Return the found GameObject - } - } - // CASE 2: Reference is a string, assume it's an asset path - else if (token.Type == JTokenType.String) - { - string assetPath = token.ToString(); - if (!string.IsNullOrEmpty(assetPath)) - { - // Attempt to load the asset from the provided path using the target type - UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType); - if (loadedAsset != null) - { - return loadedAsset; // Return the loaded asset if successful - } - else - { - // Log a warning if the asset could not be found at the path - Debug.LogWarning($"[ConvertJTokenToType] Could not load asset of type '{targetType.Name}' from path: '{assetPath}'. Make sure the path is correct and the asset exists."); - return null; - } - } - else - { - // Handle cases where an empty string might be intended to clear the reference - return null; // Assign null if the path is empty - } - } - // CASE 3: Reference is null or empty JToken, assign null - else if (token.Type == JTokenType.Null || string.IsNullOrEmpty(token.ToString())) - { - return null; - } - // CASE 4: Invalid format for Unity Object reference - else - { - Debug.LogWarning($"[ConvertJTokenToType] Expected a string asset path or a reference object to assign Unity Object of type '{targetType.Name}', but received token type '{token.Type}'. Value: {token}"); - return null; - } - } - - // Fallback: Try direct conversion (might work for other simple value types) - // Be cautious here, this might throw errors for complex types not handled above - try { - return token.ToObject(targetType); - } catch (Exception directConversionEx) { - Debug.LogWarning($"[ConvertJTokenToType] Direct conversion failed for JToken '{token}' to type '{targetType.Name}': {directConversionEx.Message}. Specific handling might be needed."); - return null; - } - } - catch (Exception ex) - { - Debug.LogWarning($"[ConvertJTokenToType] Could not convert JToken '{token}' to type '{targetType.Name}': {ex.Message}"); - return null; - } - } - - - /// - /// Helper to find a Type by name, searching relevant assemblies. - /// - private static Type FindType(string typeName) - { - if (string.IsNullOrEmpty(typeName)) return null; - - // Handle common Unity namespaces implicitly - var type = Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") ?? - Type.GetType($"UnityEngine.{typeName}, UnityEngine.PhysicsModule") ?? // Example physics - Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") ?? // Example UI - Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule") ?? - Type.GetType(typeName); // Try direct name (if fully qualified or in mscorlib) - - if (type != null) return type; - - // If not found, search all loaded assemblies (slower) - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - type = assembly.GetType(typeName); - if (type != null) return type; - // Also check with namespaces if simple name given - type = assembly.GetType("UnityEngine." + typeName); - if (type != null) return type; - type = assembly.GetType("UnityEditor." + typeName); - if (type != null) return type; - type = assembly.GetType("UnityEngine.UI." + typeName); - if (type != null) return type; - } - - return null; // Not found - } - - /// - /// Parses a JArray like [x, y, z] into a Vector3. - /// - private static Vector3? ParseVector3(JArray array) - { - if (array != null && array.Count == 3) - { - try - { - return new Vector3( - array[0].ToObject(), - array[1].ToObject(), - array[2].ToObject() - ); - } - catch { /* Ignore parsing errors */ } - } - return null; - } - - // --- Data Serialization --- - - /// - /// Creates a serializable representation of a GameObject. - /// - private static object GetGameObjectData(GameObject go) - { - if (go == null) return null; - return new - { - name = go.name, - instanceID = go.GetInstanceID(), - tag = go.tag, - layer = go.layer, - activeSelf = go.activeSelf, - activeInHierarchy = go.activeInHierarchy, - isStatic = go.isStatic, - scenePath = go.scene.path, // Identify which scene it belongs to - transform = new // Serialize transform components carefully to avoid JSON issues - { - // Serialize Vector3 components individually to prevent self-referencing loops. - // The default serializer can struggle with properties like Vector3.normalized. - position = new { x = go.transform.position.x, y = go.transform.position.y, z = go.transform.position.z }, - localPosition = new { x = go.transform.localPosition.x, y = go.transform.localPosition.y, z = go.transform.localPosition.z }, - rotation = new { x = go.transform.rotation.eulerAngles.x, y = go.transform.rotation.eulerAngles.y, z = go.transform.rotation.eulerAngles.z }, - localRotation = new { x = go.transform.localRotation.eulerAngles.x, y = go.transform.localRotation.eulerAngles.y, z = go.transform.localRotation.eulerAngles.z }, - scale = new { x = go.transform.localScale.x, y = go.transform.localScale.y, z = go.transform.localScale.z }, - forward = new { x = go.transform.forward.x, y = go.transform.forward.y, z = go.transform.forward.z }, - up = new { x = go.transform.up.x, y = go.transform.up.y, z = go.transform.up.z }, - right = new { x = go.transform.right.x, y = go.transform.right.y, z = go.transform.right.z } - }, - parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent - // Optionally include components, but can be large - // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() - // Or just component names: - componentNames = go.GetComponents().Select(c => c.GetType().FullName).ToList() - }; - } - - /// - /// Creates a serializable representation of a Component. - /// TODO: Add property serialization. - /// - private static object GetComponentData(Component c) - { - if (c == null) return null; - var data = new Dictionary { - { "typeName", c.GetType().FullName }, - { "instanceID", c.GetInstanceID() } - }; - - // Attempt to serialize public properties/fields (can be noisy/complex) - /* - try { - var properties = new Dictionary(); - var type = c.GetType(); - BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; - - foreach (var prop in type.GetProperties(flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) { - try { properties[prop.Name] = prop.GetValue(c); } catch { } - } - foreach (var field in type.GetFields(flags)) { - try { properties[field.Name] = field.GetValue(c); } catch { } - } - data["properties"] = properties; - } catch (Exception ex) { - data["propertiesError"] = ex.Message; - } - */ - return data; - } - } -} \ No newline at end of file diff --git a/Editor/Tools/ReadConsole.cs b/Editor/Tools/ReadConsole.cs deleted file mode 100644 index f58a0979..00000000 --- a/Editor/Tools/ReadConsole.cs +++ /dev/null @@ -1,397 +0,0 @@ -using UnityEngine; -using UnityEditor; -using UnityEditorInternal; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using UnityMCP.Editor.Helpers; // For Response class -using System.Globalization; - -namespace UnityMCP.Editor.Tools -{ - /// - /// Handles reading and clearing Unity Editor console log entries. - /// Uses reflection to access internal LogEntry methods/properties. - /// - public static class ReadConsole - { - // Reflection members for accessing internal LogEntry data - // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection - private static MethodInfo _startGettingEntriesMethod; - private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... - private static MethodInfo _clearMethod; - private static MethodInfo _getCountMethod; - private static MethodInfo _getEntryMethod; - private static FieldInfo _modeField; - private static FieldInfo _messageField; - private static FieldInfo _fileField; - private static FieldInfo _lineField; - private static FieldInfo _instanceIdField; - // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? - - // Static constructor for reflection setup - static ReadConsole() - { - try { - Type logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries"); - if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); - - // Include NonPublic binding flags as internal APIs might change accessibility - BindingFlags staticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - _startGettingEntriesMethod = logEntriesType.GetMethod("StartGettingEntries", staticFlags); - if (_startGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); - - // Try reflecting EndGettingEntries based on warning message - _endGettingEntriesMethod = logEntriesType.GetMethod("EndGettingEntries", staticFlags); - if (_endGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); - - _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); - if (_clearMethod == null) throw new Exception("Failed to reflect LogEntries.Clear"); - - _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); - if (_getCountMethod == null) throw new Exception("Failed to reflect LogEntries.GetCount"); - - _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); - if (_getEntryMethod == null) throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); - - Type logEntryType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntry"); - if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry"); - - _modeField = logEntryType.GetField("mode", instanceFlags); - if (_modeField == null) throw new Exception("Failed to reflect LogEntry.mode"); - - _messageField = logEntryType.GetField("message", instanceFlags); - if (_messageField == null) throw new Exception("Failed to reflect LogEntry.message"); - - _fileField = logEntryType.GetField("file", instanceFlags); - if (_fileField == null) throw new Exception("Failed to reflect LogEntry.file"); - - _lineField = logEntryType.GetField("line", instanceFlags); - if (_lineField == null) throw new Exception("Failed to reflect LogEntry.line"); - - _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); - if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID"); - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"); - // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. - _startGettingEntriesMethod = _endGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; - _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; - } - } - - // --- Main Handler --- - - public static object HandleCommand(JObject @params) - { - // Check if ALL required reflection members were successfully initialized. - if (_startGettingEntriesMethod == null || _endGettingEntriesMethod == null || - _clearMethod == null || _getCountMethod == null || _getEntryMethod == null || - _modeField == null || _messageField == null || _fileField == null || _lineField == null || _instanceIdField == null) - { - // Log the error here as well for easier debugging in Unity Console - Debug.LogError("[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."); - return Response.Error("ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."); - } - - string action = @params["action"]?.ToString().ToLower() ?? "get"; - - try - { - if (action == "clear") - { - return ClearConsole(); - } - else if (action == "get") - { - // Extract parameters for 'get' - var types = (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() ?? new List { "error", "warning", "log" }; - int? count = @params["count"]?.ToObject(); - string filterText = @params["filterText"]?.ToString(); - string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering - string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); - bool includeStacktrace = @params["includeStacktrace"]?.ToObject() ?? true; - - if (types.Contains("all")) { - types = new List { "error", "warning", "log" }; // Expand 'all' - } - - if (!string.IsNullOrEmpty(sinceTimestampStr)) - { - Debug.LogWarning("[ReadConsole] Filtering by 'since_timestamp' is not currently implemented."); - // Need a way to get timestamp per log entry. - } - - return GetConsoleEntries(types, count, filterText, format, includeStacktrace); - } - else - { - return Response.Error($"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."); - } - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object ClearConsole() - { - try - { - _clearMethod.Invoke(null, null); // Static method, no instance, no parameters - return Response.Success("Console cleared successfully."); - } - catch (Exception e) - { - Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); - return Response.Error($"Failed to clear console: {e.Message}"); - } - } - - private static object GetConsoleEntries(List types, int? count, string filterText, string format, bool includeStacktrace) - { - List formattedEntries = new List(); - int retrievedCount = 0; - - try - { - // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal - _startGettingEntriesMethod.Invoke(null, null); - - int totalEntries = (int)_getCountMethod.Invoke(null, null); - // Create instance to pass to GetEntryInternal - Ensure the type is correct - Type logEntryType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntry"); - if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."); - object logEntryInstance = Activator.CreateInstance(logEntryType); - - for (int i = 0; i < totalEntries; i++) - { - // Get the entry data into our instance using reflection - _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); - - // Extract data using reflection - int mode = (int)_modeField.GetValue(logEntryInstance); - string message = (string)_messageField.GetValue(logEntryInstance); - string file = (string)_fileField.GetValue(logEntryInstance); - - int line = (int)_lineField.GetValue(logEntryInstance); - // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); - - if (string.IsNullOrEmpty(message)) continue; // Skip empty messages - - // --- Filtering --- - // Filter by type - LogType currentType = GetLogTypeFromMode(mode); - if (!types.Contains(currentType.ToString().ToLowerInvariant())) - { - continue; - } - - // Filter by text (case-insensitive) - if (!string.IsNullOrEmpty(filterText) && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0) - { - continue; - } - - // TODO: Filter by timestamp (requires timestamp data) - - // --- Formatting --- - string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; - // Get first line if stack is present and requested, otherwise use full message - string messageOnly = (includeStacktrace && !string.IsNullOrEmpty(stackTrace)) ? message.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)[0] : message; - - object formattedEntry = null; - switch (format) - { - case "plain": - formattedEntry = messageOnly; - break; - case "json": - case "detailed": // Treat detailed as json for structured return - default: - formattedEntry = new { - type = currentType.ToString(), - message = messageOnly, - file = file, - line = line, - // timestamp = "", // TODO - stackTrace = stackTrace // Will be null if includeStacktrace is false or no stack found - }; - break; - } - - formattedEntries.Add(formattedEntry); - retrievedCount++; - - // Apply count limit (after filtering) - if (count.HasValue && retrievedCount >= count.Value) - { - break; - } - } - } - catch (Exception e) { - Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); - // Ensure EndGettingEntries is called even if there's an error during iteration - try { _endGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ } - return Response.Error($"Error retrieving log entries: {e.Message}"); - } - finally - { - // Ensure we always call EndGettingEntries - try { _endGettingEntriesMethod.Invoke(null, null); } catch (Exception e) { - Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); - // Don't return error here as we might have valid data, but log it. - } - } - - // Return the filtered and formatted list (might be empty) - return Response.Success($"Retrieved {formattedEntries.Count} log entries.", formattedEntries); - } - - // --- Internal Helpers --- - - // Mapping from LogEntry.mode bits to LogType enum - // Based on decompiled UnityEditor code or common patterns. Precise bits might change between Unity versions. - // See comments below for LogEntry mode bits exploration. - // Note: This mapping is simplified and might not cover all edge cases or future Unity versions perfectly. - private const int ModeBitError = 1 << 0; - private const int ModeBitAssert = 1 << 1; - private const int ModeBitWarning = 1 << 2; - private const int ModeBitLog = 1 << 3; - private const int ModeBitException = 1 << 4; // Often combined with Error bits - private const int ModeBitScriptingError = 1 << 9; - private const int ModeBitScriptingWarning = 1 << 10; - private const int ModeBitScriptingLog = 1 << 11; - private const int ModeBitScriptingException = 1 << 18; - private const int ModeBitScriptingAssertion = 1 << 22; - - - private static LogType GetLogTypeFromMode(int mode) - { - // First, determine the type based on the original logic (most severe first) - LogType initialType; - if ((mode & (ModeBitError | ModeBitScriptingError | ModeBitException | ModeBitScriptingException)) != 0) { - initialType = LogType.Error; - } - else if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) { - initialType = LogType.Assert; - } - else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) { - initialType = LogType.Warning; - } - else { - initialType = LogType.Log; - } - - // Apply the observed "one level lower" correction - switch (initialType) - { - case LogType.Error: return LogType.Warning; // Error becomes Warning - case LogType.Warning: return LogType.Log; // Warning becomes Log - case LogType.Assert: return LogType.Assert; // Assert remains Assert (no lower level defined) - case LogType.Log: return LogType.Log; // Log remains Log - default: return LogType.Log; // Default fallback - } - } - - /// - /// Attempts to extract the stack trace part from a log message. - /// Unity log messages often have the stack trace appended after the main message, - /// starting on a new line and typically indented or beginning with "at ". - /// - /// The complete log message including potential stack trace. - /// The extracted stack trace string, or null if none is found. - private static string ExtractStackTrace(string fullMessage) - { - if (string.IsNullOrEmpty(fullMessage)) return null; - - // Split into lines, removing empty ones to handle different line endings gracefully. - // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. - string[] lines = fullMessage.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - - // If there's only one line or less, there's no separate stack trace. - if (lines.Length <= 1) return null; - - int stackStartIndex = -1; - - // Start checking from the second line onwards. - for(int i = 1; i < lines.Length; ++i) - { - // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. - string trimmedLine = lines[i].TrimStart(); - - // Check for common stack trace patterns. - if (trimmedLine.StartsWith("at ") || - trimmedLine.StartsWith("UnityEngine.") || - trimmedLine.StartsWith("UnityEditor.") || - trimmedLine.Contains("(at ") || // Covers "(at Assets/..." pattern - // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) - (trimmedLine.Length > 0 && char.IsUpper(trimmedLine[0]) && trimmedLine.Contains('.')) - ) - { - stackStartIndex = i; - break; // Found the likely start of the stack trace - } - } - - // If a potential start index was found... - if (stackStartIndex > 0) - { - // Join the lines from the stack start index onwards using standard newline characters. - // This reconstructs the stack trace part of the message. - return string.Join("\n", lines.Skip(stackStartIndex)); - } - - // No clear stack trace found based on the patterns. - return null; - } - - - /* LogEntry.mode bits exploration (based on Unity decompilation/observation): - May change between versions. - - Basic Types: - kError = 1 << 0 (1) - kAssert = 1 << 1 (2) - kWarning = 1 << 2 (4) - kLog = 1 << 3 (8) - kFatal = 1 << 4 (16) - Often treated as Exception/Error - - Modifiers/Context: - kAssetImportError = 1 << 7 (128) - kAssetImportWarning = 1 << 8 (256) - kScriptingError = 1 << 9 (512) - kScriptingWarning = 1 << 10 (1024) - kScriptingLog = 1 << 11 (2048) - kScriptCompileError = 1 << 12 (4096) - kScriptCompileWarning = 1 << 13 (8192) - kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play - kMayIgnoreLineNumber = 1 << 15 (32768) - kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button - kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) - kScriptingException = 1 << 18 (262144) - kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI - kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior - kGraphCompileError = 1 << 21 (2097152) - kScriptingAssertion = 1 << 22 (4194304) - kVisualScriptingError = 1 << 23 (8388608) - - Example observed values: - Log: 2048 (ScriptingLog) or 8 (Log) - Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) - Error: 513 (ScriptingError | Error) or 1 (Error) - Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination - Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) - */ - } -} \ No newline at end of file diff --git a/Python.meta b/Python.meta deleted file mode 100644 index 0c9d7eb1..00000000 --- a/Python.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: ea6797cf7f34d6044a89364e1ac4d4c9 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/__init__.py.meta b/Python/__init__.py.meta deleted file mode 100644 index bd400add..00000000 --- a/Python/__init__.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 41c60e5ab0e41d84ba997afc471ac58a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/__pycache__.meta b/Python/__pycache__.meta deleted file mode 100644 index dcd76456..00000000 --- a/Python/__pycache__.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 253b8f93cd23400478080cab9d619729 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/config.py.meta b/Python/config.py.meta deleted file mode 100644 index 8808fe44..00000000 --- a/Python/config.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: bbc163679c5bb0f418c6f6af1fa50f3a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/pyproject.toml.meta b/Python/pyproject.toml.meta deleted file mode 100644 index 0b1414f5..00000000 --- a/Python/pyproject.toml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ac3ad17989088c24598726ec3e0a53ba -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/server.py.meta b/Python/server.py.meta deleted file mode 100644 index 13ba7761..00000000 --- a/Python/server.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 872659ff7f5d9294ca6d47e93f6a111f -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools.meta b/Python/tools.meta deleted file mode 100644 index f71a0f83..00000000 --- a/Python/tools.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 30b461704d14cea488b84870202ae45f -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/__init__.py.meta b/Python/tools/__init__.py.meta deleted file mode 100644 index 93a34b74..00000000 --- a/Python/tools/__init__.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 8865a8f86cc0a3240b94504bd2e5c0be -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/__pycache__.meta b/Python/tools/__pycache__.meta deleted file mode 100644 index 3c15c116..00000000 --- a/Python/tools/__pycache__.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 9230796797a49a54297a8fa444a1f5bb -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/execute_menu_item.py.meta b/Python/tools/execute_menu_item.py.meta deleted file mode 100644 index 3d0a9689..00000000 --- a/Python/tools/execute_menu_item.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 67b82e49c36517040b7cfea8e421764e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/manage_asset.py.meta b/Python/tools/manage_asset.py.meta deleted file mode 100644 index 5354ab00..00000000 --- a/Python/tools/manage_asset.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 27ffc6de0e9253e4f980ae545f07731a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/manage_editor.py.meta b/Python/tools/manage_editor.py.meta deleted file mode 100644 index 894c2095..00000000 --- a/Python/tools/manage_editor.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a2f972b61922666418f99fa8f8ba817e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/manage_gameobject.py.meta b/Python/tools/manage_gameobject.py.meta deleted file mode 100644 index 0c60c375..00000000 --- a/Python/tools/manage_gameobject.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b34907e09ab90854fa849302b96c6247 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/manage_scene.py.meta b/Python/tools/manage_scene.py.meta deleted file mode 100644 index 342b47e3..00000000 --- a/Python/tools/manage_scene.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: be712c04494a1874593719eeb2a882ac -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/manage_script.py.meta b/Python/tools/manage_script.py.meta deleted file mode 100644 index 8c84af44..00000000 --- a/Python/tools/manage_script.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: beb93a353b9140c44b7ac22d2bb8481a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/read_console.py.meta b/Python/tools/read_console.py.meta deleted file mode 100644 index 316a7d44..00000000 --- a/Python/tools/read_console.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: c94ba17ca2284764f99d61356c5feded -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/unity_connection.py.meta b/Python/unity_connection.py.meta deleted file mode 100644 index 20b08b2f..00000000 --- a/Python/unity_connection.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: c72711b644ecf0d40945ddba9b4bce77 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/unity_mcp.egg-info.meta b/Python/unity_mcp.egg-info.meta deleted file mode 100644 index 21940ff9..00000000 --- a/Python/unity_mcp.egg-info.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 714de9c710feb1a42878a16b7a4e7a6f -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/uv.lock.meta b/Python/uv.lock.meta deleted file mode 100644 index 1600cbf5..00000000 --- a/Python/uv.lock.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 37728a13ca38f894b8760d808a909148 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor.meta b/UnityMcpBridge/Editor.meta similarity index 77% rename from Editor.meta rename to UnityMcpBridge/Editor.meta index 51126d4b..26495d40 100644 --- a/Editor.meta +++ b/UnityMcpBridge/Editor.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 78cb4b2703910bc44b4dafad25cf8b35 +guid: 31e7fac5858840340a75cc6df0ad3d9e folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Editor/Data.meta b/UnityMcpBridge/Editor/Data.meta similarity index 100% rename from Editor/Data.meta rename to UnityMcpBridge/Editor/Data.meta diff --git a/Editor/Data/DefaultServerConfig.cs b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs similarity index 87% rename from Editor/Data/DefaultServerConfig.cs rename to UnityMcpBridge/Editor/Data/DefaultServerConfig.cs index 2df0185e..1555c438 100644 --- a/Editor/Data/DefaultServerConfig.cs +++ b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs @@ -1,6 +1,6 @@ -using UnityMCP.Editor.Models; +using UnityMcpBridge.Editor.Models; -namespace UnityMCP.Editor.Data +namespace UnityMcpBridge.Editor.Data { public class DefaultServerConfig : ServerConfig { @@ -14,4 +14,5 @@ public class DefaultServerConfig : ServerConfig public new int maxRetries = 3; public new float retryDelay = 1.0f; } -} \ No newline at end of file +} + diff --git a/Editor/Data/DefaultServerConfig.cs.meta b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta similarity index 100% rename from Editor/Data/DefaultServerConfig.cs.meta rename to UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta diff --git a/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs similarity index 83% rename from Editor/Data/McpClients.cs rename to UnityMcpBridge/Editor/Data/McpClients.cs index 0c692dac..d370df87 100644 --- a/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; using System.IO; -using UnityMCP.Editor.Models; +using UnityMcpBridge.Editor.Models; -namespace UnityMCP.Editor.Data +namespace UnityMcpBridge.Editor.Data { public class McpClients { - public List clients = new() { - new() { + public List clients = new() + { + new() + { name = "Claude Desktop", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @@ -23,9 +25,10 @@ public class McpClients "claude_desktop_config.json" ), mcpType = McpTypes.ClaudeDesktop, - configStatus = "Not Configured" + configStatus = "Not Configured", }, - new() { + new() + { name = "Cursor", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -38,8 +41,8 @@ public class McpClients "mcp.json" ), mcpType = McpTypes.Cursor, - configStatus = "Not Configured" - } + configStatus = "Not Configured", + }, }; // Initialize status enums after construction @@ -54,4 +57,5 @@ public McpClients() } } } -} \ No newline at end of file +} + diff --git a/Editor/Data/McpClients.cs.meta b/UnityMcpBridge/Editor/Data/McpClients.cs.meta similarity index 100% rename from Editor/Data/McpClients.cs.meta rename to UnityMcpBridge/Editor/Data/McpClients.cs.meta diff --git a/Editor/Helpers.meta b/UnityMcpBridge/Editor/Helpers.meta similarity index 100% rename from Editor/Helpers.meta rename to UnityMcpBridge/Editor/Helpers.meta diff --git a/Editor/Helpers/Response.cs b/UnityMcpBridge/Editor/Helpers/Response.cs similarity index 79% rename from Editor/Helpers/Response.cs rename to UnityMcpBridge/Editor/Helpers/Response.cs index 150f89b5..910b153d 100644 --- a/Editor/Helpers/Response.cs +++ b/UnityMcpBridge/Editor/Helpers/Response.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace UnityMCP.Editor.Helpers +namespace UnityMcpBridge.Editor.Helpers { /// /// Provides static methods for creating standardized success and error response objects. @@ -19,7 +19,12 @@ public static object Success(string message, object data = null) { if (data != null) { - return new { success = true, message = message, data = data }; + return new + { + success = true, + message = message, + data = data, + }; } else { @@ -35,10 +40,15 @@ public static object Success(string message, object data = null) /// An object representing the error response. public static object Error(string errorMessage, object data = null) { - if (data != null) + if (data != null) { // Note: The key is "error" for error messages, not "message" - return new { success = false, error = errorMessage, data = data }; + return new + { + success = false, + error = errorMessage, + data = data, + }; } else { @@ -46,4 +56,5 @@ public static object Error(string errorMessage, object data = null) } } } -} \ No newline at end of file +} + diff --git a/Editor/Helpers/Response.cs.meta b/UnityMcpBridge/Editor/Helpers/Response.cs.meta similarity index 100% rename from Editor/Helpers/Response.cs.meta rename to UnityMcpBridge/Editor/Helpers/Response.cs.meta diff --git a/Editor/Helpers/Vector3Helper.cs b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs similarity index 94% rename from Editor/Helpers/Vector3Helper.cs rename to UnityMcpBridge/Editor/Helpers/Vector3Helper.cs index 55cb68c1..3d41fa27 100644 --- a/Editor/Helpers/Vector3Helper.cs +++ b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs @@ -1,7 +1,7 @@ -using UnityEngine; using Newtonsoft.Json.Linq; +using UnityEngine; -namespace UnityMCP.Editor.Helpers +namespace UnityMcpBridge.Editor.Helpers { /// /// Helper class for Vector3 operations @@ -21,4 +21,5 @@ public static Vector3 ParseVector3(JArray array) return new Vector3((float)array[0], (float)array[1], (float)array[2]); } } -} \ No newline at end of file +} + diff --git a/Editor/Helpers/Vector3Helper.cs.meta b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta similarity index 100% rename from Editor/Helpers/Vector3Helper.cs.meta rename to UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta diff --git a/Editor/Models.meta b/UnityMcpBridge/Editor/Models.meta similarity index 100% rename from Editor/Models.meta rename to UnityMcpBridge/Editor/Models.meta diff --git a/Editor/Models/Command.cs b/UnityMcpBridge/Editor/Models/Command.cs similarity index 91% rename from Editor/Models/Command.cs rename to UnityMcpBridge/Editor/Models/Command.cs index 4f153f43..11089636 100644 --- a/Editor/Models/Command.cs +++ b/UnityMcpBridge/Editor/Models/Command.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json.Linq; -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { /// /// Represents a command received from the MCP client @@ -17,4 +17,5 @@ public class Command /// public JObject @params { get; set; } } -} \ No newline at end of file +} + diff --git a/Editor/Models/Command.cs.meta b/UnityMcpBridge/Editor/Models/Command.cs.meta similarity index 100% rename from Editor/Models/Command.cs.meta rename to UnityMcpBridge/Editor/Models/Command.cs.meta diff --git a/Editor/Models/MCPConfigServer.cs b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs similarity index 73% rename from Editor/Models/MCPConfigServer.cs rename to UnityMcpBridge/Editor/Models/MCPConfigServer.cs index d182f67d..87d953d8 100644 --- a/Editor/Models/MCPConfigServer.cs +++ b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs @@ -1,10 +1,10 @@ using System; using Newtonsoft.Json; -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { [Serializable] - public class MCPConfigServer + public class McpConfigServer { [JsonProperty("command")] public string command; diff --git a/Editor/Models/MCPConfigServer.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta similarity index 100% rename from Editor/Models/MCPConfigServer.cs.meta rename to UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServers.cs b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs new file mode 100644 index 00000000..30a68933 --- /dev/null +++ b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace UnityMcpBridge.Editor.Models +{ + [Serializable] + public class McpConfigServers + { + [JsonProperty("unityMCP")] + public McpConfigServer unityMCP; + } +} diff --git a/Editor/Models/MCPConfigServers.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta similarity index 100% rename from Editor/Models/MCPConfigServers.cs.meta rename to UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta diff --git a/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs similarity index 95% rename from Editor/Models/McpClient.cs rename to UnityMcpBridge/Editor/Models/McpClient.cs index d900f41a..9f69e903 100644 --- a/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -1,4 +1,4 @@ -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { public class McpClient { @@ -24,7 +24,7 @@ public string GetStatusDisplayString() McpStatus.UnsupportedOS => "Unsupported OS", McpStatus.MissingConfig => "Missing UnityMCP Config", McpStatus.Error => configStatus.StartsWith("Error:") ? configStatus : "Error", - _ => "Unknown" + _ => "Unknown", }; } @@ -44,5 +44,3 @@ public void SetStatus(McpStatus newStatus, string errorDetails = null) } } } - - diff --git a/Editor/Models/McpClient.cs.meta b/UnityMcpBridge/Editor/Models/McpClient.cs.meta similarity index 100% rename from Editor/Models/McpClient.cs.meta rename to UnityMcpBridge/Editor/Models/McpClient.cs.meta diff --git a/Editor/Models/MCPConfig.cs b/UnityMcpBridge/Editor/Models/McpConfig.cs similarity index 50% rename from Editor/Models/MCPConfig.cs rename to UnityMcpBridge/Editor/Models/McpConfig.cs index 9372b226..25b2b5de 100644 --- a/Editor/Models/MCPConfig.cs +++ b/UnityMcpBridge/Editor/Models/McpConfig.cs @@ -1,12 +1,12 @@ using System; using Newtonsoft.Json; -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { [Serializable] - public class MCPConfig + public class McpConfig { [JsonProperty("mcpServers")] - public MCPConfigServers mcpServers; + public McpConfigServers mcpServers; } } diff --git a/Editor/Models/MCPConfig.cs.meta b/UnityMcpBridge/Editor/Models/McpConfig.cs.meta similarity index 100% rename from Editor/Models/MCPConfig.cs.meta rename to UnityMcpBridge/Editor/Models/McpConfig.cs.meta diff --git a/UnityMcpBridge/Editor/Models/McpStatus.cs b/UnityMcpBridge/Editor/Models/McpStatus.cs new file mode 100644 index 00000000..84adfb32 --- /dev/null +++ b/UnityMcpBridge/Editor/Models/McpStatus.cs @@ -0,0 +1,18 @@ +namespace UnityMcpBridge.Editor.Models +{ + // Enum representing the various status states for MCP clients + public enum McpStatus + { + NotConfigured, // Not set up yet + Configured, // Successfully configured + Running, // Service is running + Connected, // Successfully connected + IncorrectPath, // Configuration has incorrect paths + CommunicationError, // Connected but communication issues + NoResponse, // Connected but not responding + MissingConfig, // Config file exists but missing required elements + UnsupportedOS, // OS is not supported + Error, // General error state + } +} + diff --git a/Editor/Models/McpStatus.cs.meta b/UnityMcpBridge/Editor/Models/McpStatus.cs.meta similarity index 100% rename from Editor/Models/McpStatus.cs.meta rename to UnityMcpBridge/Editor/Models/McpStatus.cs.meta diff --git a/Editor/Models/McpTypes.cs b/UnityMcpBridge/Editor/Models/McpTypes.cs similarity index 51% rename from Editor/Models/McpTypes.cs rename to UnityMcpBridge/Editor/Models/McpTypes.cs index 2c08e828..913ed47c 100644 --- a/Editor/Models/McpTypes.cs +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs @@ -1,8 +1,9 @@ -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { public enum McpTypes { ClaudeDesktop, - Cursor + Cursor, } -} \ No newline at end of file +} + diff --git a/Editor/Models/McpTypes.cs.meta b/UnityMcpBridge/Editor/Models/McpTypes.cs.meta similarity index 100% rename from Editor/Models/McpTypes.cs.meta rename to UnityMcpBridge/Editor/Models/McpTypes.cs.meta diff --git a/Editor/Models/ServerConfig.cs b/UnityMcpBridge/Editor/Models/ServerConfig.cs similarity index 95% rename from Editor/Models/ServerConfig.cs rename to UnityMcpBridge/Editor/Models/ServerConfig.cs index 4dc729d9..2c9bb7ec 100644 --- a/Editor/Models/ServerConfig.cs +++ b/UnityMcpBridge/Editor/Models/ServerConfig.cs @@ -1,7 +1,7 @@ using System; using Newtonsoft.Json; -namespace UnityMCP.Editor.Models +namespace UnityMcpBridge.Editor.Models { [Serializable] public class ServerConfig diff --git a/Editor/Models/ServerConfig.cs.meta b/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta similarity index 100% rename from Editor/Models/ServerConfig.cs.meta rename to UnityMcpBridge/Editor/Models/ServerConfig.cs.meta diff --git a/Editor/Tools.meta b/UnityMcpBridge/Editor/Tools.meta similarity index 100% rename from Editor/Tools.meta rename to UnityMcpBridge/Editor/Tools.meta diff --git a/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs similarity index 96% rename from Editor/Tools/CommandRegistry.cs rename to UnityMcpBridge/Editor/Tools/CommandRegistry.cs index 3b867e89..18e2de9b 100644 --- a/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using Newtonsoft.Json.Linq; -namespace UnityMCP.Editor.Tools +namespace UnityMcpBridge.Editor.Tools { /// /// Registry for all MCP command handlers (Refactored Version) @@ -19,7 +19,7 @@ public static class CommandRegistry { "HandleManageGameObject", ManageGameObject.HandleCommand }, { "HandleManageAsset", ManageAsset.HandleCommand }, { "HandleReadConsole", ReadConsole.HandleCommand }, - { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand } + { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, }; /// @@ -32,7 +32,7 @@ public static Func GetHandler(string commandName) // Use case-insensitive comparison for flexibility, although Python side should be consistent return _handlers.TryGetValue(commandName, out var handler) ? handler : null; // Consider adding logging here if a handler is not found - /* + /* if (_handlers.TryGetValue(commandName, out var handler)) { return handler; } else { @@ -42,4 +42,5 @@ public static Func GetHandler(string commandName) */ } } -} \ No newline at end of file +} + diff --git a/Editor/Tools/CommandRegistry.cs.meta b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta similarity index 100% rename from Editor/Tools/CommandRegistry.cs.meta rename to UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta diff --git a/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs similarity index 63% rename from Editor/Tools/ExecuteMenuItem.cs rename to UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs index e3e42b7e..b5de1658 100644 --- a/Editor/Tools/ExecuteMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs @@ -1,11 +1,11 @@ -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; // Added for HashSet -using UnityMCP.Editor.Helpers; // For Response class +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; // For Response class -namespace UnityMCP.Editor.Tools +namespace UnityMcpBridge.Editor.Tools { /// /// Handles executing Unity Editor menu items by path. @@ -14,7 +14,9 @@ public static class ExecuteMenuItem { // Basic blacklist to prevent accidental execution of potentially disruptive menu items. // This can be expanded based on needs. - private static readonly HashSet _menuPathBlacklist = new HashSet(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet _menuPathBlacklist = new HashSet( + StringComparer.OrdinalIgnoreCase + ) { "File/Quit", // Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed @@ -37,12 +39,19 @@ public static object HandleCommand(JObject @params) // Getting a comprehensive list of *all* menu items dynamically is very difficult // and often requires complex reflection or maintaining a manual list. // Returning a placeholder/acknowledgement for now. - Debug.LogWarning("[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex."); + Debug.LogWarning( + "[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex." + ); // Returning an empty list as per the refactor plan's requirements. - return Response.Success("'get_available_menus' action is not fully implemented. Returning empty list.", new List()); - // TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical. + return Response.Success( + "'get_available_menus' action is not fully implemented. Returning empty list.", + new List() + ); + // TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical. default: - return Response.Error($"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'."); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'." + ); } } catch (Exception e) @@ -69,7 +78,9 @@ private static object ExecuteItem(JObject @params) // Validate against blacklist if (_menuPathBlacklist.Contains(menuPath)) { - return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + return Response.Error( + $"Execution of menu item '{menuPath}' is blocked for safety reasons." + ); } // TODO: Implement alias lookup here if needed (Map alias to actual menuPath). @@ -82,30 +93,46 @@ private static object ExecuteItem(JObject @params) try { // Attempt to execute the menu item on the main thread using delayCall for safety. - EditorApplication.delayCall += () => { - try { + EditorApplication.delayCall += () => + { + try + { bool executed = EditorApplication.ExecuteMenuItem(menuPath); // Log potential failure inside the delayed call. - if (!executed) { - Debug.LogError($"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); + if (!executed) + { + Debug.LogError( + $"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent." + ); } - } catch (Exception delayEx) { - Debug.LogError($"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}"); + } + catch (Exception delayEx) + { + Debug.LogError( + $"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}" + ); } }; // Report attempt immediately, as execution is delayed. - return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + return Response.Success( + $"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors." + ); } catch (Exception e) { // Catch errors during setup phase. - Debug.LogError($"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"); - return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + Debug.LogError( + $"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}" + ); + return Response.Error( + $"Error setting up execution for menu item '{menuPath}': {e.Message}" + ); } } // TODO: Add helper for alias lookup if implementing aliases. // private static string LookupAlias(string alias) { ... return actualMenuPath or null ... } } -} \ No newline at end of file +} + diff --git a/Editor/Tools/ExecuteMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta similarity index 100% rename from Editor/Tools/ExecuteMenuItem.cs.meta rename to UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs new file mode 100644 index 00000000..7a0dad71 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -0,0 +1,1238 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; // For Response class + +namespace UnityMcpBridge.Editor.Tools +{ + /// + /// Handles asset management operations within the Unity project. + /// + public static class ManageAsset + { + // --- Main Handler --- + + // Define the list of valid actions + private static readonly List ValidActions = new List + { + "import", + "create", + "modify", + "delete", + "duplicate", + "move", + "rename", + "search", + "get_info", + "create_folder", + "get_components", + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Check if the action is valid before switching + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + // Common parameters + string path = @params["path"]?.ToString(); + + try + { + switch (action) + { + case "import": + // Note: Unity typically auto-imports. This might re-import or configure import settings. + return ReimportAsset(path, @params["properties"] as JObject); + case "create": + return CreateAsset(@params); + case "modify": + return ModifyAsset(path, @params["properties"] as JObject); + case "delete": + return DeleteAsset(path); + case "duplicate": + return DuplicateAsset(path, @params["destination"]?.ToString()); + case "move": // Often same as rename if within Assets/ + case "rename": + return MoveOrRenameAsset(path, @params["destination"]?.ToString()); + case "search": + return SearchAssets(@params); + case "get_info": + return GetAssetInfo( + path, + @params["generatePreview"]?.ToObject() ?? false + ); + case "create_folder": // Added specific action for clarity + return CreateFolder(path); + case "get_components": + return GetComponentsFromAsset(path); + + default: + // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. + string validActionsListDefault = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); + return Response.Error( + $"Internal error processing action '{action}' on '{path}': {e.Message}" + ); + } + } + + // --- Action Implementations --- + + private static object ReimportAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for reimport."); + string fullPath = SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + // TODO: Apply importer properties before reimporting? + // This is complex as it requires getting the AssetImporter, casting it, + // applying properties via reflection or specific methods, saving, then reimporting. + if (properties != null && properties.HasValues) + { + Debug.LogWarning( + "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." + ); + // AssetImporter importer = AssetImporter.GetAtPath(fullPath); + // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } + } + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh + return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + } + catch (Exception e) + { + return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); + } + } + + private static object CreateAsset(JObject @params) + { + string path = @params["path"]?.ToString(); + string assetType = @params["assetType"]?.ToString(); + JObject properties = @params["properties"] as JObject; + + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create."); + if (string.IsNullOrEmpty(assetType)) + return Response.Error("'assetType' is required for create."); + + string fullPath = SanitizeAssetPath(path); + string directory = Path.GetDirectoryName(fullPath); + + // Ensure directory exists + if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) + { + Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); + AssetDatabase.Refresh(); // Make sure Unity knows about the new folder + } + + if (AssetExists(fullPath)) + return Response.Error($"Asset already exists at path: {fullPath}"); + + try + { + UnityEngine.Object newAsset = null; + string lowerAssetType = assetType.ToLowerInvariant(); + + // Handle common asset types + if (lowerAssetType == "folder") + { + return CreateFolder(path); // Use dedicated method + } + else if (lowerAssetType == "material") + { + Material mat = new Material(Shader.Find("Standard")); // Default shader + // TODO: Apply properties from JObject (e.g., shader name, color, texture assignments) + if (properties != null) + ApplyMaterialProperties(mat, properties); + AssetDatabase.CreateAsset(mat, fullPath); + newAsset = mat; + } + else if (lowerAssetType == "scriptableobject") + { + string scriptClassName = properties?["scriptClass"]?.ToString(); + if (string.IsNullOrEmpty(scriptClassName)) + return Response.Error( + "'scriptClass' property required when creating ScriptableObject asset." + ); + + Type scriptType = FindType(scriptClassName); + if ( + scriptType == null + || !typeof(ScriptableObject).IsAssignableFrom(scriptType) + ) + { + return Response.Error( + $"Script class '{scriptClassName}' not found or does not inherit from ScriptableObject." + ); + } + + ScriptableObject so = ScriptableObject.CreateInstance(scriptType); + // TODO: Apply properties from JObject to the ScriptableObject instance? + AssetDatabase.CreateAsset(so, fullPath); + newAsset = so; + } + else if (lowerAssetType == "prefab") + { + // Creating prefabs usually involves saving an existing GameObject hierarchy. + // A common pattern is to create an empty GameObject, configure it, and then save it. + return Response.Error( + "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." + ); + // Example (conceptual): + // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); + // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); + } + // TODO: Add more asset types (Animation Controller, Scene, etc.) + else + { + // Generic creation attempt (might fail or create empty files) + // For some types, just creating the file might be enough if Unity imports it. + // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); + // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it + // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); + return Response.Error( + $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." + ); + } + + if ( + newAsset == null + && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) + ) // Check if it wasn't a folder and asset wasn't created + { + return Response.Error( + $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." + ); + } + + AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); // CreateAsset often handles refresh + return Response.Success( + $"Asset '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); + } + } + + private static object CreateFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create_folder."); + string fullPath = SanitizeAssetPath(path); + string parentDir = Path.GetDirectoryName(fullPath); + string folderName = Path.GetFileName(fullPath); + + if (AssetExists(fullPath)) + { + // Check if it's actually a folder already + if (AssetDatabase.IsValidFolder(fullPath)) + { + return Response.Success( + $"Folder already exists at path: {fullPath}", + GetAssetData(fullPath) + ); + } + else + { + return Response.Error( + $"An asset (not a folder) already exists at path: {fullPath}" + ); + } + } + + try + { + // Ensure parent exists + if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) + { + // Recursively create parent folders if needed (AssetDatabase handles this internally) + // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); + } + + string guid = AssetDatabase.CreateFolder(parentDir, folderName); + if (string.IsNullOrEmpty(guid)) + { + return Response.Error( + $"Failed to create folder '{fullPath}'. Check logs and permissions." + ); + } + + // AssetDatabase.Refresh(); // CreateFolder usually handles refresh + return Response.Success( + $"Folder '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); + } + } + + private static object ModifyAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for modify."); + if (properties == null || !properties.HasValues) + return Response.Error("'properties' are required for modify."); + + string fullPath = SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + bool modified = false; // Flag to track if any changes were made + + // --- NEW: Handle GameObject / Prefab Component Modification --- + if (asset is GameObject gameObject) + { + // Iterate through the properties JSON: keys are component names, values are properties objects for that component + foreach (var prop in properties.Properties()) + { + string componentName = prop.Name; // e.g., "Collectible" + // Check if the value associated with the component name is actually an object containing properties + if ( + prop.Value is JObject componentProperties + && componentProperties.HasValues + ) // e.g., {"bobSpeed": 2.0} + { + // Find the component on the GameObject using the name from the JSON key + // Using GetComponent(string) is convenient but might require exact type name or be ambiguous. + // Consider using FindType helper if needed for more complex scenarios. + Component targetComponent = gameObject.GetComponent(componentName); + + if (targetComponent != null) + { + // Apply the nested properties (e.g., bobSpeed) to the found component instance + // Use |= to ensure 'modified' becomes true if any component is successfully modified + modified |= ApplyObjectProperties( + targetComponent, + componentProperties + ); + } + else + { + // Log a warning if a specified component couldn't be found + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." + ); + } + } + else + { + // Log a warning if the structure isn't {"ComponentName": {"prop": value}} + // We could potentially try to apply this property directly to the GameObject here if needed, + // but the primary goal is component modification. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." + ); + } + } + // Note: 'modified' is now true if ANY component property was successfully changed. + } + // --- End NEW --- + + // --- Existing logic for other asset types (now as else-if) --- + // Example: Modifying a Material + else if (asset is Material material) + { + // Apply properties directly to the material. If this modifies, it sets modified=true. + // Use |= in case the asset was already marked modified by previous logic (though unlikely here) + modified |= ApplyMaterialProperties(material, properties); + } + // Example: Modifying a ScriptableObject + else if (asset is ScriptableObject so) + { + // Apply properties directly to the ScriptableObject. + modified |= ApplyObjectProperties(so, properties); // General helper + } + // Example: Modifying TextureImporter settings + else if (asset is Texture) + { + AssetImporter importer = AssetImporter.GetAtPath(fullPath); + if (importer is TextureImporter textureImporter) + { + bool importerModified = ApplyObjectProperties(textureImporter, properties); + if (importerModified) + { + // Importer settings need saving and reimporting + AssetDatabase.WriteImportSettingsIfDirty(fullPath); + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes + modified = true; // Mark overall operation as modified + } + } + else + { + Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); + } + } + // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) + else // Fallback for other asset types OR direct properties on non-GameObject assets + { + // This block handles non-GameObject/Material/ScriptableObject/Texture assets. + // Attempts to apply properties directly to the asset itself. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." + ); + modified |= ApplyObjectProperties(asset, properties); + } + // --- End Existing Logic --- + + // Check if any modification happened (either component or direct asset modification) + if (modified) + { + // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. + EditorUtility.SetDirty(asset); + // Save all modified assets to disk. + AssetDatabase.SaveAssets(); + // Refresh might be needed in some edge cases, but SaveAssets usually covers it. + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{fullPath}' modified successfully.", + GetAssetData(fullPath) + ); + } + else + { + // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. + return Response.Success( + $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", + GetAssetData(fullPath) + ); + // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + } + } + catch (Exception e) + { + // Log the detailed error internally + Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); + // Return a user-friendly error message + return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + } + } + + private static object DeleteAsset(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for delete."); + string fullPath = SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + { + // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh + return Response.Success($"Asset '{fullPath}' deleted successfully."); + } + else + { + // This might happen if the file couldn't be deleted (e.g., locked) + return Response.Error( + $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); + } + } + + private static object DuplicateAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for duplicate."); + + string sourcePath = SanitizeAssetPath(path); + if (!AssetExists(sourcePath)) + return Response.Error($"Source asset not found at path: {sourcePath}"); + + string destPath; + if (string.IsNullOrEmpty(destinationPath)) + { + // Generate a unique path if destination is not provided + destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); + } + else + { + destPath = SanitizeAssetPath(destinationPath); + if (AssetExists(destPath)) + return Response.Error($"Asset already exists at destination path: {destPath}"); + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + } + + try + { + bool success = AssetDatabase.CopyAsset(sourcePath, destPath); + if (success) + { + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{sourcePath}' duplicated to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + return Response.Error( + $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); + } + } + + private static object MoveOrRenameAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for move/rename."); + if (string.IsNullOrEmpty(destinationPath)) + return Response.Error("'destination' path is required for move/rename."); + + string sourcePath = SanitizeAssetPath(path); + string destPath = SanitizeAssetPath(destinationPath); + + if (!AssetExists(sourcePath)) + return Response.Error($"Source asset not found at path: {sourcePath}"); + if (AssetExists(destPath)) + return Response.Error( + $"An asset already exists at the destination path: {destPath}" + ); + + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + + try + { + // Validate will return an error string if failed, null if successful + string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(error)) + { + return Response.Error( + $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" + ); + } + + string guid = AssetDatabase.MoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success + { + // AssetDatabase.Refresh(); // MoveAsset usually handles refresh + return Response.Success( + $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + // This case might not be reachable if ValidateMoveAsset passes, but good to have + return Response.Error( + $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + } + } + + private static object SearchAssets(JObject @params) + { + string searchPattern = @params["searchPattern"]?.ToString(); + string filterType = @params["filterType"]?.ToString(); + string pathScope = @params["path"]?.ToString(); // Use path as folder scope + string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); + int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size + int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) + bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; + + List searchFilters = new List(); + if (!string.IsNullOrEmpty(searchPattern)) + searchFilters.Add(searchPattern); + if (!string.IsNullOrEmpty(filterType)) + searchFilters.Add($"t:{filterType}"); + + string[] folderScope = null; + if (!string.IsNullOrEmpty(pathScope)) + { + folderScope = new string[] { SanitizeAssetPath(pathScope) }; + if (!AssetDatabase.IsValidFolder(folderScope[0])) + { + // Maybe the user provided a file path instead of a folder? + // We could search in the containing folder, or return an error. + Debug.LogWarning( + $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." + ); + folderScope = null; // Search everywhere if path isn't a folder + } + } + + DateTime? filterDateAfter = null; + if (!string.IsNullOrEmpty(filterDateAfterStr)) + { + if ( + DateTime.TryParse( + filterDateAfterStr, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate + ) + ) + { + filterDateAfter = parsedDate; + } + else + { + Debug.LogWarning( + $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." + ); + } + } + + try + { + string[] guids = AssetDatabase.FindAssets( + string.Join(" ", searchFilters), + folderScope + ); + List results = new List(); + int totalFound = 0; + + foreach (string guid in guids) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(assetPath)) + continue; + + // Apply date filter if present + if (filterDateAfter.HasValue) + { + DateTime lastWriteTime = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), assetPath) + ); + if (lastWriteTime <= filterDateAfter.Value) + { + continue; // Skip assets older than or equal to the filter date + } + } + + totalFound++; // Count matching assets before pagination + results.Add(GetAssetData(assetPath, generatePreview)); + } + + // Apply pagination + int startIndex = (pageNumber - 1) * pageSize; + var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); + + return Response.Success( + $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", + new + { + totalAssets = totalFound, + pageSize = pageSize, + pageNumber = pageNumber, + assets = pagedResults, + } + ); + } + catch (Exception e) + { + return Response.Error($"Error searching assets: {e.Message}"); + } + } + + private static object GetAssetInfo(string path, bool generatePreview) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_info."); + string fullPath = SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + return Response.Success( + "Asset info retrieved.", + GetAssetData(fullPath, generatePreview) + ); + } + catch (Exception e) + { + return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); + } + } + + /// + /// Retrieves components attached to a GameObject asset (like a Prefab). + /// + /// The asset path of the GameObject or Prefab. + /// A response object containing a list of component type names or an error. + private static object GetComponentsFromAsset(string path) + { + // 1. Validate input path + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_components."); + + // 2. Sanitize and check existence + string fullPath = SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + // 3. Load the asset + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + // 4. Check if it's a GameObject (Prefabs load as GameObjects) + GameObject gameObject = asset as GameObject; + if (gameObject == null) + { + // Also check if it's *directly* a Component type (less common for primary assets) + Component componentAsset = asset as Component; + if (componentAsset != null) + { + // If the asset itself *is* a component, maybe return just its info? + // This is an edge case. Let's stick to GameObjects for now. + return Response.Error( + $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." + ); + } + return Response.Error( + $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." + ); + } + + // 5. Get components + Component[] components = gameObject.GetComponents(); + + // 6. Format component data + List componentList = components + .Select(comp => new + { + typeName = comp.GetType().FullName, + instanceID = comp.GetInstanceID(), + // TODO: Add more component-specific details here if needed in the future? + // Requires reflection or specific handling per component type. + }) + .ToList(); // Explicit cast for clarity if needed + + // 7. Return success response + return Response.Success( + $"Found {componentList.Count} component(s) on asset '{fullPath}'.", + componentList + ); + } + catch (Exception e) + { + Debug.LogError( + $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" + ); + return Response.Error( + $"Error getting components for asset '{fullPath}': {e.Message}" + ); + } + } + + // --- Internal Helpers --- + + /// + /// Ensures the asset path starts with "Assets/". + /// + private static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + path = path.Replace('\\', '/'); // Normalize separators + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + return path; + } + + /// + /// Checks if an asset exists at the given path (file or folder). + /// + private static bool AssetExists(string sanitizedPath) + { + // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. + // Check if it's a known asset GUID. + if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) + { + return true; + } + // AssetPathToGUID might not work for newly created folders not yet refreshed. + // Check directory explicitly for folders. + if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + // Check if it's considered a *valid* folder by Unity + return AssetDatabase.IsValidFolder(sanitizedPath); + } + // Check file existence for non-folder assets. + if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + return true; // Assume if file exists, it's an asset or will be imported + } + + return false; + // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); + } + + /// + /// Ensures the directory for a given asset path exists, creating it if necessary. + /// + private static void EnsureDirectoryExists(string directoryPath) + { + if (string.IsNullOrEmpty(directoryPath)) + return; + string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); + if (!Directory.Exists(fullDirPath)) + { + Directory.CreateDirectory(fullDirPath); + AssetDatabase.Refresh(); // Let Unity know about the new folder + } + } + + /// + /// Applies properties from JObject to a Material. + /// + private static bool ApplyMaterialProperties(Material mat, JObject properties) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Example: Set shader + if (properties["shader"]?.Type == JTokenType.String) + { + Shader newShader = Shader.Find(properties["shader"].ToString()); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + // Example: Set color property + if (properties["color"] is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = new Color( + colArr[0].ToObject(), + colArr[1].ToObject(), + colArr[2].ToObject(), + colArr.Count > 3 ? colArr[3].ToObject() : 1.0f + ); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing color property '{propName}': {ex.Message}" + ); + } + } + } + // Example: Set float property + if (properties["float"] is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if ( + !string.IsNullOrEmpty(propName) && floatProps["value"]?.Type == JTokenType.Float + || floatProps["value"]?.Type == JTokenType.Integer + ) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing float property '{propName}': {ex.Message}" + ); + } + } + } + // Example: Set texture property + if (properties["texture"] is JObject texProps) + { + string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture + string texPath = texProps["path"]?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + Texture newTex = AssetDatabase.LoadAssetAtPath( + SanitizeAssetPath(texPath) + ); + if ( + newTex != null + && mat.HasProperty(propName) + && mat.GetTexture(propName) != newTex + ) + { + mat.SetTexture(propName, newTex); + modified = true; + } + else if (newTex == null) + { + Debug.LogWarning($"Texture not found at path: {texPath}"); + } + } + } + + // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + return modified; + } + + /// + /// Generic helper to set properties on any UnityEngine.Object using reflection. + /// + private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) + { + if (target == null || properties == null) + return false; + bool modified = false; + Type type = target.GetType(); + + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + if (SetPropertyOrField(target, propName, propValue, type)) + { + modified = true; + } + } + return modified; + } + + /// + /// Helper to set a property or field via reflection, handling basic types and Unity objects. + /// + private static bool SetPropertyOrField( + object target, + string memberName, + JToken value, + Type type = null + ) + { + type = type ?? target.GetType(); + System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.IgnoreCase; + + try + { + System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); + if ( + convertedValue != null + && !object.Equals(propInfo.GetValue(target), convertedValue) + ) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); + if ( + convertedValue != null + && !object.Equals(fieldInfo.GetValue(target), convertedValue) + ) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" + ); + } + return false; + } + + /// + /// Simple JToken to Type conversion for common Unity types and primitives. + /// + private static object ConvertJTokenToType(JToken token, Type targetType) + { + try + { + if (token == null || token.Type == JTokenType.Null) + return null; + + if (targetType == typeof(string)) + return token.ToObject(); + if (targetType == typeof(int)) + return token.ToObject(); + if (targetType == typeof(float)) + return token.ToObject(); + if (targetType == typeof(bool)) + return token.ToObject(); + if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) + return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); + if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) + return new Vector3( + arrV3[0].ToObject(), + arrV3[1].ToObject(), + arrV3[2].ToObject() + ); + if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) + return new Vector4( + arrV4[0].ToObject(), + arrV4[1].ToObject(), + arrV4[2].ToObject(), + arrV4[3].ToObject() + ); + if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) + return new Quaternion( + arrQ[0].ToObject(), + arrQ[1].ToObject(), + arrQ[2].ToObject(), + arrQ[3].ToObject() + ); + if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA + return new Color( + arrC[0].ToObject(), + arrC[1].ToObject(), + arrC[2].ToObject(), + arrC.Count > 3 ? arrC[3].ToObject() : 1.0f + ); + if (targetType.IsEnum) + return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing + + // Handle loading Unity Objects (Materials, Textures, etc.) by path + if ( + typeof(UnityEngine.Object).IsAssignableFrom(targetType) + && token.Type == JTokenType.String + ) + { + string assetPath = SanitizeAssetPath(token.ToString()); + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( + assetPath, + targetType + ); + if (loadedAsset == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" + ); + } + return loadedAsset; + } + + // Fallback: Try direct conversion (might work for other simple value types) + return token.ToObject(targetType); + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" + ); + return null; + } + } + + /// + /// Helper to find a Type by name, searching relevant assemblies. + /// Needed for creating ScriptableObjects or finding component types by name. + /// + private static Type FindType(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + return null; + + // Try direct lookup first (common Unity types often don't need assembly qualified name) + var type = + Type.GetType(typeName) + ?? Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") + ?? Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") + ?? Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule"); + + if (type != null) + return type; + + // If not found, search loaded assemblies (slower but more robust for user scripts) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + // Look for non-namespaced first + type = assembly.GetType(typeName, false, true); // throwOnError=false, ignoreCase=true + if (type != null) + return type; + + // Check common namespaces if simple name given + type = assembly.GetType("UnityEngine." + typeName, false, true); + if (type != null) + return type; + type = assembly.GetType("UnityEditor." + typeName, false, true); + if (type != null) + return type; + // Add other likely namespaces if needed (e.g., specific plugins) + } + + Debug.LogWarning($"[FindType] Type '{typeName}' not found in any loaded assembly."); + return null; // Not found + } + + // --- Data Serialization --- + + /// + /// Creates a serializable representation of an asset. + /// + private static object GetAssetData(string path, bool generatePreview = false) + { + if (string.IsNullOrEmpty(path) || !AssetExists(path)) + return null; + + string guid = AssetDatabase.AssetPathToGUID(path); + Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); + string previewBase64 = null; + int previewWidth = 0; + int previewHeight = 0; + + if (generatePreview && asset != null) + { + Texture2D preview = AssetPreview.GetAssetPreview(asset); + + if (preview != null) + { + try + { + // Ensure texture is readable for EncodeToPNG + // Creating a temporary readable copy is safer + RenderTexture rt = RenderTexture.GetTemporary( + preview.width, + preview.height + ); + Graphics.Blit(preview, rt); + RenderTexture previous = RenderTexture.active; + RenderTexture.active = rt; + Texture2D readablePreview = new Texture2D(preview.width, preview.height); + readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readablePreview.Apply(); + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(rt); + + byte[] pngData = readablePreview.EncodeToPNG(); + previewBase64 = Convert.ToBase64String(pngData); + previewWidth = readablePreview.width; + previewHeight = readablePreview.height; + UnityEngine.Object.DestroyImmediate(readablePreview); // Clean up temp texture + } + catch (Exception ex) + { + Debug.LogWarning( + $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." + ); + // Fallback: Try getting static preview if available? + // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); + } + } + else + { + Debug.LogWarning( + $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" + ); + } + } + + return new + { + path = path, + guid = guid, + assetType = assetType?.FullName ?? "Unknown", + name = Path.GetFileNameWithoutExtension(path), + fileName = Path.GetFileName(path), + isFolder = AssetDatabase.IsValidFolder(path), + instanceID = asset?.GetInstanceID() ?? 0, + lastWriteTimeUtc = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), path) + ) + .ToString("o"), // ISO 8601 + // --- Preview Data --- + previewBase64 = previewBase64, // PNG data as Base64 string + previewWidth = previewWidth, + previewHeight = previewHeight, + // TODO: Add more metadata? Importer settings? Dependencies? + }; + } + } +} + diff --git a/Editor/Tools/ManageAsset.cs.meta b/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta similarity index 100% rename from Editor/Tools/ManageAsset.cs.meta rename to UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta diff --git a/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs similarity index 73% rename from Editor/Tools/ManageEditor.cs rename to UnityMcpBridge/Editor/Tools/ManageEditor.cs index 3c3d211d..06d057d6 100644 --- a/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -1,15 +1,13 @@ -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; using System; -using System.Linq; using System.Collections.Generic; -using UnityMCP.Editor.Helpers; // For Response class -using UnityEditor.ShortcutManagement; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; using UnityEditorInternal; // Required for tag management -using System.Reflection; // Required for layer management +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; // For Response class -namespace UnityMCP.Editor.Tools +namespace UnityMcpBridge.Editor.Tools { /// /// Handles operations related to controlling and querying the Unity Editor state, @@ -19,6 +17,7 @@ public static class ManageEditor { // Constant for starting user layer index private const int FirstUserLayerIndex = 8; + // Constant for total layer count private const int TotalLayerCount = 32; @@ -62,7 +61,9 @@ public static object HandleCommand(JObject @params) if (EditorApplication.isPlaying) { EditorApplication.isPaused = !EditorApplication.isPaused; - return Response.Success(EditorApplication.isPaused ? "Game paused." : "Game resumed."); + return Response.Success( + EditorApplication.isPaused ? "Game paused." : "Game resumed." + ); } return Response.Error("Cannot pause/resume: Not in play mode."); } @@ -96,25 +97,30 @@ public static object HandleCommand(JObject @params) return GetSelection(); case "set_active_tool": string toolName = @params["toolName"]?.ToString(); - if (string.IsNullOrEmpty(toolName)) return Response.Error("'toolName' parameter required for set_active_tool."); + if (string.IsNullOrEmpty(toolName)) + return Response.Error("'toolName' parameter required for set_active_tool."); return SetActiveTool(toolName); // Tag Management case "add_tag": - if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for add_tag."); + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for add_tag."); return AddTag(tagName); case "remove_tag": - if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for remove_tag."); + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); case "get_tags": return GetTags(); // Helper to list current tags // Layer Management case "add_layer": - if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for add_layer."); + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for add_layer."); return AddLayer(layerName); case "remove_layer": - if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for remove_layer."); + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); case "get_layers": return GetLayers(); // Helper to list current layers @@ -130,7 +136,9 @@ public static object HandleCommand(JObject @params) // return SetQualityLevel(@params["qualityLevel"]); default: - return Response.Error($"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."); + return Response.Error( + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + ); } } @@ -147,7 +155,7 @@ private static object GetEditorState() isUpdating = EditorApplication.isUpdating, applicationPath = EditorApplication.applicationPath, applicationContentsPath = EditorApplication.applicationContentsPath, - timeSinceStartup = EditorApplication.timeSinceStartup + timeSinceStartup = EditorApplication.timeSinceStartup, }; return Response.Success("Retrieved editor state.", state); } @@ -162,35 +170,47 @@ private static object GetEditorWindows() try { // Get all types deriving from EditorWindow - var windowTypes = AppDomain.CurrentDomain.GetAssemblies() + var windowTypes = AppDomain + .CurrentDomain.GetAssemblies() .SelectMany(assembly => assembly.GetTypes()) .Where(type => type.IsSubclassOf(typeof(EditorWindow))) .ToList(); var openWindows = new List(); - + // Find currently open instances // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll(); foreach (EditorWindow window in allWindows) { - if (window == null) continue; // Skip potentially destroyed windows - + if (window == null) + continue; // Skip potentially destroyed windows + try { - openWindows.Add(new - { - title = window.titleContent.text, - typeName = window.GetType().FullName, - isFocused = EditorWindow.focusedWindow == window, - position = new { x = window.position.x, y = window.position.y, width = window.position.width, height = window.position.height }, - instanceID = window.GetInstanceID() - }); + openWindows.Add( + new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height, + }, + instanceID = window.GetInstanceID(), + } + ); } catch (Exception ex) { - Debug.LogWarning($"Could not get info for window {window.GetType().Name}: {ex.Message}"); + Debug.LogWarning( + $"Could not get info for window {window.GetType().Name}: {ex.Message}" + ); } } @@ -202,22 +222,25 @@ private static object GetEditorWindows() } } - private static object GetActiveTool() + private static object GetActiveTool() { try { Tool currentTool = UnityEditor.Tools.current; string toolName = currentTool.ToString(); // Enum to string bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active - string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; // Get custom name if needed - - var toolInfo = new { + string activeToolName = customToolActive + ? EditorTools.GetActiveToolName() + : toolName; // Get custom name if needed + + var toolInfo = new + { activeTool = activeToolName, isCustom = customToolActive, pivotMode = UnityEditor.Tools.pivotMode.ToString(), pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity - handlePosition = UnityEditor.Tools.handlePosition + handlePosition = UnityEditor.Tools.handlePosition, }; return Response.Success("Retrieved active tool information.", toolInfo); @@ -230,7 +253,7 @@ private static object GetActiveTool() private static object SetActiveTool(string toolName) { - try + try { Tool targetTool; if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse @@ -242,15 +265,19 @@ private static object SetActiveTool(string toolName) return Response.Success($"Set active tool to '{targetTool}'."); } else - { - return Response.Error($"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid."); + { + return Response.Error( + $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." + ); } } else { // Potentially try activating a custom tool by name here if needed // This often requires specific editor scripting knowledge for that tool. - return Response.Error($"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)."); + return Response.Error( + $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." + ); } } catch (Exception e) @@ -270,9 +297,22 @@ private static object GetSelection() activeTransform = Selection.activeTransform?.name, activeInstanceID = Selection.activeInstanceID, count = Selection.count, - objects = Selection.objects.Select(obj => new { name = obj?.name, type = obj?.GetType().FullName, instanceID = obj?.GetInstanceID() }).ToList(), - gameObjects = Selection.gameObjects.Select(go => new { name = go?.name, instanceID = go?.GetInstanceID() }).ToList(), - assetGUIDs = Selection.assetGUIDs // GUIDs for selected assets in Project view + objects = Selection + .objects.Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceID(), + }) + .ToList(), + gameObjects = Selection + .gameObjects.Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceID(), + }) + .ToList(), + assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view }; return Response.Success("Retrieved current selection details.", selectionInfo); @@ -301,7 +341,7 @@ private static object AddTag(string tagName) // Add the tag using the internal utility InternalEditorUtility.AddTag(tagName); // Force save assets to ensure the change persists in the TagManager asset - AssetDatabase.SaveAssets(); + AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' added successfully."); } catch (Exception e) @@ -315,7 +355,7 @@ private static object RemoveTag(string tagName) if (string.IsNullOrWhiteSpace(tagName)) return Response.Error("Tag name cannot be empty or whitespace."); if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) - return Response.Error("Cannot remove the built-in 'Untagged' tag."); + return Response.Error("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal if (!InternalEditorUtility.tags.Contains(tagName)) @@ -328,7 +368,7 @@ private static object RemoveTag(string tagName) // Remove the tag using the internal utility InternalEditorUtility.RemoveTag(tagName); // Force save assets - AssetDatabase.SaveAssets(); + AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' removed successfully."); } catch (Exception e) @@ -351,7 +391,6 @@ private static object GetTags() } } - // --- Layer Management Methods --- private static object AddLayer(string layerName) @@ -361,17 +400,21 @@ private static object AddLayer(string layerName) // Access the TagManager asset SerializedObject tagManager = GetTagManager(); - if (tagManager == null) return Response.Error("Could not access TagManager asset."); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + return Response.Error("Could not find 'layers' property in TagManager."); // Check if layer name already exists (case-insensitive check recommended) for (int i = 0; i < TotalLayerCount; i++) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); - if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)) + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) { return Response.Error($"Layer '{layerName}' already exists at index {i}."); } @@ -397,13 +440,17 @@ private static object AddLayer(string layerName) // Assign the name to the found slot try { - SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(firstEmptyUserLayer); + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + firstEmptyUserLayer + ); targetLayerSP.stringValue = layerName; // Apply the changes to the TagManager asset tagManager.ApplyModifiedProperties(); // Save assets to make sure it's written to disk - AssetDatabase.SaveAssets(); - return Response.Success($"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}."); + AssetDatabase.SaveAssets(); + return Response.Success( + $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." + ); } catch (Exception e) { @@ -418,11 +465,12 @@ private static object RemoveLayer(string layerName) // Access the TagManager asset SerializedObject tagManager = GetTagManager(); - if (tagManager == null) return Response.Error("Could not access TagManager asset."); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); - if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); // Find the layer by name (must be user layer) int layerIndexToRemove = -1; @@ -430,7 +478,10 @@ private static object RemoveLayer(string layerName) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); // Case-insensitive comparison is safer - if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)) + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) { layerIndexToRemove = i; break; @@ -445,13 +496,17 @@ private static object RemoveLayer(string layerName) // Clear the name for that index try { - SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(layerIndexToRemove); + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + layerIndexToRemove + ); targetLayerSP.stringValue = string.Empty; // Set to empty string to remove // Apply the changes tagManager.ApplyModifiedProperties(); // Save assets - AssetDatabase.SaveAssets(); - return Response.Success($"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully."); + AssetDatabase.SaveAssets(); + return Response.Success( + $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." + ); } catch (Exception e) { @@ -480,7 +535,6 @@ private static object GetLayers() } } - // --- Helper Methods --- /// @@ -488,23 +542,25 @@ private static object GetLayers() /// private static SerializedObject GetTagManager() { - try - { - // Load the TagManager asset from the ProjectSettings folder - UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset"); - if (tagManagerAssets == null || tagManagerAssets.Length == 0) - { - Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); - return null; - } - // The first object in the asset file should be the TagManager - return new SerializedObject(tagManagerAssets[0]); - } - catch (Exception e) - { - Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); - return null; - } + try + { + // Load the TagManager asset from the ProjectSettings folder + UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( + "ProjectSettings/TagManager.asset" + ); + if (tagManagerAssets == null || tagManagerAssets.Length == 0) + { + Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); + return null; + } + // The first object in the asset file should be the TagManager + return new SerializedObject(tagManagerAssets[0]); + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); + return null; + } } // --- Example Implementations for Settings --- @@ -515,18 +571,21 @@ private static object SetQualityLevel(JToken qualityLevelToken) { ... } } // Helper class to get custom tool names (remains the same) - internal static class EditorTools { - public static string GetActiveToolName() { - // This is a placeholder. Real implementation depends on how custom tools + internal static class EditorTools + { + public static string GetActiveToolName() + { + // This is a placeholder. Real implementation depends on how custom tools // are registered and tracked in the specific Unity project setup. // It might involve checking static variables, calling methods on specific tool managers, etc. if (UnityEditor.Tools.current == Tool.Custom) { // Example: Check a known custom tool manager // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; - return "Unknown Custom Tool"; + return "Unknown Custom Tool"; } return UnityEditor.Tools.current.ToString(); } } -} \ No newline at end of file +} + diff --git a/Editor/Tools/ManageEditor.cs.meta b/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta similarity index 100% rename from Editor/Tools/ManageEditor.cs.meta rename to UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs new file mode 100644 index 00000000..414603da --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -0,0 +1,2220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityMcpBridge.Editor.Helpers; // For Response class + +namespace UnityMcpBridge.Editor.Tools +{ + /// + /// Handles GameObject manipulation within the current scene (CRUD, find, components). + /// + public static class ManageGameObject + { + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Parameters used by various actions + JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); + + // Get common parameters (consolidated) + string name = @params["name"]?.ToString(); + string tag = @params["tag"]?.ToString(); + string layer = @params["layer"]?.ToString(); + JToken parentToken = @params["parent"]; + + // --- Prefab Redirection Check --- + string targetPath = + targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; + if ( + !string.IsNullOrEmpty(targetPath) + && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) + if (action == "modify" || action == "set_component_property") + { + Debug.Log( + $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." + ); + // Prepare params for ManageAsset.ModifyAsset + JObject assetParams = new JObject(); + assetParams["action"] = "modify"; // ManageAsset uses "modify" + assetParams["path"] = targetPath; + + // Extract properties. + // For 'set_component_property', combine componentName and componentProperties. + // For 'modify', directly use componentProperties. + JObject properties = null; + if (action == "set_component_property") + { + string compName = @params["componentName"]?.ToString(); + JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting + if (string.IsNullOrEmpty(compName)) + return Response.Error( + "Missing 'componentName' for 'set_component_property' on prefab." + ); + if (compProps == null) + return Response.Error( + $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." + ); + + properties = new JObject(); + properties[compName] = compProps; + } + else // action == "modify" + { + properties = @params["componentProperties"] as JObject; + if (properties == null) + return Response.Error( + "Missing 'componentProperties' for 'modify' action on prefab." + ); + } + + assetParams["properties"] = properties; + + // Call ManageAsset handler + return ManageAsset.HandleCommand(assetParams); + } + else if ( + action == "delete" + || action == "add_component" + || action == "remove_component" + || action == "get_components" + ) // Added get_components here too + { + // Explicitly block other modifications on the prefab asset itself via manage_gameobject + return Response.Error( + $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." + ); + } + // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. + // No specific handling needed here, the code below will run. + } + // --- End Prefab Redirection Check --- + + try + { + switch (action) + { + case "create": + return CreateGameObject(@params); + case "modify": + return ModifyGameObject(@params, targetToken, searchMethod); + case "delete": + return DeleteGameObject(targetToken, searchMethod); + case "find": + return FindGameObjects(@params, targetToken, searchMethod); + case "get_components": + string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string + if (getCompTarget == null) + return Response.Error( + "'target' parameter required for get_components." + ); + return GetComponentsFromTarget(getCompTarget, searchMethod); + case "add_component": + return AddComponentToTarget(@params, targetToken, searchMethod); + case "remove_component": + return RemoveComponentFromTarget(@params, targetToken, searchMethod); + case "set_component_property": + return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); + + default: + return Response.Error($"Unknown action: '{action}'."); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object CreateGameObject(JObject @params) + { + string name = @params["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return Response.Error("'name' parameter is required for 'create' action."); + } + + // Get prefab creation parameters + bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; + string prefabPath = @params["prefabPath"]?.ToString(); + string tag = @params["tag"]?.ToString(); // Get tag for creation + string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check + GameObject newGo = null; // Initialize as null + + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; // Keep original for messages + if (!string.IsNullOrEmpty(prefabPath)) + { + // If no extension, search for the prefab by name + if ( + !prefabPath.Contains("/") + && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + string prefabNameOnly = prefabPath; + Debug.Log( + $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" + ); + string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); + if (guids.Length == 0) + { + return Response.Error( + $"Prefab named '{prefabNameOnly}' not found anywhere in the project." + ); + } + else if (guids.Length > 1) + { + string foundPaths = string.Join( + ", ", + guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) + ); + return Response.Error( + $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." + ); + } + else // Exactly one found + { + prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path + Debug.Log( + $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" + ); + } + } + else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. + // We could also error here, but appending might be more user-friendly. + Debug.LogWarning( + $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." + ); + prefabPath += ".prefab"; + // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. + } + + // Removed the early return error for missing .prefab ending. + // The logic above now handles finding or assuming the .prefab extension. + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) + { + try + { + // Instantiate the prefab, initially place it at the root + // Parent will be set later if specified + newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; + + if (newGo == null) + { + // This might happen if the asset exists but isn't a valid GameObject prefab somehow + Debug.LogError( + $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." + ); + return Response.Error( + $"Failed to instantiate prefab at '{prefabPath}'." + ); + } + + // Name the instance based on the 'name' parameter, not the prefab's default name + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + + // Register Undo for prefab instantiation + Undo.RegisterCreatedObjectUndo( + newGo, + $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" + ); + Debug.Log( + $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." + ); + } + catch (Exception e) + { + return Response.Error( + $"Error instantiating prefab '{prefabPath}': {e.Message}" + ); + } + } + else + { + // Only return error if prefabPath was specified but not found. + // If prefabPath was empty/null, we proceed to create primitive/empty. + Debug.LogWarning( + $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." + ); + // Do not return error here, allow fallback to primitive/empty creation + } + } + + // --- Fallback: Create Primitive or Empty GameObject --- + bool createdNewObject = false; // Flag to track if we created (not instantiated) + if (newGo == null) // Only proceed if prefab instantiation didn't happen + { + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType) + Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + // Set name *after* creation for primitives + if (!string.IsNullOrEmpty(name)) + newGo.name = name; + else + return Response.Error( + "'name' parameter is required when creating a primitive." + ); // Name is essential + createdNewObject = true; + } + catch (ArgumentException) + { + return Response.Error( + $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" + ); + } + catch (Exception e) + { + return Response.Error( + $"Failed to create primitive '{primitiveType}': {e.Message}" + ); + } + } + else // Create empty GameObject + { + if (string.IsNullOrEmpty(name)) + { + return Response.Error( + "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." + ); + } + newGo = new GameObject(name); + createdNewObject = true; + } + + // Record creation for Undo *only* if we created a new object + if (createdNewObject) + { + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); + } + } + + // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- + if (newGo == null) + { + // Should theoretically not happen if logic above is correct, but safety check. + return Response.Error("Failed to create or instantiate the GameObject."); + } + + // Record potential changes to the existing prefab instance or the new GO + // Record transform separately in case parent changes affect it + Undo.RecordObject(newGo.transform, "Set GameObject Transform"); + Undo.RecordObject(newGo, "Set GameObject Properties"); + + // Set Parent + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding + if (parentGo == null) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object + return Response.Error($"Parent specified ('{parentToken}') but not found."); + } + newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true + } + + // Set Transform + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue) + newGo.transform.localPosition = position.Value; + if (rotation.HasValue) + newGo.transform.localEulerAngles = rotation.Value; + if (scale.HasValue) + newGo.transform.localScale = scale.Value; + + // Set Tag (added for create action) + if (!string.IsNullOrEmpty(tag)) + { + // Similar logic as in ModifyGameObject for setting/creating tags + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + newGo.tag = tagToSet; + } + catch (UnityException ex) + { + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + InternalEditorUtility.AddTag(tagToSet); + newGo.tag = tagToSet; // Retry + Debug.Log( + $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." + ); + } + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." + ); + } + } + } + + // Set Layer (new for create action) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newGo.layer = layerId; + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." + ); + } + } + + // Add Components + if (@params["componentsToAdd"] is JArray componentsToAddArray) + { + foreach (var compToken in componentsToAddArray) + { + string typeName = null; + JObject properties = null; + + if (compToken.Type == JTokenType.String) + { + typeName = compToken.ToString(); + } + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(newGo, typeName, properties); + if (addResult != null) // Check if AddComponentInternal returned an error object + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return addResult; // Return the error response + } + } + else + { + Debug.LogWarning( + $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" + ); + } + } + } + + // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true + GameObject finalInstance = newGo; // Use this for selection and return data + if (createdNewObject && saveAsPrefab) + { + string finalPrefabPath = prefabPath; // Use a separate variable for saving path + // This check should now happen *before* attempting to save + if (string.IsNullOrEmpty(finalPrefabPath)) + { + // Clean up the created object before returning error + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." + ); + } + // Ensure the *saving* path ends with .prefab + if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + Debug.Log( + $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" + ); + finalPrefabPath += ".prefab"; + } + + // Removed the error check here as we now ensure the extension exists + // if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + // { + // UnityEngine.Object.DestroyImmediate(newGo); + // return Response.Error($"'prefabPath' must end with '.prefab'. Provided: '{prefabPath}'"); + // } + + try + { + // Ensure directory exists using the final saving path + string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); + if ( + !string.IsNullOrEmpty(directoryPath) + && !System.IO.Directory.Exists(directoryPath) + ) + { + System.IO.Directory.CreateDirectory(directoryPath); + AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder + Debug.Log( + $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" + ); + } + + // Use SaveAsPrefabAssetAndConnect with the final saving path + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + newGo, + finalPrefabPath, + InteractionMode.UserAction + ); + + if (finalInstance == null) + { + // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." + ); + } + Debug.Log( + $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." + ); + // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. + // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect + } + catch (Exception e) + { + // Clean up the instance if prefab saving fails + UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt + return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + } + } + + // Select the instance in the scene (either prefab instance or newly created/saved one) + Selection.activeGameObject = finalInstance; + + // Determine appropriate success message using the potentially updated or original path + string messagePrefabPath = + finalInstance == null + ? originalPrefabPath + : AssetDatabase.GetAssetPath( + PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) + ?? (UnityEngine.Object)finalInstance + ); + string successMessage; + if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab + { + successMessage = + $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; + } + else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; + } + else // Created new primitive or empty GO, didn't save as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created successfully in scene."; + } + + // Return data for the instance in the scene + return Response.Success(successMessage, GetGameObjectData(finalInstance)); + } + + private static object ModifyGameObject( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + // Record state for Undo *before* modifications + Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); + Undo.RecordObject(targetGo, "Modify GameObject Properties"); + + bool modified = false; + + // Rename (using consolidated 'name' parameter) + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) + { + targetGo.name = name; + modified = true; + } + + // Change Parent (using consolidated 'parent' parameter) + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if ( + newParentGo == null + && !( + parentToken.Type == JTokenType.Null + || ( + parentToken.Type == JTokenType.String + && string.IsNullOrEmpty(parentToken.ToString()) + ) + ) + ) + { + return Response.Error($"New parent ('{parentToken}') not found."); + } + // Check for hierarchy loops + if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) + { + return Response.Error( + $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." + ); + } + if (targetGo.transform.parent != (newParentGo?.transform)) + { + targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true + modified = true; + } + } + + // Set Active State + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + // Change Tag (using consolidated 'tag' parameter) + string tag = @params["tag"]?.ToString(); + // Only attempt to change tag if a non-null tag is provided and it's different from the current one. + // Allow setting an empty string to remove the tag (Unity uses "Untagged"). + if (tag != null && targetGo.tag != tag) + { + // Ensure the tag is not empty, if empty, it means "Untagged" implicitly + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + + try + { + // First attempt to set the tag + targetGo.tag = tagToSet; + modified = true; + } + catch (UnityException ex) + { + // Check if the error is specifically because the tag doesn't exist + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + // Attempt to create the tag using internal utility + InternalEditorUtility.AddTag(tagToSet); + // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. + // yield return null; // Cannot yield here, editor script limitation + + // Retry setting the tag immediately after creation + targetGo.tag = tagToSet; + modified = true; // Mark as modified on successful retry + Debug.Log( + $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + // Handle failure during tag creation or the second assignment attempt + Debug.LogError( + $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" + ); + return Response.Error( + $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." + ); + } + } + else + { + // If the exception was for a different reason, return the original error + return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); + } + } + } + + // Change Layer (using consolidated 'layer' parameter) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1 && layerName != "Default") + { + return Response.Error( + $"Invalid layer specified: '{layerName}'. Use a valid layer name." + ); + } + if (layerId != -1 && targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + // Transform Modifications + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + // --- Component Modifications --- + // Note: These might need more specific Undo recording per component + + // Remove Components + if (@params["componentsToRemove"] is JArray componentsToRemoveArray) + { + foreach (var compToken in componentsToRemoveArray) + { + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error if removal failed + modified = true; + } + } + } + + // Add Components (similar to create) + if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) + { + foreach (var compToken in componentsToAddArrayModify) + { + // ... (parsing logic as in CreateGameObject) ... + string typeName = null; + JObject properties = null; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; + modified = true; + } + } + } + + // Set Component Properties + if (@params["componentProperties"] is JObject componentPropertiesObj) + { + foreach (var prop in componentPropertiesObj.Properties()) + { + string compName = prop.Name; + JObject propertiesToSet = prop.Value as JObject; + if (propertiesToSet != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + compName, + propertiesToSet + ); + if (setResult != null) + return setResult; + modified = true; + } + } + } + + if (!modified) + { + return Response.Success( + $"No modifications applied to GameObject '{targetGo.name}'.", + GetGameObjectData(targetGo) + ); + } + + EditorUtility.SetDirty(targetGo); // Mark scene as dirty + return Response.Success( + $"GameObject '{targetGo.name}' modified successfully.", + GetGameObjectData(targetGo) + ); + } + + private static object DeleteGameObject(JToken targetToken, string searchMethod) + { + // Find potentially multiple objects if name/tag search is used without find_all=false implicitly + List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety + + if (targets.Count == 0) + { + return Response.Error( + $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + List deletedObjects = new List(); + foreach (var targetGo in targets) + { + if (targetGo != null) + { + string goName = targetGo.name; + int goId = targetGo.GetInstanceID(); + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(targetGo); + deletedObjects.Add(new { name = goName, instanceID = goId }); + } + } + + if (deletedObjects.Count > 0) + { + string message = + targets.Count == 1 + ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." + : $"{deletedObjects.Count} GameObjects deleted successfully."; + return Response.Success(message, deletedObjects); + } + else + { + // Should not happen if targets.Count > 0 initially, but defensive check + return Response.Error("Failed to delete target GameObject(s)."); + } + } + + private static object FindGameObjects( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + bool findAll = @params["findAll"]?.ToObject() ?? false; + List foundObjects = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + @params + ); + + if (foundObjects.Count == 0) + { + return Response.Success("No matching GameObjects found.", new List()); + } + + var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); + return Response.Success($"Found {results.Count} GameObject(s).", results); + } + + private static object GetComponentsFromTarget(string target, string searchMethod) + { + GameObject targetGo = FindObjectInternal(target, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." + ); + } + + try + { + Component[] components = targetGo.GetComponents(); + var componentData = components.Select(c => GetComponentData(c)).ToList(); + return Response.Success( + $"Retrieved {componentData.Count} components from '{targetGo.name}'.", + componentData + ); + } + catch (Exception e) + { + return Response.Error( + $"Error getting components from '{targetGo.name}': {e.Message}" + ); + } + } + + private static object AddComponentToTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + JObject properties = null; + + // Allow adding component specified directly or via componentsToAdd array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name + } + else if ( + @params["componentsToAdd"] is JArray componentsToAddArray + && componentsToAddArray.Count > 0 + ) + { + var compToken = componentsToAddArray.First; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToAdd') is required." + ); + } + + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; // Return error + + EditorUtility.SetDirty(targetGo); + return Response.Success( + $"Component '{typeName}' added to '{targetGo.name}'.", + GetGameObjectData(targetGo) + ); // Return updated GO data + } + + private static object RemoveComponentFromTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + // Allow removing component specified directly or via componentsToRemove array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + } + else if ( + @params["componentsToRemove"] is JArray componentsToRemoveArray + && componentsToRemoveArray.Count > 0 + ) + { + typeName = componentsToRemoveArray.First?.ToString(); + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToRemove') is required." + ); + } + + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error + + EditorUtility.SetDirty(targetGo); + return Response.Success( + $"Component '{typeName}' removed from '{targetGo.name}'.", + GetGameObjectData(targetGo) + ); + } + + private static object SetComponentPropertyOnTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string compName = @params["componentName"]?.ToString(); + JObject propertiesToSet = null; + + if (!string.IsNullOrEmpty(compName)) + { + // Properties might be directly under componentProperties or nested under the component name + if (@params["componentProperties"] is JObject compProps) + { + propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure + } + } + else + { + return Response.Error("'componentName' parameter is required."); + } + + if (propertiesToSet == null || !propertiesToSet.HasValues) + { + return Response.Error( + "'componentProperties' dictionary for the specified component is required and cannot be empty." + ); + } + + var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); + if (setResult != null) + return setResult; // Return error + + EditorUtility.SetDirty(targetGo); + return Response.Success( + $"Properties set for component '{compName}' on '{targetGo.name}'.", + GetGameObjectData(targetGo) + ); + } + + // --- Internal Helpers --- + + /// + /// Finds a single GameObject based on token (ID, name, path) and search method. + /// + private static GameObject FindObjectInternal( + JToken targetToken, + string searchMethod, + JObject findParams = null + ) + { + // If find_all is not explicitly false, we still want only one for most single-target operations. + bool findAll = findParams?["findAll"]?.ToObject() ?? false; + // If a specific target ID is given, always find just that one. + if ( + targetToken?.Type == JTokenType.Integer + || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) + ) + { + findAll = false; + } + List results = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + findParams + ); + return results.Count > 0 ? results[0] : null; + } + + /// + /// Core logic for finding GameObjects based on various criteria. + /// + private static List FindObjectsInternal( + JToken targetToken, + string searchMethod, + bool findAll, + JObject findParams = null + ) + { + List results = new List(); + string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself + bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; + bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; + + // Default search method if not specified + if (string.IsNullOrEmpty(searchMethod)) + { + if (targetToken?.Type == JTokenType.Integer) + searchMethod = "by_id"; + else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) + searchMethod = "by_path"; + else + searchMethod = "by_name"; // Default fallback + } + + GameObject rootSearchObject = null; + // If searching in children, find the initial target first + if (searchInChildren && targetToken != null) + { + rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search + if (rootSearchObject == null) + { + Debug.LogWarning( + $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." + ); + return results; // Return empty if root not found + } + } + + switch (searchMethod) + { + case "by_id": + if (int.TryParse(searchTerm, out int instanceId)) + { + // EditorUtility.InstanceIDToObject is slow, iterate manually if possible + // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + var allObjects = GetAllSceneObjects(searchInactive); // More efficient + GameObject obj = allObjects.FirstOrDefault(go => + go.GetInstanceID() == instanceId + ); + if (obj != null) + results.Add(obj); + } + break; + case "by_name": + var searchPoolName = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); + break; + case "by_path": + // Path is relative to scene root or rootSearchObject + Transform foundTransform = rootSearchObject + ? rootSearchObject.transform.Find(searchTerm) + : GameObject.Find(searchTerm)?.transform; + if (foundTransform != null) + results.Add(foundTransform.gameObject); + break; + case "by_tag": + var searchPoolTag = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); + break; + case "by_layer": + var searchPoolLayer = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + if (int.TryParse(searchTerm, out int layerIndex)) + { + results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); + } + else + { + int namedLayer = LayerMask.NameToLayer(searchTerm); + if (namedLayer != -1) + results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); + } + break; + case "by_component": + Type componentType = FindType(searchTerm); + if (componentType != null) + { + // Determine FindObjectsInactive based on the searchInactive flag + FindObjectsInactive findInactive = searchInactive + ? FindObjectsInactive.Include + : FindObjectsInactive.Exclude; + // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state + var searchPoolComp = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(componentType, searchInactive) + .Select(c => (c as Component).gameObject) + : UnityEngine + .Object.FindObjectsByType( + componentType, + findInactive, + FindObjectsSortMode.None + ) + .Select(c => (c as Component).gameObject); + results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Find] Component type not found: {searchTerm}" + ); + } + break; + case "by_id_or_name_or_path": // Helper method used internally + if (int.TryParse(searchTerm, out int id)) + { + var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup + GameObject objById = allObjectsId.FirstOrDefault(go => + go.GetInstanceID() == id + ); + if (objById != null) + { + results.Add(objById); + break; + } + } + GameObject objByPath = GameObject.Find(searchTerm); + if (objByPath != null) + { + results.Add(objByPath); + break; + } + + var allObjectsName = GetAllSceneObjects(true); + results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); + break; + default: + Debug.LogWarning( + $"[ManageGameObject.Find] Unknown search method: {searchMethod}" + ); + break; + } + + // If only one result is needed, return just the first one found. + if (!findAll && results.Count > 1) + { + return new List { results[0] }; + } + + return results.Distinct().ToList(); // Ensure uniqueness + } + + // Helper to get all scene objects efficiently + private static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() + var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + var allObjects = new List(); + foreach (var root in rootObjects) + { + allObjects.AddRange( + root.GetComponentsInChildren(includeInactive) + .Select(t => t.gameObject) + ); + } + return allObjects; + } + + /// + /// Adds a component by type name and optionally sets properties. + /// Returns null on success, or an error response object on failure. + /// + private static object AddComponentInternal( + GameObject targetGo, + string typeName, + JObject properties + ) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error( + $"Component type '{typeName}' not found or is not a valid Component." + ); + } + if (!typeof(Component).IsAssignableFrom(componentType)) + { + return Response.Error($"Type '{typeName}' is not a Component."); + } + + // Prevent adding Transform again + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot add another Transform component."); + } + + // Check for 2D/3D physics component conflicts + bool isAdding2DPhysics = + typeof(Rigidbody2D).IsAssignableFrom(componentType) + || typeof(Collider2D).IsAssignableFrom(componentType); + bool isAdding3DPhysics = + typeof(Rigidbody).IsAssignableFrom(componentType) + || typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + // Check if the GameObject already has any 3D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." + ); + } + } + else if (isAdding3DPhysics) + { + // Check if the GameObject already has any 2D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." + ); + } + } + + // Check if component already exists (optional, depending on desired behavior) + // if (targetGo.GetComponent(componentType) != null) { + // return Response.Error($"Component '{typeName}' already exists on '{targetGo.name}'."); + // } + + try + { + // Use Undo.AddComponent for undo support + Component newComponent = Undo.AddComponent(targetGo, componentType); + if (newComponent == null) + { + return Response.Error( + $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." + ); + } + + // Set default values for specific component types + if (newComponent is Light light) + { + // Default newly added lights to directional + light.type = LightType.Directional; + } + + // Set properties if provided + if (properties != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + typeName, + properties, + newComponent + ); // Pass the new component instance + if (setResult != null) + { + // If setting properties failed, maybe remove the added component? + Undo.DestroyObjectImmediate(newComponent); + return setResult; // Return the error from setting properties + } + } + + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Removes a component by type name. + /// Returns null on success, or an error response object on failure. + /// + private static object RemoveComponentInternal(GameObject targetGo, string typeName) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error($"Component type '{typeName}' not found for removal."); + } + + // Prevent removing essential components + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot remove the Transform component."); + } + + Component componentToRemove = targetGo.GetComponent(componentType); + if (componentToRemove == null) + { + return Response.Error( + $"Component '{typeName}' not found on '{targetGo.name}' to remove." + ); + } + + try + { + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(componentToRemove); + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Sets properties on a component. + /// Returns null on success, or an error response object on failure. + /// + private static object SetComponentPropertiesInternal( + GameObject targetGo, + string compName, + JObject propertiesToSet, + Component targetComponentInstance = null + ) + { + Component targetComponent = targetComponentInstance ?? targetGo.GetComponent(compName); + if (targetComponent == null) + { + return Response.Error( + $"Component '{compName}' not found on '{targetGo.name}' to set properties." + ); + } + + Undo.RecordObject(targetComponent, "Set Component Properties"); + + foreach (var prop in propertiesToSet.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + + try + { + if (!SetProperty(targetComponent, propName, propValue)) + { + // Log warning if property could not be set + 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) + { + 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); + return null; // Success (or partial success if warnings were logged) + } + + /// + /// Helper to set a property or field via reflection, handling basic types. + /// + private static bool SetProperty(object target, string memberName, JToken value) + { + Type type = target.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + try + { + // Handle special case for materials with dot notation (material.property) + // Examples: material.color, sharedMaterial.color, materials[0].color + if (memberName.Contains('.') || memberName.Contains('[')) + { + return SetNestedProperty(target, memberName, value); + } + + PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); + if (convertedValue != null) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); + if (convertedValue != null) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + Debug.LogError( + $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}" + ); + } + return false; + } + + /// + /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") + /// + private static bool SetNestedProperty(object target, string path, JToken value) + { + try + { + // Split the path into parts (handling both dot notation and array indexing) + string[] pathParts = SplitPropertyPath(path); + if (pathParts.Length == 0) + return false; + + object currentObject = target; + Type currentType = currentObject.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Traverse the path until we reach the final property + for (int i = 0; i < pathParts.Length - 1; i++) + { + string part = pathParts[i]; + bool isArray = false; + int arrayIndex = -1; + + // Check if this part contains array indexing + if (part.Contains("[")) + { + int startBracket = part.IndexOf('['); + int endBracket = part.IndexOf(']'); + if (startBracket > 0 && endBracket > startBracket) + { + string indexStr = part.Substring( + startBracket + 1, + endBracket - startBracket - 1 + ); + if (int.TryParse(indexStr, out arrayIndex)) + { + isArray = true; + part = part.Substring(0, startBracket); + } + } + } + + // Get the property/field + PropertyInfo propInfo = currentType.GetProperty(part, flags); + FieldInfo fieldInfo = null; + if (propInfo == null) + { + fieldInfo = currentType.GetField(part, flags); + if (fieldInfo == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" + ); + return false; + } + } + + // Get the value + currentObject = + propInfo != null + ? propInfo.GetValue(currentObject) + : fieldInfo.GetValue(currentObject); + + // If the current property is null, we need to stop + if (currentObject == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." + ); + return false; + } + + // If this is an array/list access, get the element at the index + if (isArray) + { + if (currentObject is Material[]) + { + var materials = currentObject as Material[]; + if (arrayIndex < 0 || arrayIndex >= materials.Length) + { + Debug.LogWarning( + $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" + ); + return false; + } + currentObject = materials[arrayIndex]; + } + else if (currentObject is System.Collections.IList) + { + var list = currentObject as System.Collections.IList; + if (arrayIndex < 0 || arrayIndex >= list.Count) + { + Debug.LogWarning( + $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" + ); + return false; + } + currentObject = list[arrayIndex]; + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." + ); + return false; + } + } + + // Update type for next iteration + currentType = currentObject.GetType(); + } + + // Set the final property + string finalPart = pathParts[pathParts.Length - 1]; + + // Special handling for Material properties (shader properties) + if (currentObject is Material material && finalPart.StartsWith("_")) + { + // Handle various material property types + if (value is JArray jArray) + { + if (jArray.Count == 4) // Color with alpha + { + Color color = new Color( + jArray[0].ToObject(), + jArray[1].ToObject(), + jArray[2].ToObject(), + jArray[3].ToObject() + ); + material.SetColor(finalPart, color); + return true; + } + else if (jArray.Count == 3) // Color without alpha + { + Color color = new Color( + jArray[0].ToObject(), + jArray[1].ToObject(), + jArray[2].ToObject(), + 1.0f + ); + material.SetColor(finalPart, color); + return true; + } + else if (jArray.Count == 2) // Vector2 + { + Vector2 vec = new Vector2( + jArray[0].ToObject(), + jArray[1].ToObject() + ); + material.SetVector(finalPart, vec); + return true; + } + else if (jArray.Count == 4) // Vector4 + { + Vector4 vec = new Vector4( + jArray[0].ToObject(), + jArray[1].ToObject(), + jArray[2].ToObject(), + jArray[3].ToObject() + ); + material.SetVector(finalPart, vec); + return true; + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + material.SetFloat(finalPart, value.ToObject()); + return true; + } + else if (value.Type == JTokenType.Boolean) + { + material.SetFloat(finalPart, value.ToObject() ? 1f : 0f); + return true; + } + else if (value.Type == JTokenType.String) + { + // Might be a texture path + string texturePath = value.ToString(); + if ( + texturePath.EndsWith(".png") + || texturePath.EndsWith(".jpg") + || texturePath.EndsWith(".tga") + ) + { + Texture2D texture = AssetDatabase.LoadAssetAtPath( + texturePath + ); + if (texture != null) + { + material.SetTexture(finalPart, texture); + return true; + } + } + else + { + // Materials don't have SetString, use SetTextureOffset as workaround or skip + // material.SetString(finalPart, texturePath); + Debug.LogWarning( + $"[SetNestedProperty] String values not directly supported for material property {finalPart}" + ); + return false; + } + } + + Debug.LogWarning( + $"[SetNestedProperty] Unsupported material property value type: {value.Type} for {finalPart}" + ); + return false; + } + + // For standard properties (not shader specific) + PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); + if (finalPropInfo != null && finalPropInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType); + if (convertedValue != null) + { + finalPropInfo.SetValue(currentObject, convertedValue); + return true; + } + } + else + { + FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); + if (finalFieldInfo != null) + { + object convertedValue = ConvertJTokenToType( + value, + finalFieldInfo.FieldType + ); + if (convertedValue != null) + { + finalFieldInfo.SetValue(currentObject, convertedValue); + return true; + } + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find final property or field '{finalPart}' on type '{currentType.Name}'" + ); + } + } + } + catch (Exception ex) + { + Debug.LogError( + $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}" + ); + } + + return false; + } + + /// + /// Split a property path into parts, handling both dot notation and array indexers + /// + private static string[] SplitPropertyPath(string path) + { + // Handle complex paths with both dots and array indexers + List parts = new List(); + int startIndex = 0; + bool inBrackets = false; + + for (int i = 0; i < path.Length; i++) + { + char c = path[i]; + + if (c == '[') + { + inBrackets = true; + } + else if (c == ']') + { + inBrackets = false; + } + else if (c == '.' && !inBrackets) + { + // Found a dot separator outside of brackets + parts.Add(path.Substring(startIndex, i - startIndex)); + startIndex = i + 1; + } + } + + // Add the final part + if (startIndex < path.Length) + { + parts.Add(path.Substring(startIndex)); + } + + return parts.ToArray(); + } + + /// + /// Simple JToken to Type conversion for common Unity types. + /// + private static object ConvertJTokenToType(JToken token, Type targetType) + { + try + { + // Unwrap nested material properties if we're assigning to a Material + if (typeof(Material).IsAssignableFrom(targetType) && token is JObject materialProps) + { + // Handle case where we're passing shader properties directly in a nested object + string materialPath = token["path"]?.ToString(); + if (!string.IsNullOrEmpty(materialPath)) + { + // Load the material by path + Material material = AssetDatabase.LoadAssetAtPath(materialPath); + if (material != null) + { + // If there are additional properties, set them + foreach (var prop in materialProps.Properties()) + { + if (prop.Name != "path") + { + SetProperty(material, prop.Name, prop.Value); + } + } + return material; + } + else + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not load material at path: '{materialPath}'" + ); + return null; + } + } + + // If no path is specified, could be a dynamic material or instance set by reference + return null; + } + + // Basic types first + if (targetType == typeof(string)) + return token.ToObject(); + if (targetType == typeof(int)) + return token.ToObject(); + if (targetType == typeof(float)) + return token.ToObject(); + if (targetType == typeof(bool)) + return token.ToObject(); + + // Vector/Quaternion/Color types + if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) + return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); + if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) + return new Vector3( + arrV3[0].ToObject(), + arrV3[1].ToObject(), + arrV3[2].ToObject() + ); + if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) + return new Vector4( + arrV4[0].ToObject(), + arrV4[1].ToObject(), + arrV4[2].ToObject(), + arrV4[3].ToObject() + ); + if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) + return new Quaternion( + arrQ[0].ToObject(), + arrQ[1].ToObject(), + arrQ[2].ToObject(), + arrQ[3].ToObject() + ); + if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA + return new Color( + arrC[0].ToObject(), + arrC[1].ToObject(), + arrC[2].ToObject(), + arrC.Count > 3 ? arrC[3].ToObject() : 1.0f + ); + + // Enum types + if (targetType.IsEnum) + return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing + + // Handle assigning Unity Objects (Assets, Scene Objects, Components) + if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) + { + // CASE 1: Reference is a JSON Object specifying a scene object/component find criteria + if (token is JObject refObject) + { + JToken findToken = refObject["find"]; + string findMethod = + refObject["method"]?.ToString() ?? "by_id_or_name_or_path"; // Default search + string componentTypeName = refObject["component"]?.ToString(); + + if (findToken == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Reference object missing 'find' property: {token}" + ); + return null; + } + + // Find the target GameObject + // Pass 'searchInactive: true' for internal lookups to be more robust + JObject findParams = new JObject(); + findParams["searchInactive"] = true; + GameObject foundGo = FindObjectInternal(findToken, findMethod, findParams); + + if (foundGo == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not find GameObject specified by reference object: {token}" + ); + return null; + } + + // If a component type is specified, try to get it + if (!string.IsNullOrEmpty(componentTypeName)) + { + Type compType = FindType(componentTypeName); + if (compType == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not find component type '{componentTypeName}' specified in reference object: {token}" + ); + return null; + } + + // Ensure the targetType is assignable from the found component type + if (!targetType.IsAssignableFrom(compType)) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Found component '{componentTypeName}' but it is not assignable to the target property type '{targetType.Name}'. Reference: {token}" + ); + return null; + } + + Component foundComp = foundGo.GetComponent(compType); + if (foundComp == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Found GameObject '{foundGo.name}' but could not find component '{componentTypeName}' on it. Reference: {token}" + ); + return null; + } + return foundComp; // Return the found component + } + else + { + // Otherwise, return the GameObject itself, ensuring it's assignable + if (!targetType.IsAssignableFrom(typeof(GameObject))) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Found GameObject '{foundGo.name}' but it is not assignable to the target property type '{targetType.Name}' (component name was not specified). Reference: {token}" + ); + return null; + } + return foundGo; // Return the found GameObject + } + } + // CASE 2: Reference is a string, assume it's an asset path + else if (token.Type == JTokenType.String) + { + string assetPath = token.ToString(); + if (!string.IsNullOrEmpty(assetPath)) + { + // Attempt to load the asset from the provided path using the target type + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( + assetPath, + targetType + ); + if (loadedAsset != null) + { + return loadedAsset; // Return the loaded asset if successful + } + else + { + // Log a warning if the asset could not be found at the path + Debug.LogWarning( + $"[ConvertJTokenToType] Could not load asset of type '{targetType.Name}' from path: '{assetPath}'. Make sure the path is correct and the asset exists." + ); + return null; + } + } + else + { + // Handle cases where an empty string might be intended to clear the reference + return null; // Assign null if the path is empty + } + } + // CASE 3: Reference is null or empty JToken, assign null + else if ( + token.Type == JTokenType.Null + || string.IsNullOrEmpty(token.ToString()) + ) + { + return null; + } + // CASE 4: Invalid format for Unity Object reference + else + { + Debug.LogWarning( + $"[ConvertJTokenToType] Expected a string asset path or a reference object to assign Unity Object of type '{targetType.Name}', but received token type '{token.Type}'. Value: {token}" + ); + return null; + } + } + + // Fallback: Try direct conversion (might work for other simple value types) + // Be cautious here, this might throw errors for complex types not handled above + try + { + return token.ToObject(targetType); + } + catch (Exception directConversionEx) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Direct conversion failed for JToken '{token}' to type '{targetType.Name}': {directConversionEx.Message}. Specific handling might be needed." + ); + return null; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not convert JToken '{token}' to type '{targetType.Name}': {ex.Message}" + ); + return null; + } + } + + /// + /// Helper to find a Type by name, searching relevant assemblies. + /// + private static Type FindType(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + return null; + + // Handle common Unity namespaces implicitly + var type = + Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") + ?? Type.GetType($"UnityEngine.{typeName}, UnityEngine.PhysicsModule") + ?? // Example physics + Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") + ?? // Example UI + Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule") + ?? Type.GetType(typeName); // Try direct name (if fully qualified or in mscorlib) + + if (type != null) + return type; + + // If not found, search all loaded assemblies (slower) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + type = assembly.GetType(typeName); + if (type != null) + return type; + // Also check with namespaces if simple name given + type = assembly.GetType("UnityEngine." + typeName); + if (type != null) + return type; + type = assembly.GetType("UnityEditor." + typeName); + if (type != null) + return type; + type = assembly.GetType("UnityEngine.UI." + typeName); + if (type != null) + return type; + } + + return null; // Not found + } + + /// + /// Parses a JArray like [x, y, z] into a Vector3. + /// + private static Vector3? ParseVector3(JArray array) + { + if (array != null && array.Count == 3) + { + try + { + return new Vector3( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + catch + { /* Ignore parsing errors */ + } + } + return null; + } + + // --- Data Serialization --- + + /// + /// Creates a serializable representation of a GameObject. + /// + private static object GetGameObjectData(GameObject go) + { + if (go == null) + return null; + return new + { + name = go.name, + instanceID = go.GetInstanceID(), + tag = go.tag, + layer = go.layer, + activeSelf = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + scenePath = go.scene.path, // Identify which scene it belongs to + transform = new // Serialize transform components carefully to avoid JSON issues + { + // Serialize Vector3 components individually to prevent self-referencing loops. + // The default serializer can struggle with properties like Vector3.normalized. + position = new + { + x = go.transform.position.x, + y = go.transform.position.y, + z = go.transform.position.z, + }, + localPosition = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.rotation.eulerAngles.x, + y = go.transform.rotation.eulerAngles.y, + z = go.transform.rotation.eulerAngles.z, + }, + localRotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + forward = new + { + x = go.transform.forward.x, + y = go.transform.forward.y, + z = go.transform.forward.z, + }, + up = new + { + x = go.transform.up.x, + y = go.transform.up.y, + z = go.transform.up.z, + }, + right = new + { + x = go.transform.right.x, + y = go.transform.right.y, + z = go.transform.right.z, + }, + }, + parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent + // Optionally include components, but can be large + // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() + // Or just component names: + componentNames = go.GetComponents() + .Select(c => c.GetType().FullName) + .ToList(), + }; + } + + /// + /// Creates a serializable representation of a Component. + /// TODO: Add property serialization. + /// + private static object GetComponentData(Component c) + { + if (c == null) + return null; + var data = new Dictionary + { + { "typeName", c.GetType().FullName }, + { "instanceID", c.GetInstanceID() }, + }; + + // Attempt to serialize public properties/fields (can be noisy/complex) + /* + try { + var properties = new Dictionary(); + var type = c.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + + foreach (var prop in type.GetProperties(flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) { + try { properties[prop.Name] = prop.GetValue(c); } catch { } + } + foreach (var field in type.GetFields(flags)) { + try { properties[field.Name] = field.GetValue(c); } catch { } + } + data["properties"] = properties; + } catch (Exception ex) { + data["propertiesError"] = ex.Message; + } + */ + return data; + } + } +} + diff --git a/Editor/Tools/ManageGameObject.cs.meta b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta similarity index 100% rename from Editor/Tools/ManageGameObject.cs.meta rename to UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta diff --git a/Editor/Tools/ManageScene.cs b/UnityMcpBridge/Editor/Tools/ManageScene.cs similarity index 60% rename from Editor/Tools/ManageScene.cs rename to UnityMcpBridge/Editor/Tools/ManageScene.cs index 7ef8d02c..0fb185fb 100644 --- a/Editor/Tools/ManageScene.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScene.cs @@ -1,15 +1,15 @@ -using UnityEngine; -using UnityEngine.SceneManagement; -using UnityEditor; -using UnityEditor.SceneManagement; -using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.IO; using System.Linq; -using System.Collections.Generic; -using UnityMCP.Editor.Helpers; // For Response class +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityMcpBridge.Editor.Helpers; // For Response class -namespace UnityMCP.Editor.Tools +namespace UnityMcpBridge.Editor.Tools { /// /// Handles scene management operations like loading, saving, creating, and querying hierarchy. @@ -29,19 +29,20 @@ public static object HandleCommand(JObject @params) // Ensure path is relative to Assets/, removing any leading "Assets/" string relativeDir = path ?? string.Empty; - if (!string.IsNullOrEmpty(relativeDir)) { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } } - + // Apply default *after* sanitizing, using the original path variable for the check if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness - { + { relativeDir = "Scenes"; // Default relative directory - } + } if (string.IsNullOrEmpty(action)) { @@ -51,12 +52,16 @@ public static object HandleCommand(JObject @params) string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) - string fullPath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine(fullPathDir, sceneFileName); + string fullPath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine(fullPathDir, sceneFileName); // Ensure relativePath always starts with "Assets/" and uses forward slashes - string relativePath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); + string relativePath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); // Ensure directory exists for 'create' - if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) + if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) { try { @@ -64,7 +69,9 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error($"Could not create directory '{fullPathDir}': {e.Message}"); + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); } } @@ -73,7 +80,9 @@ public static object HandleCommand(JObject @params) { case "create": if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) - return Response.Error("'name' and 'path' parameters are required for 'create' action."); + return Response.Error( + "'name' and 'path' parameters are required for 'create' action." + ); return CreateScene(fullPath, relativePath); case "load": // Loading can be done by path/name or build index @@ -82,7 +91,9 @@ public static object HandleCommand(JObject @params) else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else - return Response.Error("Either 'name'/'path' or 'buildIndex' must be provided for 'load' action."); + return Response.Error( + "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." + ); case "save": // Save current scene, optionally to a new path return SaveScene(fullPath, relativePath); @@ -90,11 +101,13 @@ public static object HandleCommand(JObject @params) return GetSceneHierarchy(); case "get_active": return GetActiveSceneInfo(); - case "get_build_settings": + case "get_build_settings": return GetBuildSettingsScenes(); // Add cases for modifying build settings, additive loading, unloading etc. default: - return Response.Error($"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings."); + return Response.Error( + $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." + ); } } @@ -108,14 +121,20 @@ private static object CreateScene(string fullPath, string relativePath) try { // Create a new empty scene - Scene newScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + Scene newScene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, + NewSceneMode.Single + ); // Save it to the specified path bool saved = EditorSceneManager.SaveScene(newScene, relativePath); if (saved) { - AssetDatabase.Refresh(); // Ensure Unity sees the new scene file - return Response.Success($"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath }); + AssetDatabase.Refresh(); // Ensure Unity sees the new scene file + return Response.Success( + $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", + new { path = relativePath } + ); } else { @@ -132,24 +151,43 @@ private static object CreateScene(string fullPath, string relativePath) private static object LoadScene(string relativePath) { - if (!File.Exists(Path.Combine(Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length), relativePath))) + if ( + !File.Exists( + Path.Combine( + Application.dataPath.Substring( + 0, + Application.dataPath.Length - "Assets".Length + ), + relativePath + ) + ) + ) { - return Response.Error($"Scene file not found at '{relativePath}'."); + return Response.Error($"Scene file not found at '{relativePath}'."); } // Check for unsaved changes in the current scene if (EditorSceneManager.GetActiveScene().isDirty) { // Optionally prompt the user or save automatically before loading - return Response.Error("Current scene has unsaved changes. Please save or discard changes before loading a new scene."); - // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); - // if (!saveOK) return Response.Error("Load cancelled by user."); + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); + // if (!saveOK) return Response.Error("Load cancelled by user."); } try { EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); - return Response.Success($"Scene '{relativePath}' loaded successfully.", new { path = relativePath, name = Path.GetFileNameWithoutExtension(relativePath) }); + return Response.Success( + $"Scene '{relativePath}' loaded successfully.", + new + { + path = relativePath, + name = Path.GetFileNameWithoutExtension(relativePath), + } + ); } catch (Exception e) { @@ -159,26 +197,40 @@ private static object LoadScene(string relativePath) private static object LoadScene(int buildIndex) { - if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) - { - return Response.Error($"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}."); - } + if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) + { + return Response.Error( + $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." + ); + } // Check for unsaved changes if (EditorSceneManager.GetActiveScene().isDirty) { - return Response.Error("Current scene has unsaved changes. Please save or discard changes before loading a new scene."); + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); } try { string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); - return Response.Success($"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { path = scenePath, name = Path.GetFileNameWithoutExtension(scenePath), buildIndex = buildIndex }); + return Response.Success( + $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", + new + { + path = scenePath, + name = Path.GetFileNameWithoutExtension(scenePath), + buildIndex = buildIndex, + } + ); } catch (Exception e) { - return Response.Error($"Error loading scene with build index {buildIndex}: {e.Message}"); + return Response.Error( + $"Error loading scene with build index {buildIndex}: {e.Message}" + ); } } @@ -198,9 +250,10 @@ private static object SaveScene(string fullPath, string relativePath) if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) { // Save As... - // Ensure directory exists + // Ensure directory exists string dir = Path.GetDirectoryName(fullPath); - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); saved = EditorSceneManager.SaveScene(currentScene, relativePath); finalPath = relativePath; @@ -211,7 +264,9 @@ private static object SaveScene(string fullPath, string relativePath) if (string.IsNullOrEmpty(currentScene.path)) { // Scene is untitled, needs a path - return Response.Error("Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality."); + return Response.Error( + "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." + ); } saved = EditorSceneManager.SaveScene(currentScene); } @@ -219,7 +274,10 @@ private static object SaveScene(string fullPath, string relativePath) if (saved) { AssetDatabase.Refresh(); - return Response.Success($"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name }); + return Response.Success( + $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", + new { path = finalPath, name = currentScene.name } + ); } else { @@ -249,7 +307,7 @@ private static object GetActiveSceneInfo() buildIndex = activeScene.buildIndex, // -1 if not in build settings isDirty = activeScene.isDirty, isLoaded = activeScene.isLoaded, - rootCount = activeScene.rootCount + rootCount = activeScene.rootCount, }; return Response.Success("Retrieved active scene information.", sceneInfo); @@ -260,7 +318,7 @@ private static object GetActiveSceneInfo() } } - private static object GetBuildSettingsScenes() + private static object GetBuildSettingsScenes() { try { @@ -268,12 +326,15 @@ private static object GetBuildSettingsScenes() for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) { var scene = EditorBuildSettings.scenes[i]; - scenes.Add(new { - path = scene.path, - guid = scene.guid.ToString(), - enabled = scene.enabled, - buildIndex = i // Actual build index considering only enabled scenes might differ - }); + scenes.Add( + new + { + path = scene.path, + guid = scene.guid.ToString(), + enabled = scene.enabled, + buildIndex = i, // Actual build index considering only enabled scenes might differ + } + ); } return Response.Success("Retrieved scenes from Build Settings.", scenes); } @@ -290,13 +351,18 @@ private static object GetSceneHierarchy() Scene activeScene = EditorSceneManager.GetActiveScene(); if (!activeScene.IsValid() || !activeScene.isLoaded) { - return Response.Error("No valid and loaded scene is active to get hierarchy from."); + return Response.Error( + "No valid and loaded scene is active to get hierarchy from." + ); } GameObject[] rootObjects = activeScene.GetRootGameObjects(); var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); - return Response.Success($"Retrieved hierarchy for scene '{activeScene.name}'.", hierarchy); + return Response.Success( + $"Retrieved hierarchy for scene '{activeScene.name}'.", + hierarchy + ); } catch (Exception e) { @@ -309,7 +375,8 @@ private static object GetSceneHierarchy() /// private static object GetGameObjectDataRecursive(GameObject go) { - if (go == null) return null; + if (go == null) + return null; var childrenData = new List(); foreach (Transform child in go.transform) @@ -326,16 +393,35 @@ private static object GetGameObjectDataRecursive(GameObject go) { "layer", go.layer }, { "isStatic", go.isStatic }, { "instanceID", go.GetInstanceID() }, // Useful unique identifier - { "transform", new { - position = new { x = go.transform.localPosition.x, y = go.transform.localPosition.y, z = go.transform.localPosition.z }, - rotation = new { x = go.transform.localRotation.eulerAngles.x, y = go.transform.localRotation.eulerAngles.y, z = go.transform.localRotation.eulerAngles.z }, // Euler for simplicity - scale = new { x = go.transform.localScale.x, y = go.transform.localScale.y, z = go.transform.localScale.z } + { + "transform", + new + { + position = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, // Euler for simplicity + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, } }, - { "children", childrenData } + { "children", childrenData }, }; return gameObjectData; } } -} \ No newline at end of file +} + diff --git a/Editor/Tools/ManageScene.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta similarity index 100% rename from Editor/Tools/ManageScene.cs.meta rename to UnityMcpBridge/Editor/Tools/ManageScene.cs.meta diff --git a/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs similarity index 74% rename from Editor/Tools/ManageScript.cs rename to UnityMcpBridge/Editor/Tools/ManageScript.cs index f041d1c7..70c9f71b 100644 --- a/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1,13 +1,13 @@ -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; using System; using System.IO; -using System.Text.RegularExpressions; using System.Linq; -using UnityMCP.Editor.Helpers; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; -namespace UnityMCP.Editor.Tools +namespace UnityMcpBridge.Editor.Tools { /// /// Handles CRUD operations for C# scripts within the Unity project. @@ -24,7 +24,7 @@ public static object HandleCommand(JObject @params) string name = @params["name"]?.ToString(); string path = @params["path"]?.ToString(); // Relative to Assets/ string contents = null; - + // Check if we have base64 encoded contents bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; if (contentsEncoded && @params["encodedContents"] != null) @@ -42,7 +42,7 @@ public static object HandleCommand(JObject @params) { contents = @params["contents"]?.ToString(); } - + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation string namespaceName = @params["namespace"]?.ToString(); // For organizing code @@ -58,7 +58,9 @@ public static object HandleCommand(JObject @params) // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) { - return Response.Error($"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."); + return Response.Error( + $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); } // Ensure path is relative to Assets/, removing any leading "Assets/" @@ -73,16 +75,18 @@ public static object HandleCommand(JObject @params) } } // Handle empty string case explicitly after processing - if (string.IsNullOrEmpty(relativeDir)) { - relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" + if (string.IsNullOrEmpty(relativeDir)) + { + relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" } // Construct paths string scriptFileName = $"{name}.cs"; string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Application.dataPath ends in "Assets" string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine("Assets", relativeDir, scriptFileName).Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes - + string relativePath = Path.Combine("Assets", relativeDir, scriptFileName) + .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + // Ensure the target directory exists for create/update if (action == "create" || action == "update") { @@ -92,15 +96,24 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error($"Could not create directory '{fullPathDir}': {e.Message}"); + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); } } - + // Route to specific action handlers switch (action) { case "create": - return CreateScript(fullPath, relativePath, name, contents, scriptType, namespaceName); + return CreateScript( + fullPath, + relativePath, + name, + contents, + scriptType, + namespaceName + ); case "read": return ReadScript(fullPath, relativePath); case "update": @@ -108,7 +121,9 @@ public static object HandleCommand(JObject @params) case "delete": return DeleteScript(fullPath, relativePath); default: - return Response.Error($"Unknown action: '{action}'. Valid actions are: create, read, update, delete."); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + ); } } @@ -130,12 +145,21 @@ private static string EncodeBase64(string text) return Convert.ToBase64String(data); } - private static object CreateScript(string fullPath, string relativePath, string name, string contents, string scriptType, string namespaceName) + private static object CreateScript( + string fullPath, + string relativePath, + string name, + string contents, + string scriptType, + string namespaceName + ) { // Check if script already exists if (File.Exists(fullPath)) { - return Response.Error($"Script already exists at '{relativePath}'. Use 'update' action to modify."); + return Response.Error( + $"Script already exists at '{relativePath}'. Use 'update' action to modify." + ); } // Generate default content if none provided @@ -157,7 +181,10 @@ private static object CreateScript(string fullPath, string relativePath, string File.WriteAllText(fullPath, contents); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); // Ensure Unity recognizes the new script - return Response.Success($"Script '{name}.cs' created successfully at '{relativePath}'.", new { path = relativePath }); + return Response.Success( + $"Script '{name}.cs' created successfully at '{relativePath}'.", + new { path = relativePath } + ); } catch (Exception e) { @@ -175,18 +202,22 @@ private static object ReadScript(string fullPath, string relativePath) try { string contents = File.ReadAllText(fullPath); - + // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version - var responseData = new { - path = relativePath, + var responseData = new + { + path = relativePath, contents = contents, // For large files, also include base64-encoded version encodedContents = isLarge ? EncodeBase64(contents) : null, - contentsEncoded = isLarge + contentsEncoded = isLarge, }; - - return Response.Success($"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData); + + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); } catch (Exception e) { @@ -194,11 +225,18 @@ private static object ReadScript(string fullPath, string relativePath) } } - private static object UpdateScript(string fullPath, string relativePath, string name, string contents) + private static object UpdateScript( + string fullPath, + string relativePath, + string name, + string contents + ) { if (!File.Exists(fullPath)) { - return Response.Error($"Script not found at '{relativePath}'. Use 'create' action to add a new script."); + return Response.Error( + $"Script not found at '{relativePath}'. Use 'create' action to add a new script." + ); } if (string.IsNullOrEmpty(contents)) { @@ -208,7 +246,7 @@ private static object UpdateScript(string fullPath, string relativePath, string // Validate syntax (basic check) if (!ValidateScriptSyntax(contents)) { - Debug.LogWarning($"Potential syntax error in script being updated: {name}"); + Debug.LogWarning($"Potential syntax error in script being updated: {name}"); // Consider if this should be a hard error or just a warning } @@ -217,7 +255,10 @@ private static object UpdateScript(string fullPath, string relativePath, string File.WriteAllText(fullPath, contents); AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes AssetDatabase.Refresh(); - return Response.Success($"Script '{name}.cs' updated successfully at '{relativePath}'.", new { path = relativePath }); + return Response.Success( + $"Script '{name}.cs' updated successfully at '{relativePath}'.", + new { path = relativePath } + ); } catch (Exception e) { @@ -239,12 +280,16 @@ private static object DeleteScript(string fullPath, string relativePath) if (deleted) { AssetDatabase.Refresh(); - return Response.Success($"Script '{Path.GetFileName(relativePath)}' moved to trash successfully."); + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully." + ); } else { // Fallback or error if MoveAssetToTrash fails - return Response.Error($"Failed to move script '{relativePath}' to trash. It might be locked or in use."); + return Response.Error( + $"Failed to move script '{relativePath}' to trash. It might be locked or in use." + ); } } catch (Exception e) @@ -256,11 +301,16 @@ private static object DeleteScript(string fullPath, string relativePath) /// /// Generates basic C# script content based on name and type. /// - private static string GenerateDefaultScriptContent(string name, string scriptType, string namespaceName) + private static string GenerateDefaultScriptContent( + string name, + string scriptType, + string namespaceName + ) { string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; string classDeclaration; - string body = "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; + string body = + "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; string baseClass = ""; if (!string.IsNullOrEmpty(scriptType)) @@ -272,7 +322,10 @@ private static string GenerateDefaultScriptContent(string name, string scriptTyp baseClass = " : ScriptableObject"; body = ""; // ScriptableObjects don't usually need Start/Update } - else if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase)) + else if ( + scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) + || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) + ) { usingStatements += "using UnityEditor;\n"; if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) @@ -313,13 +366,16 @@ private static string GenerateDefaultScriptContent(string name, string scriptTyp /// private static bool ValidateScriptSyntax(string contents) { - if (string.IsNullOrEmpty(contents)) return true; // Empty is technically valid? + if (string.IsNullOrEmpty(contents)) + return true; // Empty is technically valid? int braceBalance = 0; foreach (char c in contents) { - if (c == '{') braceBalance++; - else if (c == '}') braceBalance--; + if (c == '{') + braceBalance++; + else if (c == '}') + braceBalance--; } return braceBalance == 0; @@ -327,4 +383,5 @@ private static bool ValidateScriptSyntax(string contents) // but is complex to implement directly here. } } -} \ No newline at end of file +} + diff --git a/Editor/Tools/ManageScript.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta similarity index 100% rename from Editor/Tools/ManageScript.cs.meta rename to UnityMcpBridge/Editor/Tools/ManageScript.cs.meta diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs new file mode 100644 index 00000000..e3470cf3 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; +using UnityMcpBridge.Editor.Helpers; // For Response class + +namespace UnityMcpBridge.Editor.Tools +{ + /// + /// Handles reading and clearing Unity Editor console log entries. + /// Uses reflection to access internal LogEntry methods/properties. + /// + public static class ReadConsole + { + // Reflection members for accessing internal LogEntry data + // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection + private static MethodInfo _startGettingEntriesMethod; + private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... + private static MethodInfo _clearMethod; + private static MethodInfo _getCountMethod; + private static MethodInfo _getEntryMethod; + private static FieldInfo _modeField; + private static FieldInfo _messageField; + private static FieldInfo _fileField; + private static FieldInfo _lineField; + private static FieldInfo _instanceIdField; + + // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? + + // Static constructor for reflection setup + static ReadConsole() + { + try + { + Type logEntriesType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntries" + ); + if (logEntriesType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntries"); + + // Include NonPublic binding flags as internal APIs might change accessibility + BindingFlags staticFlags = + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + BindingFlags instanceFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + _startGettingEntriesMethod = logEntriesType.GetMethod( + "StartGettingEntries", + staticFlags + ); + if (_startGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); + + // Try reflecting EndGettingEntries based on warning message + _endGettingEntriesMethod = logEntriesType.GetMethod( + "EndGettingEntries", + staticFlags + ); + if (_endGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); + + _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); + if (_clearMethod == null) + throw new Exception("Failed to reflect LogEntries.Clear"); + + _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); + if (_getCountMethod == null) + throw new Exception("Failed to reflect LogEntries.GetCount"); + + _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); + if (_getEntryMethod == null) + throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); + + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntry"); + + _modeField = logEntryType.GetField("mode", instanceFlags); + if (_modeField == null) + throw new Exception("Failed to reflect LogEntry.mode"); + + _messageField = logEntryType.GetField("message", instanceFlags); + if (_messageField == null) + throw new Exception("Failed to reflect LogEntry.message"); + + _fileField = logEntryType.GetField("file", instanceFlags); + if (_fileField == null) + throw new Exception("Failed to reflect LogEntry.file"); + + _lineField = logEntryType.GetField("line", instanceFlags); + if (_lineField == null) + throw new Exception("Failed to reflect LogEntry.line"); + + _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); + if (_instanceIdField == null) + throw new Exception("Failed to reflect LogEntry.instanceID"); + } + catch (Exception e) + { + Debug.LogError( + $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" + ); + // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. + _startGettingEntriesMethod = + _endGettingEntriesMethod = + _clearMethod = + _getCountMethod = + _getEntryMethod = + null; + _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; + } + } + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + // Check if ALL required reflection members were successfully initialized. + if ( + _startGettingEntriesMethod == null + || _endGettingEntriesMethod == null + || _clearMethod == null + || _getCountMethod == null + || _getEntryMethod == null + || _modeField == null + || _messageField == null + || _fileField == null + || _lineField == null + || _instanceIdField == null + ) + { + // Log the error here as well for easier debugging in Unity Console + Debug.LogError( + "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." + ); + return Response.Error( + "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." + ); + } + + string action = @params["action"]?.ToString().ToLower() ?? "get"; + + try + { + if (action == "clear") + { + return ClearConsole(); + } + else if (action == "get") + { + // Extract parameters for 'get' + var types = + (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() + ?? new List { "error", "warning", "log" }; + int? count = @params["count"]?.ToObject(); + string filterText = @params["filterText"]?.ToString(); + string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering + string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); + bool includeStacktrace = + @params["includeStacktrace"]?.ToObject() ?? true; + + if (types.Contains("all")) + { + types = new List { "error", "warning", "log" }; // Expand 'all' + } + + if (!string.IsNullOrEmpty(sinceTimestampStr)) + { + Debug.LogWarning( + "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." + ); + // Need a way to get timestamp per log entry. + } + + return GetConsoleEntries(types, count, filterText, format, includeStacktrace); + } + else + { + return Response.Error( + $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object ClearConsole() + { + try + { + _clearMethod.Invoke(null, null); // Static method, no instance, no parameters + return Response.Success("Console cleared successfully."); + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); + return Response.Error($"Failed to clear console: {e.Message}"); + } + } + + private static object GetConsoleEntries( + List types, + int? count, + string filterText, + string format, + bool includeStacktrace + ) + { + List formattedEntries = new List(); + int retrievedCount = 0; + + try + { + // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal + _startGettingEntriesMethod.Invoke(null, null); + + int totalEntries = (int)_getCountMethod.Invoke(null, null); + // Create instance to pass to GetEntryInternal - Ensure the type is correct + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception( + "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." + ); + object logEntryInstance = Activator.CreateInstance(logEntryType); + + for (int i = 0; i < totalEntries; i++) + { + // Get the entry data into our instance using reflection + _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); + + // Extract data using reflection + int mode = (int)_modeField.GetValue(logEntryInstance); + string message = (string)_messageField.GetValue(logEntryInstance); + string file = (string)_fileField.GetValue(logEntryInstance); + + int line = (int)_lineField.GetValue(logEntryInstance); + // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); + + if (string.IsNullOrEmpty(message)) + continue; // Skip empty messages + + // --- Filtering --- + // Filter by type + LogType currentType = GetLogTypeFromMode(mode); + if (!types.Contains(currentType.ToString().ToLowerInvariant())) + { + continue; + } + + // Filter by text (case-insensitive) + if ( + !string.IsNullOrEmpty(filterText) + && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 + ) + { + continue; + } + + // TODO: Filter by timestamp (requires timestamp data) + + // --- Formatting --- + string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; + // Get first line if stack is present and requested, otherwise use full message + string messageOnly = + (includeStacktrace && !string.IsNullOrEmpty(stackTrace)) + ? message.Split( + new[] { '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries + )[0] + : message; + + object formattedEntry = null; + switch (format) + { + case "plain": + formattedEntry = messageOnly; + break; + case "json": + case "detailed": // Treat detailed as json for structured return + default: + formattedEntry = new + { + type = currentType.ToString(), + message = messageOnly, + file = file, + line = line, + // timestamp = "", // TODO + stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found + }; + break; + } + + formattedEntries.Add(formattedEntry); + retrievedCount++; + + // Apply count limit (after filtering) + if (count.HasValue && retrievedCount >= count.Value) + { + break; + } + } + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); + // Ensure EndGettingEntries is called even if there's an error during iteration + try + { + _endGettingEntriesMethod.Invoke(null, null); + } + catch + { /* Ignore nested exception */ + } + return Response.Error($"Error retrieving log entries: {e.Message}"); + } + finally + { + // Ensure we always call EndGettingEntries + try + { + _endGettingEntriesMethod.Invoke(null, null); + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); + // Don't return error here as we might have valid data, but log it. + } + } + + // Return the filtered and formatted list (might be empty) + return Response.Success( + $"Retrieved {formattedEntries.Count} log entries.", + formattedEntries + ); + } + + // --- Internal Helpers --- + + // Mapping from LogEntry.mode bits to LogType enum + // Based on decompiled UnityEditor code or common patterns. Precise bits might change between Unity versions. + // See comments below for LogEntry mode bits exploration. + // Note: This mapping is simplified and might not cover all edge cases or future Unity versions perfectly. + private const int ModeBitError = 1 << 0; + private const int ModeBitAssert = 1 << 1; + private const int ModeBitWarning = 1 << 2; + private const int ModeBitLog = 1 << 3; + private const int ModeBitException = 1 << 4; // Often combined with Error bits + private const int ModeBitScriptingError = 1 << 9; + private const int ModeBitScriptingWarning = 1 << 10; + private const int ModeBitScriptingLog = 1 << 11; + private const int ModeBitScriptingException = 1 << 18; + private const int ModeBitScriptingAssertion = 1 << 22; + + private static LogType GetLogTypeFromMode(int mode) + { + // First, determine the type based on the original logic (most severe first) + LogType initialType; + if ( + ( + mode + & ( + ModeBitError + | ModeBitScriptingError + | ModeBitException + | ModeBitScriptingException + ) + ) != 0 + ) + { + initialType = LogType.Error; + } + else if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) + { + initialType = LogType.Assert; + } + else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) + { + initialType = LogType.Warning; + } + else + { + initialType = LogType.Log; + } + + // Apply the observed "one level lower" correction + switch (initialType) + { + case LogType.Error: + return LogType.Warning; // Error becomes Warning + case LogType.Warning: + return LogType.Log; // Warning becomes Log + case LogType.Assert: + return LogType.Assert; // Assert remains Assert (no lower level defined) + case LogType.Log: + return LogType.Log; // Log remains Log + default: + return LogType.Log; // Default fallback + } + } + + /// + /// Attempts to extract the stack trace part from a log message. + /// Unity log messages often have the stack trace appended after the main message, + /// starting on a new line and typically indented or beginning with "at ". + /// + /// The complete log message including potential stack trace. + /// The extracted stack trace string, or null if none is found. + private static string ExtractStackTrace(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) + return null; + + // Split into lines, removing empty ones to handle different line endings gracefully. + // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. + string[] lines = fullMessage.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries + ); + + // If there's only one line or less, there's no separate stack trace. + if (lines.Length <= 1) + return null; + + int stackStartIndex = -1; + + // Start checking from the second line onwards. + for (int i = 1; i < lines.Length; ++i) + { + // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. + string trimmedLine = lines[i].TrimStart(); + + // Check for common stack trace patterns. + if ( + trimmedLine.StartsWith("at ") + || trimmedLine.StartsWith("UnityEngine.") + || trimmedLine.StartsWith("UnityEditor.") + || trimmedLine.Contains("(at ") + || // Covers "(at Assets/..." pattern + // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) + ( + trimmedLine.Length > 0 + && char.IsUpper(trimmedLine[0]) + && trimmedLine.Contains('.') + ) + ) + { + stackStartIndex = i; + break; // Found the likely start of the stack trace + } + } + + // If a potential start index was found... + if (stackStartIndex > 0) + { + // Join the lines from the stack start index onwards using standard newline characters. + // This reconstructs the stack trace part of the message. + return string.Join("\n", lines.Skip(stackStartIndex)); + } + + // No clear stack trace found based on the patterns. + return null; + } + + /* LogEntry.mode bits exploration (based on Unity decompilation/observation): + May change between versions. + + Basic Types: + kError = 1 << 0 (1) + kAssert = 1 << 1 (2) + kWarning = 1 << 2 (4) + kLog = 1 << 3 (8) + kFatal = 1 << 4 (16) - Often treated as Exception/Error + + Modifiers/Context: + kAssetImportError = 1 << 7 (128) + kAssetImportWarning = 1 << 8 (256) + kScriptingError = 1 << 9 (512) + kScriptingWarning = 1 << 10 (1024) + kScriptingLog = 1 << 11 (2048) + kScriptCompileError = 1 << 12 (4096) + kScriptCompileWarning = 1 << 13 (8192) + kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play + kMayIgnoreLineNumber = 1 << 15 (32768) + kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button + kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) + kScriptingException = 1 << 18 (262144) + kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI + kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior + kGraphCompileError = 1 << 21 (2097152) + kScriptingAssertion = 1 << 22 (4194304) + kVisualScriptingError = 1 << 23 (8388608) + + Example observed values: + Log: 2048 (ScriptingLog) or 8 (Log) + Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) + Error: 513 (ScriptingError | Error) or 1 (Error) + Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination + Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) + */ + } +} + diff --git a/Editor/Tools/ReadConsole.cs.meta b/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta similarity index 100% rename from Editor/Tools/ReadConsole.cs.meta rename to UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta diff --git a/Editor/UnityMCPBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs similarity index 80% rename from Editor/UnityMCPBridge.cs rename to UnityMcpBridge/Editor/UnityMcpBridge.cs index 33be6aa3..705fa337 100644 --- a/Editor/UnityMCPBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -1,27 +1,30 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; -using UnityEditor; -using UnityEngine; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.IO; -using UnityMCP.Editor.Models; -using UnityMCP.Editor.Tools; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Models; +using UnityMcpBridge.Editor.Tools; -namespace UnityMCP.Editor +namespace UnityMcpBridge.Editor { [InitializeOnLoad] - public static partial class UnityMCPBridge + public static partial class UnityMcpBridge { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); - private static Dictionary tcs)> commandQueue = new(); - private static readonly int unityPort = 6400; // Hardcoded port + private static Dictionary< + string, + (string commandJson, TaskCompletionSource tcs) + > commandQueue = new(); + private static readonly int unityPort = 6400; // Hardcoded port public static bool IsRunning => isRunning; @@ -33,11 +36,14 @@ public static bool FolderExists(string path) if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) return true; - string fullPath = Path.Combine(Application.dataPath, path.StartsWith("Assets/") ? path.Substring(7) : path); + string fullPath = Path.Combine( + Application.dataPath, + path.StartsWith("Assets/") ? path.Substring(7) : path + ); return Directory.Exists(fullPath); } - static UnityMCPBridge() + static UnityMcpBridge() { Start(); EditorApplication.quitting += Stop; @@ -45,7 +51,8 @@ static UnityMCPBridge() public static void Start() { - if (isRunning) return; + if (isRunning) + return; isRunning = true; listener = new TcpListener(IPAddress.Loopback, unityPort); listener.Start(); @@ -56,7 +63,8 @@ public static void Start() public static void Stop() { - if (!isRunning) return; + if (!isRunning) + return; isRunning = false; listener.Stop(); EditorApplication.update -= ProcessCommands; @@ -71,7 +79,11 @@ private static async Task ListenerLoop() { var client = await listener.AcceptTcpClientAsync(); // Enable basic socket keepalive - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + client.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.KeepAlive, + true + ); // Set longer receive timeout to prevent quick disconnections client.ReceiveTimeout = 60000; // 60 seconds @@ -81,7 +93,8 @@ private static async Task ListenerLoop() } catch (Exception ex) { - if (isRunning) Debug.LogError($"Listener error: {ex.Message}"); + if (isRunning) + Debug.LogError($"Listener error: {ex.Message}"); } } } @@ -97,9 +110,14 @@ private static async Task HandleClientAsync(TcpClient client) try { int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) break; // Client disconnected - - string commandText = System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead); + if (bytesRead == 0) + break; // Client disconnected + + string commandText = System.Text.Encoding.UTF8.GetString( + buffer, + 0, + bytesRead + ); string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource(); @@ -107,7 +125,9 @@ private static async Task HandleClientAsync(TcpClient client) if (commandText.Trim() == "ping") { // Direct response to ping without going through JSON parsing - byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes("{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"); + byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( + "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" + ); await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); continue; } @@ -149,7 +169,7 @@ private static void ProcessCommands() var emptyResponse = new { status = "error", - error = "Empty command received" + error = "Empty command received", }; tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); processedIds.Add(id); @@ -165,7 +185,7 @@ private static void ProcessCommands() var pingResponse = new { status = "success", - result = new { message = "pong" } + result = new { message = "pong" }, }; tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); processedIds.Add(id); @@ -179,7 +199,9 @@ private static void ProcessCommands() { status = "error", error = "Invalid JSON format", - receivedText = commandText.Length > 50 ? commandText.Substring(0, 50) + "..." : commandText + receivedText = commandText.Length > 50 + ? commandText.Substring(0, 50) + "..." + : commandText, }; tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); processedIds.Add(id); @@ -194,7 +216,7 @@ private static void ProcessCommands() { status = "error", error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object" + details = "The command was valid JSON but could not be deserialized to a Command object", }; tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); } @@ -213,7 +235,9 @@ private static void ProcessCommands() status = "error", error = ex.Message, commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 ? commandText.Substring(0, 50) + "..." : commandText + receivedText = commandText?.Length > 50 + ? commandText.Substring(0, 50) + "..." + : commandText, }; string responseJson = JsonConvert.SerializeObject(response); tcs.SetResult(responseJson); @@ -236,8 +260,11 @@ private static bool IsValidJson(string text) return false; text = text.Trim(); - if ((text.StartsWith("{") && text.EndsWith("}")) || // Object - (text.StartsWith("[") && text.EndsWith("]"))) // Array + if ( + (text.StartsWith("{") && text.EndsWith("}")) + || // Object + (text.StartsWith("[") && text.EndsWith("]")) + ) // Array { try { @@ -263,7 +290,7 @@ private static string ExecuteCommand(Command command) { status = "error", error = "Command type cannot be empty", - details = "A valid command type is required for processing" + details = "A valid command type is required for processing", }; return JsonConvert.SerializeObject(errorResponse); } @@ -271,7 +298,11 @@ private static string ExecuteCommand(Command command) // Handle ping command for connection verification if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) { - var pingResponse = new { status = "success", result = new { message = "pong" } }; + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; return JsonConvert.SerializeObject(pingResponse); } @@ -290,7 +321,9 @@ private static string ExecuteCommand(Command command) "manage_asset" => ManageAsset.HandleCommand(paramsObject), "read_console" => ReadConsole.HandleCommand(paramsObject), "execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), - _ => throw new ArgumentException($"Unknown or unsupported command type: {command.type}") + _ => throw new ArgumentException( + $"Unknown or unsupported command type: {command.type}" + ), }; // Standard success response format @@ -300,7 +333,9 @@ private static string ExecuteCommand(Command command) catch (Exception ex) { // Log the detailed error in Unity for debugging - Debug.LogError($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"); + Debug.LogError( + $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}" + ); // Standard error response format var response = new @@ -309,7 +344,9 @@ private static string ExecuteCommand(Command command) error = ex.Message, // Provide the specific error message command = command?.type ?? "Unknown", // Include the command type if available stackTrace = ex.StackTrace, // Include stack trace for detailed debugging - paramsSummary = command?.@params != null ? GetParamsSummary(command.@params) : "No parameters" // Summarize parameters for context + paramsSummary = command?.@params != null + ? GetParamsSummary(command.@params) + : "No parameters", // Summarize parameters for context }; return JsonConvert.SerializeObject(response); } @@ -323,7 +360,14 @@ private static string GetParamsSummary(JObject @params) if (@params == null || !@params.HasValues) return "No parameters"; - return string.Join(", ", @params.Properties().Select(p => $"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}")); + return string.Join( + ", ", + @params + .Properties() + .Select(p => + $"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}" + ) + ); } catch { diff --git a/Editor/UnityMCPBridge.cs.meta b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta similarity index 100% rename from Editor/UnityMCPBridge.cs.meta rename to UnityMcpBridge/Editor/UnityMcpBridge.cs.meta diff --git a/Editor/Windows.meta b/UnityMcpBridge/Editor/Windows.meta similarity index 100% rename from Editor/Windows.meta rename to UnityMcpBridge/Editor/Windows.meta diff --git a/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs similarity index 60% rename from Editor/Windows/ManualConfigEditorWindow.cs rename to UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index dd1ca059..17c93e0b 100644 --- a/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -1,9 +1,9 @@ -using UnityEngine; -using UnityEditor; using System.Runtime.InteropServices; -using UnityMCP.Editor.Models; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Models; -namespace UnityMCP.Editor.Windows +namespace UnityMcpBridge.Editor.Windows { // Editor window to display manual configuration instructions public class ManualConfigEditorWindow : EditorWindow @@ -33,36 +33,65 @@ private void OnGUI() // Header with improved styling EditorGUILayout.Space(10); Rect titleRect = EditorGUILayout.GetControlRect(false, 30); - EditorGUI.DrawRect(new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), new Color(0.2f, 0.2f, 0.2f, 0.1f)); - GUI.Label(new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - mcpClient.name + " Manual Configuration", EditorStyles.boldLabel); + EditorGUI.DrawRect( + new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), + new Color(0.2f, 0.2f, 0.2f, 0.1f) + ); + GUI.Label( + new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), + mcpClient.name + " Manual Configuration", + EditorStyles.boldLabel + ); EditorGUILayout.Space(10); // Instructions with improved styling EditorGUILayout.BeginVertical(EditorStyles.helpBox); Rect headerRect = EditorGUILayout.GetControlRect(false, 24); - EditorGUI.DrawRect(new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), new Color(0.1f, 0.1f, 0.1f, 0.2f)); - GUI.Label(new Rect(headerRect.x + 8, headerRect.y + 4, headerRect.width - 16, headerRect.height), - "The automatic configuration failed. Please follow these steps:", EditorStyles.boldLabel); + EditorGUI.DrawRect( + new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), + new Color(0.1f, 0.1f, 0.1f, 0.2f) + ); + GUI.Label( + new Rect( + headerRect.x + 8, + headerRect.y + 4, + headerRect.width - 16, + headerRect.height + ), + "The automatic configuration failed. Please follow these steps:", + EditorStyles.boldLabel + ); EditorGUILayout.Space(10); GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) { - margin = new RectOffset(10, 10, 5, 5) + margin = new RectOffset(10, 10, 5, 5), }; - EditorGUILayout.LabelField("1. Open " + mcpClient.name + " config file by either:", instructionStyle); + EditorGUILayout.LabelField( + "1. Open " + mcpClient.name + " config file by either:", + instructionStyle + ); if (mcpClient.mcpType == McpTypes.ClaudeDesktop) { - EditorGUILayout.LabelField(" a) Going to Settings > Developer > Edit Config", instructionStyle); + EditorGUILayout.LabelField( + " a) Going to Settings > Developer > Edit Config", + instructionStyle + ); } else if (mcpClient.mcpType == McpTypes.Cursor) { - EditorGUILayout.LabelField(" a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", instructionStyle); + EditorGUILayout.LabelField( + " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", + instructionStyle + ); } EditorGUILayout.LabelField(" OR", instructionStyle); - EditorGUILayout.LabelField(" b) Opening the configuration file at:", instructionStyle); + EditorGUILayout.LabelField( + " b) Opening the configuration file at:", + instructionStyle + ); // Path section with improved styling EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -71,7 +100,10 @@ private void OnGUI() { displayPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { displayPath = mcpClient.linuxConfigPath; } @@ -81,12 +113,13 @@ private void OnGUI() } // Prevent text overflow by allowing the text field to wrap - GUIStyle pathStyle = new(EditorStyles.textField) - { - wordWrap = true - }; + GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; - EditorGUILayout.TextField(displayPath, pathStyle, GUILayout.Height(EditorGUIUtility.singleLineHeight)); + EditorGUILayout.TextField( + displayPath, + pathStyle, + GUILayout.Height(EditorGUIUtility.singleLineHeight) + ); // Copy button with improved styling EditorGUILayout.BeginHorizontal(); @@ -94,24 +127,40 @@ private void OnGUI() GUIStyle copyButtonStyle = new(GUI.skin.button) { padding = new RectOffset(15, 15, 5, 5), - margin = new RectOffset(10, 10, 5, 5) + margin = new RectOffset(10, 10, 5, 5), }; - if (GUILayout.Button("Copy Path", copyButtonStyle, GUILayout.Height(25), GUILayout.Width(100))) + 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))) + 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 - }); + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = displayPath, + UseShellExecute = true, + } + ); } if (pathCopied) @@ -126,16 +175,19 @@ private void OnGUI() EditorGUILayout.Space(10); - EditorGUILayout.LabelField("2. Paste the following JSON configuration:", instructionStyle); + EditorGUILayout.LabelField( + "2. Paste the following JSON configuration:", + instructionStyle + ); - // JSON section with improved styling + // 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 + wordWrap = true, }; jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue @@ -146,7 +198,14 @@ private void OnGUI() EditorGUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); - if (GUILayout.Button("Copy JSON", copyButtonStyle, GUILayout.Height(25), GUILayout.Width(100))) + if ( + GUILayout.Button( + "Copy JSON", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) { EditorGUIUtility.systemCopyBuffer = configJson; jsonCopied = true; @@ -164,7 +223,10 @@ private void OnGUI() EditorGUILayout.EndVertical(); EditorGUILayout.Space(10); - EditorGUILayout.LabelField("3. Save the file and restart " + mcpClient.name, instructionStyle); + EditorGUILayout.LabelField( + "3. Save the file and restart " + mcpClient.name, + instructionStyle + ); EditorGUILayout.EndVertical(); diff --git a/Editor/Windows/ManualConfigEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta similarity index 100% rename from Editor/Windows/ManualConfigEditorWindow.cs.meta rename to UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta diff --git a/Editor/Windows/UnityMCPEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta similarity index 100% rename from Editor/Windows/UnityMCPEditorWindow.cs.meta rename to UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta diff --git a/Editor/Windows/UnityMCPEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs similarity index 75% rename from Editor/Windows/UnityMCPEditorWindow.cs rename to UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 999761a9..2c334e39 100644 --- a/Editor/Windows/UnityMCPEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1,21 +1,19 @@ -using UnityEngine; -using UnityEditor; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; using System; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.IO; using System.Net.Sockets; -using System.Threading.Tasks; +using System.Runtime.InteropServices; using System.Text; -using System.Collections.Generic; -using System.Linq; -using UnityMCP.Editor.Models; -using UnityMCP.Editor.Data; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; +using UnityMcpBridge.Editor.Data; +using UnityMcpBridge.Editor.Models; -namespace UnityMCP.Editor.Windows +namespace UnityMcpBridge.Editor.Windows { - public class UnityMCPEditorWindow : EditorWindow + public class UnityMcpEditorWindow : EditorWindow { private bool isUnityBridgeRunning = false; private Vector2 scrollPosition; @@ -23,30 +21,59 @@ public class UnityMCPEditorWindow : EditorWindow private string cursorConfigStatus = "Not configured"; private string pythonServerStatus = "Not Connected"; private Color pythonServerStatusColor = Color.red; - private const int unityPort = 6400; // Hardcoded Unity port - private const int mcpPort = 6500; // Hardcoded MCP port + private const int unityPort = 6400; // Hardcoded Unity port + private const int mcpPort = 6500; // Hardcoded MCP port private const float CONNECTION_CHECK_INTERVAL = 2f; // Check every 2 seconds private float lastCheckTime = 0f; private McpClients mcpClients = new(); private List possiblePaths = new() { - Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python", "server.py")), - Path.GetFullPath(Path.Combine(Application.dataPath, "Packages", "com.justinpbarnett.unity-mcp", "Python", "server.py")), - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library", "PackageCache", "com.justinpbarnett.unity-mcp@*", "Python", "server.py")), - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "com.justinpbarnett.unity-mcp", "Python", "server.py")) + Path.GetFullPath( + Path.Combine(Application.dataPath, "unity-mcp", "Python", "server.py") + ), + Path.GetFullPath( + Path.Combine( + Application.dataPath, + "Packages", + "com.justinpbarnett.unity-mcp", + "Python", + "server.py" + ) + ), + Path.GetFullPath( + Path.Combine( + Application.dataPath, + "..", + "Library", + "PackageCache", + "com.justinpbarnett.unity-mcp@*", + "Python", + "server.py" + ) + ), + Path.GetFullPath( + Path.Combine( + Application.dataPath, + "..", + "Packages", + "com.justinpbarnett.unity-mcp", + "Python", + "server.py" + ) + ), }; [MenuItem("Window/Unity MCP")] public static void ShowWindow() { - GetWindow("MCP Editor"); + GetWindow("MCP Editor"); } private void OnEnable() { // Check initial states - isUnityBridgeRunning = UnityMCPBridge.IsRunning; + isUnityBridgeRunning = UnityMcpBridge.IsRunning; CheckPythonServerConnection(); foreach (McpClient mcpClient in mcpClients.clients) { @@ -107,7 +134,9 @@ private async void CheckPythonServerConnection() { // Received response but not the expected one pythonServerStatus = "Invalid Server"; - pythonServerStatusColor = GetStatusColor(McpStatus.CommunicationError); + pythonServerStatusColor = GetStatusColor( + McpStatus.CommunicationError + ); } } else @@ -115,7 +144,9 @@ private async void CheckPythonServerConnection() // No response received pythonServerStatus = "No Response"; pythonServerStatusColor = GetStatusColor(McpStatus.NoResponse); - UnityEngine.Debug.LogWarning($"Python server not responding on port {unityPort}"); + UnityEngine.Debug.LogWarning( + $"Python server not responding on port {unityPort}" + ); } } catch (Exception e) @@ -123,7 +154,9 @@ private async void CheckPythonServerConnection() // Connection established but communication failed pythonServerStatus = "Communication Error"; pythonServerStatusColor = GetStatusColor(McpStatus.CommunicationError); - UnityEngine.Debug.LogWarning($"Error communicating with Python server: {e.Message}"); + UnityEngine.Debug.LogWarning( + $"Error communicating with Python server: {e.Message}" + ); } } else @@ -131,7 +164,9 @@ private async void CheckPythonServerConnection() // Connection failed pythonServerStatus = "Not Connected"; pythonServerStatusColor = GetStatusColor(McpStatus.NotConfigured); - UnityEngine.Debug.LogWarning($"Python server is not running or not accessible on port {unityPort}"); + UnityEngine.Debug.LogWarning( + $"Python server is not running or not accessible on port {unityPort}" + ); } client.Close(); } @@ -155,7 +190,7 @@ private Color GetStatusColor(McpStatus status) McpStatus.IncorrectPath => Color.yellow, McpStatus.CommunicationError => Color.yellow, McpStatus.NoResponse => Color.yellow, - _ => Color.red // Default to red for error states or not configured + _ => Color.red, // Default to red for error states or not configured }; } @@ -178,8 +213,16 @@ private void ConfigurationSection(McpClient mcpClient) // Header with improved styling EditorGUILayout.Space(5); Rect headerRect = EditorGUILayout.GetControlRect(false, 24); - GUI.Label(new Rect(headerRect.x + 8, headerRect.y + 4, headerRect.width - 16, headerRect.height), - mcpClient.name + " Configuration", EditorStyles.boldLabel); + GUI.Label( + new Rect( + headerRect.x + 8, + headerRect.y + 4, + headerRect.width - 16, + headerRect.height + ), + mcpClient.name + " Configuration", + EditorStyles.boldLabel + ); EditorGUILayout.Space(5); // Status indicator with colored dot @@ -190,7 +233,11 @@ private void ConfigurationSection(McpClient mcpClient) DrawStatusDot(statusRect, statusColor); // Status text with some padding - EditorGUILayout.LabelField(new GUIContent(" " + mcpClient.configStatus), GUILayout.Height(20), GUILayout.MinWidth(100)); + EditorGUILayout.LabelField( + new GUIContent(" " + mcpClient.configStatus), + GUILayout.Height(20), + GUILayout.MinWidth(100) + ); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); @@ -203,7 +250,13 @@ private void ConfigurationSection(McpClient mcpClient) // Create muted button style for Manual Setup GUIStyle mutedButtonStyle = new(buttonStyle); - if (GUILayout.Button($"Auto Configure {mcpClient.name}", buttonStyle, GUILayout.Height(28))) + if ( + GUILayout.Button( + $"Auto Configure {mcpClient.name}", + buttonStyle, + GUILayout.Height(28) + ) + ) { ConfigureMcpClient(mcpClient); } @@ -227,7 +280,10 @@ private void ConfigurationSection(McpClient mcpClient) EditorGUILayout.Space(5); } // Add space and end the horizontal layout if last item is odd - else if (useHalfWidth && mcpClients.clients.IndexOf(mcpClient) == mcpClients.clients.Count - 1) + else if ( + useHalfWidth + && mcpClients.clients.IndexOf(mcpClient) == mcpClients.clients.Count - 1 + ) { EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(5); @@ -245,7 +301,11 @@ private void DrawStatusDot(Rect statusRect, Color statusColor) Handles.DrawSolidDisc(center, Vector3.forward, radius); // Draw the border - Color borderColor = new(statusColor.r * 0.7f, statusColor.g * 0.7f, statusColor.b * 0.7f); + Color borderColor = new( + statusColor.r * 0.7f, + statusColor.g * 0.7f, + statusColor.b * 0.7f + ); Handles.color = borderColor; Handles.DrawWireDisc(center, Vector3.forward, radius); } @@ -257,9 +317,15 @@ private void OnGUI() EditorGUILayout.Space(10); // Title with improved styling Rect titleRect = EditorGUILayout.GetControlRect(false, 30); - EditorGUI.DrawRect(new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), new Color(0.2f, 0.2f, 0.2f, 0.1f)); - GUI.Label(new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), - "MCP Editor", EditorStyles.boldLabel); + EditorGUI.DrawRect( + new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), + new Color(0.2f, 0.2f, 0.2f, 0.1f) + ); + GUI.Label( + new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), + "MCP Editor", + EditorStyles.boldLabel + ); EditorGUILayout.Space(10); // Python Server Status Section @@ -274,7 +340,10 @@ private void OnGUI() EditorGUILayout.LabelField($"Unity Port: {unityPort}"); EditorGUILayout.LabelField($"MCP Port: {mcpPort}"); - EditorGUILayout.HelpBox("Your MCP client (e.g. Cursor or Claude Desktop) will start the server automatically when you start it.", MessageType.Info); + EditorGUILayout.HelpBox( + "Your MCP client (e.g. Cursor or Claude Desktop) will start the server automatically when you start it.", + MessageType.Info + ); EditorGUILayout.EndVertical(); EditorGUILayout.Space(10); @@ -304,23 +373,23 @@ private void ToggleUnityBridge() { if (isUnityBridgeRunning) { - UnityMCPBridge.Stop(); + UnityMcpBridge.Stop(); } else { - UnityMCPBridge.Start(); + UnityMcpBridge.Start(); } isUnityBridgeRunning = !isUnityBridgeRunning; } - private string GetPythonDirectory(List possiblePaths) { foreach (var path in possiblePaths) { // Skip wildcard paths for now - if (path.Contains("*")) continue; + if (path.Contains("*")) + continue; if (File.Exists(path)) { @@ -330,7 +399,8 @@ private string GetPythonDirectory(List possiblePaths) foreach (var path in possiblePaths) { - if (!path.Contains("*")) continue; + if (!path.Contains("*")) + continue; string directoryPath = Path.GetDirectoryName(path); string searchPattern = Path.GetFileName(Path.GetDirectoryName(path)); @@ -358,22 +428,13 @@ private string GetPythonDirectory(List possiblePaths) private string WriteToConfig(string pythonDir, string configPath) { // Create configuration object for unityMCP - var unityMCPConfig = new MCPConfigServer + var unityMCPConfig = new McpConfigServer { command = "uv", - args = new[] - { - "--directory", - pythonDir, - "run", - "server.py" - } + args = new[] { "--directory", pythonDir, "run", "server.py" }, }; - var jsonSettings = new JsonSerializerSettings - { - Formatting = Formatting.Indented - }; + var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented }; // Read existing config if it exists string existingJson = "{}"; @@ -403,9 +464,10 @@ private string WriteToConfig(string pythonDir, string configPath) } // Add/update unityMCP while preserving other servers - existingConfig.mcpServers.unityMCP = JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig) - ); + existingConfig.mcpServers.unityMCP = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig) + ); // Write the merged configuration back to file string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); @@ -428,28 +490,19 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient string pythonDir = FindPackagePythonDirectory(); // Create the manual configuration message - var jsonConfig = new MCPConfig + var jsonConfig = new McpConfig { - mcpServers = new MCPConfigServers + mcpServers = new McpConfigServers { - unityMCP = new MCPConfigServer + unityMCP = new McpConfigServer { command = "uv", - args = new[] - { - "--directory", - pythonDir, - "run", - "server.py" - } - } - } + args = new[] { "--directory", pythonDir, "run", "server.py" }, + }, + }, }; - var jsonSettings = new JsonSerializerSettings - { - Formatting = Formatting.Indented - }; + var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented }; string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); @@ -474,8 +527,10 @@ private string FindPackagePythonDirectory() string packagePath = package.resolvedPath; string potentialPythonDir = Path.Combine(packagePath, "Python"); - if (Directory.Exists(potentialPythonDir) && - File.Exists(Path.Combine(potentialPythonDir, "server.py"))) + if ( + Directory.Exists(potentialPythonDir) + && File.Exists(Path.Combine(potentialPythonDir, "server.py")) + ) { return potentialPythonDir; } @@ -489,8 +544,9 @@ private string FindPackagePythonDirectory() // If not found via Package Manager, try manual approaches // First check for local installation - string[] possibleDirs = { - Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python")) + string[] possibleDirs = + { + Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python")), }; foreach (var dir in possibleDirs) @@ -502,7 +558,9 @@ private string FindPackagePythonDirectory() } // If still not found, return the placeholder path - UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); + UnityEngine.Debug.LogWarning( + "Could not find Python directory, using placeholder path" + ); } catch (Exception e) { @@ -523,7 +581,10 @@ private string ConfigureMcpClient(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { configPath = mcpClient.linuxConfigPath; } @@ -562,19 +623,26 @@ private string ConfigureMcpClient(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { configPath = mcpClient.linuxConfigPath; } ShowManualInstructionsWindow(configPath, mcpClient); - UnityEngine.Debug.LogError($"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}"); + UnityEngine.Debug.LogError( + $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" + ); return $"Failed to configure {mcpClient.name}"; } } - - private void ShowCursorManualConfigurationInstructions(string configPath, McpClient mcpClient) + private void ShowCursorManualConfigurationInstructions( + string configPath, + McpClient mcpClient + ) { mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); @@ -582,28 +650,19 @@ private void ShowCursorManualConfigurationInstructions(string configPath, McpCli string pythonDir = FindPackagePythonDirectory(); // Create the manual configuration message - var jsonConfig = new MCPConfig + var jsonConfig = new McpConfig { - mcpServers = new MCPConfigServers + mcpServers = new McpConfigServers { - unityMCP = new MCPConfigServer + unityMCP = new McpConfigServer { command = "uv", - args = new[] - { - "--directory", - pythonDir, - "run", - "server.py" - } - } - } + args = new[] { "--directory", pythonDir, "run", "server.py" }, + }, + }, }; - var jsonSettings = new JsonSerializerSettings - { - Formatting = Formatting.Indented - }; + var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented }; string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); @@ -618,7 +677,10 @@ private void CheckMcpConfiguration(McpClient mcpClient) { configPath = mcpClient.windowsConfigPath; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) { configPath = mcpClient.linuxConfigPath; } @@ -635,12 +697,18 @@ private void CheckMcpConfiguration(McpClient mcpClient) } string configJson = File.ReadAllText(configPath); - var config = JsonConvert.DeserializeObject(configJson); + var config = JsonConvert.DeserializeObject(configJson); if (config?.mcpServers?.unityMCP != null) { string pythonDir = GetPythonDirectory(possiblePaths); - if (pythonDir != null && Array.Exists(config.mcpServers.unityMCP.args, arg => arg.Contains(pythonDir, StringComparison.Ordinal))) + if ( + pythonDir != null + && Array.Exists( + config.mcpServers.unityMCP.args, + arg => arg.Contains(pythonDir, StringComparison.Ordinal) + ) + ) { mcpClient.SetStatus(McpStatus.Configured); } @@ -660,4 +728,4 @@ private void CheckMcpConfiguration(McpClient mcpClient) } } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json new file mode 100644 index 00000000..d1ee0081 --- /dev/null +++ b/UnityMcpBridge/package.json @@ -0,0 +1,10 @@ +{ + "name": "com.justinpbarnett.unity-mcp", + "version": "2.0.0", + "displayName": "Unity MCP Bridge", + "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", + "unity": "2020.3", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.0.2" + } +} diff --git a/Python/.python-version b/UnityMcpServer/src/.python-version similarity index 100% rename from Python/.python-version rename to UnityMcpServer/src/.python-version diff --git a/Python/__init__.py b/UnityMcpServer/src/__init__.py similarity index 100% rename from Python/__init__.py rename to UnityMcpServer/src/__init__.py diff --git a/Python/config.py b/UnityMcpServer/src/config.py similarity index 100% rename from Python/config.py rename to UnityMcpServer/src/config.py diff --git a/Python/pyproject.toml b/UnityMcpServer/src/pyproject.toml similarity index 90% rename from Python/pyproject.toml rename to UnityMcpServer/src/pyproject.toml index abea75c8..40af94cb 100644 --- a/Python/pyproject.toml +++ b/UnityMcpServer/src/pyproject.toml @@ -1,6 +1,6 @@ [project] -name = "unity-mcp" -version = "1.0.1" +name = "unity-mcp-server" +version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.12" diff --git a/Python/server.py b/UnityMcpServer/src/server.py similarity index 92% rename from Python/server.py rename to UnityMcpServer/src/server.py index fc11d99e..90d0c721 100644 --- a/Python/server.py +++ b/UnityMcpServer/src/server.py @@ -12,7 +12,7 @@ level=getattr(logging, config.log_level), format=config.log_format ) -logger = logging.getLogger("UnityMCP") +logger = logging.getLogger("unity-mcp-server") # Global connection state _unity_connection: UnityConnection = None @@ -21,7 +21,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" global _unity_connection - logger.info("UnityMCP server starting up") + logger.info("Unity MCP Server starting up") try: _unity_connection = get_unity_connection() logger.info("Connected to Unity on startup") @@ -36,11 +36,11 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: if _unity_connection: _unity_connection.disconnect() _unity_connection = None - logger.info("UnityMCP server shut down") + logger.info("Unity MCP Server shut down") # Initialize MCP server mcp = FastMCP( - "UnityMCP", + "unity-mcp-server", description="Unity Editor integration via Model Context Protocol", lifespan=server_lifespan ) @@ -69,4 +69,4 @@ def asset_creation_strategy() -> str: # Run the server if __name__ == "__main__": - mcp.run(transport='stdio') \ No newline at end of file + mcp.run(transport='stdio') diff --git a/Python/tools/__init__.py b/UnityMcpServer/src/tools/__init__.py similarity index 86% rename from Python/tools/__init__.py rename to UnityMcpServer/src/tools/__init__.py index eb4a7e37..8cfc38ea 100644 --- a/Python/tools/__init__.py +++ b/UnityMcpServer/src/tools/__init__.py @@ -8,7 +8,7 @@ def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" - print("Registering UnityMCP refactored tools...") + print("Registering Unity MCP Server refactored tools...") register_manage_script_tools(mcp) register_manage_scene_tools(mcp) register_manage_editor_tools(mcp) @@ -16,4 +16,4 @@ def register_all_tools(mcp): register_manage_asset_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) - print("UnityMCP tool registration complete.") \ No newline at end of file + print("Unity MCP Server tool registration complete.") diff --git a/Python/tools/execute_menu_item.py b/UnityMcpServer/src/tools/execute_menu_item.py similarity index 100% rename from Python/tools/execute_menu_item.py rename to UnityMcpServer/src/tools/execute_menu_item.py diff --git a/Python/tools/manage_asset.py b/UnityMcpServer/src/tools/manage_asset.py similarity index 100% rename from Python/tools/manage_asset.py rename to UnityMcpServer/src/tools/manage_asset.py diff --git a/Python/tools/manage_editor.py b/UnityMcpServer/src/tools/manage_editor.py similarity index 100% rename from Python/tools/manage_editor.py rename to UnityMcpServer/src/tools/manage_editor.py diff --git a/Python/tools/manage_gameobject.py b/UnityMcpServer/src/tools/manage_gameobject.py similarity index 100% rename from Python/tools/manage_gameobject.py rename to UnityMcpServer/src/tools/manage_gameobject.py diff --git a/Python/tools/manage_scene.py b/UnityMcpServer/src/tools/manage_scene.py similarity index 100% rename from Python/tools/manage_scene.py rename to UnityMcpServer/src/tools/manage_scene.py diff --git a/Python/tools/manage_script.py b/UnityMcpServer/src/tools/manage_script.py similarity index 100% rename from Python/tools/manage_script.py rename to UnityMcpServer/src/tools/manage_script.py diff --git a/Python/tools/read_console.py b/UnityMcpServer/src/tools/read_console.py similarity index 100% rename from Python/tools/read_console.py rename to UnityMcpServer/src/tools/read_console.py diff --git a/Python/unity_connection.py b/UnityMcpServer/src/unity_connection.py similarity index 99% rename from Python/unity_connection.py rename to UnityMcpServer/src/unity_connection.py index ce30316d..252b5048 100644 --- a/Python/unity_connection.py +++ b/UnityMcpServer/src/unity_connection.py @@ -10,7 +10,7 @@ level=getattr(logging, config.log_level), format=config.log_format ) -logger = logging.getLogger("UnityMCP") +logger = logging.getLogger("unity-mcp-server") @dataclass class UnityConnection: @@ -198,4 +198,4 @@ def get_unity_connection() -> UnityConnection: except: pass _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") \ No newline at end of file + raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/Python/uv.lock b/UnityMcpServer/src/uv.lock similarity index 99% rename from Python/uv.lock rename to UnityMcpServer/src/uv.lock index 30467329..2f8a4d59 100644 --- a/Python/uv.lock +++ b/UnityMcpServer/src/uv.lock @@ -322,7 +322,7 @@ wheels = [ [[package]] name = "unity-mcp" -version = "0.1.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/package.json b/package.json deleted file mode 100644 index aa30057f..00000000 --- a/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "com.justinpbarnett.unity-mcp", - "version": "1.0.1", - "displayName": "Unity MCP", - "description": "A Unity package to communicate with a local MCP Client via a Python server.", - "unity": "2020.3", - "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.0.2" - } -} diff --git a/package.json.meta b/package.json.meta deleted file mode 100644 index 11aa47e9..00000000 --- a/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 1429c59739af3fc4d8b706950221d476 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From 75b62d371034556b93e23138deb8592ea5c76a90 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 07:22:06 -0400 Subject: [PATCH 02/27] updated gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 715f0970..a0919dfc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ CLAUDE.md # Code-copy related files -.codeignore -*codeclip* +.clipignore # Python-generated files __pycache__/ From 61a7cb9e289336267d424ef14467286a87eb27b1 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 07:22:24 -0400 Subject: [PATCH 03/27] added remote install of python server --- .../Editor/Helpers/ServerInstaller.cs | 124 ++++++++++++++++++ .../Editor/Helpers/ServerInstaller.cs.meta | 2 + UnityMcpBridge/Editor/UnityMcpBridge.cs | 76 +++++++---- 3 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/ServerInstaller.cs create mode 100644 UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs new file mode 100644 index 00000000..4cd5975e --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -0,0 +1,124 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using UnityEngine; + +namespace UnityMcpBridge.Editor.Helpers +{ + public static class ServerInstaller + { + private const string PackageName = "unity-mcp-server"; + private const string GitUrlTemplate = + "git+https://github.com/justinpbarnett/unity-mcp.git@{0}#subdirectory=UnityMcpServer"; + private const string PyprojectUrlTemplate = + "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/{0}/UnityMcpServer/pyproject.toml"; + private const string DefaultBranch = "master"; + + /// + /// Ensures that UnityMcpServer is installed and up to date by checking the typical application path via Python's package manager. + /// + /// The GitHub branch to install from. Defaults to "master" if not specified. + public static void EnsureServerInstalled(string branch = DefaultBranch) + { + try + { + // Format the URLs with the specified branch + string gitUrl = string.Format(GitUrlTemplate, branch); + + // Check if unity-mcp-server is installed using uv + string output = RunCommand("uv", $"pip show {PackageName}"); + if (output.Contains("WARNING: Package(s) not found")) + { + Debug.Log($"Installing {PackageName} from branch '{branch}'..."); + RunCommand("uv", $"pip install {gitUrl}"); + Debug.Log($"{PackageName} installed successfully."); + } + else + { + // Extract the installed version + string installedVersion = GetVersionFromPipShow(output); + // Get the latest version from GitHub + string latestVersion = GetLatestVersionFromGitHub(branch); + // Compare versions + if (new Version(installedVersion) < new Version(latestVersion)) + { + Debug.Log( + $"Updating {PackageName} from {installedVersion} to {latestVersion} (branch '{branch}')..." + ); + RunCommand("uv", $"pip install --upgrade {gitUrl}"); + Debug.Log($"{PackageName} updated successfully."); + } + else + { + Debug.Log($"{PackageName} is up to date (version {installedVersion})."); + } + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to ensure {PackageName} is installed: {ex.Message}"); + Debug.LogWarning( + "Please ensure 'uv' is installed and accessible. See the Unity MCP README for installation instructions." + ); + } + } + + /// + /// Executes a command and returns its output. + /// + private static string RunCommand(string fileName, string arguments) + { + System.Diagnostics.Process process = new(); + process.StartInfo.FileName = fileName; + process.StartInfo.Arguments = arguments; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new Exception( + $"Command '{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}" + ); + } + return output; + } + + /// + /// Extracts the version from 'uv pip show' output. + /// + private static string GetVersionFromPipShow(string output) + { + string[] lines = output.Split('\n'); + foreach (string line in lines) + { + if (line.StartsWith("Version:")) + { + return line["Version:".Length..].Trim(); + } + } + throw new Exception("Version not found in pip show output"); + } + + /// + /// Fetches the latest version from the GitHub repository's pyproject.toml. + /// + /// The GitHub branch to fetch the version from. + private static string GetLatestVersionFromGitHub(string branch) + { + string pyprojectUrl = string.Format(PyprojectUrlTemplate, branch); + using HttpClient client = new(); + // Add GitHub headers to avoid rate limiting + client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); + string content = client.GetStringAsync(pyprojectUrl).Result; + string pattern = @"version\s*=\s*""(.*?)"""; + Match match = Regex.Match(content, pattern); + return match.Success + ? match.Groups[1].Value + : throw new Exception("Could not find version in pyproject.toml"); + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta new file mode 100644 index 00000000..67bd7f4e --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5862c6a6d0a914f4d83224f8d039cf7b \ No newline at end of file diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 705fa337..0b37f9e4 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using UnityMcpBridge.Editor.Helpers; using UnityMcpBridge.Editor.Models; using UnityMcpBridge.Editor.Tools; @@ -31,14 +32,18 @@ private static Dictionary< public static bool FolderExists(string path) { if (string.IsNullOrEmpty(path)) + { return false; + } if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { return true; + } string fullPath = Path.Combine( Application.dataPath, - path.StartsWith("Assets/") ? path.Substring(7) : path + path.StartsWith("Assets/") ? path[7..] : path ); return Directory.Exists(fullPath); } @@ -51,12 +56,23 @@ static UnityMcpBridge() public static void Start() { + try + { + ServerInstaller.EnsureServerInstalled(); + } + catch (Exception ex) + { + Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}"); + } + if (isRunning) + { return; + } + isRunning = true; listener = new TcpListener(IPAddress.Loopback, unityPort); listener.Start(); - Debug.Log($"UnityMCPBridge started on port {unityPort}."); Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; } @@ -64,7 +80,10 @@ public static void Start() public static void Stop() { if (!isRunning) + { return; + } + isRunning = false; listener.Stop(); EditorApplication.update -= ProcessCommands; @@ -77,7 +96,7 @@ private static async Task ListenerLoop() { try { - var client = await listener.AcceptTcpClientAsync(); + TcpClient client = await listener.AcceptTcpClientAsync(); // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, @@ -94,7 +113,9 @@ private static async Task ListenerLoop() catch (Exception ex) { if (isRunning) + { Debug.LogError($"Listener error: {ex.Message}"); + } } } } @@ -102,16 +123,18 @@ private static async Task ListenerLoop() private static async Task HandleClientAsync(TcpClient client) { using (client) - using (var stream = client.GetStream()) + using (NetworkStream stream = client.GetStream()) { - var buffer = new byte[8192]; + byte[] buffer = new byte[8192]; while (isRunning) { try { int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); if (bytesRead == 0) + { break; // Client disconnected + } string commandText = System.Text.Encoding.UTF8.GetString( buffer, @@ -119,13 +142,14 @@ private static async Task HandleClientAsync(TcpClient client) bytesRead ); string commandId = Guid.NewGuid().ToString(); - var tcs = new TaskCompletionSource(); + TaskCompletionSource tcs = new(); // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( + /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); @@ -155,11 +179,16 @@ private static void ProcessCommands() List processedIds = new(); lock (lockObj) { - foreach (var kvp in commandQueue.ToList()) + foreach ( + KeyValuePair< + string, + (string commandJson, TaskCompletionSource tcs) + > kvp in commandQueue.ToList() + ) { string id = kvp.Key; string commandText = kvp.Value.commandJson; - var tcs = kvp.Value.tcs; + TaskCompletionSource tcs = kvp.Value.tcs; try { @@ -200,7 +229,7 @@ private static void ProcessCommands() status = "error", error = "Invalid JSON format", receivedText = commandText.Length > 50 - ? commandText.Substring(0, 50) + "..." + ? commandText[..50] + "..." : commandText, }; tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); @@ -209,7 +238,7 @@ private static void ProcessCommands() } // Normal JSON command processing - var command = JsonConvert.DeserializeObject(commandText); + Command command = JsonConvert.DeserializeObject(commandText); if (command == null) { var nullCommandResponse = new @@ -236,7 +265,7 @@ private static void ProcessCommands() error = ex.Message, commandType = "Unknown (error during processing)", receivedText = commandText?.Length > 50 - ? commandText.Substring(0, 50) + "..." + ? commandText[..50] + "..." : commandText, }; string responseJson = JsonConvert.SerializeObject(response); @@ -246,7 +275,7 @@ private static void ProcessCommands() processedIds.Add(id); } - foreach (var id in processedIds) + foreach (string id in processedIds) { commandQueue.Remove(id); } @@ -257,7 +286,9 @@ private static void ProcessCommands() private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) + { return false; + } text = text.Trim(); if ( @@ -357,17 +388,16 @@ private static string GetParamsSummary(JObject @params) { try { - if (@params == null || !@params.HasValues) - return "No parameters"; - - return string.Join( - ", ", - @params - .Properties() - .Select(p => - $"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}" - ) - ); + return @params == null || !@params.HasValues + ? "No parameters" + : string.Join( + ", ", + @params + .Properties() + .Select(static p => + $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" + ) + ); } catch { From ca7124ef422d914f17b743eb7d57f1a909fba612 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 07:29:23 -0400 Subject: [PATCH 04/27] updated gitignore --- .gitignore | 3 +++ LICENSE.meta | 7 ------- README.md.meta | 7 ------- 3 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 LICENSE.meta delete mode 100644 README.md.meta diff --git a/.gitignore b/.gitignore index a0919dfc..53bebc84 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ UnityMcpServer.meta *.asset UnityMcpBridge.meta package.json.meta +LICENSE.meta +README.md.meta +CONTRIBUTING.md.meta # IDE .idea/ diff --git a/LICENSE.meta b/LICENSE.meta deleted file mode 100644 index fb0e557a..00000000 --- a/LICENSE.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: d231e305a15ea564d80b5a26eaafe8b0 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/README.md.meta b/README.md.meta deleted file mode 100644 index a59d60c7..00000000 --- a/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: dbd796eca62f7114daebcdc30bccc862 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From 183b44ae9ca7891ff8275b647daeeb3d059d765d Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 07:44:46 -0400 Subject: [PATCH 05/27] update gitignore --- .gitignore | 1 - UnityMcpBridge/package.json.meta | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 UnityMcpBridge/package.json.meta diff --git a/.gitignore b/.gitignore index 53bebc84..75612dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ UnityMcpServer.meta *.unitypackage *.asset UnityMcpBridge.meta -package.json.meta LICENSE.meta README.md.meta CONTRIBUTING.md.meta diff --git a/UnityMcpBridge/package.json.meta b/UnityMcpBridge/package.json.meta new file mode 100644 index 00000000..5a96937c --- /dev/null +++ b/UnityMcpBridge/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a2f7ae0675bf4fb478a0a1df7a3f6c64 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 8a89f55daf660494a2f013b66e01956f0c52600d Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 07:52:44 -0400 Subject: [PATCH 06/27] update installer to account for different os --- .../Editor/Helpers/ServerInstaller.cs | 212 +++++++++++++----- 1 file changed, 161 insertions(+), 51 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 4cd5975e..da04b24e 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,5 +1,8 @@ using System; +using System.IO; +using System.Linq; using System.Net.Http; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; using UnityEngine; @@ -8,44 +11,61 @@ namespace UnityMcpBridge.Editor.Helpers public static class ServerInstaller { private const string PackageName = "unity-mcp-server"; - private const string GitUrlTemplate = - "git+https://github.com/justinpbarnett/unity-mcp.git@{0}#subdirectory=UnityMcpServer"; - private const string PyprojectUrlTemplate = - "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/{0}/UnityMcpServer/pyproject.toml"; - private const string DefaultBranch = "master"; - - /// - /// Ensures that UnityMcpServer is installed and up to date by checking the typical application path via Python's package manager. - /// - /// The GitHub branch to install from. Defaults to "master" if not specified. - public static void EnsureServerInstalled(string branch = DefaultBranch) + private const string GitUrl = + "git+https://github.com/justinpbarnett/unity-mcp.git#subdirectory=UnityMcpServer"; + private const string PyprojectUrl = + "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/master/UnityMcpServer/pyproject.toml"; + + // Typical uv installation paths per OS + private static readonly string[] WindowsUvPaths = new[] + { + @"C:\Users\$USER$\.local\bin\uv.exe", // Default uv install path + @"C:\Program Files\uv\uv.exe", // Possible system-wide install + @"C:\Users\$USER$\AppData\Local\Programs\uv\uv.exe", + }; + + private static readonly string[] LinuxUvPaths = new[] + { + "/home/$USER$/.local/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + }; + + private static readonly string[] MacUvPaths = new[] + { + "/Users/$USER$/.local/bin/uv", + "/usr/local/bin/uv", + "/opt/homebrew/bin/uv", // Homebrew on Apple Silicon + }; + + public static void EnsureServerInstalled() { try { - // Format the URLs with the specified branch - string gitUrl = string.Format(GitUrlTemplate, branch); + string uvPath = FindUvExecutable(); + if (string.IsNullOrEmpty(uvPath)) + { + throw new Exception(GetUvNotFoundMessage()); + } - // Check if unity-mcp-server is installed using uv - string output = RunCommand("uv", $"pip show {PackageName}"); + // Check if unity-mcp-server is installed + string output = RunCommand(uvPath, $"pip show {PackageName}"); if (output.Contains("WARNING: Package(s) not found")) { - Debug.Log($"Installing {PackageName} from branch '{branch}'..."); - RunCommand("uv", $"pip install {gitUrl}"); + Debug.Log($"Installing {PackageName}..."); + RunCommand(uvPath, $"pip install {GitUrl}"); Debug.Log($"{PackageName} installed successfully."); } else { - // Extract the installed version string installedVersion = GetVersionFromPipShow(output); - // Get the latest version from GitHub - string latestVersion = GetLatestVersionFromGitHub(branch); - // Compare versions + string latestVersion = GetLatestVersionFromGitHub(); if (new Version(installedVersion) < new Version(latestVersion)) { Debug.Log( - $"Updating {PackageName} from {installedVersion} to {latestVersion} (branch '{branch}')..." + $"Updating {PackageName} from {installedVersion} to {latestVersion}..." ); - RunCommand("uv", $"pip install --upgrade {gitUrl}"); + RunCommand(uvPath, $"pip install --upgrade {GitUrl}"); Debug.Log($"{PackageName} updated successfully."); } else @@ -57,20 +77,81 @@ public static void EnsureServerInstalled(string branch = DefaultBranch) catch (Exception ex) { Debug.LogError($"Failed to ensure {PackageName} is installed: {ex.Message}"); - Debug.LogWarning( - "Please ensure 'uv' is installed and accessible. See the Unity MCP README for installation instructions." - ); + Debug.LogWarning(GetInstallInstructions()); + } + } + + private static string FindUvExecutable() + { + string username = Environment.UserName; + string[] uvPaths; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + uvPaths = WindowsUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + uvPaths = LinuxUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + uvPaths = MacUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); + } + else + { + throw new PlatformNotSupportedException("Unsupported operating system."); + } + + // First, try 'uv' directly from PATH + try + { + System.Diagnostics.Process process = new(); + process.StartInfo.FileName = "uv"; + process.StartInfo.Arguments = "--version"; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + process.WaitForExit(2000); // Wait up to 2 seconds + if (process.ExitCode == 0) + { + return "uv"; // Found in PATH + } + } + catch + { + // Not in PATH, proceed to check specific locations + } + + // Check specific paths + foreach (string path in uvPaths) + { + if (File.Exists(path)) + { + return path; + } } + + return null; // Not found } - /// - /// Executes a command and returns its output. - /// private static string RunCommand(string fileName, string arguments) { System.Diagnostics.Process process = new(); process.StartInfo.FileName = fileName; - process.StartInfo.Arguments = arguments; + + // On non-Windows, might need to adjust for shell execution + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + process.StartInfo.FileName = "/bin/sh"; + process.StartInfo.Arguments = $"-c \"{fileName} {arguments}\""; + } + else + { + process.StartInfo.Arguments = arguments; + } + process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; @@ -87,38 +168,67 @@ private static string RunCommand(string fileName, string arguments) return output; } - /// - /// Extracts the version from 'uv pip show' output. - /// private static string GetVersionFromPipShow(string output) { - string[] lines = output.Split('\n'); - foreach (string line in lines) + var lines = output.Split('\n'); + foreach (var line in lines) { if (line.StartsWith("Version:")) { - return line["Version:".Length..].Trim(); + return line.Substring("Version:".Length).Trim(); } } throw new Exception("Version not found in pip show output"); } - /// - /// Fetches the latest version from the GitHub repository's pyproject.toml. - /// - /// The GitHub branch to fetch the version from. - private static string GetLatestVersionFromGitHub(string branch) + private static string GetLatestVersionFromGitHub() + { + using (HttpClient client = new HttpClient()) + { + client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); + string content = client.GetStringAsync(PyprojectUrl).Result; + string pattern = @"version\s*=\s*""(.*?)"""; + Match match = Regex.Match(content, pattern); + if (match.Success) + { + return match.Groups[1].Value; + } + throw new Exception("Could not find version in pyproject.toml"); + } + } + + private static string GetUvNotFoundMessage() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "uv not found in PATH or typical Windows locations."; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "uv not found in PATH or typical Linux locations."; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "uv not found in PATH or typical macOS locations."; + } + return "uv not found on this platform."; + } + + private static string GetInstallInstructions() { - string pyprojectUrl = string.Format(PyprojectUrlTemplate, branch); - using HttpClient client = new(); - // Add GitHub headers to avoid rate limiting - client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); - string content = client.GetStringAsync(pyprojectUrl).Result; - string pattern = @"version\s*=\s*""(.*?)"""; - Match match = Regex.Match(content, pattern); - return match.Success - ? match.Groups[1].Value - : throw new Exception("Could not find version in pyproject.toml"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Install uv with: powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\" and ensure it's in your PATH."; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Install uv with: curl -LsSf https://astral.sh/uv/install.sh | sh and ensure it's in your PATH."; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Install uv with: brew install uv or curl -LsSf https://astral.sh/uv/install.sh | sh and ensure it's in your PATH."; + } + return "Install uv following platform-specific instructions and add it to your PATH."; } } } From 7beaab1fcb3ba62c0ff98daaae8b8c3ba6deb315 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 08:59:26 -0400 Subject: [PATCH 07/27] updated GitUrl to include branch name --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index da04b24e..43f32777 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -11,10 +11,15 @@ namespace UnityMcpBridge.Editor.Helpers public static class ServerInstaller { private const string PackageName = "unity-mcp-server"; + private const string BranchName = "feature/install-overhaul"; private const string GitUrl = - "git+https://github.com/justinpbarnett/unity-mcp.git#subdirectory=UnityMcpServer"; + "git+https://github.com/justinpbarnett/unity-mcp.git@" + + BranchName + + "#subdirectory=UnityMcpServer"; private const string PyprojectUrl = - "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/master/UnityMcpServer/pyproject.toml"; + "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/" + + BranchName + + "/UnityMcpServer/pyproject.toml"; // Typical uv installation paths per OS private static readonly string[] WindowsUvPaths = new[] From 99ceb49ac3356b93ebdd69379a1a9d95cc2eb19c Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 09:46:34 -0400 Subject: [PATCH 08/27] update server install process --- .../Editor/Helpers/ServerInstaller.cs | 135 ++++++++++-------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 43f32777..f4da7906 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -24,8 +24,8 @@ public static class ServerInstaller // Typical uv installation paths per OS private static readonly string[] WindowsUvPaths = new[] { - @"C:\Users\$USER$\.local\bin\uv.exe", // Default uv install path - @"C:\Program Files\uv\uv.exe", // Possible system-wide install + @"C:\Users\$USER$\.local\bin\uv.exe", + @"C:\Program Files\uv\uv.exe", @"C:\Users\$USER$\AppData\Local\Programs\uv\uv.exe", }; @@ -40,7 +40,7 @@ public static class ServerInstaller { "/Users/$USER$/.local/bin/uv", "/usr/local/bin/uv", - "/opt/homebrew/bin/uv", // Homebrew on Apple Silicon + "/opt/homebrew/bin/uv", }; public static void EnsureServerInstalled() @@ -50,19 +50,26 @@ public static void EnsureServerInstalled() string uvPath = FindUvExecutable(); if (string.IsNullOrEmpty(uvPath)) { - throw new Exception(GetUvNotFoundMessage()); + throw new Exception( + "Could not find 'uv' executable. Please ensure it is installed." + ); } - // Check if unity-mcp-server is installed - string output = RunCommand(uvPath, $"pip show {PackageName}"); - if (output.Contains("WARNING: Package(s) not found")) - { - Debug.Log($"Installing {PackageName}..."); - RunCommand(uvPath, $"pip install {GitUrl}"); - Debug.Log($"{PackageName} installed successfully."); - } - else + // Check if the package is installed + System.Diagnostics.Process process = new(); + process.StartInfo.FileName = uvPath; + process.StartInfo.Arguments = "pip show " + PackageName; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode == 0) { + // Package is installed, check version string installedVersion = GetVersionFromPipShow(output); string latestVersion = GetLatestVersionFromGitHub(); if (new Version(installedVersion) < new Version(latestVersion)) @@ -70,7 +77,7 @@ public static void EnsureServerInstalled() Debug.Log( $"Updating {PackageName} from {installedVersion} to {latestVersion}..." ); - RunCommand(uvPath, $"pip install --upgrade {GitUrl}"); + RunCommand(uvPath, "pip install --upgrade " + GitUrl); Debug.Log($"{PackageName} updated successfully."); } else @@ -78,11 +85,51 @@ public static void EnsureServerInstalled() Debug.Log($"{PackageName} is up to date (version {installedVersion})."); } } + else if (process.ExitCode == 1 && output.Contains("Package(s) not found")) + { + // Package not found, install it from GitHub + Debug.Log("Installing " + PackageName + "..."); + RunCommand(uvPath, "pip install " + GitUrl); + Debug.Log(PackageName + " installed successfully."); + } + else + { + throw new Exception( + $"Command 'uv pip show {PackageName}' failed with exit code {process.ExitCode}. Output: {output} Error: {error}" + ); + } } catch (Exception ex) { Debug.LogError($"Failed to ensure {PackageName} is installed: {ex.Message}"); - Debug.LogWarning(GetInstallInstructions()); + Debug.LogWarning( + "Please install " + + PackageName + + " manually using 'uv pip install " + + GitUrl + + "'." + ); + } + } + + private static void RunCommand(string uvPath, string arguments) + { + System.Diagnostics.Process installProcess = new(); + installProcess.StartInfo.FileName = uvPath; + installProcess.StartInfo.Arguments = arguments; + installProcess.StartInfo.UseShellExecute = false; + installProcess.StartInfo.RedirectStandardOutput = true; + installProcess.StartInfo.RedirectStandardError = true; + installProcess.Start(); + string installOutput = installProcess.StandardOutput.ReadToEnd(); + string installError = installProcess.StandardError.ReadToEnd(); + installProcess.WaitForExit(); + + if (installProcess.ExitCode != 0) + { + throw new Exception( + $"Command '{uvPath} {arguments}' failed. Output: {installOutput} Error: {installError}" + ); } } @@ -141,46 +188,14 @@ private static string FindUvExecutable() return null; // Not found } - private static string RunCommand(string fileName, string arguments) - { - System.Diagnostics.Process process = new(); - process.StartInfo.FileName = fileName; - - // On non-Windows, might need to adjust for shell execution - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - process.StartInfo.FileName = "/bin/sh"; - process.StartInfo.Arguments = $"-c \"{fileName} {arguments}\""; - } - else - { - process.StartInfo.Arguments = arguments; - } - - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (process.ExitCode != 0) - { - throw new Exception( - $"Command '{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}" - ); - } - return output; - } - private static string GetVersionFromPipShow(string output) { - var lines = output.Split('\n'); - foreach (var line in lines) + string[] lines = output.Split('\n'); + foreach (string line in lines) { if (line.StartsWith("Version:")) { - return line.Substring("Version:".Length).Trim(); + return line["Version:".Length..].Trim(); } } throw new Exception("Version not found in pip show output"); @@ -188,18 +203,14 @@ private static string GetVersionFromPipShow(string output) private static string GetLatestVersionFromGitHub() { - using (HttpClient client = new HttpClient()) - { - client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); - string content = client.GetStringAsync(PyprojectUrl).Result; - string pattern = @"version\s*=\s*""(.*?)"""; - Match match = Regex.Match(content, pattern); - if (match.Success) - { - return match.Groups[1].Value; - } - throw new Exception("Could not find version in pyproject.toml"); - } + using HttpClient client = new(); + client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); + string content = client.GetStringAsync(PyprojectUrl).Result; + string pattern = @"version\s*=\s*""(.*?)"""; + Match match = Regex.Match(content, pattern); + return match.Success + ? match.Groups[1].Value + : throw new Exception("Could not find version in pyproject.toml"); } private static string GetUvNotFoundMessage() From 441c0aac57b80852badf2355d1935b4993051e60 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 09:46:52 -0400 Subject: [PATCH 09/27] gracefully handle multiple processes --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 47 ++++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 0b37f9e4..ad6b6d86 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -70,11 +70,32 @@ public static void Start() return; } - isRunning = true; - listener = new TcpListener(IPAddress.Loopback, unityPort); - listener.Start(); - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; + // Stop any existing listener to free the port + Stop(); + + try + { + listener = new TcpListener(IPAddress.Loopback, unityPort); + listener.Start(); + isRunning = true; + Debug.Log($"UnityMcpBridge started on port {unityPort}."); + // Assuming ListenerLoop and ProcessCommands are defined elsewhere + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + } + catch (SocketException ex) + { + if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + Debug.LogError( + $"Port {unityPort} is already in use. Ensure no other instances are running or change the port." + ); + } + else + { + Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + } + } } public static void Stop() @@ -84,10 +105,18 @@ public static void Stop() return; } - isRunning = false; - listener.Stop(); - EditorApplication.update -= ProcessCommands; - Debug.Log("UnityMCPBridge stopped."); + try + { + listener?.Stop(); + listener = null; + isRunning = false; + EditorApplication.update -= ProcessCommands; + Debug.Log("UnityMcpBridge stopped."); + } + catch (Exception ex) + { + Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + } } private static async Task ListenerLoop() From 2f6d748607654b9e7bc208fa190bed88a61e636e Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 10:33:14 -0400 Subject: [PATCH 10/27] updated server install process --- .../Editor/Helpers/ServerInstaller.cs | 325 +++++++++--------- UnityMcpBridge/Editor/UnityMcpBridge.cs | 5 +- 2 files changed, 155 insertions(+), 175 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index f4da7906..304629a8 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,9 +1,8 @@ using System; using System.IO; using System.Linq; -using System.Net.Http; +using System.Net; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using UnityEngine; namespace UnityMcpBridge.Editor.Helpers @@ -11,240 +10,222 @@ namespace UnityMcpBridge.Editor.Helpers public static class ServerInstaller { private const string PackageName = "unity-mcp-server"; - private const string BranchName = "feature/install-overhaul"; - private const string GitUrl = - "git+https://github.com/justinpbarnett/unity-mcp.git@" - + BranchName - + "#subdirectory=UnityMcpServer"; + private const string BranchName = "feature/install-overhaul"; // Adjust branch as needed + private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/" + BranchName + "/UnityMcpServer/pyproject.toml"; - // Typical uv installation paths per OS - private static readonly string[] WindowsUvPaths = new[] - { - @"C:\Users\$USER$\.local\bin\uv.exe", - @"C:\Program Files\uv\uv.exe", - @"C:\Users\$USER$\AppData\Local\Programs\uv\uv.exe", - }; - - private static readonly string[] LinuxUvPaths = new[] - { - "/home/$USER$/.local/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - }; - - private static readonly string[] MacUvPaths = new[] - { - "/Users/$USER$/.local/bin/uv", - "/usr/local/bin/uv", - "/opt/homebrew/bin/uv", - }; - + /// + /// Ensures the unity-mcp-server is installed and up to date. + /// public static void EnsureServerInstalled() { try { - string uvPath = FindUvExecutable(); - if (string.IsNullOrEmpty(uvPath)) + string saveLocation = GetSaveLocation(); + Debug.Log($"Server save location: {saveLocation}"); + + if (!IsServerInstalled(saveLocation)) { - throw new Exception( - "Could not find 'uv' executable. Please ensure it is installed." - ); + Debug.Log("Server not found. Installing..."); + InstallServer(saveLocation); } - - // Check if the package is installed - System.Diagnostics.Process process = new(); - process.StartInfo.FileName = uvPath; - process.StartInfo.Arguments = "pip show " + PackageName; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode == 0) + else { - // Package is installed, check version - string installedVersion = GetVersionFromPipShow(output); - string latestVersion = GetLatestVersionFromGitHub(); - if (new Version(installedVersion) < new Version(latestVersion)) + Debug.Log("Server is installed. Checking version..."); + string installedVersion = GetInstalledVersion(saveLocation); + string latestVersion = GetLatestVersion(); + + if (IsNewerVersion(latestVersion, installedVersion)) { Debug.Log( - $"Updating {PackageName} from {installedVersion} to {latestVersion}..." + $"Newer version available ({latestVersion} > {installedVersion}). Updating..." ); - RunCommand(uvPath, "pip install --upgrade " + GitUrl); - Debug.Log($"{PackageName} updated successfully."); + UpdateServer(saveLocation); } else { - Debug.Log($"{PackageName} is up to date (version {installedVersion})."); + Debug.Log("Server is up to date."); } } - else if (process.ExitCode == 1 && output.Contains("Package(s) not found")) - { - // Package not found, install it from GitHub - Debug.Log("Installing " + PackageName + "..."); - RunCommand(uvPath, "pip install " + GitUrl); - Debug.Log(PackageName + " installed successfully."); - } - else - { - throw new Exception( - $"Command 'uv pip show {PackageName}' failed with exit code {process.ExitCode}. Output: {output} Error: {error}" - ); - } } catch (Exception ex) { - Debug.LogError($"Failed to ensure {PackageName} is installed: {ex.Message}"); - Debug.LogWarning( - "Please install " - + PackageName - + " manually using 'uv pip install " - + GitUrl - + "'." - ); - } - } - - private static void RunCommand(string uvPath, string arguments) - { - System.Diagnostics.Process installProcess = new(); - installProcess.StartInfo.FileName = uvPath; - installProcess.StartInfo.Arguments = arguments; - installProcess.StartInfo.UseShellExecute = false; - installProcess.StartInfo.RedirectStandardOutput = true; - installProcess.StartInfo.RedirectStandardError = true; - installProcess.Start(); - string installOutput = installProcess.StandardOutput.ReadToEnd(); - string installError = installProcess.StandardError.ReadToEnd(); - installProcess.WaitForExit(); - - if (installProcess.ExitCode != 0) - { - throw new Exception( - $"Command '{uvPath} {arguments}' failed. Output: {installOutput} Error: {installError}" - ); + Debug.LogError($"Failed to ensure server installation: {ex.Message}"); } } - private static string FindUvExecutable() + /// + /// Gets the platform-specific save location for the server. + /// + private static string GetSaveLocation() { - string username = Environment.UserName; - string[] uvPaths; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - uvPaths = WindowsUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); + // Use a user-specific program directory under %USERPROFILE%\AppData\Local\Programs + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "AppData", + "Local", + "Programs", + PackageName + ); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - uvPaths = LinuxUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "bin", + PackageName + ); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - uvPaths = MacUvPaths.Select(p => p.Replace("$USER$", username)).ToArray(); - } - else - { - throw new PlatformNotSupportedException("Unsupported operating system."); + string path = "/usr/local/bin"; + if (!Directory.Exists(path) || !IsDirectoryWritable(path)) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Applications", + PackageName + ); + } + return Path.Combine(path, PackageName); } + throw new Exception("Unsupported operating system."); + } - // First, try 'uv' directly from PATH + private static bool IsDirectoryWritable(string path) + { try { - System.Diagnostics.Process process = new(); - process.StartInfo.FileName = "uv"; - process.StartInfo.Arguments = "--version"; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.Start(); - process.WaitForExit(2000); // Wait up to 2 seconds - if (process.ExitCode == 0) - { - return "uv"; // Found in PATH - } + File.Create(Path.Combine(path, "test.txt")).Dispose(); + File.Delete(Path.Combine(path, "test.txt")); + return true; } catch { - // Not in PATH, proceed to check specific locations + return false; } + } - // Check specific paths - foreach (string path in uvPaths) - { - if (File.Exists(path)) - { - return path; - } - } + /// + /// Checks if the server is installed at the specified location. + /// + private static bool IsServerInstalled(string location) + { + return Directory.Exists(location) && File.Exists(Path.Combine(location, "version.txt")); + } - return null; // Not found + /// + /// Installs the server by cloning the repository and setting up dependencies. + /// + private static void InstallServer(string location) + { + // Clone the repository + RunCommand("git", $"clone -b {BranchName} {GitUrl} \"{location}\""); + + // Set up virtual environment and install dependencies + string venvPath = Path.Combine(location, "venv"); + RunCommand("python", $"-m venv \"{venvPath}\""); + string uvPath = Path.Combine( + venvPath, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts/uv.exe" : "bin/uv" + ); + RunCommand(uvPath, "install"); } - private static string GetVersionFromPipShow(string output) + /// + /// Retrieves the installed server version from version.txt. + /// + private static string GetInstalledVersion(string location) { - string[] lines = output.Split('\n'); - foreach (string line in lines) - { - if (line.StartsWith("Version:")) - { - return line["Version:".Length..].Trim(); - } - } - throw new Exception("Version not found in pip show output"); + string versionFile = Path.Combine(location, "version.txt"); + return File.ReadAllText(versionFile).Trim(); } - private static string GetLatestVersionFromGitHub() + /// + /// Fetches the latest version from the GitHub pyproject.toml file. + /// + private static string GetLatestVersion() { - using HttpClient client = new(); - client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge"); - string content = client.GetStringAsync(PyprojectUrl).Result; - string pattern = @"version\s*=\s*""(.*?)"""; - Match match = Regex.Match(content, pattern); - return match.Success - ? match.Groups[1].Value - : throw new Exception("Could not find version in pyproject.toml"); + using var webClient = new WebClient(); + string pyprojectContent = webClient.DownloadString(PyprojectUrl); + return ParseVersionFromPyproject(pyprojectContent); } - private static string GetUvNotFoundMessage() + /// + /// Updates the server by pulling the latest changes. + /// + private static void UpdateServer(string location) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "uv not found in PATH or typical Windows locations."; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return "uv not found in PATH or typical Linux locations."; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + RunCommand("git", $"-C \"{location}\" pull"); + // Optionally reinstall dependencies if requirements changed + } + + /// + /// Parses the version number from pyproject.toml content. + /// + private static string ParseVersionFromPyproject(string content) + { + foreach (var line in content.Split('\n')) { - return "uv not found in PATH or typical macOS locations."; + if (line.Trim().StartsWith("version =")) + { + var parts = line.Split('='); + if (parts.Length == 2) + return parts[1].Trim().Trim('"'); + } } - return "uv not found on this platform."; + throw new Exception("Version not found in pyproject.toml"); } - private static string GetInstallInstructions() + /// + /// Compares two version strings to determine if the latest is newer. + /// + private static bool IsNewerVersion(string latest, string installed) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + var latestParts = latest.Split('.').Select(int.Parse).ToArray(); + var installedParts = installed.Split('.').Select(int.Parse).ToArray(); + for (int i = 0; i < Math.Min(latestParts.Length, installedParts.Length); i++) { - return "Install uv with: powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\" and ensure it's in your PATH."; + if (latestParts[i] > installedParts[i]) + return true; + if (latestParts[i] < installedParts[i]) + return false; } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return latestParts.Length > installedParts.Length; + } + + /// + /// Runs a command-line process and handles output/errors. + /// + private static void RunCommand(string command, string arguments) + { + var process = new System.Diagnostics.Process { - return "Install uv with: curl -LsSf https://astral.sh/uv/install.sh | sh and ensure it's in your PATH."; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = command, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) { - return "Install uv with: brew install uv or curl -LsSf https://astral.sh/uv/install.sh | sh and ensure it's in your PATH."; + throw new Exception( + $"Command failed: {command} {arguments}\nOutput: {output}\nError: {error}" + ); } - return "Install uv following platform-specific instructions and add it to your PATH."; } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index ad6b6d86..9276c05b 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -56,6 +56,8 @@ static UnityMcpBridge() public static void Start() { + Stop(); + try { ServerInstaller.EnsureServerInstalled(); @@ -70,9 +72,6 @@ public static void Start() return; } - // Stop any existing listener to free the port - Stop(); - try { listener = new TcpListener(IPAddress.Loopback, unityPort); From 33b6486b707a9835cde6668f53bb961a822db764 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 10:39:14 -0400 Subject: [PATCH 11/27] backslashes for windows --- .../Editor/Helpers/ServerInstaller.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 304629a8..e20996ca 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -127,12 +127,25 @@ private static void InstallServer(string location) // Clone the repository RunCommand("git", $"clone -b {BranchName} {GitUrl} \"{location}\""); - // Set up virtual environment and install dependencies + // Set up virtual environment string venvPath = Path.Combine(location, "venv"); RunCommand("python", $"-m venv \"{venvPath}\""); + + // Determine the path to the virtual environment's Python interpreter + string pythonPath = Path.Combine( + venvPath, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "Scripts\\python.exe" + : "bin/python" + ); + + // Install uv into the virtual environment + RunCommand(pythonPath, "-m pip install uv"); + + // Use uv to install dependencies string uvPath = Path.Combine( venvPath, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts/uv.exe" : "bin/uv" + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" ); RunCommand(uvPath, "install"); } From b36c4576d9f3dd323f7b6a4d29af2729f7fd8232 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 10:48:39 -0400 Subject: [PATCH 12/27] only install UnityMcpServer folder, not whole repo --- .../Editor/Helpers/ServerInstaller.cs | 70 ++++++++++++++++--- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index e20996ca..15dfff21 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -120,12 +120,42 @@ private static bool IsServerInstalled(string location) } /// - /// Installs the server by cloning the repository and setting up dependencies. + /// Installs the server by cloning only the UnityMcpServer folder from the repository and setting up dependencies. /// private static void InstallServer(string location) { - // Clone the repository - RunCommand("git", $"clone -b {BranchName} {GitUrl} \"{location}\""); + // Create the directory if it doesn't exist + Directory.CreateDirectory(location); + + // Initialize git repo + RunCommand("git", $"init", workingDirectory: location); + + // Add remote + RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location); + + // Configure sparse checkout + RunCommand("git", "config core.sparseCheckout true", workingDirectory: location); + + // Set sparse checkout path to only include UnityMcpServer folder + string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); + File.WriteAllText(sparseCheckoutPath, "UnityMcpServer/"); + + // Fetch and checkout the branch + RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); + RunCommand("git", $"checkout {BranchName}", workingDirectory: location); + + // Create version.txt file based on the pyproject.toml + string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); + if (File.Exists(pyprojectPath)) + { + string pyprojectContent = File.ReadAllText(pyprojectPath); + string version = ParseVersionFromPyproject(pyprojectContent); + File.WriteAllText(Path.Combine(location, "version.txt"), version); + } + else + { + throw new Exception("Failed to find pyproject.toml after checkout"); + } // Set up virtual environment string venvPath = Path.Combine(location, "venv"); @@ -142,12 +172,12 @@ private static void InstallServer(string location) // Install uv into the virtual environment RunCommand(pythonPath, "-m pip install uv"); - // Use uv to install dependencies + // Use uv to install dependencies from the UnityMcpServer subdirectory string uvPath = Path.Combine( venvPath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" ); - RunCommand(uvPath, "install"); + RunCommand(uvPath, "pip install ./UnityMcpServer", workingDirectory: location); } /// @@ -170,12 +200,29 @@ private static string GetLatestVersion() } /// - /// Updates the server by pulling the latest changes. + /// Updates the server by pulling the latest changes for the UnityMcpServer folder only. /// private static void UpdateServer(string location) { - RunCommand("git", $"-C \"{location}\" pull"); - // Optionally reinstall dependencies if requirements changed + // Pull only the sparse checkout paths (UnityMcpServer folder) + RunCommand("git", "pull origin " + BranchName, workingDirectory: location); + + // Update version.txt file + string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); + if (File.Exists(pyprojectPath)) + { + string pyprojectContent = File.ReadAllText(pyprojectPath); + string version = ParseVersionFromPyproject(pyprojectContent); + File.WriteAllText(Path.Combine(location, "version.txt"), version); + } + + // Reinstall dependencies to ensure they're up to date + string venvPath = Path.Combine(location, "venv"); + string uvPath = Path.Combine( + venvPath, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" + ); + RunCommand(uvPath, "pip install -U ./UnityMcpServer", workingDirectory: location); } /// @@ -215,7 +262,11 @@ private static bool IsNewerVersion(string latest, string installed) /// /// Runs a command-line process and handles output/errors. /// - private static void RunCommand(string command, string arguments) + private static void RunCommand( + string command, + string arguments, + string workingDirectory = null + ) { var process = new System.Diagnostics.Process { @@ -227,6 +278,7 @@ private static void RunCommand(string command, string arguments) RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? string.Empty, }, }; process.Start(); From b8c89103237448766f63b415ffddd235873b158e Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 10:56:05 -0400 Subject: [PATCH 13/27] update install location --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 15dfff21..030d494e 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -125,7 +125,8 @@ private static bool IsServerInstalled(string location) private static void InstallServer(string location) { // Create the directory if it doesn't exist - Directory.CreateDirectory(location); + string serverDir = Path.Combine(location, "src"); + Directory.CreateDirectory(serverDir); // Initialize git repo RunCommand("git", $"init", workingDirectory: location); From 0aa191c7e3cdefd9d9f7af9dea4b07feac85cabd Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 11:10:01 -0400 Subject: [PATCH 14/27] update pyproject name and save location again --- .../Editor/Helpers/ServerInstaller.cs | 39 ++++++++++--------- UnityMcpServer/src/pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 030d494e..553f0e76 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -9,7 +9,7 @@ namespace UnityMcpBridge.Editor.Helpers { public static class ServerInstaller { - private const string PackageName = "unity-mcp-server"; + private const string PackageName = "UnityMcpServer"; private const string BranchName = "feature/install-overhaul"; // Adjust branch as needed private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = @@ -124,29 +124,29 @@ private static bool IsServerInstalled(string location) /// private static void InstallServer(string location) { - // Create the directory if it doesn't exist + // Create the src directory where the server code will reside string serverDir = Path.Combine(location, "src"); Directory.CreateDirectory(serverDir); - // Initialize git repo - RunCommand("git", $"init", workingDirectory: location); + // Initialize git repo in the src directory + RunCommand("git", $"init", workingDirectory: serverDir); // Add remote - RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location); + RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: serverDir); // Configure sparse checkout - RunCommand("git", "config core.sparseCheckout true", workingDirectory: location); + RunCommand("git", "config core.sparseCheckout true", workingDirectory: serverDir); // Set sparse checkout path to only include UnityMcpServer folder - string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); + string sparseCheckoutPath = Path.Combine(serverDir, ".git", "info", "sparse-checkout"); File.WriteAllText(sparseCheckoutPath, "UnityMcpServer/"); // Fetch and checkout the branch - RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); - RunCommand("git", $"checkout {BranchName}", workingDirectory: location); + RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: serverDir); + RunCommand("git", $"checkout {BranchName}", workingDirectory: serverDir); - // Create version.txt file based on the pyproject.toml - string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); + // Create version.txt file based on pyproject.toml, stored at the root level + string pyprojectPath = Path.Combine(serverDir, "UnityMcpServer", "pyproject.toml"); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); @@ -158,7 +158,7 @@ private static void InstallServer(string location) throw new Exception("Failed to find pyproject.toml after checkout"); } - // Set up virtual environment + // Set up virtual environment at the root level string venvPath = Path.Combine(location, "venv"); RunCommand("python", $"-m venv \"{venvPath}\""); @@ -173,12 +173,12 @@ private static void InstallServer(string location) // Install uv into the virtual environment RunCommand(pythonPath, "-m pip install uv"); - // Use uv to install dependencies from the UnityMcpServer subdirectory + // Use uv to install dependencies from the UnityMcpServer subdirectory in src string uvPath = Path.Combine( venvPath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" ); - RunCommand(uvPath, "pip install ./UnityMcpServer", workingDirectory: location); + RunCommand(uvPath, "pip install ./src/UnityMcpServer", workingDirectory: location); } /// @@ -205,11 +205,12 @@ private static string GetLatestVersion() /// private static void UpdateServer(string location) { - // Pull only the sparse checkout paths (UnityMcpServer folder) - RunCommand("git", "pull origin " + BranchName, workingDirectory: location); + // Pull latest changes in the src directory + string serverDir = Path.Combine(location, "src"); + RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); - // Update version.txt file - string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); + // Update version.txt file based on pyproject.toml in src + string pyprojectPath = Path.Combine(serverDir, "UnityMcpServer", "pyproject.toml"); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); @@ -223,7 +224,7 @@ private static void UpdateServer(string location) venvPath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" ); - RunCommand(uvPath, "pip install -U ./UnityMcpServer", workingDirectory: location); + RunCommand(uvPath, "pip install -U ./src/UnityMcpServer", workingDirectory: location); } /// diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpServer/src/pyproject.toml index 40af94cb..eebcde11 100644 --- a/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpServer/src/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "unity-mcp-server" +name = "UnityMcpServer" version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" From e4ccf614c52c7e1dad646b598fa72d9c6c5ab7e3 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 11:41:14 -0400 Subject: [PATCH 15/27] update server save location, again --- .../Editor/Helpers/ServerInstaller.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 553f0e76..24678390 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -9,7 +9,8 @@ namespace UnityMcpBridge.Editor.Helpers { public static class ServerInstaller { - private const string PackageName = "UnityMcpServer"; + private const string RootFolder = "UnityMCP"; + private const string ServerFolder = "UnityMcpServer"; private const string BranchName = "feature/install-overhaul"; // Adjust branch as needed private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = @@ -70,7 +71,7 @@ private static string GetSaveLocation() "AppData", "Local", "Programs", - PackageName + RootFolder ); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) @@ -78,7 +79,7 @@ private static string GetSaveLocation() return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "bin", - PackageName + RootFolder ); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) @@ -89,10 +90,10 @@ private static string GetSaveLocation() return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", - PackageName + RootFolder ); } - return Path.Combine(path, PackageName); + return Path.Combine(path, RootFolder); } throw new Exception("Unsupported operating system."); } @@ -125,28 +126,27 @@ private static bool IsServerInstalled(string location) private static void InstallServer(string location) { // Create the src directory where the server code will reside - string serverDir = Path.Combine(location, "src"); - Directory.CreateDirectory(serverDir); + Directory.CreateDirectory(location); // Initialize git repo in the src directory - RunCommand("git", $"init", workingDirectory: serverDir); + RunCommand("git", $"init", workingDirectory: location); // Add remote - RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: serverDir); + RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location); // Configure sparse checkout - RunCommand("git", "config core.sparseCheckout true", workingDirectory: serverDir); + RunCommand("git", "config core.sparseCheckout true", workingDirectory: location); // Set sparse checkout path to only include UnityMcpServer folder - string sparseCheckoutPath = Path.Combine(serverDir, ".git", "info", "sparse-checkout"); + string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); File.WriteAllText(sparseCheckoutPath, "UnityMcpServer/"); // Fetch and checkout the branch - RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: serverDir); - RunCommand("git", $"checkout {BranchName}", workingDirectory: serverDir); + RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); + RunCommand("git", $"checkout {BranchName}", workingDirectory: location); // Create version.txt file based on pyproject.toml, stored at the root level - string pyprojectPath = Path.Combine(serverDir, "UnityMcpServer", "pyproject.toml"); + string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); @@ -206,7 +206,7 @@ private static string GetLatestVersion() private static void UpdateServer(string location) { // Pull latest changes in the src directory - string serverDir = Path.Combine(location, "src"); + string serverDir = Path.Combine(location, ServerFolder, "src"); RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); // Update version.txt file based on pyproject.toml in src From 7b303b5ba3bc81c363a7f681add073bca5dd19c2 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 11:44:12 -0400 Subject: [PATCH 16/27] update pyproject.toml get location --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 24678390..2ae3a9f8 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -146,7 +146,12 @@ private static void InstallServer(string location) RunCommand("git", $"checkout {BranchName}", workingDirectory: location); // Create version.txt file based on pyproject.toml, stored at the root level - string pyprojectPath = Path.Combine(location, "UnityMcpServer", "pyproject.toml"); + string pyprojectPath = Path.Combine( + location, + "UnityMcpServer", + "src", + "pyproject.toml" + ); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); From 3199421586cc3c21ac9b4b8e11f5c64b37d49680 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 14:03:56 -0400 Subject: [PATCH 17/27] removed python and uv check during install --- .../Editor/Helpers/ServerInstaller.cs | 51 +++++++------------ 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 2ae3a9f8..b4e3232d 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -85,15 +85,13 @@ private static string GetSaveLocation() else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { string path = "/usr/local/bin"; - if (!Directory.Exists(path) || !IsDirectoryWritable(path)) - { - return Path.Combine( + return !Directory.Exists(path) || !IsDirectoryWritable(path) + ? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", RootFolder - ); - } - return Path.Combine(path, RootFolder); + ) + : Path.Combine(path, RootFolder); } throw new Exception("Unsupported operating system."); } @@ -162,28 +160,6 @@ private static void InstallServer(string location) { throw new Exception("Failed to find pyproject.toml after checkout"); } - - // Set up virtual environment at the root level - string venvPath = Path.Combine(location, "venv"); - RunCommand("python", $"-m venv \"{venvPath}\""); - - // Determine the path to the virtual environment's Python interpreter - string pythonPath = Path.Combine( - venvPath, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "Scripts\\python.exe" - : "bin/python" - ); - - // Install uv into the virtual environment - RunCommand(pythonPath, "-m pip install uv"); - - // Use uv to install dependencies from the UnityMcpServer subdirectory in src - string uvPath = Path.Combine( - venvPath, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" - ); - RunCommand(uvPath, "pip install ./src/UnityMcpServer", workingDirectory: location); } /// @@ -200,7 +176,7 @@ private static string GetInstalledVersion(string location) /// private static string GetLatestVersion() { - using var webClient = new WebClient(); + using WebClient webClient = new(); string pyprojectContent = webClient.DownloadString(PyprojectUrl); return ParseVersionFromPyproject(pyprojectContent); } @@ -237,13 +213,15 @@ private static void UpdateServer(string location) /// private static string ParseVersionFromPyproject(string content) { - foreach (var line in content.Split('\n')) + foreach (string line in content.Split('\n')) { if (line.Trim().StartsWith("version =")) { - var parts = line.Split('='); + string[] parts = line.Split('='); if (parts.Length == 2) + { return parts[1].Trim().Trim('"'); + } } } throw new Exception("Version not found in pyproject.toml"); @@ -254,14 +232,19 @@ private static string ParseVersionFromPyproject(string content) /// private static bool IsNewerVersion(string latest, string installed) { - var latestParts = latest.Split('.').Select(int.Parse).ToArray(); - var installedParts = installed.Split('.').Select(int.Parse).ToArray(); + int[] latestParts = latest.Split('.').Select(int.Parse).ToArray(); + int[] installedParts = installed.Split('.').Select(int.Parse).ToArray(); for (int i = 0; i < Math.Min(latestParts.Length, installedParts.Length); i++) { if (latestParts[i] > installedParts[i]) + { return true; + } + if (latestParts[i] < installedParts[i]) + { return false; + } } return latestParts.Length > installedParts.Length; } @@ -275,7 +258,7 @@ private static void RunCommand( string workingDirectory = null ) { - var process = new System.Diagnostics.Process + System.Diagnostics.Process process = new() { StartInfo = new System.Diagnostics.ProcessStartInfo { From d5917bdbec52a4000819808ae9c75ca7eebb1072 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 14:12:30 -0400 Subject: [PATCH 18/27] added loging, updated paths --- .../Editor/Helpers/ServerInstaller.cs | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index b4e3232d..d4fd5e25 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -115,7 +115,11 @@ private static bool IsDirectoryWritable(string path) /// private static bool IsServerInstalled(string location) { - return Directory.Exists(location) && File.Exists(Path.Combine(location, "version.txt")); + bool doesExist = + Directory.Exists(location) + && File.Exists(Path.Combine(location, ServerFolder, "version.txt")); + Debug.Log($"Server does exist: {doesExist}"); + return doesExist; } /// @@ -137,24 +141,19 @@ private static void InstallServer(string location) // Set sparse checkout path to only include UnityMcpServer folder string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); - File.WriteAllText(sparseCheckoutPath, "UnityMcpServer/"); + File.WriteAllText(sparseCheckoutPath, $"{ServerFolder}/"); // Fetch and checkout the branch RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); RunCommand("git", $"checkout {BranchName}", workingDirectory: location); // Create version.txt file based on pyproject.toml, stored at the root level - string pyprojectPath = Path.Combine( - location, - "UnityMcpServer", - "src", - "pyproject.toml" - ); + string pyprojectPath = Path.Combine(location, ServerFolder, "src", "pyproject.toml"); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); string version = ParseVersionFromPyproject(pyprojectContent); - File.WriteAllText(Path.Combine(location, "version.txt"), version); + File.WriteAllText(Path.Combine(location, ServerFolder, "version.txt"), version); } else { @@ -167,7 +166,7 @@ private static void InstallServer(string location) /// private static string GetInstalledVersion(string location) { - string versionFile = Path.Combine(location, "version.txt"); + string versionFile = Path.Combine(location, ServerFolder, "version.txt"); return File.ReadAllText(versionFile).Trim(); } @@ -186,26 +185,19 @@ private static string GetLatestVersion() /// private static void UpdateServer(string location) { + Debug.Log("Updating Server"); // Pull latest changes in the src directory string serverDir = Path.Combine(location, ServerFolder, "src"); RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); // Update version.txt file based on pyproject.toml in src - string pyprojectPath = Path.Combine(serverDir, "UnityMcpServer", "pyproject.toml"); + string pyprojectPath = Path.Combine(serverDir, "pyproject.toml"); if (File.Exists(pyprojectPath)) { string pyprojectContent = File.ReadAllText(pyprojectPath); string version = ParseVersionFromPyproject(pyprojectContent); - File.WriteAllText(Path.Combine(location, "version.txt"), version); + File.WriteAllText(Path.Combine(location, ServerFolder, "version.txt"), version); } - - // Reinstall dependencies to ensure they're up to date - string venvPath = Path.Combine(location, "venv"); - string uvPath = Path.Combine( - venvPath, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts\\uv.exe" : "bin/uv" - ); - RunCommand(uvPath, "pip install -U ./src/UnityMcpServer", workingDirectory: location); } /// @@ -220,7 +212,9 @@ private static string ParseVersionFromPyproject(string content) string[] parts = line.Split('='); if (parts.Length == 2) { - return parts[1].Trim().Trim('"'); + string version = parts[1].Trim().Trim('"'); + Debug.Log($"Version is: {version}"); + return version; } } } From e9bc45c0df1e0dd259021d2cd7106b23dcb81a9d Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 14:20:54 -0400 Subject: [PATCH 19/27] updated PyprojectUrl --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index d4fd5e25..68b46074 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -14,9 +14,9 @@ public static class ServerInstaller private const string BranchName = "feature/install-overhaul"; // Adjust branch as needed private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = - "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/" + "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/refs/heads/" + BranchName - + "/UnityMcpServer/pyproject.toml"; + + "/UnityMcpServer/src/pyproject.toml"; /// /// Ensures the unity-mcp-server is installed and up to date. @@ -167,7 +167,10 @@ private static void InstallServer(string location) private static string GetInstalledVersion(string location) { string versionFile = Path.Combine(location, ServerFolder, "version.txt"); - return File.ReadAllText(versionFile).Trim(); + Debug.Log($"Looking for version at: {versionFile}"); + string versionFileText = File.ReadAllText(versionFile).Trim(); + Debug.Log($"versionFile text: {versionFileText}"); + return versionFileText; } /// @@ -175,6 +178,7 @@ private static string GetInstalledVersion(string location) /// private static string GetLatestVersion() { + Debug.Log("Getting latest version."); using WebClient webClient = new(); string pyprojectContent = webClient.DownloadString(PyprojectUrl); return ParseVersionFromPyproject(pyprojectContent); From f446b502eec11963d2f2a781da9a1a4d8e5a95d9 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:02:59 -0400 Subject: [PATCH 20/27] removed logging --- .../Editor/Helpers/ServerInstaller.cs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 68b46074..c48a40fe 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -11,7 +11,7 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; - private const string BranchName = "feature/install-overhaul"; // Adjust branch as needed + private const string BranchName = "feature/install-overhaul"; private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/refs/heads/" @@ -26,30 +26,21 @@ public static void EnsureServerInstalled() try { string saveLocation = GetSaveLocation(); - Debug.Log($"Server save location: {saveLocation}"); if (!IsServerInstalled(saveLocation)) { - Debug.Log("Server not found. Installing..."); InstallServer(saveLocation); } else { - Debug.Log("Server is installed. Checking version..."); string installedVersion = GetInstalledVersion(saveLocation); string latestVersion = GetLatestVersion(); if (IsNewerVersion(latestVersion, installedVersion)) { - Debug.Log( - $"Newer version available ({latestVersion} > {installedVersion}). Updating..." - ); UpdateServer(saveLocation); } - else - { - Debug.Log("Server is up to date."); - } + else { } } } catch (Exception ex) @@ -58,6 +49,11 @@ public static void EnsureServerInstalled() } } + public static string GetServerPath() + { + return Path.Combine(GetSaveLocation(), ServerFolder, "src"); + } + /// /// Gets the platform-specific save location for the server. /// @@ -65,7 +61,6 @@ private static string GetSaveLocation() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Use a user-specific program directory under %USERPROFILE%\AppData\Local\Programs return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", @@ -115,11 +110,8 @@ private static bool IsDirectoryWritable(string path) /// private static bool IsServerInstalled(string location) { - bool doesExist = - Directory.Exists(location) + return Directory.Exists(location) && File.Exists(Path.Combine(location, ServerFolder, "version.txt")); - Debug.Log($"Server does exist: {doesExist}"); - return doesExist; } /// @@ -167,10 +159,7 @@ private static void InstallServer(string location) private static string GetInstalledVersion(string location) { string versionFile = Path.Combine(location, ServerFolder, "version.txt"); - Debug.Log($"Looking for version at: {versionFile}"); - string versionFileText = File.ReadAllText(versionFile).Trim(); - Debug.Log($"versionFile text: {versionFileText}"); - return versionFileText; + return File.ReadAllText(versionFile).Trim(); } /// @@ -178,7 +167,6 @@ private static string GetInstalledVersion(string location) /// private static string GetLatestVersion() { - Debug.Log("Getting latest version."); using WebClient webClient = new(); string pyprojectContent = webClient.DownloadString(PyprojectUrl); return ParseVersionFromPyproject(pyprojectContent); @@ -189,7 +177,6 @@ private static string GetLatestVersion() /// private static void UpdateServer(string location) { - Debug.Log("Updating Server"); // Pull latest changes in the src directory string serverDir = Path.Combine(location, ServerFolder, "src"); RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); @@ -216,9 +203,7 @@ private static string ParseVersionFromPyproject(string content) string[] parts = line.Split('='); if (parts.Length == 2) { - string version = parts[1].Trim().Trim('"'); - Debug.Log($"Version is: {version}"); - return version; + return parts[1].Trim().Trim('"'); } } } From 8ed8c62cce05fa04d65d2954c61e8496bc9e98e1 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:03:16 -0400 Subject: [PATCH 21/27] updated auto-config server location --- .../Editor/Windows/UnityMcpEditorWindow.cs | 185 +----------------- 1 file changed, 3 insertions(+), 182 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 2c334e39..c9ebd769 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -9,6 +9,7 @@ using UnityEditor; using UnityEngine; using UnityMcpBridge.Editor.Data; +using UnityMcpBridge.Editor.Helpers; using UnityMcpBridge.Editor.Models; namespace UnityMcpBridge.Editor.Windows @@ -23,47 +24,8 @@ public class UnityMcpEditorWindow : EditorWindow private Color pythonServerStatusColor = Color.red; private const int unityPort = 6400; // Hardcoded Unity port private const int mcpPort = 6500; // Hardcoded MCP port - private const float CONNECTION_CHECK_INTERVAL = 2f; // Check every 2 seconds - private float lastCheckTime = 0f; private McpClients mcpClients = new(); - private List possiblePaths = new() - { - Path.GetFullPath( - Path.Combine(Application.dataPath, "unity-mcp", "Python", "server.py") - ), - Path.GetFullPath( - Path.Combine( - Application.dataPath, - "Packages", - "com.justinpbarnett.unity-mcp", - "Python", - "server.py" - ) - ), - Path.GetFullPath( - Path.Combine( - Application.dataPath, - "..", - "Library", - "PackageCache", - "com.justinpbarnett.unity-mcp@*", - "Python", - "server.py" - ) - ), - Path.GetFullPath( - Path.Combine( - Application.dataPath, - "..", - "Packages", - "com.justinpbarnett.unity-mcp", - "Python", - "server.py" - ) - ), - }; - [MenuItem("Window/Unity MCP")] public static void ShowWindow() { @@ -74,111 +36,12 @@ private void OnEnable() { // Check initial states isUnityBridgeRunning = UnityMcpBridge.IsRunning; - CheckPythonServerConnection(); foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); } } - private void Update() - { - // Check Python server connection periodically - if (Time.realtimeSinceStartup - lastCheckTime >= CONNECTION_CHECK_INTERVAL) - { - CheckPythonServerConnection(); - lastCheckTime = Time.realtimeSinceStartup; - } - } - - private async void CheckPythonServerConnection() - { - try - { - using (var client = new TcpClient()) - { - // Try to connect with a short timeout - var connectTask = client.ConnectAsync("localhost", unityPort); - if (await Task.WhenAny(connectTask, Task.Delay(1000)) == connectTask) - { - // Try to send a ping message to verify connection is alive - try - { - NetworkStream stream = client.GetStream(); - byte[] pingMessage = Encoding.UTF8.GetBytes("ping"); - await stream.WriteAsync(pingMessage, 0, pingMessage.Length); - - // Wait for response with timeout - byte[] buffer = new byte[1024]; - var readTask = stream.ReadAsync(buffer, 0, buffer.Length); - if (await Task.WhenAny(readTask, Task.Delay(1000)) == readTask) - { - int bytesRead = await readTask; - if (bytesRead <= 0) - { - // Received empty response - pythonServerStatus = "Invalid Response"; - pythonServerStatusColor = GetStatusColor(McpStatus.NoResponse); - return; - } - - // Validate the response is actually from our server - string response = Encoding.UTF8.GetString(buffer, 0, bytesRead); - if (response.Contains("pong")) - { - // Connection successful and responsive with valid response - pythonServerStatus = "Connected"; - pythonServerStatusColor = GetStatusColor(McpStatus.Connected); - } - else - { - // Received response but not the expected one - pythonServerStatus = "Invalid Server"; - pythonServerStatusColor = GetStatusColor( - McpStatus.CommunicationError - ); - } - } - else - { - // No response received - pythonServerStatus = "No Response"; - pythonServerStatusColor = GetStatusColor(McpStatus.NoResponse); - UnityEngine.Debug.LogWarning( - $"Python server not responding on port {unityPort}" - ); - } - } - catch (Exception e) - { - // Connection established but communication failed - pythonServerStatus = "Communication Error"; - pythonServerStatusColor = GetStatusColor(McpStatus.CommunicationError); - UnityEngine.Debug.LogWarning( - $"Error communicating with Python server: {e.Message}" - ); - } - } - else - { - // Connection failed - pythonServerStatus = "Not Connected"; - pythonServerStatusColor = GetStatusColor(McpStatus.NotConfigured); - UnityEngine.Debug.LogWarning( - $"Python server is not running or not accessible on port {unityPort}" - ); - } - client.Close(); - } - } - catch (Exception e) - { - pythonServerStatus = "Connection Error"; - pythonServerStatusColor = GetStatusColor(McpStatus.Error); - UnityEngine.Debug.LogError($"Error checking Python server connection: {e.Message}"); - } - } - private Color GetStatusColor(McpStatus status) { // Return appropriate color based on the status enum @@ -383,48 +246,6 @@ private void ToggleUnityBridge() isUnityBridgeRunning = !isUnityBridgeRunning; } - private string GetPythonDirectory(List possiblePaths) - { - foreach (var path in possiblePaths) - { - // Skip wildcard paths for now - if (path.Contains("*")) - continue; - - if (File.Exists(path)) - { - return Path.GetDirectoryName(path); - } - } - - foreach (var path in possiblePaths) - { - if (!path.Contains("*")) - continue; - - string directoryPath = Path.GetDirectoryName(path); - string searchPattern = Path.GetFileName(Path.GetDirectoryName(path)); - string parentDir = Path.GetDirectoryName(directoryPath); - - if (Directory.Exists(parentDir)) - { - var matchingDirs = Directory.GetDirectories(parentDir, searchPattern); - - foreach (var dir in matchingDirs) - { - string candidatePath = Path.Combine(dir, "Python", "server.py"); - - if (File.Exists(candidatePath)) - { - return Path.GetDirectoryName(candidatePath); - } - } - } - } - - return null; - } - private string WriteToConfig(string pythonDir, string configPath) { // Create configuration object for unityMCP @@ -597,7 +418,7 @@ private string ConfigureMcpClient(McpClient mcpClient) Directory.CreateDirectory(Path.GetDirectoryName(configPath)); // Find the server.py file location - string pythonDir = GetPythonDirectory(possiblePaths); + string pythonDir = ServerInstaller.GetServerPath(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { @@ -701,7 +522,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) if (config?.mcpServers?.unityMCP != null) { - string pythonDir = GetPythonDirectory(possiblePaths); + string pythonDir = ServerInstaller.GetServerPath(); if ( pythonDir != null && Array.Exists( From 64b771210bb34ec8530b3610192d61ff3157379a Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:13:41 -0400 Subject: [PATCH 22/27] update version analysis --- .../Editor/Helpers/ServerInstaller.cs | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index c48a40fe..4ca4de3e 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -33,7 +33,8 @@ public static void EnsureServerInstalled() } else { - string installedVersion = GetInstalledVersion(saveLocation); + string pyprojectPath = Path.Combine(saveLocation, ServerFolder, "src"); + string installedVersion = ParseVersionFromPyproject(pyprojectPath); string latestVersion = GetLatestVersion(); if (IsNewerVersion(latestVersion, installedVersion)) @@ -111,7 +112,7 @@ private static bool IsDirectoryWritable(string path) private static bool IsServerInstalled(string location) { return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "version.txt")); + && File.Exists(Path.Combine(location, ServerFolder, "src", "pyproject.toml")); } /// @@ -138,28 +139,6 @@ private static void InstallServer(string location) // Fetch and checkout the branch RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); RunCommand("git", $"checkout {BranchName}", workingDirectory: location); - - // Create version.txt file based on pyproject.toml, stored at the root level - string pyprojectPath = Path.Combine(location, ServerFolder, "src", "pyproject.toml"); - if (File.Exists(pyprojectPath)) - { - string pyprojectContent = File.ReadAllText(pyprojectPath); - string version = ParseVersionFromPyproject(pyprojectContent); - File.WriteAllText(Path.Combine(location, ServerFolder, "version.txt"), version); - } - else - { - throw new Exception("Failed to find pyproject.toml after checkout"); - } - } - - /// - /// Retrieves the installed server version from version.txt. - /// - private static string GetInstalledVersion(string location) - { - string versionFile = Path.Combine(location, ServerFolder, "version.txt"); - return File.ReadAllText(versionFile).Trim(); } /// @@ -180,15 +159,6 @@ private static void UpdateServer(string location) // Pull latest changes in the src directory string serverDir = Path.Combine(location, ServerFolder, "src"); RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); - - // Update version.txt file based on pyproject.toml in src - string pyprojectPath = Path.Combine(serverDir, "pyproject.toml"); - if (File.Exists(pyprojectPath)) - { - string pyprojectContent = File.ReadAllText(pyprojectPath); - string version = ParseVersionFromPyproject(pyprojectContent); - File.WriteAllText(Path.Combine(location, ServerFolder, "version.txt"), version); - } } /// From 2f260861df8702b9de4d2142946fc274803b5109 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:17:26 -0400 Subject: [PATCH 23/27] update pyproject.toml path --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 4ca4de3e..0d8b1620 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -33,7 +33,12 @@ public static void EnsureServerInstalled() } else { - string pyprojectPath = Path.Combine(saveLocation, ServerFolder, "src"); + string pyprojectPath = Path.Combine( + saveLocation, + ServerFolder, + "src", + "pyproject.toml" + ); string installedVersion = ParseVersionFromPyproject(pyprojectPath); string latestVersion = GetLatestVersion(); From a3f192633893735c1ceb6e03afbc30d6cfb5d806 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:19:52 -0400 Subject: [PATCH 24/27] pass content, not path --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 0d8b1620..129929d5 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -39,7 +39,9 @@ public static void EnsureServerInstalled() "src", "pyproject.toml" ); - string installedVersion = ParseVersionFromPyproject(pyprojectPath); + string installedVersion = ParseVersionFromPyproject( + File.ReadAllText(pyprojectPath) + ); string latestVersion = GetLatestVersion(); if (IsNewerVersion(latestVersion, installedVersion)) From 8c50e01add0f02ba62a2bd20afc8989c5d4a006f Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:23:46 -0400 Subject: [PATCH 25/27] update update path --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 129929d5..a6943917 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -46,6 +46,7 @@ public static void EnsureServerInstalled() if (IsNewerVersion(latestVersion, installedVersion)) { + Debug.Log($"is newer version"); UpdateServer(saveLocation); } else { } @@ -163,9 +164,8 @@ private static string GetLatestVersion() /// private static void UpdateServer(string location) { - // Pull latest changes in the src directory - string serverDir = Path.Combine(location, ServerFolder, "src"); - RunCommand("git", $"pull origin {BranchName}", workingDirectory: serverDir); + Debug.Log("updating server"); + RunCommand("git", $"pull origin {BranchName}", workingDirectory: location); } /// From 9032c5809d4988c19baa18099ddfb546e54204e4 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 15:25:55 -0400 Subject: [PATCH 26/27] update server test --- UnityMcpServer/src/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpServer/src/pyproject.toml index eebcde11..0e42b754 100644 --- a/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpServer/src/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "UnityMcpServer" -version = "2.0.0" +version = "2.0.1" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.12" From 99f38e21bd5d0c1a0c2f2f63be7c7e1371256e60 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Tue, 8 Apr 2025 20:55:22 -0400 Subject: [PATCH 27/27] PR prep --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 4 +--- UnityMcpServer/src/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index a6943917..7f22c41a 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -11,7 +11,7 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; - private const string BranchName = "feature/install-overhaul"; + private const string BranchName = "master"; private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; private const string PyprojectUrl = "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/refs/heads/" @@ -46,7 +46,6 @@ public static void EnsureServerInstalled() if (IsNewerVersion(latestVersion, installedVersion)) { - Debug.Log($"is newer version"); UpdateServer(saveLocation); } else { } @@ -164,7 +163,6 @@ private static string GetLatestVersion() /// private static void UpdateServer(string location) { - Debug.Log("updating server"); RunCommand("git", $"pull origin {BranchName}", workingDirectory: location); } diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpServer/src/pyproject.toml index 0e42b754..eebcde11 100644 --- a/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpServer/src/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "UnityMcpServer" -version = "2.0.1" +version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.12"