From 8d86cada1cfb2caed4aabc8cfb1e23c2a0c62979 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Sun, 30 Mar 2025 15:58:01 -0400 Subject: [PATCH 1/5] new tools --- .gitignore | 3 +- Editor/Commands/AssetCommandHandler.cs | 232 ---- Editor/Commands/AssetCommandHandler.cs.meta | 2 - Editor/Commands/CommandRegistry.cs | 51 - Editor/Commands/EditorControlHandler.cs | 950 -------------- Editor/Commands/EditorControlHandler.cs.meta | 2 - Editor/Commands/MaterialCommandHandler.cs | 95 -- .../Commands/MaterialCommandHandler.cs.meta | 2 - Editor/Commands/ObjectCommandHandler.cs | 505 ------- Editor/Commands/ObjectCommandHandler.cs.meta | 2 - Editor/Commands/SceneCommandHandler.cs | 140 -- Editor/Commands/SceneCommandHandler.cs.meta | 2 - Editor/Commands/ScriptCommandHandler.cs | 496 ------- Editor/Commands/ScriptCommandHandler.cs.meta | 2 - Editor/Helpers/Response.cs | 49 + Editor/Helpers/Response.cs.meta | 2 + Editor/{Commands.meta => Tools.meta} | 0 Editor/Tools/CommandRegistry.cs | 45 + .../CommandRegistry.cs.meta | 0 Editor/Tools/ExecuteMenuItem.cs | 111 ++ Editor/Tools/ExecuteMenuItem.cs.meta | 2 + Editor/Tools/ManageAsset.cs | 828 ++++++++++++ Editor/Tools/ManageAsset.cs.meta | 2 + Editor/Tools/ManageEditor.cs | 532 ++++++++ Editor/Tools/ManageEditor.cs.meta | 2 + Editor/Tools/ManageGameObject.cs | 1159 +++++++++++++++++ Editor/Tools/ManageGameObject.cs.meta | 2 + Editor/Tools/ManageScene.cs | 344 +++++ Editor/Tools/ManageScene.cs.meta | 2 + Editor/Tools/ManageScript.cs | 277 ++++ Editor/Tools/ManageScript.cs.meta | 2 + Editor/Tools/ReadConsole.cs | 366 ++++++ Editor/Tools/ReadConsole.cs.meta | 2 + Editor/UnityMCPBridge.cs | 64 +- Python/server.py | 63 +- Python/tools/__init__.py | 30 +- Python/tools/asset_tools.py | 259 ---- Python/tools/editor_tools.py | 295 ----- Python/tools/execute_menu_item.py | 53 + ...ools.py.meta => execute_menu_item.py.meta} | 2 +- Python/tools/manage_asset.py | 66 + ...tor_tools.py.meta => manage_asset.py.meta} | 2 +- Python/tools/manage_editor.py | 69 + ...al_tools.py.meta => manage_editor.py.meta} | 2 +- Python/tools/manage_gameobject.py | 116 ++ ...ools.py.meta => manage_gameobject.py.meta} | 2 +- Python/tools/manage_scene.py | 56 + Python/tools/manage_scene.py.meta | 7 + Python/tools/manage_script.py | 63 + Python/tools/manage_script.py.meta | 7 + Python/tools/material_tools.py | 89 -- Python/tools/object_tools.py | 250 ---- Python/tools/read_console.py | 60 + Python/tools/read_console.py.meta | 7 + Python/tools/scene_tools.py | 338 ----- Python/tools/scene_tools.py.meta | 7 - Python/tools/script_tools.py | 280 ---- Python/tools/script_tools.py.meta | 7 - example-prompt-v2.md | 20 + example-prompt-v2.md.meta | 7 + example-prompt.md | 83 ++ example-prompt.md.meta | 7 + tool-refactor-plan.md | 98 ++ tool-refactor-plan.md.meta | 7 + 64 files changed, 4515 insertions(+), 4112 deletions(-) delete mode 100644 Editor/Commands/AssetCommandHandler.cs delete mode 100644 Editor/Commands/AssetCommandHandler.cs.meta delete mode 100644 Editor/Commands/CommandRegistry.cs delete mode 100644 Editor/Commands/EditorControlHandler.cs delete mode 100644 Editor/Commands/EditorControlHandler.cs.meta delete mode 100644 Editor/Commands/MaterialCommandHandler.cs delete mode 100644 Editor/Commands/MaterialCommandHandler.cs.meta delete mode 100644 Editor/Commands/ObjectCommandHandler.cs delete mode 100644 Editor/Commands/ObjectCommandHandler.cs.meta delete mode 100644 Editor/Commands/SceneCommandHandler.cs delete mode 100644 Editor/Commands/SceneCommandHandler.cs.meta delete mode 100644 Editor/Commands/ScriptCommandHandler.cs delete mode 100644 Editor/Commands/ScriptCommandHandler.cs.meta create mode 100644 Editor/Helpers/Response.cs create mode 100644 Editor/Helpers/Response.cs.meta rename Editor/{Commands.meta => Tools.meta} (100%) create mode 100644 Editor/Tools/CommandRegistry.cs rename Editor/{Commands => Tools}/CommandRegistry.cs.meta (100%) create mode 100644 Editor/Tools/ExecuteMenuItem.cs create mode 100644 Editor/Tools/ExecuteMenuItem.cs.meta create mode 100644 Editor/Tools/ManageAsset.cs create mode 100644 Editor/Tools/ManageAsset.cs.meta create mode 100644 Editor/Tools/ManageEditor.cs create mode 100644 Editor/Tools/ManageEditor.cs.meta create mode 100644 Editor/Tools/ManageGameObject.cs create mode 100644 Editor/Tools/ManageGameObject.cs.meta create mode 100644 Editor/Tools/ManageScene.cs create mode 100644 Editor/Tools/ManageScene.cs.meta create mode 100644 Editor/Tools/ManageScript.cs create mode 100644 Editor/Tools/ManageScript.cs.meta create mode 100644 Editor/Tools/ReadConsole.cs create mode 100644 Editor/Tools/ReadConsole.cs.meta delete mode 100644 Python/tools/asset_tools.py delete mode 100644 Python/tools/editor_tools.py create mode 100644 Python/tools/execute_menu_item.py rename Python/tools/{asset_tools.py.meta => execute_menu_item.py.meta} (74%) create mode 100644 Python/tools/manage_asset.py rename Python/tools/{editor_tools.py.meta => manage_asset.py.meta} (74%) create mode 100644 Python/tools/manage_editor.py rename Python/tools/{material_tools.py.meta => manage_editor.py.meta} (74%) create mode 100644 Python/tools/manage_gameobject.py rename Python/tools/{object_tools.py.meta => manage_gameobject.py.meta} (74%) create mode 100644 Python/tools/manage_scene.py create mode 100644 Python/tools/manage_scene.py.meta create mode 100644 Python/tools/manage_script.py create mode 100644 Python/tools/manage_script.py.meta delete mode 100644 Python/tools/material_tools.py delete mode 100644 Python/tools/object_tools.py create mode 100644 Python/tools/read_console.py create mode 100644 Python/tools/read_console.py.meta delete mode 100644 Python/tools/scene_tools.py delete mode 100644 Python/tools/scene_tools.py.meta delete mode 100644 Python/tools/script_tools.py delete mode 100644 Python/tools/script_tools.py.meta create mode 100644 example-prompt-v2.md create mode 100644 example-prompt-v2.md.meta create mode 100644 example-prompt.md create mode 100644 example-prompt.md.meta create mode 100644 tool-refactor-plan.md create mode 100644 tool-refactor-plan.md.meta diff --git a/.gitignore b/.gitignore index c674e302..ddad8cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ wheels/ # IDE .idea/ -.vscode/ \ No newline at end of file +.vscode/ +.aider* diff --git a/Editor/Commands/AssetCommandHandler.cs b/Editor/Commands/AssetCommandHandler.cs deleted file mode 100644 index 77f953e1..00000000 --- a/Editor/Commands/AssetCommandHandler.cs +++ /dev/null @@ -1,232 +0,0 @@ -using UnityEngine; -using UnityEditor; -using System.IO; -using Newtonsoft.Json.Linq; -using System.Collections.Generic; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles asset-related commands for the MCP Server - /// - public static class AssetCommandHandler - { - /// - /// Imports an asset into the project - /// - public static object ImportAsset(JObject @params) - { - try - { - string sourcePath = (string)@params["source_path"]; - string targetPath = (string)@params["target_path"]; - - if (string.IsNullOrEmpty(sourcePath)) - return new { success = false, error = "Source path cannot be empty" }; - - if (string.IsNullOrEmpty(targetPath)) - return new { success = false, error = "Target path cannot be empty" }; - - if (!File.Exists(sourcePath)) - return new { success = false, error = $"Source file not found: {sourcePath}" }; - - // Ensure target directory exists - string targetDir = Path.GetDirectoryName(targetPath); - if (!Directory.Exists(targetDir)) - { - Directory.CreateDirectory(targetDir); - } - - // Copy file to target location - File.Copy(sourcePath, targetPath, true); - AssetDatabase.Refresh(); - - return new - { - success = true, - message = $"Successfully imported asset to {targetPath}", - path = targetPath - }; - } - catch (System.Exception e) - { - return new - { - success = false, - error = $"Failed to import asset: {e.Message}", - stackTrace = e.StackTrace - }; - } - } - - /// - /// Instantiates a prefab in the current scene - /// - public static object InstantiatePrefab(JObject @params) - { - try - { - string prefabPath = (string)@params["prefab_path"]; - - if (string.IsNullOrEmpty(prefabPath)) - return new { success = false, error = "Prefab path cannot be empty" }; - - Vector3 position = new( - (float)@params["position_x"], - (float)@params["position_y"], - (float)@params["position_z"] - ); - Vector3 rotation = new( - (float)@params["rotation_x"], - (float)@params["rotation_y"], - (float)@params["rotation_z"] - ); - - GameObject prefab = AssetDatabase.LoadAssetAtPath(prefabPath); - if (prefab == null) - { - return new { success = false, error = $"Prefab not found at path: {prefabPath}" }; - } - - GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); - if (instance == null) - { - return new { success = false, error = $"Failed to instantiate prefab: {prefabPath}" }; - } - - instance.transform.position = position; - instance.transform.rotation = Quaternion.Euler(rotation); - - return new - { - success = true, - message = "Successfully instantiated prefab", - instance_name = instance.name - }; - } - catch (System.Exception e) - { - return new - { - success = false, - error = $"Failed to instantiate prefab: {e.Message}", - stackTrace = e.StackTrace - }; - } - } - - /// - /// Creates a new prefab from a GameObject in the scene - /// - public static object CreatePrefab(JObject @params) - { - try - { - string objectName = (string)@params["object_name"]; - string prefabPath = (string)@params["prefab_path"]; - - if (string.IsNullOrEmpty(objectName)) - return new { success = false, error = "GameObject name cannot be empty" }; - - if (string.IsNullOrEmpty(prefabPath)) - return new { success = false, error = "Prefab path cannot be empty" }; - - // Ensure prefab has .prefab extension - if (!prefabPath.ToLower().EndsWith(".prefab")) - prefabPath = $"{prefabPath}.prefab"; - - GameObject sourceObject = GameObject.Find(objectName); - if (sourceObject == null) - { - return new { success = false, error = $"GameObject not found in scene: {objectName}" }; - } - - // Ensure target directory exists - string targetDir = Path.GetDirectoryName(prefabPath); - if (!Directory.Exists(targetDir)) - { - Directory.CreateDirectory(targetDir); - } - - GameObject prefab = PrefabUtility.SaveAsPrefabAsset(sourceObject, prefabPath); - if (prefab == null) - { - return new { success = false, error = "Failed to create prefab. Verify the path is writable." }; - } - - return new - { - success = true, - message = $"Successfully created prefab at {prefabPath}", - path = prefabPath - }; - } - catch (System.Exception e) - { - return new - { - success = false, - error = $"Failed to create prefab: {e.Message}", - stackTrace = e.StackTrace, - sourceInfo = $"Object: {@params["object_name"]}, Path: {@params["prefab_path"]}" - }; - } - } - - /// - /// Applies changes from a prefab instance back to the original prefab asset - /// - public static object ApplyPrefab(JObject @params) - { - string objectName = (string)@params["object_name"]; - - GameObject instance = GameObject.Find(objectName); - if (instance == null) - { - return new { error = $"GameObject not found in scene: {objectName}" }; - } - - Object prefabAsset = PrefabUtility.GetCorrespondingObjectFromSource(instance); - if (prefabAsset == null) - { - return new { error = "Selected object is not a prefab instance" }; - } - - PrefabUtility.ApplyPrefabInstance(instance, InteractionMode.AutomatedAction); - return new { message = "Successfully applied changes to prefab asset" }; - } - - /// - /// Gets a list of assets in the project, optionally filtered by type - /// - public static object GetAssetList(JObject @params) - { - string type = (string)@params["type"]; - string searchPattern = (string)@params["search_pattern"] ?? "*"; - string folder = (string)@params["folder"] ?? "Assets"; - - var guids = AssetDatabase.FindAssets(searchPattern, new[] { folder }); - var assets = new List(); - - foreach (var guid in guids) - { - var path = AssetDatabase.GUIDToAssetPath(guid); - var assetType = AssetDatabase.GetMainAssetTypeAtPath(path); - - // Skip if type filter is specified and doesn't match - if (!string.IsNullOrEmpty(type) && assetType?.Name != type) - continue; - - assets.Add(new - { - name = Path.GetFileNameWithoutExtension(path), - path, - type = assetType?.Name ?? "Unknown", - guid - }); - } - - return new { assets }; - } - } -} \ No newline at end of file diff --git a/Editor/Commands/AssetCommandHandler.cs.meta b/Editor/Commands/AssetCommandHandler.cs.meta deleted file mode 100644 index c4d3b86a..00000000 --- a/Editor/Commands/AssetCommandHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 72d89b7645a23af4bb8bf1deda8f2b36 \ No newline at end of file diff --git a/Editor/Commands/CommandRegistry.cs b/Editor/Commands/CommandRegistry.cs deleted file mode 100644 index 19983864..00000000 --- a/Editor/Commands/CommandRegistry.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Registry for all MCP command handlers - /// - public static class CommandRegistry - { - private static readonly Dictionary> _handlers = new() - { - // Scene management commands - { "GET_SCENE_INFO", _ => SceneCommandHandler.GetSceneInfo() }, - { "OPEN_SCENE", parameters => SceneCommandHandler.OpenScene(parameters) }, - { "SAVE_SCENE", _ => SceneCommandHandler.SaveScene() }, - { "NEW_SCENE", parameters => SceneCommandHandler.NewScene(parameters) }, - { "CHANGE_SCENE", parameters => SceneCommandHandler.ChangeScene(parameters) }, - - // Asset management commands - { "IMPORT_ASSET", parameters => AssetCommandHandler.ImportAsset(parameters) }, - { "INSTANTIATE_PREFAB", parameters => AssetCommandHandler.InstantiatePrefab(parameters) }, - { "CREATE_PREFAB", parameters => AssetCommandHandler.CreatePrefab(parameters) }, - { "APPLY_PREFAB", parameters => AssetCommandHandler.ApplyPrefab(parameters) }, - { "GET_ASSET_LIST", parameters => AssetCommandHandler.GetAssetList(parameters) }, - - // Object management commands - { "GET_OBJECT_PROPERTIES", parameters => ObjectCommandHandler.GetObjectProperties(parameters) }, - { "GET_COMPONENT_PROPERTIES", parameters => ObjectCommandHandler.GetComponentProperties(parameters) }, - { "FIND_OBJECTS_BY_NAME", parameters => ObjectCommandHandler.FindObjectsByName(parameters) }, - { "FIND_OBJECTS_BY_TAG", parameters => ObjectCommandHandler.FindObjectsByTag(parameters) }, - { "GET_HIERARCHY", _ => ObjectCommandHandler.GetHierarchy() }, - { "SELECT_OBJECT", parameters => ObjectCommandHandler.SelectObject(parameters) }, - { "GET_SELECTED_OBJECT", _ => ObjectCommandHandler.GetSelectedObject() }, - - // Editor control commands - { "EDITOR_CONTROL", parameters => EditorControlHandler.HandleEditorControl(parameters) } - }; - - /// - /// Gets a command handler by name - /// - /// Name of the command to get - /// The command handler function if found, null otherwise - public static Func GetHandler(string commandName) - { - return _handlers.TryGetValue(commandName, out var handler) ? handler : null; - } - } -} \ No newline at end of file diff --git a/Editor/Commands/EditorControlHandler.cs b/Editor/Commands/EditorControlHandler.cs deleted file mode 100644 index be48fe2b..00000000 --- a/Editor/Commands/EditorControlHandler.cs +++ /dev/null @@ -1,950 +0,0 @@ -using UnityEngine; -using UnityEditor; -using UnityEditor.Build.Reporting; -using Newtonsoft.Json.Linq; -using System; -using System.Reflection; -using System.Collections.Generic; -using System.Linq; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles editor control commands like undo, redo, play, pause, stop, and build operations. - /// - public static class EditorControlHandler - { - /// - /// Handles editor control commands - /// - public static object HandleEditorControl(JObject @params) - { - string command = (string)@params["command"]; - JObject commandParams = (JObject)@params["params"]; - - return command.ToUpper() switch - { - "UNDO" => HandleUndo(), - "REDO" => HandleRedo(), - "PLAY" => HandlePlay(), - "PAUSE" => HandlePause(), - "STOP" => HandleStop(), - "BUILD" => HandleBuild(commandParams), - "EXECUTE_COMMAND" => HandleExecuteCommand(commandParams), - "READ_CONSOLE" => ReadConsole(commandParams), - "GET_AVAILABLE_COMMANDS" => GetAvailableCommands(), - _ => new { error = $"Unknown editor control command: {command}" }, - }; - } - - private static object HandleUndo() - { - Undo.PerformUndo(); - return new { message = "Undo performed successfully" }; - } - - private static object HandleRedo() - { - Undo.PerformRedo(); - return new { message = "Redo performed successfully" }; - } - - private static object HandlePlay() - { - if (!EditorApplication.isPlaying) - { - EditorApplication.isPlaying = true; - return new { message = "Entered play mode" }; - } - return new { message = "Already in play mode" }; - } - - private static object HandlePause() - { - if (EditorApplication.isPlaying) - { - EditorApplication.isPaused = !EditorApplication.isPaused; - return new { message = EditorApplication.isPaused ? "Game paused" : "Game resumed" }; - } - return new { message = "Not in play mode" }; - } - - private static object HandleStop() - { - if (EditorApplication.isPlaying) - { - EditorApplication.isPlaying = false; - return new { message = "Exited play mode" }; - } - return new { message = "Not in play mode" }; - } - - private static object HandleBuild(JObject @params) - { - string platform = (string)@params["platform"]; - string buildPath = (string)@params["buildPath"]; - - try - { - BuildTarget target = GetBuildTarget(platform); - if ((int)target == -1) - { - return new { error = $"Unsupported platform: {platform}" }; - } - - BuildPlayerOptions buildPlayerOptions = new() - { - scenes = GetEnabledScenes(), - target = target, - locationPathName = buildPath - }; - - BuildReport report = BuildPipeline.BuildPlayer(buildPlayerOptions); - return new - { - message = "Build completed successfully", - report.summary - }; - } - catch (Exception e) - { - return new { error = $"Build failed: {e.Message}" }; - } - } - - private static object HandleExecuteCommand(JObject @params) - { - string commandName = (string)@params["commandName"]; - try - { - EditorApplication.ExecuteMenuItem(commandName); - return new { message = $"Executed command: {commandName}" }; - } - catch (Exception e) - { - return new { error = $"Failed to execute command: {e.Message}" }; - } - } - - /// - /// Reads log messages from the Unity Console - /// - /// Parameters containing filtering options - /// Object containing console messages filtered by type - public static object ReadConsole(JObject @params) - { - // Default values for show flags - bool showLogs = true; - bool showWarnings = true; - bool showErrors = true; - string searchTerm = string.Empty; - - // Get filter parameters if provided - if (@params != null) - { - if (@params["show_logs"] != null) showLogs = (bool)@params["show_logs"]; - if (@params["show_warnings"] != null) showWarnings = (bool)@params["show_warnings"]; - if (@params["show_errors"] != null) showErrors = (bool)@params["show_errors"]; - if (@params["search_term"] != null) searchTerm = (string)@params["search_term"]; - } - - try - { - // Get required types and methods via reflection - Type logEntriesType = Type.GetType("UnityEditor.LogEntries,UnityEditor"); - Type logEntryType = Type.GetType("UnityEditor.LogEntry,UnityEditor"); - - if (logEntriesType == null || logEntryType == null) - return new { error = "Could not find required Unity logging types", entries = new List() }; - - // Get essential methods - MethodInfo getCountMethod = logEntriesType.GetMethod("GetCount", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - MethodInfo getEntryMethod = logEntriesType.GetMethod("GetEntryAt", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) ?? - logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - - if (getCountMethod == null || getEntryMethod == null) - return new { error = "Could not find required Unity logging methods", entries = new List() }; - - // Get stack trace method if available - MethodInfo getStackTraceMethod = logEntriesType.GetMethod("GetEntryStackTrace", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, - null, new[] { typeof(int) }, null) ?? logEntriesType.GetMethod("GetStackTrace", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, - null, new[] { typeof(int) }, null); - - // Get entry count and prepare result list - int count = (int)getCountMethod.Invoke(null, null); - var entries = new List(); - - // Create LogEntry instance to populate - object logEntryInstance = Activator.CreateInstance(logEntryType); - - // Find properties on LogEntry type - PropertyInfo modeProperty = logEntryType.GetProperty("mode") ?? logEntryType.GetProperty("Mode"); - PropertyInfo messageProperty = logEntryType.GetProperty("message") ?? logEntryType.GetProperty("Message"); - - // Parse search terms if provided - string[] searchWords = !string.IsNullOrWhiteSpace(searchTerm) ? - searchTerm.ToLower().Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) : null; - - // Process each log entry - for (int i = 0; i < count; i++) - { - try - { - // Get log entry at index i - var methodParams = getEntryMethod.GetParameters(); - if (methodParams.Length == 2 && methodParams[1].ParameterType == logEntryType) - { - getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); - } - else if (methodParams.Length >= 1 && methodParams[0].ParameterType == typeof(int)) - { - var parameters = new object[methodParams.Length]; - parameters[0] = i; - for (int p = 1; p < parameters.Length; p++) - { - parameters[p] = methodParams[p].ParameterType.IsValueType ? - Activator.CreateInstance(methodParams[p].ParameterType) : null; - } - getEntryMethod.Invoke(null, parameters); - } - else continue; - - // Extract log data - int logType = modeProperty != null ? - Convert.ToInt32(modeProperty.GetValue(logEntryInstance) ?? 0) : 0; - - string message = messageProperty != null ? - (messageProperty.GetValue(logEntryInstance)?.ToString() ?? "") : ""; - - // If message is empty, try to get it via a field - if (string.IsNullOrEmpty(message)) - { - var msgField = logEntryType.GetField("message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (msgField != null) - { - object msgValue = msgField.GetValue(logEntryInstance); - message = msgValue != null ? msgValue.ToString() : ""; - } - - // If still empty, try alternate approach with Console window - if (string.IsNullOrEmpty(message)) - { - // Access ConsoleWindow and its data - Type consoleWindowType = Type.GetType("UnityEditor.ConsoleWindow,UnityEditor"); - if (consoleWindowType != null) - { - try - { - // Get Console window instance - var getWindowMethod = consoleWindowType.GetMethod("GetWindow", - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, - null, new[] { typeof(bool) }, null) ?? - consoleWindowType.GetMethod("GetConsoleWindow", - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - - if (getWindowMethod != null) - { - object consoleWindow = getWindowMethod.Invoke(null, - getWindowMethod.GetParameters().Length > 0 ? new object[] { false } : null); - - if (consoleWindow != null) - { - // Try to find log entries collection - foreach (var prop in consoleWindowType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (prop.PropertyType.IsArray || - (prop.PropertyType.IsGenericType && prop.PropertyType.GetGenericTypeDefinition() == typeof(List<>))) - { - try - { - var logItems = prop.GetValue(consoleWindow); - if (logItems != null) - { - if (logItems.GetType().IsArray && i < ((Array)logItems).Length) - { - var entry = ((Array)logItems).GetValue(i); - if (entry != null) - { - var entryType = entry.GetType(); - var entryMessageProp = entryType.GetProperty("message") ?? - entryType.GetProperty("Message"); - if (entryMessageProp != null) - { - object value = entryMessageProp.GetValue(entry); - if (value != null) - { - message = value.ToString(); - break; - } - } - } - } - } - } - catch - { - // Ignore errors in this fallback approach - } - } - } - } - } - } - catch - { - // Ignore errors in this fallback approach - } - } - } - - // If still empty, try one more approach with log files - if (string.IsNullOrEmpty(message)) - { - // This is our last resort - try to get log messages from the most recent Unity log file - try - { - string logPath = string.Empty; - - // Determine the log file path based on the platform - if (Application.platform == RuntimePlatform.WindowsEditor) - { - logPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Unity", "Editor", "Editor.log"); - } - else if (Application.platform == RuntimePlatform.OSXEditor) - { - logPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), - "Library", "Logs", "Unity", "Editor.log"); - } - else if (Application.platform == RuntimePlatform.LinuxEditor) - { - logPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), - ".config", "unity3d", "logs", "Editor.log"); - } - - if (!string.IsNullOrEmpty(logPath) && System.IO.File.Exists(logPath)) - { - // Read last few lines from the log file - var logLines = ReadLastLines(logPath, 100); - if (logLines.Count > i) - { - message = logLines[logLines.Count - 1 - i]; - } - } - } - catch - { - // Ignore errors in this fallback approach - } - } - } - - // Get stack trace if method available - string stackTrace = ""; - if (getStackTraceMethod != null) - { - stackTrace = getStackTraceMethod.Invoke(null, new object[] { i })?.ToString() ?? ""; - } - - // Filter by type - bool typeMatch = (logType == 0 && showLogs) || - (logType == 1 && showWarnings) || - (logType == 2 && showErrors); - if (!typeMatch) continue; - - // Filter by search term - bool searchMatch = true; - if (searchWords != null && searchWords.Length > 0) - { - string lowerMessage = message.ToLower(); - string lowerStackTrace = stackTrace.ToLower(); - - foreach (string word in searchWords) - { - if (!lowerMessage.Contains(word) && !lowerStackTrace.Contains(word)) - { - searchMatch = false; - break; - } - } - } - if (!searchMatch) continue; - - // Add matching entry to results - string typeStr = logType == 0 ? "Log" : logType == 1 ? "Warning" : "Error"; - entries.Add(new - { - type = typeStr, - message, - stackTrace - }); - } - catch (Exception) - { - // Skip entries that cause errors - continue; - } - } - - // Return filtered results - return new - { - message = "Console logs retrieved successfully", - entries, - total_entries = count, - filtered_count = entries.Count, - show_logs = showLogs, - show_warnings = showWarnings, - show_errors = showErrors - }; - } - catch (Exception e) - { - return new - { - error = $"Failed to read console logs: {e.Message}", - entries = new List() - }; - } - } - - private static MethodInfo FindMethod(Type type, string[] methodNames) - { - foreach (var methodName in methodNames) - { - var method = type.GetMethod(methodName, - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - if (method != null) - return method; - } - return null; - } - - private static BuildTarget GetBuildTarget(string platform) - { - BuildTarget target; - switch (platform.ToLower()) - { - case "windows": target = BuildTarget.StandaloneWindows64; break; - case "mac": target = BuildTarget.StandaloneOSX; break; - case "linux": target = BuildTarget.StandaloneLinux64; break; - case "android": target = BuildTarget.Android; break; - case "ios": target = BuildTarget.iOS; break; - case "webgl": target = BuildTarget.WebGL; break; - default: target = (BuildTarget)(-1); break; // Invalid target - } - return target; - } - - private static string[] GetEnabledScenes() - { - var scenes = new List(); - for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) - { - if (EditorBuildSettings.scenes[i].enabled) - { - scenes.Add(EditorBuildSettings.scenes[i].path); - } - } - return scenes.ToArray(); - } - - /// - /// Helper method to get information about available properties and fields in a type - /// - private static Dictionary GetTypeInfo(Type type) - { - var result = new Dictionary(); - - // Get all public and non-public properties - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | - BindingFlags.Static | BindingFlags.Instance); - var propList = new List(); - foreach (var prop in properties) - { - propList.Add($"{prop.PropertyType.Name} {prop.Name}"); - } - result["Properties"] = propList; - - // Get all public and non-public fields - var fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | - BindingFlags.Static | BindingFlags.Instance); - var fieldList = new List(); - foreach (var field in fields) - { - fieldList.Add($"{field.FieldType.Name} {field.Name}"); - } - result["Fields"] = fieldList; - - // Get all public and non-public methods - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | - BindingFlags.Static | BindingFlags.Instance); - var methodList = new List(); - foreach (var method in methods) - { - if (!method.Name.StartsWith("get_") && !method.Name.StartsWith("set_")) - { - var parameters = string.Join(", ", method.GetParameters() - .Select(p => $"{p.ParameterType.Name} {p.Name}")); - methodList.Add($"{method.ReturnType.Name} {method.Name}({parameters})"); - } - } - result["Methods"] = methodList; - - return result; - } - - /// - /// Helper method to get all property and field values from an object - /// - private static Dictionary GetObjectValues(object obj) - { - if (obj == null) return new Dictionary(); - - var result = new Dictionary(); - var type = obj.GetType(); - - // Get all property values - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - foreach (var prop in properties) - { - try - { - var value = prop.GetValue(obj); - result[$"Property:{prop.Name}"] = value?.ToString() ?? "null"; - } - catch (Exception) - { - result[$"Property:{prop.Name}"] = "ERROR"; - } - } - - // Get all field values - var fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - foreach (var field in fields) - { - try - { - var value = field.GetValue(obj); - result[$"Field:{field.Name}"] = value?.ToString() ?? "null"; - } - catch (Exception) - { - result[$"Field:{field.Name}"] = "ERROR"; - } - } - - return result; - } - - /// - /// Reads the last N lines from a file - /// - private static List ReadLastLines(string filePath, int lineCount) - { - var result = new List(); - - using (var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite)) - using (var reader = new System.IO.StreamReader(stream)) - { - string line; - var circularBuffer = new List(lineCount); - int currentIndex = 0; - - // Read all lines keeping only the last N in a circular buffer - while ((line = reader.ReadLine()) != null) - { - if (circularBuffer.Count < lineCount) - { - circularBuffer.Add(line); - } - else - { - circularBuffer[currentIndex] = line; - currentIndex = (currentIndex + 1) % lineCount; - } - } - - // Reorder the circular buffer so that lines are returned in order - if (circularBuffer.Count == lineCount) - { - for (int i = 0; i < lineCount; i++) - { - result.Add(circularBuffer[(currentIndex + i) % lineCount]); - } - } - else - { - result.AddRange(circularBuffer); - } - } - - return result; - } - - /// - /// Gets a comprehensive list of available Unity commands, including editor menu items, - /// internal commands, utility methods, and other actionable operations that can be executed. - /// - /// Object containing categorized lists of available command paths - private static object GetAvailableCommands() - { - var menuCommands = new HashSet(); - var utilityCommands = new HashSet(); - var assetCommands = new HashSet(); - var sceneCommands = new HashSet(); - var gameObjectCommands = new HashSet(); - var prefabCommands = new HashSet(); - var shortcutCommands = new HashSet(); - var otherCommands = new HashSet(); - - // Add a simple command that we know will work for testing - menuCommands.Add("Window/Unity MCP"); - - Debug.Log("Starting command collection..."); - - try - { - // Add all EditorApplication static methods - these are guaranteed to work - Debug.Log("Adding EditorApplication methods..."); - foreach (MethodInfo method in typeof(EditorApplication).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - utilityCommands.Add($"EditorApplication.{method.Name}"); - } - Debug.Log($"Added {utilityCommands.Count} EditorApplication methods"); - - // Add built-in menu commands directly - these are common ones that should always be available - Debug.Log("Adding built-in menu commands..."); - string[] builtInMenus = new[] { - "File/New Scene", - "File/Open Scene", - "File/Save", - "File/Save As...", - "Edit/Undo", - "Edit/Redo", - "Edit/Cut", - "Edit/Copy", - "Edit/Paste", - "Edit/Duplicate", - "Edit/Delete", - "GameObject/Create Empty", - "GameObject/3D Object/Cube", - "GameObject/3D Object/Sphere", - "GameObject/3D Object/Capsule", - "GameObject/3D Object/Cylinder", - "GameObject/3D Object/Plane", - "GameObject/Light/Directional Light", - "GameObject/Light/Point Light", - "GameObject/Light/Spotlight", - "GameObject/Light/Area Light", - "Component/Mesh/Mesh Filter", - "Component/Mesh/Mesh Renderer", - "Component/Physics/Rigidbody", - "Component/Physics/Box Collider", - "Component/Physics/Sphere Collider", - "Component/Physics/Capsule Collider", - "Component/Audio/Audio Source", - "Component/Audio/Audio Listener", - "Window/General/Scene", - "Window/General/Game", - "Window/General/Inspector", - "Window/General/Hierarchy", - "Window/General/Project", - "Window/General/Console", - "Window/Analysis/Profiler", - "Window/Package Manager", - "Assets/Create/Material", - "Assets/Create/C# Script", - "Assets/Create/Prefab", - "Assets/Create/Scene", - "Assets/Create/Folder", - }; - - foreach (string menuItem in builtInMenus) - { - menuCommands.Add(menuItem); - } - Debug.Log($"Added {builtInMenus.Length} built-in menu commands"); - - // Get menu commands from MenuItem attributes - wrapped in separate try block - Debug.Log("Searching for MenuItem attributes..."); - try - { - int itemCount = 0; - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - if (assembly.IsDynamic) continue; - - try - { - foreach (Type type in assembly.GetExportedTypes()) - { - try - { - foreach (MethodInfo method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) - { - try - { - object[] attributes = method.GetCustomAttributes(typeof(UnityEditor.MenuItem), false); - if (attributes != null && attributes.Length > 0) - { - foreach (var attr in attributes) - { - var menuItem = attr as UnityEditor.MenuItem; - if (menuItem != null && !string.IsNullOrEmpty(menuItem.menuItem)) - { - menuCommands.Add(menuItem.menuItem); - itemCount++; - } - } - } - } - catch (Exception methodEx) - { - Debug.LogWarning($"Error getting menu items for method {method.Name}: {methodEx.Message}"); - continue; - } - } - } - catch (Exception typeEx) - { - Debug.LogWarning($"Error processing type: {typeEx.Message}"); - continue; - } - } - } - catch (Exception assemblyEx) - { - Debug.LogWarning($"Error examining assembly {assembly.GetName().Name}: {assemblyEx.Message}"); - continue; - } - } - Debug.Log($"Found {itemCount} menu items from attributes"); - } - catch (Exception menuItemEx) - { - Debug.LogError($"Failed to get menu items: {menuItemEx.Message}"); - } - - // Add EditorUtility methods as commands - Debug.Log("Adding EditorUtility methods..."); - foreach (MethodInfo method in typeof(EditorUtility).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - utilityCommands.Add($"EditorUtility.{method.Name}"); - } - Debug.Log($"Added {typeof(EditorUtility).GetMethods(BindingFlags.Public | BindingFlags.Static).Length} EditorUtility methods"); - - // Add AssetDatabase methods as commands - Debug.Log("Adding AssetDatabase methods..."); - foreach (MethodInfo method in typeof(AssetDatabase).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - assetCommands.Add($"AssetDatabase.{method.Name}"); - } - Debug.Log($"Added {typeof(AssetDatabase).GetMethods(BindingFlags.Public | BindingFlags.Static).Length} AssetDatabase methods"); - - // Add EditorSceneManager methods as commands - Debug.Log("Adding EditorSceneManager methods..."); - Type sceneManagerType = typeof(UnityEditor.SceneManagement.EditorSceneManager); - if (sceneManagerType != null) - { - foreach (MethodInfo method in sceneManagerType.GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - sceneCommands.Add($"EditorSceneManager.{method.Name}"); - } - Debug.Log($"Added {sceneManagerType.GetMethods(BindingFlags.Public | BindingFlags.Static).Length} EditorSceneManager methods"); - } - - // Add GameObject manipulation commands - Debug.Log("Adding GameObject methods..."); - foreach (MethodInfo method in typeof(GameObject).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - gameObjectCommands.Add($"GameObject.{method.Name}"); - } - Debug.Log($"Added {typeof(GameObject).GetMethods(BindingFlags.Public | BindingFlags.Static).Length} GameObject methods"); - - // Add Selection-related commands - Debug.Log("Adding Selection methods..."); - foreach (MethodInfo method in typeof(Selection).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - gameObjectCommands.Add($"Selection.{method.Name}"); - } - Debug.Log($"Added {typeof(Selection).GetMethods(BindingFlags.Public | BindingFlags.Static).Length} Selection methods"); - - // Add PrefabUtility methods as commands - Debug.Log("Adding PrefabUtility methods..."); - Type prefabUtilityType = typeof(UnityEditor.PrefabUtility); - if (prefabUtilityType != null) - { - foreach (MethodInfo method in prefabUtilityType.GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - prefabCommands.Add($"PrefabUtility.{method.Name}"); - } - Debug.Log($"Added {prefabUtilityType.GetMethods(BindingFlags.Public | BindingFlags.Static).Length} PrefabUtility methods"); - } - - // Add Undo related methods - Debug.Log("Adding Undo methods..."); - foreach (MethodInfo method in typeof(Undo).GetMethods(BindingFlags.Public | BindingFlags.Static)) - { - utilityCommands.Add($"Undo.{method.Name}"); - } - Debug.Log($"Added {typeof(Undo).GetMethods(BindingFlags.Public | BindingFlags.Static).Length} Undo methods"); - - // The rest of the command gathering can be attempted but might not be critical - try - { - // Get commands from Unity's internal command system - Debug.Log("Trying to get internal CommandService commands..."); - Type commandServiceType = typeof(UnityEditor.EditorWindow).Assembly.GetType("UnityEditor.CommandService"); - if (commandServiceType != null) - { - Debug.Log("Found CommandService type"); - PropertyInfo instanceProperty = commandServiceType.GetProperty("Instance", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); - - if (instanceProperty != null) - { - Debug.Log("Found Instance property"); - object commandService = instanceProperty.GetValue(null); - if (commandService != null) - { - Debug.Log("Got CommandService instance"); - MethodInfo findAllCommandsMethod = commandServiceType.GetMethod("FindAllCommands", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - - if (findAllCommandsMethod != null) - { - Debug.Log("Found FindAllCommands method"); - var commandsResult = findAllCommandsMethod.Invoke(commandService, null); - if (commandsResult != null) - { - Debug.Log("Got commands result"); - var commandsList = commandsResult as System.Collections.IEnumerable; - if (commandsList != null) - { - int commandCount = 0; - foreach (var cmd in commandsList) - { - try - { - PropertyInfo nameProperty = cmd.GetType().GetProperty("name") ?? - cmd.GetType().GetProperty("path") ?? - cmd.GetType().GetProperty("commandName"); - if (nameProperty != null) - { - string commandName = nameProperty.GetValue(cmd)?.ToString(); - if (!string.IsNullOrEmpty(commandName)) - { - otherCommands.Add(commandName); - commandCount++; - } - } - } - catch (Exception cmdEx) - { - Debug.LogWarning($"Error processing command: {cmdEx.Message}"); - continue; - } - } - Debug.Log($"Added {commandCount} internal commands"); - } - } - else - { - Debug.LogWarning("FindAllCommands returned null"); - } - } - else - { - Debug.LogWarning("FindAllCommands method not found"); - } - } - else - { - Debug.LogWarning("CommandService instance is null"); - } - } - else - { - Debug.LogWarning("Instance property not found on CommandService"); - } - } - else - { - Debug.LogWarning("CommandService type not found"); - } - } - catch (Exception e) - { - Debug.LogWarning($"Failed to get internal Unity commands: {e.Message}"); - } - - // Other additional command sources can be tried - // ... other commands ... - } - catch (Exception e) - { - Debug.LogError($"Error getting Unity commands: {e.Message}\n{e.StackTrace}"); - } - - // Create command categories dictionary for the result - var commandCategories = new Dictionary> - { - { "MenuCommands", menuCommands.OrderBy(x => x).ToList() }, - { "UtilityCommands", utilityCommands.OrderBy(x => x).ToList() }, - { "AssetCommands", assetCommands.OrderBy(x => x).ToList() }, - { "SceneCommands", sceneCommands.OrderBy(x => x).ToList() }, - { "GameObjectCommands", gameObjectCommands.OrderBy(x => x).ToList() }, - { "PrefabCommands", prefabCommands.OrderBy(x => x).ToList() }, - { "ShortcutCommands", shortcutCommands.OrderBy(x => x).ToList() }, - { "OtherCommands", otherCommands.OrderBy(x => x).ToList() } - }; - - // Calculate total command count - int totalCount = commandCategories.Values.Sum(list => list.Count); - - Debug.Log($"Command retrieval complete. Found {totalCount} total commands."); - - // Create a simplified response with just the essential data - // The complex object structure might be causing serialization issues - var allCommandsList = commandCategories.Values.SelectMany(x => x).OrderBy(x => x).ToList(); - - // Use simple string array instead of JArray for better serialization - string[] commandsArray = allCommandsList.ToArray(); - - // Log the array size for verification - Debug.Log($"Final commands array contains {commandsArray.Length} items"); - - try - { - // Return a simple object with just the commands array and count - var result = new - { - commands = commandsArray, - count = commandsArray.Length - }; - - // Verify the result can be serialized properly - var jsonTest = JsonUtility.ToJson(new { test = "This is a test" }); - Debug.Log($"JSON serialization test successful: {jsonTest}"); - - return result; - } - catch (Exception ex) - { - Debug.LogError($"Error creating response: {ex.Message}"); - - // Ultimate fallback - don't use any JObject/JArray - return new - { - message = $"Found {commandsArray.Length} commands", - firstTen = commandsArray.Take(10).ToArray(), - count = commandsArray.Length - }; - } - } - } -} \ No newline at end of file diff --git a/Editor/Commands/EditorControlHandler.cs.meta b/Editor/Commands/EditorControlHandler.cs.meta deleted file mode 100644 index 4ef1f170..00000000 --- a/Editor/Commands/EditorControlHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c38b437302cc6a846a955fe2cfe746c5 \ No newline at end of file diff --git a/Editor/Commands/MaterialCommandHandler.cs b/Editor/Commands/MaterialCommandHandler.cs deleted file mode 100644 index e47a4034..00000000 --- a/Editor/Commands/MaterialCommandHandler.cs +++ /dev/null @@ -1,95 +0,0 @@ -using UnityEngine; -using Newtonsoft.Json.Linq; -using UnityEngine.Rendering.Universal; -using UnityEngine.Rendering; -using UnityEditor; -using System.IO; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles material-related commands - /// - public static class MaterialCommandHandler - { - /// - /// Sets or modifies a material on an object - /// - public static object SetMaterial(JObject @params) - { - string objectName = (string)@params["object_name"] ?? throw new System.Exception("Parameter 'object_name' is required."); - var obj = GameObject.Find(objectName) ?? throw new System.Exception($"Object '{objectName}' not found."); - var renderer = obj.GetComponent() ?? throw new System.Exception($"Object '{objectName}' has no renderer."); - - // Check if URP is being used - bool isURP = GraphicsSettings.currentRenderPipeline is UniversalRenderPipelineAsset; - - Material material = null; - string materialName = (string)@params["material_name"]; - bool createIfMissing = (bool)(@params["create_if_missing"] ?? true); - string materialPath = null; - - // If material name is specified, try to find or create it - if (!string.IsNullOrEmpty(materialName)) - { - // Ensure Materials folder exists - const string materialsFolder = "Assets/Materials"; - if (!Directory.Exists(materialsFolder)) - { - Directory.CreateDirectory(materialsFolder); - } - - materialPath = $"{materialsFolder}/{materialName}.mat"; - material = AssetDatabase.LoadAssetAtPath(materialPath); - - if (material == null && createIfMissing) - { - // Create new material with appropriate shader - material = new Material(isURP ? Shader.Find("Universal Render Pipeline/Lit") : Shader.Find("Standard")); - material.name = materialName; - - // Save the material asset - AssetDatabase.CreateAsset(material, materialPath); - AssetDatabase.SaveAssets(); - } - else if (material == null) - { - throw new System.Exception($"Material '{materialName}' not found and create_if_missing is false."); - } - } - else - { - // Create a temporary material if no name specified - material = new Material(isURP ? Shader.Find("Universal Render Pipeline/Lit") : Shader.Find("Standard")); - } - - // Apply color if specified - if (@params.ContainsKey("color")) - { - var colorArray = (JArray)@params["color"]; - if (colorArray.Count < 3 || colorArray.Count > 4) - throw new System.Exception("Color must be an array of 3 (RGB) or 4 (RGBA) floats."); - - Color color = new( - (float)colorArray[0], - (float)colorArray[1], - (float)colorArray[2], - colorArray.Count > 3 ? (float)colorArray[3] : 1.0f - ); - material.color = color; - - // If this is a saved material, make sure to save the color change - if (!string.IsNullOrEmpty(materialPath)) - { - EditorUtility.SetDirty(material); - AssetDatabase.SaveAssets(); - } - } - - // Apply the material to the renderer - renderer.material = material; - - return new { material_name = material.name, path = materialPath }; - } - } -} \ No newline at end of file diff --git a/Editor/Commands/MaterialCommandHandler.cs.meta b/Editor/Commands/MaterialCommandHandler.cs.meta deleted file mode 100644 index be357881..00000000 --- a/Editor/Commands/MaterialCommandHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 50ed709388e81a741ac984de1c78427c \ No newline at end of file diff --git a/Editor/Commands/ObjectCommandHandler.cs b/Editor/Commands/ObjectCommandHandler.cs deleted file mode 100644 index 6176b285..00000000 --- a/Editor/Commands/ObjectCommandHandler.cs +++ /dev/null @@ -1,505 +0,0 @@ -using UnityEngine; -using Newtonsoft.Json.Linq; -using System; -using System.Linq; -using System.Collections.Generic; -using UnityEditor; -using UnityEngine.SceneManagement; -using UnityEditor.SceneManagement; -using UnityMCP.Editor.Helpers; -using System.Reflection; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles object-related commands - /// - public static class ObjectCommandHandler - { - /// - /// Gets information about a specific object - /// - public static object GetObjectInfo(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var obj = GameObject.Find(name) ?? throw new Exception($"Object '{name}' not found."); - return new - { - obj.name, - position = new[] { obj.transform.position.x, obj.transform.position.y, obj.transform.position.z }, - rotation = new[] { obj.transform.eulerAngles.x, obj.transform.eulerAngles.y, obj.transform.eulerAngles.z }, - scale = new[] { obj.transform.localScale.x, obj.transform.localScale.y, obj.transform.localScale.z } - }; - } - - /// - /// Creates a new object in the scene - /// - public static object CreateObject(JObject @params) - { - string type = (string)@params["type"] ?? throw new Exception("Parameter 'type' is required."); - GameObject obj = type.ToUpper() switch - { - "CUBE" => GameObject.CreatePrimitive(PrimitiveType.Cube), - "SPHERE" => GameObject.CreatePrimitive(PrimitiveType.Sphere), - "CYLINDER" => GameObject.CreatePrimitive(PrimitiveType.Cylinder), - "CAPSULE" => GameObject.CreatePrimitive(PrimitiveType.Capsule), - "PLANE" => GameObject.CreatePrimitive(PrimitiveType.Plane), - "EMPTY" => new GameObject(), - "CAMERA" => new GameObject("Camera") { }.AddComponent().gameObject, - "LIGHT" => new GameObject("Light") { }.AddComponent().gameObject, - "DIRECTIONAL_LIGHT" => CreateDirectionalLight(), - _ => throw new Exception($"Unsupported object type: {type}") - }; - - if (@params.ContainsKey("name")) obj.name = (string)@params["name"]; - if (@params.ContainsKey("location")) obj.transform.position = Vector3Helper.ParseVector3((JArray)@params["location"]); - if (@params.ContainsKey("rotation")) obj.transform.eulerAngles = Vector3Helper.ParseVector3((JArray)@params["rotation"]); - if (@params.ContainsKey("scale")) obj.transform.localScale = Vector3Helper.ParseVector3((JArray)@params["scale"]); - - return new { obj.name }; - } - - /// - /// Modifies an existing object's properties - /// - public static object ModifyObject(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var obj = GameObject.Find(name) ?? throw new Exception($"Object '{name}' not found."); - - // Handle basic transform properties - if (@params.ContainsKey("location")) obj.transform.position = Vector3Helper.ParseVector3((JArray)@params["location"]); - if (@params.ContainsKey("rotation")) obj.transform.eulerAngles = Vector3Helper.ParseVector3((JArray)@params["rotation"]); - if (@params.ContainsKey("scale")) obj.transform.localScale = Vector3Helper.ParseVector3((JArray)@params["scale"]); - if (@params.ContainsKey("visible")) obj.SetActive((bool)@params["visible"]); - - // Handle parent setting - if (@params.ContainsKey("set_parent")) - { - string parentName = (string)@params["set_parent"]; - var parent = GameObject.Find(parentName) ?? throw new Exception($"Parent object '{parentName}' not found."); - obj.transform.SetParent(parent.transform); - } - - // Handle component operations - if (@params.ContainsKey("add_component")) - { - string componentType = (string)@params["add_component"]; - Type type = componentType switch - { - "Rigidbody" => typeof(Rigidbody), - "BoxCollider" => typeof(BoxCollider), - "SphereCollider" => typeof(SphereCollider), - "CapsuleCollider" => typeof(CapsuleCollider), - "MeshCollider" => typeof(MeshCollider), - "Camera" => typeof(Camera), - "Light" => typeof(Light), - "Renderer" => typeof(Renderer), - "MeshRenderer" => typeof(MeshRenderer), - "SkinnedMeshRenderer" => typeof(SkinnedMeshRenderer), - "Animator" => typeof(Animator), - "AudioSource" => typeof(AudioSource), - "AudioListener" => typeof(AudioListener), - "ParticleSystem" => typeof(ParticleSystem), - "ParticleSystemRenderer" => typeof(ParticleSystemRenderer), - "TrailRenderer" => typeof(TrailRenderer), - "LineRenderer" => typeof(LineRenderer), - "TextMesh" => typeof(TextMesh), - "TextMeshPro" => typeof(TMPro.TextMeshPro), - "TextMeshProUGUI" => typeof(TMPro.TextMeshProUGUI), - _ => Type.GetType($"UnityEngine.{componentType}") ?? - Type.GetType(componentType) ?? - throw new Exception($"Component type '{componentType}' not found.") - }; - obj.AddComponent(type); - } - - if (@params.ContainsKey("remove_component")) - { - string componentType = (string)@params["remove_component"]; - Type type = Type.GetType($"UnityEngine.{componentType}") ?? - Type.GetType(componentType) ?? - throw new Exception($"Component type '{componentType}' not found."); - var component = obj.GetComponent(type); - if (component != null) - UnityEngine.Object.DestroyImmediate(component); - } - - // Handle property setting - if (@params.ContainsKey("set_property")) - { - var propertyData = (JObject)@params["set_property"]; - string componentType = (string)propertyData["component"]; - string propertyName = (string)propertyData["property"]; - var value = propertyData["value"]; - - // Handle GameObject properties separately - if (componentType == "GameObject") - { - var gameObjectProperty = typeof(GameObject).GetProperty(propertyName) ?? - throw new Exception($"Property '{propertyName}' not found on GameObject."); - - // Convert value based on property type - object gameObjectValue = Convert.ChangeType(value, gameObjectProperty.PropertyType); - gameObjectProperty.SetValue(obj, gameObjectValue); - return new { obj.name }; - } - - // Handle component properties - Type type = componentType switch - { - "Rigidbody" => typeof(Rigidbody), - "BoxCollider" => typeof(BoxCollider), - "SphereCollider" => typeof(SphereCollider), - "CapsuleCollider" => typeof(CapsuleCollider), - "MeshCollider" => typeof(MeshCollider), - "Camera" => typeof(Camera), - "Light" => typeof(Light), - "Renderer" => typeof(Renderer), - "MeshRenderer" => typeof(MeshRenderer), - "SkinnedMeshRenderer" => typeof(SkinnedMeshRenderer), - "Animator" => typeof(Animator), - "AudioSource" => typeof(AudioSource), - "AudioListener" => typeof(AudioListener), - "ParticleSystem" => typeof(ParticleSystem), - "ParticleSystemRenderer" => typeof(ParticleSystemRenderer), - "TrailRenderer" => typeof(TrailRenderer), - "LineRenderer" => typeof(LineRenderer), - "TextMesh" => typeof(TextMesh), - "TextMeshPro" => typeof(TMPro.TextMeshPro), - "TextMeshProUGUI" => typeof(TMPro.TextMeshProUGUI), - _ => Type.GetType($"UnityEngine.{componentType}") ?? - Type.GetType(componentType) ?? - throw new Exception($"Component type '{componentType}' not found.") - }; - - var component = obj.GetComponent(type) ?? - throw new Exception($"Component '{componentType}' not found on object '{name}'."); - - var property = type.GetProperty(propertyName) ?? - throw new Exception($"Property '{propertyName}' not found on component '{componentType}'."); - - // Convert value based on property type - object propertyValue = Convert.ChangeType(value, property.PropertyType); - property.SetValue(component, propertyValue); - } - - return new { obj.name }; - } - - /// - /// Deletes an object from the scene - /// - public static object DeleteObject(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var obj = GameObject.Find(name) ?? throw new Exception($"Object '{name}' not found."); - UnityEngine.Object.DestroyImmediate(obj); - return new { name }; - } - - /// - /// Gets all properties of a specified game object - /// - public static object GetObjectProperties(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var obj = GameObject.Find(name) ?? throw new Exception($"Object '{name}' not found."); - - var components = obj.GetComponents() - .Select(c => new - { - type = c.GetType().Name, - properties = GetComponentProperties(c) - }) - .ToList(); - - return new - { - obj.name, - obj.tag, - obj.layer, - active = obj.activeSelf, - transform = new - { - position = new[] { obj.transform.position.x, obj.transform.position.y, obj.transform.position.z }, - rotation = new[] { obj.transform.eulerAngles.x, obj.transform.eulerAngles.y, obj.transform.eulerAngles.z }, - scale = new[] { obj.transform.localScale.x, obj.transform.localScale.y, obj.transform.localScale.z } - }, - components - }; - } - - /// - /// Gets properties of a specific component - /// - public static object GetComponentProperties(JObject @params) - { - string objectName = (string)@params["object_name"] ?? throw new Exception("Parameter 'object_name' is required."); - string componentType = (string)@params["component_type"] ?? throw new Exception("Parameter 'component_type' is required."); - - var obj = GameObject.Find(objectName) ?? throw new Exception($"Object '{objectName}' not found."); - var component = obj.GetComponent(componentType) ?? throw new Exception($"Component '{componentType}' not found on object '{objectName}'."); - - return GetComponentProperties(component); - } - - /// - /// Finds objects by name in the scene - /// - public static object FindObjectsByName(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var objects = GameObject.FindObjectsByType(FindObjectsSortMode.None) - .Where(o => o.name.Contains(name)) - .Select(o => new - { - o.name, - path = GetGameObjectPath(o) - }) - .ToList(); - - return new { objects }; - } - - /// - /// Finds objects by tag in the scene - /// - public static object FindObjectsByTag(JObject @params) - { - string tag = (string)@params["tag"] ?? throw new Exception("Parameter 'tag' is required."); - var objects = GameObject.FindGameObjectsWithTag(tag) - .Select(o => new - { - o.name, - path = GetGameObjectPath(o) - }) - .ToList(); - - return new { objects }; - } - - /// - /// Gets the current hierarchy of game objects in the scene - /// - public static object GetHierarchy() - { - var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); - var hierarchy = rootObjects.Select(o => BuildHierarchyNode(o)).ToList(); - - return new { hierarchy }; - } - - /// - /// Selects a specified game object in the editor - /// - public static object SelectObject(JObject @params) - { - string name = (string)@params["name"] ?? throw new Exception("Parameter 'name' is required."); - var obj = GameObject.Find(name) ?? throw new Exception($"Object '{name}' not found."); - - Selection.activeGameObject = obj; - return new { obj.name }; - } - - /// - /// Gets the currently selected game object in the editor - /// - public static object GetSelectedObject() - { - var selected = Selection.activeGameObject; - if (selected == null) - return new { selected = (object)null }; - - return new - { - selected = new - { - selected.name, - path = GetGameObjectPath(selected) - } - }; - } - - // Helper methods - private static Dictionary GetComponentProperties(Component component) - { - var properties = new Dictionary(); - var serializedObject = new SerializedObject(component); - var property = serializedObject.GetIterator(); - - while (property.Next(true)) - { - properties[property.name] = GetPropertyValue(property); - } - - return properties; - } - - private static object GetPropertyValue(SerializedProperty property) - { - switch (property.propertyType) - { - case SerializedPropertyType.Integer: - return property.intValue; - case SerializedPropertyType.Float: - return property.floatValue; - case SerializedPropertyType.Boolean: - return property.boolValue; - case SerializedPropertyType.String: - return property.stringValue; - case SerializedPropertyType.Vector3: - return new[] { property.vector3Value.x, property.vector3Value.y, property.vector3Value.z }; - case SerializedPropertyType.Vector2: - return new[] { property.vector2Value.x, property.vector2Value.y }; - case SerializedPropertyType.Color: - return new[] { property.colorValue.r, property.colorValue.g, property.colorValue.b, property.colorValue.a }; - case SerializedPropertyType.ObjectReference: - return property.objectReferenceValue ? property.objectReferenceValue.name : null; - default: - return property.propertyType.ToString(); - } - } - - private static string GetGameObjectPath(GameObject obj) - { - var path = obj.name; - var parent = obj.transform.parent; - - while (parent != null) - { - path = parent.name + "/" + path; - parent = parent.parent; - } - - return path; - } - - private static object BuildHierarchyNode(GameObject obj) - { - return new - { - obj.name, - children = Enumerable.Range(0, obj.transform.childCount) - .Select(i => BuildHierarchyNode(obj.transform.GetChild(i).gameObject)) - .ToList() - }; - } - - /// - /// Creates a directional light game object - /// - private static GameObject CreateDirectionalLight() - { - var obj = new GameObject("DirectionalLight"); - var light = obj.AddComponent(); - light.type = LightType.Directional; - light.intensity = 1.0f; - light.shadows = LightShadows.Soft; - return obj; - } - - /// - /// Executes a context menu method on a component of a game object - /// - public static object ExecuteContextMenuItem(JObject @params) - { - string objectName = (string)@params["object_name"] ?? throw new Exception("Parameter 'object_name' is required."); - string componentName = (string)@params["component"] ?? throw new Exception("Parameter 'component' is required."); - string contextMenuItemName = (string)@params["context_menu_item"] ?? throw new Exception("Parameter 'context_menu_item' is required."); - - // Find the game object - var obj = GameObject.Find(objectName) ?? throw new Exception($"Object '{objectName}' not found."); - - // Find the component type - Type componentType = FindTypeInLoadedAssemblies(componentName) ?? - throw new Exception($"Component type '{componentName}' not found."); - - // Get the component from the game object - var component = obj.GetComponent(componentType) ?? - throw new Exception($"Component '{componentName}' not found on object '{objectName}'."); - - // Find methods with ContextMenu attribute matching the context menu item name - var methods = componentType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(m => m.GetCustomAttributes(typeof(ContextMenuItemAttribute), true).Any() || - m.GetCustomAttributes(typeof(ContextMenu), true) - .Cast() - .Any(attr => attr.menuItem == contextMenuItemName)) - .ToList(); - - // If no methods with ContextMenuItemAttribute are found, look for methods with name matching the context menu item - if (methods.Count == 0) - { - methods = componentType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(m => m.Name == contextMenuItemName) - .ToList(); - } - - if (methods.Count == 0) - throw new Exception($"No context menu method '{contextMenuItemName}' found on component '{componentName}'."); - - // If multiple methods match, use the first one and log a warning - if (methods.Count > 1) - { - Debug.LogWarning($"Found multiple methods for context menu item '{contextMenuItemName}' on component '{componentName}'. Using the first one."); - } - - var method = methods[0]; - - // Execute the method - try - { - method.Invoke(component, null); - return new - { - success = true, - message = $"Successfully executed context menu item '{contextMenuItemName}' on component '{componentName}' of object '{objectName}'." - }; - } - catch (Exception ex) - { - throw new Exception($"Error executing context menu item: {ex.Message}"); - } - } - - // Add this helper method to find types across all loaded assemblies - private static Type FindTypeInLoadedAssemblies(string typeName) - { - // First try standard approach - Type type = Type.GetType(typeName); - if (type != null) - return type; - - type = Type.GetType($"UnityEngine.{typeName}"); - if (type != null) - return type; - - // Then search all loaded assemblies - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - // Try with the simple name - type = assembly.GetType(typeName); - if (type != null) - return type; - - // Try with the fully qualified name (assembly.GetTypes() can be expensive, so we do this last) - var types = assembly.GetTypes().Where(t => t.Name == typeName).ToArray(); - - if (types.Length > 0) - { - // If we found multiple types with the same name, log a warning - if (types.Length > 1) - { - Debug.LogWarning( - $"Found multiple types named '{typeName}'. Using the first one: {types[0].FullName}" - ); - } - return types[0]; - } - } - - return null; - } - } -} \ No newline at end of file diff --git a/Editor/Commands/ObjectCommandHandler.cs.meta b/Editor/Commands/ObjectCommandHandler.cs.meta deleted file mode 100644 index f06891d1..00000000 --- a/Editor/Commands/ObjectCommandHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fd293dd9be195b94e926aa1c1f44ba72 \ No newline at end of file diff --git a/Editor/Commands/SceneCommandHandler.cs b/Editor/Commands/SceneCommandHandler.cs deleted file mode 100644 index 8f5dbc54..00000000 --- a/Editor/Commands/SceneCommandHandler.cs +++ /dev/null @@ -1,140 +0,0 @@ -using UnityEngine.SceneManagement; -using System.Linq; -using System; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.SceneManagement; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles scene-related commands for the MCP Server - /// - public static class SceneCommandHandler - { - /// - /// Gets information about the current scene - /// - /// Scene information including name and root objects - public static object GetSceneInfo() - { - var scene = SceneManager.GetActiveScene(); - var rootObjects = scene.GetRootGameObjects().Select(o => o.name).ToArray(); - return new { sceneName = scene.name, rootObjects }; - } - - /// - /// Opens a specified scene in the Unity editor - /// - /// Parameters containing the scene path - /// Result of the operation - public static object OpenScene(JObject @params) - { - try - { - string scenePath = (string)@params["scene_path"]; - if (string.IsNullOrEmpty(scenePath)) - return new { success = false, error = "Scene path cannot be empty" }; - - if (!System.IO.File.Exists(scenePath)) - return new { success = false, error = $"Scene file not found: {scenePath}" }; - - EditorSceneManager.OpenScene(scenePath); - return new { success = true, message = $"Opened scene: {scenePath}" }; - } - catch (Exception e) - { - return new { success = false, error = $"Failed to open scene: {e.Message}", stackTrace = e.StackTrace }; - } - } - - /// - /// Saves the current scene - /// - /// Result of the operation - public static object SaveScene() - { - try - { - var scene = SceneManager.GetActiveScene(); - EditorSceneManager.SaveScene(scene); - return new { success = true, message = $"Saved scene: {scene.path}" }; - } - catch (Exception e) - { - return new { success = false, error = $"Failed to save scene: {e.Message}", stackTrace = e.StackTrace }; - } - } - - /// - /// Creates a new empty scene - /// - /// Parameters containing the new scene path - /// Result of the operation - public static object NewScene(JObject @params) - { - try - { - string scenePath = (string)@params["scene_path"]; - if (string.IsNullOrEmpty(scenePath)) - return new { success = false, error = "Scene path cannot be empty" }; - - // Create new scene - var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene); - - // Ensure the scene is loaded and active - if (!scene.isLoaded) - { - EditorSceneManager.LoadScene(scenePath); - } - - // Save the scene - EditorSceneManager.SaveScene(scene, scenePath); - - // Force a refresh of the scene view - EditorApplication.ExecuteMenuItem("Window/General/Scene"); - - return new { success = true, message = $"Created new scene at: {scenePath}" }; - } - catch (Exception e) - { - return new { success = false, error = $"Failed to create new scene: {e.Message}", stackTrace = e.StackTrace }; - } - } - - /// - /// Changes to a different scene, optionally saving the current one - /// - /// Parameters containing the target scene path and save option - /// Result of the operation - public static object ChangeScene(JObject @params) - { - try - { - string scenePath = (string)@params["scene_path"]; - bool saveCurrent = @params["save_current"]?.Value() ?? false; - - if (string.IsNullOrEmpty(scenePath)) - return new { success = false, error = "Scene path cannot be empty" }; - - if (!System.IO.File.Exists(scenePath)) - return new { success = false, error = $"Scene file not found: {scenePath}" }; - - // Save current scene if requested - if (saveCurrent) - { - var currentScene = SceneManager.GetActiveScene(); - EditorSceneManager.SaveScene(currentScene); - } - - // Open the new scene - EditorSceneManager.OpenScene(scenePath); - return new { success = true, message = $"Changed to scene: {scenePath}" }; - } - catch (Exception e) - { - return new { success = false, error = $"Failed to change scene: {e.Message}", stackTrace = e.StackTrace }; - } - } - } -} \ No newline at end of file diff --git a/Editor/Commands/SceneCommandHandler.cs.meta b/Editor/Commands/SceneCommandHandler.cs.meta deleted file mode 100644 index f6db1d4b..00000000 --- a/Editor/Commands/SceneCommandHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cef46a6ad7d43744ab874553102f032a \ No newline at end of file diff --git a/Editor/Commands/ScriptCommandHandler.cs b/Editor/Commands/ScriptCommandHandler.cs deleted file mode 100644 index 666976c2..00000000 --- a/Editor/Commands/ScriptCommandHandler.cs +++ /dev/null @@ -1,496 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; - -namespace UnityMCP.Editor.Commands -{ - /// - /// Handles script-related commands for Unity - /// - public static class ScriptCommandHandler - { - /// - /// Views the contents of a Unity script file - /// - public static object ViewScript(JObject @params) - { - string scriptPath = - (string)@params["script_path"] - ?? throw new Exception("Parameter 'script_path' is required."); - bool requireExists = (bool?)@params["require_exists"] ?? true; - - // Handle path correctly to avoid double "Assets" folder issue - string relativePath; - if (scriptPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - // If path already starts with Assets/, remove it for local path operations - relativePath = scriptPath.Substring(7); - } - else - { - relativePath = scriptPath; - } - - string fullPath = Path.Combine(Application.dataPath, relativePath); - - if (!File.Exists(fullPath)) - { - if (requireExists) - { - throw new Exception($"Script file not found: {scriptPath}"); - } - else - { - return new { exists = false, message = $"Script file not found: {scriptPath}" }; - } - } - - string content = File.ReadAllText(fullPath); - byte[] contentBytes = System.Text.Encoding.UTF8.GetBytes(content); - string base64Content = Convert.ToBase64String(contentBytes); - - return new - { - exists = true, - content = base64Content, - encoding = "base64" - }; - } - - /// - /// Ensures the Scripts folder exists in the project - /// - private static void EnsureScriptsFolderExists() - { - // Never create an "Assets" folder as it's the project root - // Instead create "Scripts" within the existing Assets folder - string scriptsFolderPath = Path.Combine(Application.dataPath, "Scripts"); - if (!Directory.Exists(scriptsFolderPath)) - { - Directory.CreateDirectory(scriptsFolderPath); - AssetDatabase.Refresh(); - } - } - - /// - /// Creates a new Unity script file in the specified folder - /// - public static object CreateScript(JObject @params) - { - string scriptName = - (string)@params["script_name"] - ?? throw new Exception("Parameter 'script_name' is required."); - string scriptType = (string)@params["script_type"] ?? "MonoBehaviour"; - string namespaceName = (string)@params["namespace"]; - string template = (string)@params["template"]; - string scriptFolder = (string)@params["script_folder"]; - string content = (string)@params["content"]; - bool overwrite = (bool?)@params["overwrite"] ?? false; - - // Ensure script name ends with .cs - if (!scriptName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - scriptName += ".cs"; - - // Make sure scriptName doesn't contain path separators - extract base name - scriptName = Path.GetFileName(scriptName); - - // Determine the script path - string scriptPath; - - // Handle the script folder parameter - if (string.IsNullOrEmpty(scriptFolder)) - { - // Default to Scripts folder within Assets - scriptPath = "Scripts"; - EnsureScriptsFolderExists(); - } - else - { - // Use provided folder path - scriptPath = scriptFolder; - - // If scriptFolder starts with "Assets/", remove it for local path operations - if (scriptPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - scriptPath = scriptPath.Substring(7); - } - } - - // Create the full directory path, avoiding Assets/Assets issue - string folderPath = Path.Combine(Application.dataPath, scriptPath); - - // Create directory if it doesn't exist - if (!Directory.Exists(folderPath)) - { - try - { - Directory.CreateDirectory(folderPath); - AssetDatabase.Refresh(); - } - catch (Exception ex) - { - throw new Exception($"Failed to create directory '{scriptPath}': {ex.Message}"); - } - } - - // Check if script already exists - string fullFilePath = Path.Combine(folderPath, scriptName); - if (File.Exists(fullFilePath) && !overwrite) - { - throw new Exception( - $"Script file '{scriptName}' already exists in '{scriptPath}' and overwrite is not enabled." - ); - } - - try - { - // If content is provided, use it directly - if (!string.IsNullOrEmpty(content)) - { - // Create the script file with provided content - File.WriteAllText(fullFilePath, content); - } - else - { - // Otherwise generate content based on template and parameters - StringBuilder contentBuilder = new(); - - // Add using directives - contentBuilder.AppendLine("using UnityEngine;"); - contentBuilder.AppendLine(); - - // Add namespace if specified - if (!string.IsNullOrEmpty(namespaceName)) - { - contentBuilder.AppendLine($"namespace {namespaceName}"); - contentBuilder.AppendLine("{"); - } - - // Add class definition with indent based on namespace - string indent = string.IsNullOrEmpty(namespaceName) ? "" : " "; - contentBuilder.AppendLine( - $"{indent}public class {Path.GetFileNameWithoutExtension(scriptName)} : {scriptType}" - ); - contentBuilder.AppendLine($"{indent}{{"); - - // Add default Unity methods based on script type - if (scriptType == "MonoBehaviour") - { - contentBuilder.AppendLine($"{indent} private void Start()"); - contentBuilder.AppendLine($"{indent} {{"); - contentBuilder.AppendLine( - $"{indent} // Initialize your component here" - ); - contentBuilder.AppendLine($"{indent} }}"); - contentBuilder.AppendLine(); - contentBuilder.AppendLine($"{indent} private void Update()"); - contentBuilder.AppendLine($"{indent} {{"); - contentBuilder.AppendLine($"{indent} // Update your component here"); - contentBuilder.AppendLine($"{indent} }}"); - } - else if (scriptType == "ScriptableObject") - { - contentBuilder.AppendLine($"{indent} private void OnEnable()"); - contentBuilder.AppendLine($"{indent} {{"); - contentBuilder.AppendLine( - $"{indent} // Initialize your ScriptableObject here" - ); - contentBuilder.AppendLine($"{indent} }}"); - } - - // Close class - contentBuilder.AppendLine($"{indent}}}"); - - // Close namespace if specified - if (!string.IsNullOrEmpty(namespaceName)) - { - contentBuilder.AppendLine("}"); - } - - // Write the generated content to file - File.WriteAllText(fullFilePath, contentBuilder.ToString()); - } - - // Refresh the AssetDatabase to recognize the new script - AssetDatabase.Refresh(); - - // Return the relative path for easier reference - string relativePath = scriptPath.Replace('\\', '/'); - if (!relativePath.StartsWith("Assets/")) - { - relativePath = $"Assets/{relativePath}"; - } - - return new - { - message = $"Created script: {Path.Combine(relativePath, scriptName).Replace('\\', '/')}", - script_path = Path.Combine(relativePath, scriptName).Replace('\\', '/') - }; - } - catch (Exception ex) - { - Debug.LogError($"Failed to create script: {ex.Message}\n{ex.StackTrace}"); - throw new Exception($"Failed to create script '{scriptName}': {ex.Message}"); - } - } - - /// - /// Updates the contents of an existing Unity script - /// - public static object UpdateScript(JObject @params) - { - string scriptPath = - (string)@params["script_path"] - ?? throw new Exception("Parameter 'script_path' is required."); - string content = - (string)@params["content"] - ?? throw new Exception("Parameter 'content' is required."); - bool createIfMissing = (bool?)@params["create_if_missing"] ?? false; - bool createFolderIfMissing = (bool?)@params["create_folder_if_missing"] ?? false; - - // Handle path correctly to avoid double "Assets" folder - string relativePath; - if (scriptPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - // If path already starts with Assets/, remove it for local path operations - relativePath = scriptPath.Substring(7); - } - else - { - relativePath = scriptPath; - } - - string fullPath = Path.Combine(Application.dataPath, relativePath); - string directory = Path.GetDirectoryName(fullPath); - - // Debug the paths to help diagnose issues - - - // Check if file exists, create if requested - if (!File.Exists(fullPath)) - { - if (createIfMissing) - { - // Create the directory if requested and needed - if (!Directory.Exists(directory) && createFolderIfMissing) - { - Directory.CreateDirectory(directory); - } - else if (!Directory.Exists(directory)) - { - throw new Exception( - $"Directory does not exist: {Path.GetDirectoryName(scriptPath)}" - ); - } - - // Create the file with content - File.WriteAllText(fullPath, content); - AssetDatabase.Refresh(); - return new { message = $"Created script: {scriptPath}" }; - } - else - { - throw new Exception($"Script file not found: {scriptPath}"); - } - } - - // Update existing script - File.WriteAllText(fullPath, content); - - // Refresh the AssetDatabase - AssetDatabase.Refresh(); - - return new { message = $"Updated script: {scriptPath}" }; - } - - /// - /// Lists all script files in a specified folder - /// - public static object ListScripts(JObject @params) - { - string folderPath = (string)@params["folder_path"] ?? "Assets"; - - // Special handling for "Assets" path since it's already the root - string fullPath; - if (folderPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)) - { - fullPath = Application.dataPath; - } - else if (folderPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - // Remove "Assets/" from the path since Application.dataPath already points to it - string relativePath = folderPath.Substring(7); - fullPath = Path.Combine(Application.dataPath, relativePath); - } - else - { - // Assume it's a relative path from Assets - fullPath = Path.Combine(Application.dataPath, folderPath); - } - - if (!Directory.Exists(fullPath)) - throw new Exception($"Folder not found: {folderPath}"); - - string[] scripts = Directory - .GetFiles(fullPath, "*.cs", SearchOption.AllDirectories) - .Select(path => path.Replace(Application.dataPath, "Assets")) - .ToArray(); - - return new { scripts }; - } - - /// - /// Attaches a script component to a GameObject - /// - public static object AttachScript(JObject @params) - { - string objectName = - (string)@params["object_name"] - ?? throw new Exception("Parameter 'object_name' is required."); - string scriptName = - (string)@params["script_name"] - ?? throw new Exception("Parameter 'script_name' is required."); - string scriptPath = (string)@params["script_path"]; // Optional - - // Find the target object - GameObject targetObject = GameObject.Find(objectName); - if (targetObject == null) - throw new Exception($"Object '{objectName}' not found in scene."); - - // Ensure script name ends with .cs - if (!scriptName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - scriptName += ".cs"; - - // Remove the path from the scriptName if it contains path separators - string scriptFileName = Path.GetFileName(scriptName); - string scriptNameWithoutExtension = Path.GetFileNameWithoutExtension(scriptFileName); - - // Find the script asset - string[] guids; - - if (!string.IsNullOrEmpty(scriptPath)) - { - // If a specific path is provided, try that first - if ( - File.Exists( - Path.Combine(Application.dataPath, scriptPath.Replace("Assets/", "")) - ) - ) - { - // Use the direct path if it exists - MonoScript scriptAsset = AssetDatabase.LoadAssetAtPath(scriptPath); - if (scriptAsset != null) - { - Type scriptType = scriptAsset.GetClass(); - if (scriptType != null) - { - try - { - // Try to add the component - Component component = targetObject.AddComponent(scriptType); - if (component != null) - { - return new - { - message = $"Successfully attached script '{scriptFileName}' to object '{objectName}'", - component_type = scriptType.Name - }; - } - } - catch (Exception ex) - { - Debug.LogError($"Error attaching script component: {ex.Message}"); - throw new Exception($"Failed to add component: {ex.Message}"); - } - } - } - } - } - - // Use the file name for searching if direct path didn't work - guids = AssetDatabase.FindAssets(scriptNameWithoutExtension + " t:script"); - - if (guids.Length == 0) - { - // Try a broader search if exact match fails - guids = AssetDatabase.FindAssets(scriptNameWithoutExtension); - - if (guids.Length == 0) - throw new Exception($"Script '{scriptFileName}' not found in project."); - } - - // Check each potential script until we find one that can be attached - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - - // Filter to only consider .cs files - if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - continue; - - // Double check the file name to avoid false matches - string foundFileName = Path.GetFileName(path); - if ( - !string.Equals( - foundFileName, - scriptFileName, - StringComparison.OrdinalIgnoreCase - ) - && !string.Equals( - Path.GetFileNameWithoutExtension(foundFileName), - scriptNameWithoutExtension, - StringComparison.OrdinalIgnoreCase - ) - ) - continue; - - MonoScript scriptAsset = AssetDatabase.LoadAssetAtPath(path); - if (scriptAsset == null) - continue; - - Type scriptType = scriptAsset.GetClass(); - if (scriptType == null || !typeof(MonoBehaviour).IsAssignableFrom(scriptType)) - continue; - - try - { - // Check if component is already attached - if (targetObject.GetComponent(scriptType) != null) - { - return new - { - message = $"Script '{scriptNameWithoutExtension}' is already attached to object '{objectName}'", - component_type = scriptType.Name - }; - } - - // Add the component - Component component = targetObject.AddComponent(scriptType); - if (component != null) - { - return new - { - message = $"Successfully attached script '{scriptFileName}' to object '{objectName}'", - component_type = scriptType.Name, - script_path = path - }; - } - } - catch (Exception ex) - { - Debug.LogError($"Error attaching script '{path}': {ex.Message}"); - // Continue trying other matches instead of failing immediately - } - } - - // If we've tried all possibilities and nothing worked - throw new Exception( - $"Could not attach script '{scriptFileName}' to object '{objectName}'. No valid script found or component creation failed." - ); - } - } -} diff --git a/Editor/Commands/ScriptCommandHandler.cs.meta b/Editor/Commands/ScriptCommandHandler.cs.meta deleted file mode 100644 index 3a50abbb..00000000 --- a/Editor/Commands/ScriptCommandHandler.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 300f7736385f85e41bf90d820ff46645 \ No newline at end of file diff --git a/Editor/Helpers/Response.cs b/Editor/Helpers/Response.cs new file mode 100644 index 00000000..150f89b5 --- /dev/null +++ b/Editor/Helpers/Response.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace UnityMCP.Editor.Helpers +{ + /// + /// Provides static methods for creating standardized success and error response objects. + /// Ensures consistent JSON structure for communication back to the Python server. + /// + public static class Response + { + /// + /// Creates a standardized success response object. + /// + /// A message describing the successful operation. + /// Optional additional data to include in the response. + /// An object representing the success response. + public static object Success(string message, object data = null) + { + if (data != null) + { + return new { success = true, message = message, data = data }; + } + else + { + return new { success = true, message = message }; + } + } + + /// + /// Creates a standardized error response object. + /// + /// A message describing the error. + /// Optional additional data (e.g., error details) to include. + /// An object representing the error response. + public static object Error(string errorMessage, object data = null) + { + if (data != null) + { + // Note: The key is "error" for error messages, not "message" + return new { success = false, error = errorMessage, data = data }; + } + else + { + return new { success = false, error = errorMessage }; + } + } + } +} \ No newline at end of file diff --git a/Editor/Helpers/Response.cs.meta b/Editor/Helpers/Response.cs.meta new file mode 100644 index 00000000..da593068 --- /dev/null +++ b/Editor/Helpers/Response.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 80c09a76b944f8c4691e06c4d76c4be8 \ No newline at end of file diff --git a/Editor/Commands.meta b/Editor/Tools.meta similarity index 100% rename from Editor/Commands.meta rename to Editor/Tools.meta diff --git a/Editor/Tools/CommandRegistry.cs b/Editor/Tools/CommandRegistry.cs new file mode 100644 index 00000000..3b867e89 --- /dev/null +++ b/Editor/Tools/CommandRegistry.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace UnityMCP.Editor.Tools +{ + /// + /// Registry for all MCP command handlers (Refactored Version) + /// + public static class CommandRegistry + { + // Maps command names (matching those called from Python via ctx.bridge.unity_editor.HandlerName) + // to the corresponding static HandleCommand method in the appropriate tool class. + private static readonly Dictionary> _handlers = new() + { + { "HandleManageScript", ManageScript.HandleCommand }, + { "HandleManageScene", ManageScene.HandleCommand }, + { "HandleManageEditor", ManageEditor.HandleCommand }, + { "HandleManageGameObject", ManageGameObject.HandleCommand }, + { "HandleManageAsset", ManageAsset.HandleCommand }, + { "HandleReadConsole", ReadConsole.HandleCommand }, + { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand } + }; + + /// + /// Gets a command handler by name. + /// + /// Name of the command handler (e.g., "HandleManageAsset"). + /// The command handler function if found, null otherwise. + 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 { + UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\"); + return null; + } + */ + } + } +} \ No newline at end of file diff --git a/Editor/Commands/CommandRegistry.cs.meta b/Editor/Tools/CommandRegistry.cs.meta similarity index 100% rename from Editor/Commands/CommandRegistry.cs.meta rename to Editor/Tools/CommandRegistry.cs.meta diff --git a/Editor/Tools/ExecuteMenuItem.cs b/Editor/Tools/ExecuteMenuItem.cs new file mode 100644 index 00000000..e3e42b7e --- /dev/null +++ b/Editor/Tools/ExecuteMenuItem.cs @@ -0,0 +1,111 @@ +using UnityEngine; +using UnityEditor; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; // Added for HashSet +using UnityMCP.Editor.Helpers; // For Response class + +namespace UnityMCP.Editor.Tools +{ + /// + /// Handles executing Unity Editor menu items by path. + /// + 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) + { + "File/Quit", + // Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed + }; + + /// + /// Main handler for executing menu items or getting available ones. + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower() ?? "execute"; // Default action + + try + { + switch (action) + { + case "execute": + return ExecuteItem(@params); + case "get_available_menus": + // 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."); + // 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. + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'."); + } + } + catch (Exception e) + { + Debug.LogError($"[ExecuteMenuItem] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + /// + /// Executes a specific menu item. + /// + private static object ExecuteItem(JObject @params) + { + string menuPath = @params["menu_path"]?.ToString(); + // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. + // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). + + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' is missing or empty."); + } + + // Validate against blacklist + if (_menuPathBlacklist.Contains(menuPath)) + { + 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). + // if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); } + + // TODO: Handle parameters ('parameters' object) if a viable method is found. + // This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly. + // It might require finding the underlying EditorWindow or command if parameters are needed. + + try + { + // Attempt to execute the menu item on the main thread using delayCall for safety. + 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."); + } + } 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."); + } + 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}"); + } + } + + // 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/Editor/Tools/ExecuteMenuItem.cs.meta new file mode 100644 index 00000000..b398ddf7 --- /dev/null +++ b/Editor/Tools/ExecuteMenuItem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 896e8045986eb0d449ee68395479f1d6 \ No newline at end of file diff --git a/Editor/Tools/ManageAsset.cs b/Editor/Tools/ManageAsset.cs new file mode 100644 index 00000000..6e00314d --- /dev/null +++ b/Editor/Tools/ManageAsset.cs @@ -0,0 +1,828 @@ +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 --- + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // 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); + + default: + return Response.Error($"Unknown action: '{action}'."); + } + } + 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; + // Example: Modifying a Material + if (asset is Material material) + { + modified = ApplyMaterialProperties(material, properties); + } + // Example: Modifying a ScriptableObject (more complex, needs reflection or specific interface) + else if (asset is ScriptableObject so) + { + 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) + { + modified = ApplyObjectProperties(textureImporter, properties); + if (modified) { + // Importer settings need saving + AssetDatabase.WriteImportSettingsIfDirty(fullPath); + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes + } + } + else { + Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); + } + } + // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) + else + { + Debug.LogWarning($"Modification for asset type '{asset.GetType().Name}' at '{fullPath}' is not fully implemented. Attempting generic property setting."); + modified = ApplyObjectProperties(asset, properties); + } + + if (modified) + { + EditorUtility.SetDirty(asset); // Mark the asset itself as dirty + AssetDatabase.SaveAssets(); // Save changes to disk + // AssetDatabase.Refresh(); // SaveAssets usually handles refresh + return Response.Success($"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath)); + } else { + return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + } + } + catch (Exception e) + { + 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}"); + } + } + + // --- 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/ManageAsset.cs.meta b/Editor/Tools/ManageAsset.cs.meta new file mode 100644 index 00000000..c4d71d4e --- /dev/null +++ b/Editor/Tools/ManageAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de90a1d9743a2874cb235cf0b83444b1 \ No newline at end of file diff --git a/Editor/Tools/ManageEditor.cs b/Editor/Tools/ManageEditor.cs new file mode 100644 index 00000000..3c3d211d --- /dev/null +++ b/Editor/Tools/ManageEditor.cs @@ -0,0 +1,532 @@ +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 UnityEditorInternal; // Required for tag management +using System.Reflection; // Required for layer management + +namespace UnityMCP.Editor.Tools +{ + /// + /// Handles operations related to controlling and querying the Unity Editor state, + /// including managing Tags and Layers. + /// + 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; + + /// + /// Main handler for editor management actions. + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + // Parameters for specific actions + string tagName = @params["tagName"]?.ToString(); + string layerName = @params["layerName"]?.ToString(); + bool waitForCompletion = @params["waitForCompletion"]?.ToObject() ?? false; // Example - not used everywhere + + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Route action + switch (action) + { + // Play Mode Control + case "play": + try + { + if (!EditorApplication.isPlaying) + { + EditorApplication.isPlaying = true; + return Response.Success("Entered play mode."); + } + return Response.Success("Already in play mode."); + } + catch (Exception e) + { + return Response.Error($"Error entering play mode: {e.Message}"); + } + case "pause": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPaused = !EditorApplication.isPaused; + return Response.Success(EditorApplication.isPaused ? "Game paused." : "Game resumed."); + } + return Response.Error("Cannot pause/resume: Not in play mode."); + } + catch (Exception e) + { + return Response.Error($"Error pausing/resuming game: {e.Message}"); + } + case "stop": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPlaying = false; + return Response.Success("Exited play mode."); + } + return Response.Success("Already stopped (not in play mode)."); + } + catch (Exception e) + { + return Response.Error($"Error stopping play mode: {e.Message}"); + } + + // Editor State/Info + case "get_state": + return GetEditorState(); + case "get_windows": + return GetEditorWindows(); + case "get_active_tool": + return GetActiveTool(); + case "get_selection": + 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."); + return SetActiveTool(toolName); + + // Tag Management + case "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."); + 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."); + return AddLayer(layerName); + case "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 + + // --- Settings (Example) --- + // case "set_resolution": + // int? width = @params["width"]?.ToObject(); + // int? height = @params["height"]?.ToObject(); + // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); + // return SetGameViewResolution(width.Value, height.Value); + // case "set_quality": + // // Handle string name or int index + // 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."); + } + } + + // --- Editor State/Info Methods --- + private static object GetEditorState() + { + try + { + var state = new + { + isPlaying = EditorApplication.isPlaying, + isPaused = EditorApplication.isPaused, + isCompiling = EditorApplication.isCompiling, + isUpdating = EditorApplication.isUpdating, + applicationPath = EditorApplication.applicationPath, + applicationContentsPath = EditorApplication.applicationContentsPath, + timeSinceStartup = EditorApplication.timeSinceStartup + }; + return Response.Success("Retrieved editor state.", state); + } + catch (Exception e) + { + return Response.Error($"Error getting editor state: {e.Message}"); + } + } + + private static object GetEditorWindows() + { + try + { + // Get all types deriving from EditorWindow + 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 + + 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() + }); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not get info for window {window.GetType().Name}: {ex.Message}"); + } + } + + return Response.Success("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return Response.Error($"Error getting editor windows: {e.Message}"); + } + } + + 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 { + 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 + }; + + return Response.Success("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting active tool: {e.Message}"); + } + } + + private static object SetActiveTool(string toolName) + { + try + { + Tool targetTool; + if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse + { + // Check if it's a valid built-in tool + if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool + { + UnityEditor.Tools.current = targetTool; + 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."); + } + } + 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)."); + } + } + catch (Exception e) + { + return Response.Error($"Error setting active tool: {e.Message}"); + } + } + + private static object GetSelection() + { + try + { + var selectionInfo = new + { + activeObject = Selection.activeObject?.name, + activeGameObject = Selection.activeGameObject?.name, + 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 + }; + + return Response.Success("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting selection: {e.Message}"); + } + } + + // --- Tag Management Methods --- + + private static object AddTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + + // Check if tag already exists + if (InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' already exists."); + } + + try + { + // Add the tag using the internal utility + InternalEditorUtility.AddTag(tagName); + // Force save assets to ensure the change persists in the TagManager asset + AssetDatabase.SaveAssets(); + return Response.Success($"Tag '{tagName}' added successfully."); + } + catch (Exception e) + { + return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); + } + } + + 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."); + + // Check if tag exists before attempting removal + if (!InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' does not exist."); + } + + try + { + // Remove the tag using the internal utility + InternalEditorUtility.RemoveTag(tagName); + // Force save assets + AssetDatabase.SaveAssets(); + return Response.Success($"Tag '{tagName}' removed successfully."); + } + catch (Exception e) + { + // Catch potential issues if the tag is somehow in use or removal fails + return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); + } + } + + private static object GetTags() + { + try + { + string[] tags = InternalEditorUtility.tags; + return Response.Success("Retrieved current tags.", tags); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve tags: {e.Message}"); + } + } + + + // --- Layer Management Methods --- + + private static object AddLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + 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."); + + // 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)) + { + return Response.Error($"Layer '{layerName}' already exists at index {i}."); + } + } + + // Find the first empty user layer slot (indices 8 to 31) + int firstEmptyUserLayer = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) + { + firstEmptyUserLayer = i; + break; + } + } + + if (firstEmptyUserLayer == -1) + { + return Response.Error("No empty User Layer slots available (8-31 are full)."); + } + + // Assign the name to the found slot + try + { + 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}."); + } + catch (Exception e) + { + return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); + } + } + + private static object RemoveLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + 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."); + + // Find the layer by name (must be user layer) + int layerIndexToRemove = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + // Case-insensitive comparison is safer + if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)) + { + layerIndexToRemove = i; + break; + } + } + + if (layerIndexToRemove == -1) + { + return Response.Error($"User layer '{layerName}' not found."); + } + + // Clear the name for that index + try + { + 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."); + } + catch (Exception e) + { + return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); + } + } + + private static object GetLayers() + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names + { + layers.Add(i, layerName); + } + } + return Response.Success("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve layers: {e.Message}"); + } + } + + + // --- Helper Methods --- + + /// + /// Gets the SerializedObject for the TagManager asset. + /// + 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; + } + } + + // --- Example Implementations for Settings --- + /* + private static object SetGameViewResolution(int width, int height) { ... } + 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 + // 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 UnityEditor.Tools.current.ToString(); + } + } +} \ No newline at end of file diff --git a/Editor/Tools/ManageEditor.cs.meta b/Editor/Tools/ManageEditor.cs.meta new file mode 100644 index 00000000..ed7502eb --- /dev/null +++ b/Editor/Tools/ManageEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 43ac60aa36b361b4dbe4a038ae9f35c8 \ No newline at end of file diff --git a/Editor/Tools/ManageGameObject.cs b/Editor/Tools/ManageGameObject.cs new file mode 100644 index 00000000..1af13ebc --- /dev/null +++ b/Editor/Tools/ManageGameObject.cs @@ -0,0 +1,1159 @@ +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(); + string name = @params["name"]?.ToString(); + + 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 + + if (saveAsPrefab && string.IsNullOrEmpty(prefabPath)) + { + return Response.Error("'prefabPath' is required when 'saveAsPrefab' is true."); + } + if (saveAsPrefab && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + return Response.Error($"'prefabPath' must end with '.prefab'. Provided: '{prefabPath}'"); + } + + string primitiveType = @params["primitiveType"]?.ToString(); + GameObject newGo; + + // Create primitive or empty GameObject + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + newGo.name = name; // Set name after creation + } + 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 + { + newGo = new GameObject(name); + } + + // Record creation for Undo (initial object) + // Note: Prefab saving might have its own Undo implications or require different handling. + // PrefabUtility operations often handle their own Undo steps. + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{name}'"); + + // Set Parent (before potentially making it a prefab root) + 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}."); + } + } + } + + // 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 if requested + GameObject prefabInstance = newGo; // Keep track of the instance potentially linked to the prefab + if (saveAsPrefab) + { + try + { + // Ensure directory exists + string directoryPath = System.IO.Path.GetDirectoryName(prefabPath); + if (!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}"); + } + + // Save the GameObject as a prefab asset and connect the instance + // Use SaveAsPrefabAssetAndConnect to keep the instance in the scene linked + prefabInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, prefabPath, InteractionMode.UserAction); + + if (prefabInstance == 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 '{prefabPath}'. Check path and permissions."); + } + Debug.Log($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{prefabPath}' and instance connected."); + // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. + // EditorUtility.SetDirty(prefabInstance); // 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 '{prefabPath}': {e.Message}"); + } + } + + // Select the instance in the scene (which might now be a prefab instance) + Selection.activeGameObject = prefabInstance; + + string successMessage = saveAsPrefab + ? $"GameObject '{name}' created and saved as prefab to '{prefabPath}'." + : $"GameObject '{name}' created successfully in scene."; + + // Return data for the instance in the scene + return Response.Success(successMessage, GetGameObjectData(prefabInstance)); + } + + 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 + string newName = @params["newName"]?.ToString(); + if (!string.IsNullOrEmpty(newName) && targetGo.name != newName) + { + targetGo.name = newName; + modified = true; + } + + // Change Parent + JToken newParentToken = @params["newParent"]; + if (newParentToken != null) + { + GameObject newParentGo = FindObjectInternal(newParentToken, "by_id_or_name_or_path"); + if (newParentGo == null && !(newParentToken.Type == JTokenType.Null || (newParentToken.Type == JTokenType.String && string.IsNullOrEmpty(newParentToken.ToString())))) + { + return Response.Error($"New parent ('{newParentToken}') 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 + string newTag = @params["newTag"]?.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 (newTag != null && targetGo.tag != newTag) + { + // Ensure the tag is not empty, if empty, it means "Untagged" implicitly + string tagToSet = string.IsNullOrEmpty(newTag) ? "Untagged" : newTag; + + 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 + JToken newLayerToken = @params["newLayer"]; + if (newLayerToken != null) + { + int layer = -1; + if (newLayerToken.Type == JTokenType.Integer) + { + layer = newLayerToken.ToObject(); + } + else if (newLayerToken.Type == JTokenType.String) + { + layer = LayerMask.NameToLayer(newLayerToken.ToString()); + } + + if (layer == -1 && newLayerToken.ToString() != "Default") // LayerMask.NameToLayer returns -1 for invalid names + { + return Response.Error($"Invalid layer specified: '{newLayerToken}'. Use a valid layer name or index."); + } + if (layer != -1 && targetGo.layer != layer) + { + targetGo.layer = layer; + 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 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 + { + 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; + } + + /// + /// Simple JToken to Type conversion for common Unity types. + /// + private static object ConvertJTokenToType(JToken token, Type targetType) + { + try + { + 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 assigning Unity Objects (like Prefabs, Materials, Textures) using their asset path + if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) + { + // Check if the input token is a string, which we'll assume is the asset path + 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 + } + } + else + { + // Log a warning if the input token is not a string (path) for a Unity Object assignment + Debug.LogWarning($"[ConvertJTokenToType] Expected a string asset path 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 simple value types) + return token.ToObject(targetType); + } + 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/ManageGameObject.cs.meta b/Editor/Tools/ManageGameObject.cs.meta new file mode 100644 index 00000000..ec958a90 --- /dev/null +++ b/Editor/Tools/ManageGameObject.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7641d7388f0f6634b9d83d34de87b2ee \ No newline at end of file diff --git a/Editor/Tools/ManageScene.cs b/Editor/Tools/ManageScene.cs new file mode 100644 index 00000000..aae70cef --- /dev/null +++ b/Editor/Tools/ManageScene.cs @@ -0,0 +1,344 @@ +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEditor; +using UnityEditor.SceneManagement; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using UnityMCP.Editor.Helpers; // For Response class + +namespace UnityMCP.Editor.Tools +{ + /// + /// Handles scene management operations like loading, saving, creating, and querying hierarchy. + /// + public static class ManageScene + { + /// + /// Main handler for scene management actions. + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + int? buildIndex = @params["buildIndex"]?.ToObject(); + // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension + + // 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('/'); + } + } + + // 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)) + { + return Response.Error("Action parameter is required."); + } + + 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); + // Ensure relativePath always starts with "Assets/" and uses forward slashes + string relativePath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); + + // Ensure directory exists for 'create' + if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error($"Could not create directory '{fullPathDir}': {e.Message}"); + } + } + + // Route action + switch (action) + { + case "create": + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) + 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 + if (!string.IsNullOrEmpty(relativePath)) + return LoadScene(relativePath); + else if (buildIndex.HasValue) + return LoadScene(buildIndex.Value); + else + 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); + case "get_hierarchy": + return GetSceneHierarchy(); + case "get_active": + return GetActiveSceneInfo(); + 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."); + } + } + + private static object CreateScene(string fullPath, string relativePath) + { + if (File.Exists(fullPath)) + { + return Response.Error($"Scene already exists at '{relativePath}'."); + } + + try + { + // Create a new empty scene + 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 }); + } + else + { + // If SaveScene fails, it might leave an untitled scene open. + // Optionally try to close it, but be cautious. + return Response.Error($"Failed to save new scene to '{relativePath}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(string 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}'."); + } + + // 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."); + } + + try + { + EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); + return Response.Success($"Scene '{relativePath}' loaded successfully.", new { path = relativePath, name = Path.GetFileNameWithoutExtension(relativePath) }); + } + catch (Exception e) + { + return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); + } + } + + 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}."); + } + + // 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."); + } + + 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 }); + } + catch (Exception e) + { + return Response.Error($"Error loading scene with build index {buildIndex}: {e.Message}"); + } + } + + private static object SaveScene(string fullPath, string relativePath) + { + try + { + Scene currentScene = EditorSceneManager.GetActiveScene(); + if (!currentScene.IsValid()) + { + return Response.Error("No valid scene is currently active to save."); + } + + bool saved; + string finalPath = currentScene.path; // Path where it was last saved or will be saved + + if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) + { + // Save As... + // Ensure directory exists + string dir = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + + saved = EditorSceneManager.SaveScene(currentScene, relativePath); + finalPath = relativePath; + } + else + { + // Save (overwrite existing or save untitled) + 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."); + } + saved = EditorSceneManager.SaveScene(currentScene); + } + + if (saved) + { + AssetDatabase.Refresh(); + return Response.Success($"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name }); + } + else + { + return Response.Error($"Failed to save scene '{currentScene.name}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error saving scene: {e.Message}"); + } + } + + private static object GetActiveSceneInfo() + { + try + { + Scene activeScene = EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid()) + { + return Response.Error("No active scene found."); + } + + var sceneInfo = new + { + name = activeScene.name, + path = activeScene.path, + buildIndex = activeScene.buildIndex, // -1 if not in build settings + isDirty = activeScene.isDirty, + isLoaded = activeScene.isLoaded, + rootCount = activeScene.rootCount + }; + + return Response.Success("Retrieved active scene information.", sceneInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting active scene info: {e.Message}"); + } + } + + private static object GetBuildSettingsScenes() + { + try + { + var scenes = new List(); + 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 + }); + } + return Response.Success("Retrieved scenes from Build Settings.", scenes); + } + catch (Exception e) + { + return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); + } + } + + private static object GetSceneHierarchy() + { + try + { + Scene activeScene = EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + 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); + } + catch (Exception e) + { + return Response.Error($"Error getting scene hierarchy: {e.Message}"); + } + } + + /// + /// Recursively builds a data representation of a GameObject and its children. + /// + private static object GetGameObjectDataRecursive(GameObject go) + { + if (go == null) return null; + + var childrenData = new List(); + foreach (Transform child in go.transform) + { + childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); + } + + // Basic info + var gameObjectData = new Dictionary + { + { "name", go.name }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "instanceID", go.GetInstanceID() }, // Useful unique identifier + { "transform", new { + position = go.transform.localPosition, + rotation = go.transform.localRotation.eulerAngles, // Euler for simplicity + scale = go.transform.localScale + } + }, + { "children", childrenData } + // Add components if needed - potentially large data + // { "components", go.GetComponents().Select(c => c.GetType().FullName).ToList() } + }; + + return gameObjectData; + } + } +} \ No newline at end of file diff --git a/Editor/Tools/ManageScene.cs.meta b/Editor/Tools/ManageScene.cs.meta new file mode 100644 index 00000000..9fd63b34 --- /dev/null +++ b/Editor/Tools/ManageScene.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6ddda47f4077e74fbb5092388cefcc2 \ No newline at end of file diff --git a/Editor/Tools/ManageScript.cs b/Editor/Tools/ManageScript.cs new file mode 100644 index 00000000..6c049d38 --- /dev/null +++ b/Editor/Tools/ManageScript.cs @@ -0,0 +1,277 @@ +using UnityEngine; +using UnityEditor; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Linq; +using UnityMCP.Editor.Helpers; + +namespace UnityMCP.Editor.Tools +{ + /// + /// Handles CRUD operations for C# scripts within the Unity project. + /// + public static class ManageScript + { + /// + /// Main handler for script management actions. + /// + public static object HandleCommand(JObject @params) + { + // Extract parameters + string action = @params["action"]?.ToString().ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = @params["contents"]?.ToString(); + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation + string namespaceName = @params["namespace"]?.ToString(); // For organizing code + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + return Response.Error("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + return Response.Error($"Invalid 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/" + 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('/'); + } + } + + // 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 + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + 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); + case "read": + return ReadScript(fullPath, relativePath); + case "update": + return UpdateScript(fullPath, relativePath, name, contents); + case "delete": + return DeleteScript(fullPath, relativePath); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: create, read, update, delete."); + } + } + + 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."); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); + } + + // Validate syntax (basic check) + if (!ValidateScriptSyntax(contents)) + { + // Optionally return a specific error or warning about syntax + // return Response.Error("Provided script content has potential syntax errors."); + Debug.LogWarning($"Potential syntax error in script being created: {name}"); + } + + try + { + 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 }); + } + catch (Exception e) + { + return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + } + } + + private static object ReadScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + return Response.Success($"Script '{Path.GetFileName(relativePath)}' read successfully.", new { path = relativePath, contents = contents }); + } + catch (Exception e) + { + return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + } + } + + 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."); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + // Validate syntax (basic check) + if (!ValidateScriptSyntax(contents)) + { + Debug.LogWarning($"Potential syntax error in script being updated: {name}"); + // Consider if this should be a hard error or just a warning + } + + try + { + 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 }); + } + catch (Exception e) + { + return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + } + } + + private static object DeleteScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + } + + try + { + // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) + bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); + if (deleted) + { + AssetDatabase.Refresh(); + 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."); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + } + } + + /// + /// Generates basic C# script content based on name and type. + /// + 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 baseClass = ""; + if (!string.IsNullOrEmpty(scriptType)) + { + if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) + baseClass = " : MonoBehaviour"; + else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) + { + baseClass = " : ScriptableObject"; + body = ""; // ScriptableObjects don't usually need Start/Update + } + else if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase)) + { + usingStatements += "using UnityEditor;\n"; + if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) + baseClass = " : Editor"; + else + baseClass = " : EditorWindow"; + body = ""; // Editor scripts have different structures + } + // Add more types as needed + } + + classDeclaration = $"public class {name}{baseClass}"; + + string fullContent = $"{usingStatements}\n"; + bool useNamespace = !string.IsNullOrEmpty(namespaceName); + + if (useNamespace) + { + fullContent += $"namespace {namespaceName}\n{{\n"; + // Indent class and body if using namespace + classDeclaration = " " + classDeclaration; + body = string.Join("\n", body.Split('\n').Select(line => " " + line)); + } + + fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; + + if (useNamespace) + { + fullContent += "\n}"; // Close namespace + } + + return fullContent.Trim() + "\n"; // Ensure a trailing newline + } + + /// + /// Performs a very basic syntax validation (checks for balanced braces). + /// TODO: Implement more robust syntax checking if possible. + /// + private static bool ValidateScriptSyntax(string contents) + { + 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--; + } + + return braceBalance == 0; + // This is extremely basic. A real C# parser/compiler check would be ideal + // but is complex to implement directly here. + } + } +} \ No newline at end of file diff --git a/Editor/Tools/ManageScript.cs.meta b/Editor/Tools/ManageScript.cs.meta new file mode 100644 index 00000000..171abb65 --- /dev/null +++ b/Editor/Tools/ManageScript.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 626d2d44668019a45ae52e9ee066b7ec \ No newline at end of file diff --git a/Editor/Tools/ReadConsole.cs b/Editor/Tools/ReadConsole.cs new file mode 100644 index 00000000..cc1353a5 --- /dev/null +++ b/Editor/Tools/ReadConsole.cs @@ -0,0 +1,366 @@ +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; + private static MethodInfo _startGettingEntriesMethod; + private static MethodInfo _stopGettingEntriesMethod; + 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"); + + _getEntriesMethod = logEntriesType.GetMethod("GetEntries", BindingFlags.Static | BindingFlags.Public); + _startGettingEntriesMethod = logEntriesType.GetMethod("StartGettingEntries", BindingFlags.Static | BindingFlags.Public); + _stopGettingEntriesMethod = logEntriesType.GetMethod("StopGettingEntries", BindingFlags.Static | BindingFlags.Public); + _clearMethod = logEntriesType.GetMethod("Clear", BindingFlags.Static | BindingFlags.Public); + _getCountMethod = logEntriesType.GetMethod("GetCount", BindingFlags.Static | BindingFlags.Public); + _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public); + + 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", BindingFlags.Instance | BindingFlags.Public); + _messageField = logEntryType.GetField("message", BindingFlags.Instance | BindingFlags.Public); + _fileField = logEntryType.GetField("file", BindingFlags.Instance | BindingFlags.Public); + _lineField = logEntryType.GetField("line", BindingFlags.Instance | BindingFlags.Public); + _instanceIdField = logEntryType.GetField("instanceID", BindingFlags.Instance | BindingFlags.Public); + + // Basic check if reflection worked + if (_getEntriesMethod == null || _clearMethod == null || _modeField == null || _messageField == null) + { + throw new Exception("Failed to get required reflection members for LogEntries/LogEntry."); + } + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries. Console reading/clearing will likely fail. Error: {e}"); + // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. + _getEntriesMethod = _startGettingEntriesMethod = _stopGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; + _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; + } + } + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + // Check if reflection setup failed in static constructor + if (_clearMethod == null || _getEntriesMethod == null || _startGettingEntriesMethod == null || _stopGettingEntriesMethod == null || _getCountMethod == null || _getEntryMethod == null || _modeField == null || _messageField == null) + { + 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 StopGettingEntries is called even if there's an error during iteration + try { _stopGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ } + return Response.Error($"Error retrieving log entries: {e.Message}"); + } + finally + { + // Ensure we always call StopGettingEntries + try { _stopGettingEntriesMethod.Invoke(null, null); } catch (Exception e) { + Debug.LogError($"[ReadConsole] Failed to call StopGettingEntries: {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) + { + // Check for specific error/exception/assert types first + // Combine general and scripting-specific bits for broader matching. + if ((mode & (ModeBitError | ModeBitScriptingError | ModeBitException | ModeBitScriptingException)) != 0) { + return LogType.Error; + } + if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) { + return LogType.Assert; + } + if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) { + return LogType.Warning; + } + // If none of the above, assume it's a standard log message. + // This covers ModeBitLog and ModeBitScriptingLog. + return LogType.Log; + } + + /// + /// 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/Editor/Tools/ReadConsole.cs.meta b/Editor/Tools/ReadConsole.cs.meta new file mode 100644 index 00000000..98ef7171 --- /dev/null +++ b/Editor/Tools/ReadConsole.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46c4f3614ed61f547ba823f0b2790267 \ No newline at end of file diff --git a/Editor/UnityMCPBridge.cs b/Editor/UnityMCPBridge.cs index 4b36e819..33be6aa3 100644 --- a/Editor/UnityMCPBridge.cs +++ b/Editor/UnityMCPBridge.cs @@ -10,7 +10,7 @@ using Newtonsoft.Json.Linq; using System.IO; using UnityMCP.Editor.Models; -using UnityMCP.Editor.Commands; +using UnityMCP.Editor.Tools; namespace UnityMCP.Editor { @@ -269,59 +269,47 @@ private static string ExecuteCommand(Command command) } // Handle ping command for connection verification - if (command.type == "ping") + if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = new { status = "success", result = new { message = "pong" } }; return JsonConvert.SerializeObject(pingResponse); } + // Use JObject for parameters as the new handlers likely expect this + JObject paramsObject = command.@params ?? new JObject(); + + // Route command based on the new tool structure from the refactor plan object result = command.type switch { - "GET_SCENE_INFO" => SceneCommandHandler.GetSceneInfo(), - "OPEN_SCENE" => SceneCommandHandler.OpenScene(command.@params), - "SAVE_SCENE" => SceneCommandHandler.SaveScene(), - "NEW_SCENE" => SceneCommandHandler.NewScene(command.@params), - "CHANGE_SCENE" => SceneCommandHandler.ChangeScene(command.@params), - "GET_OBJECT_INFO" => ObjectCommandHandler.GetObjectInfo(command.@params), - "CREATE_OBJECT" => ObjectCommandHandler.CreateObject(command.@params), - "MODIFY_OBJECT" => ObjectCommandHandler.ModifyObject(command.@params), - "DELETE_OBJECT" => ObjectCommandHandler.DeleteObject(command.@params), - "EXECUTE_CONTEXT_MENU_ITEM" => ObjectCommandHandler.ExecuteContextMenuItem(command.@params), - "GET_OBJECT_PROPERTIES" => ObjectCommandHandler.GetObjectProperties(command.@params), - "GET_COMPONENT_PROPERTIES" => ObjectCommandHandler.GetComponentProperties(command.@params), - "FIND_OBJECTS_BY_NAME" => ObjectCommandHandler.FindObjectsByName(command.@params), - "FIND_OBJECTS_BY_TAG" => ObjectCommandHandler.FindObjectsByTag(command.@params), - "GET_HIERARCHY" => ObjectCommandHandler.GetHierarchy(), - "SELECT_OBJECT" => ObjectCommandHandler.SelectObject(command.@params), - "GET_SELECTED_OBJECT" => ObjectCommandHandler.GetSelectedObject(), - "SET_MATERIAL" => MaterialCommandHandler.SetMaterial(command.@params), - "VIEW_SCRIPT" => ScriptCommandHandler.ViewScript(command.@params), - "CREATE_SCRIPT" => ScriptCommandHandler.CreateScript(command.@params), - "UPDATE_SCRIPT" => ScriptCommandHandler.UpdateScript(command.@params), - "LIST_SCRIPTS" => ScriptCommandHandler.ListScripts(command.@params), - "ATTACH_SCRIPT" => ScriptCommandHandler.AttachScript(command.@params), - "IMPORT_ASSET" => AssetCommandHandler.ImportAsset(command.@params), - "INSTANTIATE_PREFAB" => AssetCommandHandler.InstantiatePrefab(command.@params), - "CREATE_PREFAB" => AssetCommandHandler.CreatePrefab(command.@params), - "APPLY_PREFAB" => AssetCommandHandler.ApplyPrefab(command.@params), - "GET_ASSET_LIST" => AssetCommandHandler.GetAssetList(command.@params), - "EDITOR_CONTROL" => EditorControlHandler.HandleEditorControl(command.@params), - _ => throw new Exception($"Unknown command type: {command.type}") + // Maps the command type (tool name) to the corresponding handler's static HandleCommand method + // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters + "manage_script" => ManageScript.HandleCommand(paramsObject), + "manage_scene" => ManageScene.HandleCommand(paramsObject), + "manage_editor" => ManageEditor.HandleCommand(paramsObject), + "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), + "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}") }; + // Standard success response format var response = new { status = "success", result }; return JsonConvert.SerializeObject(response); } catch (Exception ex) { - Debug.LogError($"Error executing command {command.type}: {ex.Message}\n{ex.StackTrace}"); + // Log the detailed error in Unity for debugging + Debug.LogError($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"); + + // Standard error response format var response = new { status = "error", - error = ex.Message, - command = command.type, - stackTrace = ex.StackTrace, - paramsSummary = command.@params != null ? GetParamsSummary(command.@params) : "No parameters" + 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 }; return JsonConvert.SerializeObject(response); } @@ -343,4 +331,4 @@ private static string GetParamsSummary(JObject @params) } } } -} \ No newline at end of file +} diff --git a/Python/server.py b/Python/server.py index 17d3b4e5..ceca968a 100644 --- a/Python/server.py +++ b/Python/server.py @@ -29,7 +29,9 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: logger.warning(f"Could not connect to Unity on startup: {str(e)}") _unity_connection = None try: - yield {} + # Yield the connection object so it can be attached to the context + # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) + yield {"bridge": _unity_connection} finally: if _unity_connection: _unity_connection.disconnect() @@ -50,56 +52,17 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: @mcp.prompt() def asset_creation_strategy() -> str: - """Guide for creating and managing assets in Unity.""" + """Guide for discovering and using Unity MCP tools effectively.""" return ( - "Unity MCP Server Tools and Best Practices:\n\n" - "1. **Editor Control**\n" - " - `editor_action` - Performs editor-wide actions such as `PLAY`, `PAUSE`, `STOP`, `BUILD`, `SAVE`\n" - " - `read_console(show_logs=True, show_warnings=True, show_errors=True, search_term=None)` - Read and filter Unity Console logs\n" - "2. **Scene Management**\n" - " - `get_current_scene()`, `get_scene_list()` - Get scene details\n" - " - `open_scene(path)`, `save_scene(path)` - Open/save scenes\n" - " - `new_scene(path)`, `change_scene(path, save_current)` - Create/switch scenes\n\n" - "3. **Object Management**\n" - " - ALWAYS use `find_objects_by_name(name)` to check if an object exists before creating or modifying it\n" - " - `create_object(name, type)` - Create objects (e.g. `CUBE`, `SPHERE`, `EMPTY`, `CAMERA`)\n" - " - `delete_object(name)` - Remove objects\n" - " - `set_object_transform(name, location, rotation, scale)` - Modify object position, rotation, and scale\n" - " - `add_component(name, component_type)` - Add components to objects (e.g. `Rigidbody`, `BoxCollider`)\n" - " - `remove_component(name, component_type)` - Remove components from objects\n" - " - `get_object_properties(name)` - Get object properties\n" - " - `find_objects_by_name(name)` - Find objects by name\n" - " - `get_hierarchy()` - Get object hierarchy\n" - "4. **Script Management**\n" - " - ALWAYS use `list_scripts(folder_path)` or `view_script(path)` to check if a script exists before creating or updating it\n" - " - `create_script(name, type, namespace, template)` - Create scripts\n" - " - `view_script(path)`, `update_script(path, content)` - View/modify scripts\n" - " - `attach_script(object_name, script_name)` - Add scripts to objects\n" - " - `list_scripts(folder_path)` - List scripts in folder\n\n" - "5. **Asset Management**\n" - " - ALWAYS use `get_asset_list(type, search_pattern, folder)` to check if an asset exists before creating or importing it\n" - " - `import_asset(source_path, target_path)` - Import external assets\n" - " - `instantiate_prefab(path, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z)` - Create prefab instances\n" - " - `create_prefab(object_name, path)`, `apply_prefab(object_name, path)` - Manage prefabs\n" - " - `get_asset_list(type, search_pattern, folder)` - List project assets\n" - " - Use relative paths for Unity assets (e.g., 'Assets/Models/MyModel.fbx')\n" - " - Use absolute paths for external files\n\n" - "6. **Material Management**\n" - " - ALWAYS check if a material exists before creating or modifying it\n" - " - `set_material(object_name, material_name, color)` - Apply/create materials\n" - " - Use RGB colors (0.0-1.0 range)\n\n" - "7. **Best Practices**\n" - " - ALWAYS verify existence before creating or updating any objects, scripts, assets, or materials\n" - " - Use meaningful names for objects and scripts\n" - " - Keep scripts organized in folders with namespaces\n" - " - Verify changes after modifications\n" - " - Save scenes before major changes\n" - " - Use full component names (e.g., 'Rigidbody', 'BoxCollider')\n" - " - Provide correct value types for properties\n" - " - Keep prefabs in dedicated folders\n" - " - Regularly apply prefab changes\n" - " - Monitor console logs for errors and warnings\n" - " - Use search terms to filter console output when debugging\n" + "Available Unity MCP Server Tools:\\n\\n" + "For detailed usage, please refer to the specific tool's documentation.\\n\\n" + "- `manage_editor`: Controls editor state (play/pause/stop) and queries info (state, selection).\\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path (e.g., 'File/Save Project').\\n" + "- `read_console`: Reads or clears Unity console messages, with filtering options.\\n" + "- `manage_scene`: Manages scenes (load, save, create, get hierarchy).\\n" + "- `manage_gameobject`: Manages GameObjects in the scene (CRUD, find, components, assign properties).\\n" + "- `manage_script`: Manages C# script files (CRUD).\\n" + "- `manage_asset`: Manages project assets (import, create, modify, delete, search).\\n\\n" ) # Run the server diff --git a/Python/tools/__init__.py b/Python/tools/__init__.py index a7787e0e..eb4a7e37 100644 --- a/Python/tools/__init__.py +++ b/Python/tools/__init__.py @@ -1,15 +1,19 @@ -from .scene_tools import register_scene_tools -from .script_tools import register_script_tools -from .material_tools import register_material_tools -from .editor_tools import register_editor_tools -from .asset_tools import register_asset_tools -from .object_tools import register_object_tools +from .manage_script import register_manage_script_tools +from .manage_scene import register_manage_scene_tools +from .manage_editor import register_manage_editor_tools +from .manage_gameobject import register_manage_gameobject_tools +from .manage_asset import register_manage_asset_tools +from .read_console import register_read_console_tools +from .execute_menu_item import register_execute_menu_item_tools def register_all_tools(mcp): - """Register all tools with the MCP server.""" - register_scene_tools(mcp) - register_script_tools(mcp) - register_material_tools(mcp) - register_editor_tools(mcp) - register_asset_tools(mcp) - register_object_tools(mcp) \ No newline at end of file + """Register all refactored tools with the MCP server.""" + print("Registering UnityMCP refactored tools...") + register_manage_script_tools(mcp) + register_manage_scene_tools(mcp) + register_manage_editor_tools(mcp) + register_manage_gameobject_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 diff --git a/Python/tools/asset_tools.py b/Python/tools/asset_tools.py deleted file mode 100644 index 1a5bdbf3..00000000 --- a/Python/tools/asset_tools.py +++ /dev/null @@ -1,259 +0,0 @@ -from typing import Optional -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection - -def register_asset_tools(mcp: FastMCP): - """Register all asset management tools with the MCP server.""" - - @mcp.tool() - def import_asset( - ctx: Context, - source_path: str, - target_path: str, - overwrite: bool = False - ) -> str: - """Import an asset (e.g., 3D model, texture) into the Unity project. - - Args: - ctx: The MCP context - source_path: Path to the source file on disk - target_path: Path where the asset should be imported in the Unity project (relative to Assets folder) - overwrite: Whether to overwrite if an asset already exists at the target path (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Parameter validation - if not source_path or not isinstance(source_path, str): - return f"Error importing asset: source_path must be a valid string" - - if not target_path or not isinstance(target_path, str): - return f"Error importing asset: target_path must be a valid string" - - # Check if the source file exists (on local disk) - import os - if not os.path.exists(source_path): - return f"Error importing asset: Source file '{source_path}' does not exist" - - # Extract the target directory and filename - target_dir = '/'.join(target_path.split('/')[:-1]) - target_filename = target_path.split('/')[-1] - - # Check if an asset already exists at the target path - existing_assets = unity.send_command("GET_ASSET_LIST", { - "search_pattern": target_filename, - "folder": target_dir or "Assets" - }).get("assets", []) - - # Check if any asset matches the exact path - asset_exists = any(asset.get("path") == target_path for asset in existing_assets) - if asset_exists and not overwrite: - return f"Asset already exists at '{target_path}'. Use overwrite=True to replace it." - - response = unity.send_command("IMPORT_ASSET", { - "source_path": source_path, - "target_path": target_path, - "overwrite": overwrite - }) - - if not response.get("success", False): - return f"Error importing asset: {response.get('error', 'Unknown error')} (Source: {source_path}, Target: {target_path})" - - return response.get("message", "Asset imported successfully") - except Exception as e: - return f"Error importing asset: {str(e)} (Source: {source_path}, Target: {target_path})" - - @mcp.tool() - def instantiate_prefab( - ctx: Context, - prefab_path: str, - position_x: float = 0.0, - position_y: float = 0.0, - position_z: float = 0.0, - rotation_x: float = 0.0, - rotation_y: float = 0.0, - rotation_z: float = 0.0 - ) -> str: - """Instantiate a prefab into the current scene at a specified location. - - Args: - ctx: The MCP context - prefab_path: Path to the prefab asset (relative to Assets folder) - position_x: X position in world space (default: 0.0) - position_y: Y position in world space (default: 0.0) - position_z: Z position in world space (default: 0.0) - rotation_x: X rotation in degrees (default: 0.0) - rotation_y: Y rotation in degrees (default: 0.0) - rotation_z: Z rotation in degrees (default: 0.0) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Parameter validation - if not prefab_path or not isinstance(prefab_path, str): - return f"Error instantiating prefab: prefab_path must be a valid string" - - # Validate numeric parameters - position_params = { - "position_x": position_x, - "position_y": position_y, - "position_z": position_z, - "rotation_x": rotation_x, - "rotation_y": rotation_y, - "rotation_z": rotation_z - } - - for param_name, param_value in position_params.items(): - if not isinstance(param_value, (int, float)): - return f"Error instantiating prefab: {param_name} must be a number" - - # Check if the prefab exists - prefab_dir = '/'.join(prefab_path.split('/')[:-1]) or "Assets" - prefab_name = prefab_path.split('/')[-1] - - # Ensure prefab has .prefab extension for searching - if not prefab_name.lower().endswith('.prefab'): - prefab_name = f"{prefab_name}.prefab" - prefab_path = f"{prefab_path}.prefab" - - prefab_assets = unity.send_command("GET_ASSET_LIST", { - "type": "Prefab", - "search_pattern": prefab_name, - "folder": prefab_dir - }).get("assets", []) - - prefab_exists = any(asset.get("path") == prefab_path for asset in prefab_assets) - if not prefab_exists: - return f"Prefab '{prefab_path}' not found in the project." - - response = unity.send_command("INSTANTIATE_PREFAB", { - "prefab_path": prefab_path, - "position_x": position_x, - "position_y": position_y, - "position_z": position_z, - "rotation_x": rotation_x, - "rotation_y": rotation_y, - "rotation_z": rotation_z - }) - - if not response.get("success", False): - return f"Error instantiating prefab: {response.get('error', 'Unknown error')} (Path: {prefab_path})" - - return f"Prefab instantiated successfully as '{response.get('instance_name', 'unknown')}'" - except Exception as e: - return f"Error instantiating prefab: {str(e)} (Path: {prefab_path})" - - @mcp.tool() - def create_prefab( - ctx: Context, - object_name: str, - prefab_path: str, - overwrite: bool = False - ) -> str: - """Create a new prefab asset from a GameObject in the scene. - - Args: - ctx: The MCP context - object_name: Name of the GameObject in the scene to create prefab from - prefab_path: Path where the prefab should be saved (relative to Assets folder) - overwrite: Whether to overwrite if a prefab already exists at the path (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Parameter validation - if not object_name or not isinstance(object_name, str): - return f"Error creating prefab: object_name must be a valid string" - - if not prefab_path or not isinstance(prefab_path, str): - return f"Error creating prefab: prefab_path must be a valid string" - - # Check if the GameObject exists - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": object_name - }).get("objects", []) - - if not found_objects: - return f"GameObject '{object_name}' not found in the scene." - - # Verify prefab path has proper extension - if not prefab_path.lower().endswith('.prefab'): - prefab_path = f"{prefab_path}.prefab" - - # Check if a prefab already exists at this path - prefab_dir = '/'.join(prefab_path.split('/')[:-1]) or "Assets" - prefab_name = prefab_path.split('/')[-1] - - prefab_assets = unity.send_command("GET_ASSET_LIST", { - "type": "Prefab", - "search_pattern": prefab_name, - "folder": prefab_dir - }).get("assets", []) - - prefab_exists = any(asset.get("path") == prefab_path for asset in prefab_assets) - if prefab_exists and not overwrite: - return f"Prefab already exists at '{prefab_path}'. Use overwrite=True to replace it." - - response = unity.send_command("CREATE_PREFAB", { - "object_name": object_name, - "prefab_path": prefab_path, - "overwrite": overwrite - }) - - if not response.get("success", False): - return f"Error creating prefab: {response.get('error', 'Unknown error')} (Object: {object_name}, Path: {prefab_path})" - - return f"Prefab created successfully at {response.get('path', prefab_path)}" - except Exception as e: - return f"Error creating prefab: {str(e)} (Object: {object_name}, Path: {prefab_path})" - - @mcp.tool() - def apply_prefab( - ctx: Context, - object_name: str - ) -> str: - """Apply changes made to a prefab instance back to the original prefab asset. - - Args: - ctx: The MCP context - object_name: Name of the prefab instance in the scene - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if the GameObject exists - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": object_name - }).get("objects", []) - - if not found_objects: - return f"GameObject '{object_name}' not found in the scene." - - # Check if the object is a prefab instance - object_props = unity.send_command("GET_OBJECT_PROPERTIES", { - "name": object_name - }) - - # Try to extract prefab status from properties - is_prefab_instance = object_props.get("isPrefabInstance", False) - if not is_prefab_instance: - return f"GameObject '{object_name}' is not a prefab instance." - - response = unity.send_command("APPLY_PREFAB", { - "object_name": object_name - }) - return response.get("message", "Prefab changes applied successfully") - except Exception as e: - return f"Error applying prefab changes: {str(e)}" \ No newline at end of file diff --git a/Python/tools/editor_tools.py b/Python/tools/editor_tools.py deleted file mode 100644 index d3a634ad..00000000 --- a/Python/tools/editor_tools.py +++ /dev/null @@ -1,295 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Optional, List, Dict, Any -from unity_connection import get_unity_connection - -def register_editor_tools(mcp: FastMCP): - """Register all editor control tools with the MCP server.""" - - @mcp.tool() - def undo(ctx: Context) -> str: - """Undo the last action performed in the Unity editor. - - Returns: - str: Success message or error details - """ - try: - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "UNDO" - }) - return response.get("message", "Undo performed successfully") - except Exception as e: - return f"Error performing undo: {str(e)}" - - @mcp.tool() - def redo(ctx: Context) -> str: - """Redo the last undone action in the Unity editor. - - Returns: - str: Success message or error details - """ - try: - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "REDO" - }) - return response.get("message", "Redo performed successfully") - except Exception as e: - return f"Error performing redo: {str(e)}" - - @mcp.tool() - def play(ctx: Context) -> str: - """Start the game in play mode within the Unity editor. - - Returns: - str: Success message or error details - """ - try: - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "PLAY" - }) - return response.get("message", "Entered play mode") - except Exception as e: - return f"Error entering play mode: {str(e)}" - - @mcp.tool() - def pause(ctx: Context) -> str: - """Pause the game while in play mode. - - Returns: - str: Success message or error details - """ - try: - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "PAUSE" - }) - return response.get("message", "Game paused") - except Exception as e: - return f"Error pausing game: {str(e)}" - - @mcp.tool() - def stop(ctx: Context) -> str: - """Stop the game and exit play mode. - - Returns: - str: Success message or error details - """ - try: - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "STOP" - }) - return response.get("message", "Exited play mode") - except Exception as e: - return f"Error stopping game: {str(e)}" - - @mcp.tool() - def build(ctx: Context, platform: str, build_path: str) -> str: - """Build the project for a specified platform. - - Args: - platform: Target platform (windows, mac, linux, android, ios, webgl) - build_path: Path where the build should be saved - - Returns: - str: Success message or error details - """ - try: - # Validate platform - valid_platforms = ["windows", "mac", "linux", "android", "ios", "webgl"] - if platform.lower() not in valid_platforms: - return f"Error: '{platform}' is not a valid platform. Valid platforms are: {', '.join(valid_platforms)}" - - # Check if build_path exists and is writable - import os - - # Check if the directory exists - build_dir = os.path.dirname(build_path) - if not os.path.exists(build_dir): - return f"Error: Build directory '{build_dir}' does not exist. Please create it first." - - # Check if the directory is writable - if not os.access(build_dir, os.W_OK): - return f"Error: Build directory '{build_dir}' is not writable." - - # If the build path itself exists, check if it's a file or directory - if os.path.exists(build_path): - if os.path.isfile(build_path): - # If it's a file, check if it's writable - if not os.access(build_path, os.W_OK): - return f"Error: Existing build file '{build_path}' is not writable." - elif os.path.isdir(build_path): - # If it's a directory, check if it's writable - if not os.access(build_path, os.W_OK): - return f"Error: Existing build directory '{build_path}' is not writable." - - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "BUILD", - "params": { - "platform": platform, - "buildPath": build_path - } - }) - return response.get("message", "Build completed successfully") - except Exception as e: - return f"Error building project: {str(e)}" - - @mcp.tool() - def execute_command(ctx: Context, command_name: str, validate_command: bool = True) -> str: - """Execute a specific editor command or custom script within the Unity editor. - - Args: - command_name: Name of the editor command to execute (e.g., "Edit/Preferences") - validate_command: Whether to validate the command existence before executing (default: True) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Optionally validate if the command exists - if validate_command: - # Get a list of available commands from Unity - available_commands = unity.send_command("EDITOR_CONTROL", { - "command": "GET_AVAILABLE_COMMANDS" - }).get("commands", []) - - # Check if the command exists in the list - if available_commands and command_name not in available_commands: - # If command doesn't exist, try to find similar commands as suggestions - similar_commands = [cmd for cmd in available_commands if command_name.lower() in cmd.lower()] - suggestion_msg = "" - if similar_commands: - suggestion_msg = f" Did you mean one of these: {', '.join(similar_commands[:5])}" - if len(similar_commands) > 5: - suggestion_msg += " or others?" - else: - suggestion_msg += "?" - - return f"Error: Command '{command_name}' not found.{suggestion_msg}" - - response = unity.send_command("EDITOR_CONTROL", { - "command": "EXECUTE_COMMAND", - "params": { - "commandName": command_name - } - }) - return response.get("message", f"Executed command: {command_name}") - except Exception as e: - return f"Error executing command: {str(e)}" - - @mcp.tool() - def read_console( - ctx: Context, - show_logs: bool = True, - show_warnings: bool = True, - show_errors: bool = True, - search_term: Optional[str] = None - ) -> List[Dict[str, Any]]: - """Read log messages from the Unity Console. - - Args: - ctx: The MCP context - show_logs: Whether to include regular log messages (default: True) - show_warnings: Whether to include warning messages (default: True) - show_errors: Whether to include error messages (default: True) - search_term: Optional text to filter logs by content. If multiple words are provided, - entries must contain all words (not necessarily in order) to be included. (default: None) - - Returns: - List[Dict[str, Any]]: A list of console log entries, each containing 'type', 'message', and 'stackTrace' fields - """ - try: - # Prepare params with only the provided values - params = { - "show_logs": show_logs, - "show_warnings": show_warnings, - "show_errors": show_errors - } - - # Only add search_term if it's provided - if search_term is not None: - params["search_term"] = search_term - - response = get_unity_connection().send_command("EDITOR_CONTROL", { - "command": "READ_CONSOLE", - "params": params - }) - - if "error" in response: - return [{ - "type": "Error", - "message": f"Failed to read console: {response['error']}", - "stackTrace": response.get("stackTrace", "") - }] - - entries = response.get("entries", []) - total_entries = response.get("total_entries", 0) - filtered_count = response.get("filtered_count", 0) - filter_applied = response.get("filter_applied", False) - - # Add summary info - summary = [] - if total_entries > 0: - summary.append(f"Total console entries: {total_entries}") - if filter_applied: - summary.append(f"Filtered entries: {filtered_count}") - if filtered_count == 0: - summary.append(f"No entries matched the search term: '{search_term}'") - else: - summary.append(f"Showing all entries") - else: - summary.append("No entries in console") - - # Add filter info - filter_types = [] - if show_logs: filter_types.append("logs") - if show_warnings: filter_types.append("warnings") - if show_errors: filter_types.append("errors") - if filter_types: - summary.append(f"Showing: {', '.join(filter_types)}") - - # Add summary as first entry - if summary: - entries.insert(0, { - "type": "Info", - "message": " | ".join(summary), - "stackTrace": "" - }) - - return entries if entries else [{ - "type": "Info", - "message": "No logs found in console", - "stackTrace": "" - }] - - except Exception as e: - return [{ - "type": "Error", - "message": f"Error reading console: {str(e)}", - "stackTrace": "" - }] - - @mcp.tool() - def get_available_commands(ctx: Context) -> List[str]: - """Get a list of all available editor commands that can be executed. - - This tool provides direct access to the list of commands that can be executed - in the Unity Editor through the MCP system. - - Returns: - List[str]: List of available command paths - """ - try: - unity = get_unity_connection() - - # Send request for available commands - response = unity.send_command("EDITOR_CONTROL", { - "command": "GET_AVAILABLE_COMMANDS" - }) - - # Extract commands list - commands = response.get("commands", []) - - # Return the commands list - return commands - except Exception as e: - return [f"Error fetching commands: {str(e)}"] \ No newline at end of file diff --git a/Python/tools/execute_menu_item.py b/Python/tools/execute_menu_item.py new file mode 100644 index 00000000..5efab12f --- /dev/null +++ b/Python/tools/execute_menu_item.py @@ -0,0 +1,53 @@ +""" +Defines the execute_menu_item tool for running Unity Editor menu commands. +""" +from typing import Optional, Dict, Any +from mcp.server.fastmcp import FastMCP, Context + +def register_execute_menu_item_tools(mcp: FastMCP): + """Registers the execute_menu_item tool with the MCP server.""" + + @mcp.tool() + async def execute_menu_item( + ctx: Context, + menu_path: str, + action: Optional[str] = 'execute', # Allows extending later (e.g., 'validate', 'get_available') + parameters: Optional[Dict[str, Any]] = None, # For menu items that might accept parameters (less common) + # alias: Optional[str] = None, # Potential future addition for common commands + # context: Optional[Dict[str, Any]] = None # Potential future addition for context-specific menus + ) -> Dict[str, Any]: + """Executes a Unity Editor menu item via its path (e.g., "File/Save Project"). + + Args: + ctx: The MCP context. + menu_path: The full path of the menu item to execute. + action: The operation to perform (default: 'execute'). + parameters: Optional parameters for the menu item (rarely used). + + Returns: + A dictionary indicating success or failure, with optional message/error. + """ + + action = action.lower() if action else 'execute' + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "menuPath": menu_path, + "parameters": parameters if parameters else {}, + # "alias": alias, + # "context": context + } + + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + if "parameters" not in params_dict: + params_dict["parameters"] = {} # Ensure parameters dict exists + + # Forward the command to the Unity editor handler + # The C# handler is the static method HandleCommand in the ExecuteMenuItem class. + # We assume ctx.call is the correct way to invoke it via FastMCP. + # Note: The exact target string might need adjustment based on FastMCP's specifics. + csharp_handler_target = "UnityMCP.Editor.Tools.ExecuteMenuItem.HandleCommand" + return await ctx.call(csharp_handler_target, params_dict) \ No newline at end of file diff --git a/Python/tools/asset_tools.py.meta b/Python/tools/execute_menu_item.py.meta similarity index 74% rename from Python/tools/asset_tools.py.meta rename to Python/tools/execute_menu_item.py.meta index 72495a6b..3d0a9689 100644 --- a/Python/tools/asset_tools.py.meta +++ b/Python/tools/execute_menu_item.py.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 44d6968eea5de444880d425390b19ff4 +guid: 67b82e49c36517040b7cfea8e421764e DefaultImporter: externalObjects: {} userData: diff --git a/Python/tools/manage_asset.py b/Python/tools/manage_asset.py new file mode 100644 index 00000000..56be50e2 --- /dev/null +++ b/Python/tools/manage_asset.py @@ -0,0 +1,66 @@ +""" +Defines the manage_asset tool for interacting with Unity assets. +""" +from typing import Optional, Dict, Any, List +from mcp.server.fastmcp import FastMCP, Context + +def register_manage_asset_tools(mcp: FastMCP): + """Registers the manage_asset tool with the MCP server.""" + + @mcp.tool() + async def manage_asset( + ctx: Context, + action: str, + path: str, + asset_type: Optional[str] = None, + properties: Optional[Dict[str, Any]] = None, + destination: Optional[str] = None, # Used for move/duplicate + generate_preview: Optional[bool] = False, + # Search specific parameters + search_pattern: Optional[str] = None, # Replaces path for search action? Or use path as pattern? + filter_type: Optional[str] = None, # Redundant with asset_type? + filter_date_after: Optional[str] = None, # ISO 8601 format + page_size: Optional[int] = None, + page_number: Optional[int] = None + ) -> Dict[str, Any]: + """Performs asset operations (import, create, modify, delete, etc.) in Unity. + + Args: + ctx: The MCP context. + action: Operation to perform (e.g., 'import', 'create', 'search'). + path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. + asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. + properties: Dictionary of properties for 'create'/'modify'. + destination: Target path for 'duplicate'/'move'. + search_pattern: Search pattern (e.g., '*.prefab'). + filter_*: Filters for search (type, date). + page_*: Pagination for search. + + Returns: + A dictionary with operation results ('success', 'data', 'error'). + """ + # Ensure properties is a dict if None + if properties is None: + properties = {} + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } + + # Remove None values to avoid sending unnecessary nulls + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Forward the command to the Unity editor handler using the send_command method + # The C# side expects a command type and parameters. + return await ctx.send_command("manage_asset", params_dict) \ No newline at end of file diff --git a/Python/tools/editor_tools.py.meta b/Python/tools/manage_asset.py.meta similarity index 74% rename from Python/tools/editor_tools.py.meta rename to Python/tools/manage_asset.py.meta index faea180d..5354ab00 100644 --- a/Python/tools/editor_tools.py.meta +++ b/Python/tools/manage_asset.py.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 9ac5db8cf38041644a81e7d655d879a9 +guid: 27ffc6de0e9253e4f980ae545f07731a DefaultImporter: externalObjects: {} userData: diff --git a/Python/tools/manage_editor.py b/Python/tools/manage_editor.py new file mode 100644 index 00000000..2ff8de03 --- /dev/null +++ b/Python/tools/manage_editor.py @@ -0,0 +1,69 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Optional, Dict, Any, Union +from unity_connection import get_unity_connection + +def register_manage_editor_tools(mcp: FastMCP): + """Register all editor management tools with the MCP server.""" + + @mcp.tool() + def manage_editor( + ctx: Context, + action: str, + wait_for_completion: Optional[bool] = None, + # --- Parameters for specific actions --- + # For 'set_active_tool' + tool_name: Optional[str] = None, + # For 'add_tag', 'remove_tag' + tag_name: Optional[str] = None, + # For 'add_layer', 'remove_layer' + layer_name: Optional[str] = None, + # Example: width: Optional[int] = None, height: Optional[int] = None + # Example: window_name: Optional[str] = None + # context: Optional[Dict[str, Any]] = None # Additional context + ) -> Dict[str, Any]: + """Controls and queries the Unity editor's state and settings. + + Args: + action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag'). + wait_for_completion: Optional. If True, waits for certain actions. + Action-specific arguments (e.g., tool_name, tag_name, layer_name). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + """ + try: + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_editor", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} + + # Example of potentially splitting into more specific tools: + # @mcp.tool() + # def get_editor_state(ctx: Context) -> Dict[str, Any]: ... + # @mcp.tool() + # def set_editor_playmode(ctx: Context, state: str) -> Dict[str, Any]: ... # state='play'/'pause'/'stop' + # @mcp.tool() + # def add_editor_tag(ctx: Context, tag_name: str) -> Dict[str, Any]: ... + # @mcp.tool() + # def add_editor_layer(ctx: Context, layer_name: str) -> Dict[str, Any]: ... \ No newline at end of file diff --git a/Python/tools/material_tools.py.meta b/Python/tools/manage_editor.py.meta similarity index 74% rename from Python/tools/material_tools.py.meta rename to Python/tools/manage_editor.py.meta index 1c164ebc..894c2095 100644 --- a/Python/tools/material_tools.py.meta +++ b/Python/tools/manage_editor.py.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 53b3a554a0ffeb04fb41b71ca78fda29 +guid: a2f972b61922666418f99fa8f8ba817e DefaultImporter: externalObjects: {} userData: diff --git a/Python/tools/manage_gameobject.py b/Python/tools/manage_gameobject.py new file mode 100644 index 00000000..732781b4 --- /dev/null +++ b/Python/tools/manage_gameobject.py @@ -0,0 +1,116 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Optional, Dict, Any, List, Union +from unity_connection import get_unity_connection + +def register_manage_gameobject_tools(mcp: FastMCP): + """Register all GameObject management tools with the MCP server.""" + + @mcp.tool() + def manage_gameobject( + ctx: Context, + action: str, + target: Optional[Union[str, int]] = None, # Name, path, or instance ID + search_method: Optional[str] = None, # by_name, by_tag, by_layer, by_component, by_id + # --- Parameters for 'create' --- + name: Optional[str] = None, # Required for 'create' + tag: Optional[str] = None, # Tag to assign during creation + parent: Optional[Union[str, int]] = None, # Name or ID of parent + position: Optional[List[float]] = None, # [x, y, z] + rotation: Optional[List[float]] = None, # [x, y, z] Euler angles + scale: Optional[List[float]] = None, # [x, y, z] + components_to_add: Optional[List[Union[str, Dict[str, Any]]]] = None, # List of component names or dicts with properties + primitive_type: Optional[str] = None, # Optional: create primitive (Cube, Sphere, etc.) instead of empty + save_as_prefab: Optional[bool] = False, # If True, save the created object as a prefab + prefab_path: Optional[str] = None, # Full path to save prefab (e.g., "Assets/Prefabs/MyObject.prefab"). Overrides prefab_folder. + prefab_folder: Optional[str] = "Assets/Prefabs", # Default folder if prefab_path not set (e.g., "Assets/Prefabs") + # --- Parameters for 'modify' --- + new_name: Optional[str] = None, + new_parent: Optional[Union[str, int]] = None, + set_active: Optional[bool] = None, + new_tag: Optional[str] = None, + new_layer: Optional[Union[str, int]] = None, # Layer name or number + components_to_remove: Optional[List[str]] = None, + component_properties: Optional[Dict[str, Dict[str, Any]]] = None, # { "ComponentName": { "propName": value } } + # --- Parameters for 'find' --- + search_term: Optional[str] = None, # Used with search_method (e.g., name, tag value, component type) + find_all: Optional[bool] = False, # Find all matches or just the first? + search_in_children: Optional[bool] = False, # Limit search scope + search_inactive: Optional[bool] = False, # Include inactive GameObjects? + # -- Component Management Arguments -- + component_name: Optional[str] = None, # Target component for component actions + ) -> Dict[str, Any]: + """Manages GameObjects: create, modify, delete, find, and component operations. + + Args: + action: Operation (e.g., 'create', 'modify', 'find', 'add_component'). + target: GameObject identifier (name, path, ID) for modify/delete/component actions. + search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find'. + Action-specific arguments (e.g., name, parent, position for 'create'; + component_name, component_properties for component actions; + search_term, find_all for 'find'). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + """ + try: + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "newName": new_name, + "newParent": new_parent, + "setActive": set_active, + "newTag": new_tag, + "newLayer": new_layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name + } + params = {k: v for k, v in params.items() if v is not None} + + # --- Handle Prefab Path Logic --- + if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefab_folder", None) + # -------------------------------- + + # Send the command to Unity via the established connection + # Use the get_unity_connection function to retrieve the active connection instance + # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation + response = get_unity_connection().send_command("manage_gameobject", params) + + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/Python/tools/object_tools.py.meta b/Python/tools/manage_gameobject.py.meta similarity index 74% rename from Python/tools/object_tools.py.meta rename to Python/tools/manage_gameobject.py.meta index ebe1c975..0c60c375 100644 --- a/Python/tools/object_tools.py.meta +++ b/Python/tools/manage_gameobject.py.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0b8eb3f808238b040a4b41766228664f +guid: b34907e09ab90854fa849302b96c6247 DefaultImporter: externalObjects: {} userData: diff --git a/Python/tools/manage_scene.py b/Python/tools/manage_scene.py new file mode 100644 index 00000000..af923c48 --- /dev/null +++ b/Python/tools/manage_scene.py @@ -0,0 +1,56 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Optional, Dict, Any +from unity_connection import get_unity_connection + +def register_manage_scene_tools(mcp: FastMCP): + """Register all scene management tools with the MCP server.""" + + @mcp.tool() + def manage_scene( + ctx: Context, + action: str, + name: Optional[str] = None, + path: Optional[str] = None, + build_index: Optional[int] = None, + # Add other potential parameters like load_additive, etc. if needed + # context: Optional[Dict[str, Any]] = None # Future: Contextual info (e.g., current project settings) + ) -> Dict[str, Any]: + """Manages Unity scenes (load, save, create, get hierarchy, etc.). + + Args: + action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). + name: Scene name (no extension) for create/load/save. + path: Asset path for scene operations (default: "Assets/"). + build_index: Build index for load/build settings actions. + # Add other action-specific args as needed (e.g., for hierarchy depth) + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters, removing None values + params = { + "action": action, + "name": name, + "path": path, + "buildIndex": build_index + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_scene", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} + + # Consider adding specific tools if the single 'manage_scene' becomes too complex: + # @mcp.tool() + # def load_scene(ctx: Context, name: str, path: Optional[str] = None, build_index: Optional[int] = None) -> Dict[str, Any]: ... + # @mcp.tool() + # def get_scene_hierarchy(ctx: Context) -> Dict[str, Any]: ... \ No newline at end of file diff --git a/Python/tools/manage_scene.py.meta b/Python/tools/manage_scene.py.meta new file mode 100644 index 00000000..342b47e3 --- /dev/null +++ b/Python/tools/manage_scene.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: be712c04494a1874593719eeb2a882ac +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Python/tools/manage_script.py b/Python/tools/manage_script.py new file mode 100644 index 00000000..1a04f372 --- /dev/null +++ b/Python/tools/manage_script.py @@ -0,0 +1,63 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Optional, Dict, Any +from unity_connection import get_unity_connection +import os + +def register_manage_script_tools(mcp: FastMCP): + """Register all script management tools with the MCP server.""" + + @mcp.tool() + def manage_script( + ctx: Context, + action: str, + name: str, + path: Optional[str] = None, + contents: Optional[str] = None, + script_type: Optional[str] = None, + namespace: Optional[str] = None + ) -> Dict[str, Any]: + """Manages C# scripts in Unity (create, read, update, delete). + + Args: + action: Operation ('create', 'read', 'update', 'delete'). + name: Script name (no .cs extension). + path: Asset path (optional, default: "Assets/"). + contents: C# code for 'create'/'update'. + script_type: Type hint (e.g., 'MonoBehaviour', optional). + namespace: Script namespace (optional). + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + "contents": contents, + "scriptType": script_type, + "namespace": namespace + } + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_script", params) + + # Process response from Unity + if response.get("success"): + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing script: {str(e)}"} + + # Potentially add more specific helper tools if needed later, e.g.: + # @mcp.tool() + # def create_script(...): ... + # @mcp.tool() + # def read_script(...): ... + # etc. \ No newline at end of file diff --git a/Python/tools/manage_script.py.meta b/Python/tools/manage_script.py.meta new file mode 100644 index 00000000..8c84af44 --- /dev/null +++ b/Python/tools/manage_script.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: beb93a353b9140c44b7ac22d2bb8481a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Python/tools/material_tools.py b/Python/tools/material_tools.py deleted file mode 100644 index 01df0f8a..00000000 --- a/Python/tools/material_tools.py +++ /dev/null @@ -1,89 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import List, Optional -from unity_connection import get_unity_connection - -def register_material_tools(mcp: FastMCP): - """Register all material-related tools with the MCP server.""" - - @mcp.tool() - def set_material( - ctx: Context, - object_name: str, - material_name: Optional[str] = None, - color: Optional[List[float]] = None, - create_if_missing: bool = True - ) -> str: - """ - Apply or create a material for a game object. If material_name is provided, - the material will be saved as a shared asset in the Materials folder. - - Args: - object_name: Target game object. - material_name: Optional material name. If provided, creates/uses a shared material asset. - color: Optional [R, G, B] or [R, G, B, A] values (0.0-1.0). - create_if_missing: Whether to create the material if it doesn't exist (default: True). - - Returns: - str: Status message indicating success or failure. - """ - try: - unity = get_unity_connection() - - # Check if the object exists - object_response = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": object_name - }) - - objects = object_response.get("objects", []) - if not objects: - return f"GameObject '{object_name}' not found in the scene." - - # If a material name is specified, check if it exists - if material_name: - material_assets = unity.send_command("GET_ASSET_LIST", { - "type": "Material", - "search_pattern": material_name, - "folder": "Assets/Materials" - }).get("assets", []) - - material_exists = any(asset.get("name") == material_name for asset in material_assets) - - if not material_exists and not create_if_missing: - return f"Material '{material_name}' not found. Use create_if_missing=True to create it." - - # Validate color values if provided - if color: - # Check if color has the right number of components (RGB or RGBA) - if not (len(color) == 3 or len(color) == 4): - return f"Error: Color must have 3 (RGB) or 4 (RGBA) components, but got {len(color)}." - - # Check if all color values are in the 0-1 range - for i, value in enumerate(color): - if not isinstance(value, (int, float)): - return f"Error: Color component at index {i} is not a number." - - if value < 0.0 or value > 1.0: - channel = "RGBA"[i] if i < 4 else f"component {i}" - return f"Error: Color {channel} value must be in the range 0.0-1.0, but got {value}." - - # Set up parameters for the command - params = { - "object_name": object_name, - "create_if_missing": create_if_missing - } - if material_name: - params["material_name"] = material_name - if color: - params["color"] = color - - result = unity.send_command("SET_MATERIAL", params) - material_name = result.get("material_name", "unknown") - material_path = result.get("path") - - if material_path: - return f"Applied shared material '{material_name}' to {object_name} (saved at {material_path})" - else: - return f"Applied instance material '{material_name}' to {object_name}" - - except Exception as e: - return f"Error setting material: {str(e)}" \ No newline at end of file diff --git a/Python/tools/object_tools.py b/Python/tools/object_tools.py deleted file mode 100644 index 99534f2f..00000000 --- a/Python/tools/object_tools.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Tools for inspecting and manipulating Unity objects.""" - -from typing import Optional, List, Dict, Any -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection - -def register_object_tools(mcp: FastMCP): - """Register all object inspection and manipulation tools with the MCP server.""" - - @mcp.tool() - def get_object_properties( - ctx: Context, - name: str - ) -> Dict[str, Any]: - """Get all properties of a specified game object. - - Args: - ctx: The MCP context - name: Name of the game object to inspect - - Returns: - Dict containing the object's properties, components, and their values - """ - try: - response = get_unity_connection().send_command("GET_OBJECT_PROPERTIES", { - "name": name - }) - return response - except Exception as e: - return {"error": f"Failed to get object properties: {str(e)}"} - - @mcp.tool() - def get_component_properties( - ctx: Context, - object_name: str, - component_type: str - ) -> Dict[str, Any]: - """Get properties of a specific component on a game object. - - Args: - ctx: The MCP context - object_name: Name of the game object - component_type: Type of the component to inspect - - Returns: - Dict containing the component's properties and their values - """ - try: - response = get_unity_connection().send_command("GET_COMPONENT_PROPERTIES", { - "object_name": object_name, - "component_type": component_type - }) - return response - except Exception as e: - return {"error": f"Failed to get component properties: {str(e)}"} - - @mcp.tool() - def find_objects_by_name( - ctx: Context, - name: str - ) -> List[Dict[str, str]]: - """Find game objects in the scene by name. - - Args: - ctx: The MCP context - name: Name to search for (partial matches are supported) - - Returns: - List of dicts containing object names and their paths - """ - try: - response = get_unity_connection().send_command("FIND_OBJECTS_BY_NAME", { - "name": name - }) - return response.get("objects", []) - except Exception as e: - return [{"error": f"Failed to find objects: {str(e)}"}] - - @mcp.tool() - def find_objects_by_tag( - ctx: Context, - tag: str - ) -> List[Dict[str, str]]: - """Find game objects in the scene by tag. - - Args: - ctx: The MCP context - tag: Tag to search for - - Returns: - List of dicts containing object names and their paths - """ - try: - response = get_unity_connection().send_command("FIND_OBJECTS_BY_TAG", { - "tag": tag - }) - return response.get("objects", []) - except Exception as e: - return [{"error": f"Failed to find objects: {str(e)}"}] - - @mcp.tool() - def get_scene_info(ctx: Context) -> Dict[str, Any]: - """Get information about the current scene. - - Args: - ctx: The MCP context - - Returns: - Dict containing scene information including name and root objects - """ - try: - response = get_unity_connection().send_command("GET_SCENE_INFO") - return response - except Exception as e: - return {"error": f"Failed to get scene info: {str(e)}"} - - @mcp.tool() - def get_hierarchy(ctx: Context) -> Dict[str, Any]: - """Get the current hierarchy of game objects in the scene. - - Args: - ctx: The MCP context - - Returns: - Dict containing the scene hierarchy as a tree structure - """ - try: - response = get_unity_connection().send_command("GET_HIERARCHY") - return response - except Exception as e: - return {"error": f"Failed to get hierarchy: {str(e)}"} - - @mcp.tool() - def select_object( - ctx: Context, - name: str - ) -> Dict[str, str]: - """Select a game object in the Unity Editor. - - Args: - ctx: The MCP context - name: Name of the object to select - - Returns: - Dict containing the name of the selected object - """ - try: - response = get_unity_connection().send_command("SELECT_OBJECT", { - "name": name - }) - return response - except Exception as e: - return {"error": f"Failed to select object: {str(e)}"} - - @mcp.tool() - def get_selected_object(ctx: Context) -> Optional[Dict[str, str]]: - """Get the currently selected game object in the Unity Editor. - - Args: - ctx: The MCP context - - Returns: - Dict containing the selected object's name and path, or None if no object is selected - """ - try: - response = get_unity_connection().send_command("GET_SELECTED_OBJECT") - return response.get("selected") - except Exception as e: - return {"error": f"Failed to get selected object: {str(e)}"} - - @mcp.tool() - def get_asset_list( - ctx: Context, - type: Optional[str] = None, - search_pattern: str = "*", - folder: str = "Assets" - ) -> List[Dict[str, str]]: - """Get a list of assets in the project. - - Args: - ctx: The MCP context - type: Optional asset type to filter by - search_pattern: Pattern to search for in asset names - folder: Folder to search in (default: "Assets") - - Returns: - List of dicts containing asset information - """ - try: - response = get_unity_connection().send_command("GET_ASSET_LIST", { - "type": type, - "search_pattern": search_pattern, - "folder": folder - }) - return response.get("assets", []) - except Exception as e: - return [{"error": f"Failed to get asset list: {str(e)}"}] - - @mcp.tool() - def execute_context_menu_item( - ctx: Context, - object_name: str, - component: str, - context_menu_item: str - ) -> Dict[str, Any]: - """Execute a specific [ContextMenu] method on a component of a given game object. - - Args: - ctx: The MCP context - object_name: Name of the game object to call - component: Name of the component type - context_menu_item: Name of the context menu item to execute - - Returns: - Dict containing the result of the operation - """ - try: - unity = get_unity_connection() - - # Check if the object exists - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": object_name - }).get("objects", []) - - if not found_objects: - return {"error": f"Object with name '{object_name}' not found in the scene."} - - # Check if the component exists on the object - object_props = unity.send_command("GET_OBJECT_PROPERTIES", { - "name": object_name - }) - - if "error" in object_props: - return {"error": f"Failed to get object properties: {object_props['error']}"} - - components = object_props.get("components", []) - component_exists = any(comp.get("type") == component for comp in components) - - if not component_exists: - return {"error": f"Component '{component}' is not attached to object '{object_name}'."} - - # Now execute the context menu item - response = unity.send_command("EXECUTE_CONTEXT_MENU_ITEM", { - "object_name": object_name, - "component": component, - "context_menu_item": context_menu_item - }) - return response - except Exception as e: - return {"error": f"Failed to execute context menu item: {str(e)}"} \ No newline at end of file diff --git a/Python/tools/read_console.py b/Python/tools/read_console.py new file mode 100644 index 00000000..4409f315 --- /dev/null +++ b/Python/tools/read_console.py @@ -0,0 +1,60 @@ +""" +Defines the read_console tool for accessing Unity Editor console messages. +""" +from typing import Optional, List, Dict, Any +from mcp.server.fastmcp import FastMCP, Context + +def register_read_console_tools(mcp: FastMCP): + """Registers the read_console tool with the MCP server.""" + + @mcp.tool() + async def read_console( + ctx: Context, + action: Optional[str] = 'get', # Default action is to get messages + types: Optional[List[str]] = ['error', 'warning', 'log'], # Default types to retrieve + count: Optional[int] = None, # Max number of messages to return (null for all matching) + filter_text: Optional[str] = None, # Text to filter messages by + since_timestamp: Optional[str] = None, # ISO 8601 timestamp to get messages since + format: Optional[str] = 'detailed', # 'plain', 'detailed', 'json' + include_stacktrace: Optional[bool] = True, # Whether to include stack traces in detailed/json formats + # context: Optional[Dict[str, Any]] = None # Future context + ) -> Dict[str, Any]: + """Gets messages from or clears the Unity Editor console. + + Args: + action: Operation ('get' or 'clear'). + types: Message types to get ('error', 'warning', 'log', 'all'). + count: Max messages to return. + filter_text: Text filter for messages. + since_timestamp: Get messages after this timestamp (ISO 8601). + format: Output format ('plain', 'detailed', 'json'). + include_stacktrace: Include stack traces in output. + + Returns: + Dictionary with results. For 'get', includes 'data' (messages). + """ + + # Normalize action + action = action.lower() if action else 'get' + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types if types else ['error', 'warning', 'log'], # Ensure types is not None + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if format else 'detailed', + "includeStacktrace": include_stacktrace + } + + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} + + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None + + # Forward the command to the Unity editor handler + # The C# handler name might need adjustment (e.g., CommandRegistry) + return await ctx.bridge.unity_editor.HandleReadConsole(params_dict) \ No newline at end of file diff --git a/Python/tools/read_console.py.meta b/Python/tools/read_console.py.meta new file mode 100644 index 00000000..316a7d44 --- /dev/null +++ b/Python/tools/read_console.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c94ba17ca2284764f99d61356c5feded +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Python/tools/scene_tools.py b/Python/tools/scene_tools.py deleted file mode 100644 index 4d7e531f..00000000 --- a/Python/tools/scene_tools.py +++ /dev/null @@ -1,338 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import List, Dict, Any, Optional -import json -from unity_connection import get_unity_connection - -def register_scene_tools(mcp: FastMCP): - """Register all scene-related tools with the MCP server.""" - - @mcp.tool() - def get_scene_info(ctx: Context) -> str: - """Retrieve detailed info about the current Unity scene. - - Returns: - str: JSON string containing scene information including: - - sceneName: Name of the current scene - - rootObjects: List of root GameObject names in the scene - """ - try: - unity = get_unity_connection() - result = unity.send_command("GET_SCENE_INFO") - return json.dumps(result, indent=2) - except Exception as e: - return f"Error getting scene info: {str(e)}" - - @mcp.tool() - def open_scene(ctx: Context, scene_path: str) -> str: - """Open a specified scene in the Unity editor. - - Args: - scene_path: Full path to the scene file (e.g., "Assets/Scenes/MyScene.unity") - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if the scene exists in the project - scenes = unity.send_command("GET_ASSET_LIST", { - "type": "Scene", - "search_pattern": scene_path.split('/')[-1], - "folder": '/'.join(scene_path.split('/')[:-1]) or "Assets" - }).get("assets", []) - - # Check if any scene matches the exact path - scene_exists = any(scene.get("path") == scene_path for scene in scenes) - if not scene_exists: - return f"Scene at '{scene_path}' not found in the project." - - result = unity.send_command("OPEN_SCENE", {"scene_path": scene_path}) - return result.get("message", "Scene opened successfully") - except Exception as e: - return f"Error opening scene: {str(e)}" - - @mcp.tool() - def save_scene(ctx: Context) -> str: - """Save the current scene to its file. - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - result = unity.send_command("SAVE_SCENE") - return result.get("message", "Scene saved successfully") - except Exception as e: - return f"Error saving scene: {str(e)}" - - @mcp.tool() - def new_scene(ctx: Context, scene_path: str, overwrite: bool = False) -> str: - """Create a new empty scene in the Unity editor. - - Args: - scene_path: Full path where the new scene should be saved (e.g., "Assets/Scenes/NewScene.unity") - overwrite: Whether to overwrite if scene already exists (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if a scene with this path already exists - scenes = unity.send_command("GET_ASSET_LIST", { - "type": "Scene", - "search_pattern": scene_path.split('/')[-1], - "folder": '/'.join(scene_path.split('/')[:-1]) or "Assets" - }).get("assets", []) - - # Check if any scene matches the exact path - scene_exists = any(scene.get("path") == scene_path for scene in scenes) - if scene_exists and not overwrite: - return f"Scene at '{scene_path}' already exists. Use overwrite=True to replace it." - - # Create new scene - result = unity.send_command("NEW_SCENE", { - "scene_path": scene_path, - "overwrite": overwrite - }) - - # Save the scene to ensure it's properly created - unity.send_command("SAVE_SCENE") - - # Get scene info to verify it's loaded - scene_info = unity.send_command("GET_SCENE_INFO") - - return result.get("message", "New scene created successfully") - except Exception as e: - return f"Error creating new scene: {str(e)}" - - @mcp.tool() - def change_scene(ctx: Context, scene_path: str, save_current: bool = False) -> str: - """Change to a different scene, optionally saving the current one. - - Args: - scene_path: Full path to the target scene file (e.g., "Assets/Scenes/TargetScene.unity") - save_current: Whether to save the current scene before changing (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - result = unity.send_command("CHANGE_SCENE", { - "scene_path": scene_path, - "save_current": save_current - }) - return result.get("message", "Scene changed successfully") - except Exception as e: - return f"Error changing scene: {str(e)}" - - @mcp.tool() - def get_object_info(ctx: Context, object_name: str) -> str: - """ - Get info about a specific game object. - - Args: - object_name: Name of the game object. - """ - try: - unity = get_unity_connection() - result = unity.send_command("GET_OBJECT_INFO", {"name": object_name}) - return json.dumps(result, indent=2) - except Exception as e: - return f"Error getting object info: {str(e)}" - - @mcp.tool() - def create_object( - ctx: Context, - type: str = "CUBE", - name: str = None, - location: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - replace_if_exists: bool = False - ) -> str: - """ - Create a game object in the Unity scene. - - Args: - type: Object type (CUBE, SPHERE, CYLINDER, CAPSULE, PLANE, EMPTY, CAMERA, LIGHT). - name: Optional name for the game object. - location: [x, y, z] position (defaults to [0, 0, 0]). - rotation: [x, y, z] rotation in degrees (defaults to [0, 0, 0]). - scale: [x, y, z] scale factors (defaults to [1, 1, 1]). - replace_if_exists: Whether to replace if an object with the same name exists (default: False) - - Returns: - Confirmation message with the created object's name. - """ - try: - unity = get_unity_connection() - - # Check if an object with the specified name already exists (if name is provided) - if name: - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": name - }).get("objects", []) - - if found_objects and not replace_if_exists: - return f"Object with name '{name}' already exists. Use replace_if_exists=True to replace it." - elif found_objects and replace_if_exists: - # Delete the existing object - unity.send_command("DELETE_OBJECT", {"name": name}) - - # Create the new object - params = { - "type": type.upper(), - "location": location or [0, 0, 0], - "rotation": rotation or [0, 0, 0], - "scale": scale or [1, 1, 1] - } - if name: - params["name"] = name - - result = unity.send_command("CREATE_OBJECT", params) - return f"Created {type} game object: {result['name']}" - except Exception as e: - return f"Error creating game object: {str(e)}" - - @mcp.tool() - def modify_object( - ctx: Context, - name: str, - location: Optional[List[float]] = None, - rotation: Optional[List[float]] = None, - scale: Optional[List[float]] = None, - visible: Optional[bool] = None, - set_parent: Optional[str] = None, - add_component: Optional[str] = None, - remove_component: Optional[str] = None, - set_property: Optional[Dict[str, Any]] = None - ) -> str: - """ - Modify a game object's properties and components. - - Args: - name: Name of the game object to modify. - location: Optional [x, y, z] position. - rotation: Optional [x, y, z] rotation in degrees. - scale: Optional [x, y, z] scale factors. - visible: Optional visibility toggle. - set_parent: Optional name of the parent object to set. - add_component: Optional name of the component type to add (e.g., "Rigidbody", "BoxCollider"). - remove_component: Optional name of the component type to remove. - set_property: Optional dict with keys: - - component: Name of the component type - - property: Name of the property to set - - value: Value to set the property to - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if the object exists - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": name - }).get("objects", []) - - if not found_objects: - return f"Object with name '{name}' not found in the scene." - - # If set_parent is provided, check if parent object exists - if set_parent is not None: - parent_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": set_parent - }).get("objects", []) - - if not parent_objects: - return f"Parent object '{set_parent}' not found in the scene." - - # If we're adding a component, we could also check if it's already attached - if add_component is not None: - object_props = unity.send_command("GET_OBJECT_PROPERTIES", { - "name": name - }) - - components = object_props.get("components", []) - component_exists = any(comp.get("type") == add_component for comp in components) - - if component_exists: - return f"Component '{add_component}' is already attached to '{name}'." - - # If we're removing a component, check if it exists - if remove_component is not None: - object_props = unity.send_command("GET_OBJECT_PROPERTIES", { - "name": name - }) - - components = object_props.get("components", []) - component_exists = any(comp.get("type") == remove_component for comp in components) - - if not component_exists: - return f"Component '{remove_component}' is not attached to '{name}'." - - params = {"name": name} - - # Add basic transform properties - if location is not None: - params["location"] = location - if rotation is not None: - params["rotation"] = rotation - if scale is not None: - params["scale"] = scale - if visible is not None: - params["visible"] = visible - - # Add parent setting - if set_parent is not None: - params["set_parent"] = set_parent - - # Add component operations - if add_component is not None: - params["add_component"] = add_component - if remove_component is not None: - params["remove_component"] = remove_component - - # Add property setting - if set_property is not None: - params["set_property"] = set_property - - result = unity.send_command("MODIFY_OBJECT", params) - return f"Modified game object: {result['name']}" - except Exception as e: - return f"Error modifying game object: {str(e)}" - - @mcp.tool() - def delete_object(ctx: Context, name: str, ignore_missing: bool = False) -> str: - """ - Remove a game object from the scene. - - Args: - name: Name of the game object to delete. - ignore_missing: Whether to silently ignore if the object doesn't exist (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if the object exists - found_objects = unity.send_command("FIND_OBJECTS_BY_NAME", { - "name": name - }).get("objects", []) - - if not found_objects: - if ignore_missing: - return f"No object named '{name}' found to delete. Ignoring." - else: - return f"Error: Object '{name}' not found in the scene." - - result = unity.send_command("DELETE_OBJECT", {"name": name}) - return f"Deleted game object: {name}" - except Exception as e: - return f"Error deleting game object: {str(e)}" \ No newline at end of file diff --git a/Python/tools/scene_tools.py.meta b/Python/tools/scene_tools.py.meta deleted file mode 100644 index 0c6802bd..00000000 --- a/Python/tools/scene_tools.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ed5ef5bed9e67a34297b908a0e15a8dc -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Python/tools/script_tools.py b/Python/tools/script_tools.py deleted file mode 100644 index b7dee854..00000000 --- a/Python/tools/script_tools.py +++ /dev/null @@ -1,280 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import List -from unity_connection import get_unity_connection -import base64 - - -def register_script_tools(mcp: FastMCP): - """Register all script-related tools with the MCP server.""" - - @mcp.tool() - def view_script(ctx: Context, script_path: str, require_exists: bool = True) -> str: - """View the contents of a Unity script file. - - Args: - ctx: The MCP context - script_path: Path to the script file relative to the Assets folder - require_exists: Whether to raise an error if the file doesn't exist (default: True) - - Returns: - str: The contents of the script file or error message - """ - try: - # Normalize script path to ensure it has the correct format - if not script_path.startswith("Assets/"): - script_path = f"Assets/{script_path}" - - # Debug to help diagnose issues - print(f"ViewScript - Using normalized script path: {script_path}") - - # Send command to Unity to read the script file - response = get_unity_connection().send_command( - "VIEW_SCRIPT", - {"script_path": script_path, "require_exists": require_exists}, - ) - - if response.get("exists", True): - if response.get("encoding", "base64") == "base64": - decoded_content = base64.b64decode(response.get("content")).decode( - "utf-8" - ) - return decoded_content - return response.get("content", "Script contents not available") - else: - return response.get("message", "Script not found") - except Exception as e: - return f"Error viewing script: {str(e)}" - - @mcp.tool() - def create_script( - ctx: Context, - script_name: str, - script_type: str = "MonoBehaviour", - namespace: str = None, - template: str = None, - script_folder: str = None, - overwrite: bool = False, - content: str = None, - ) -> str: - """Create a new Unity script file. - - Args: - ctx: The MCP context - script_name: Name of the script (without .cs extension) - script_type: Type of script (e.g., MonoBehaviour, ScriptableObject) - namespace: Optional namespace for the script - template: Optional custom template to use - script_folder: Optional folder path within Assets to create the script - overwrite: Whether to overwrite if script already exists (default: False) - content: Optional custom content for the script - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Determine script path based on script_folder parameter - if script_folder: - # Use provided folder path - # Normalize the folder path first - if script_folder.startswith("Assets/"): - normalized_folder = script_folder - else: - normalized_folder = f"Assets/{script_folder}" - - # Create the full path - if normalized_folder.endswith("/"): - script_path = f"{normalized_folder}{script_name}.cs" - else: - script_path = f"{normalized_folder}/{script_name}.cs" - - # Debug to help diagnose issues - print(f"CreateScript - Folder: {script_folder}") - print(f"CreateScript - Normalized folder: {normalized_folder}") - print(f"CreateScript - Script path: {script_path}") - else: - # Default to Scripts folder when no folder is provided - script_path = f"Assets/Scripts/{script_name}.cs" - print(f"CreateScript - Using default script path: {script_path}") - - # Send command to Unity to create the script directly - # The C# handler will handle the file existence check - params = { - "script_name": script_name, - "script_type": script_type, - "namespace": namespace, - "template": template, - "overwrite": overwrite, - } - - # Add script_folder if provided - if script_folder: - params["script_folder"] = script_folder - - # Add content if provided - if content: - params["content"] = content - - response = unity.send_command("CREATE_SCRIPT", params) - return response.get("message", "Script created successfully") - except Exception as e: - return f"Error creating script: {str(e)}" - - @mcp.tool() - def update_script( - ctx: Context, - script_path: str, - content: str, - create_if_missing: bool = False, - create_folder_if_missing: bool = False, - ) -> str: - """Update the contents of an existing Unity script. - - Args: - ctx: The MCP context - script_path: Path to the script file relative to the Assets folder - content: New content for the script - create_if_missing: Whether to create the script if it doesn't exist (default: False) - create_folder_if_missing: Whether to create the parent directory if it doesn't exist (default: False) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Normalize script path to ensure it has the correct format - # Make sure the path starts with Assets/ but not Assets/Assets/ - if not script_path.startswith("Assets/"): - script_path = f"Assets/{script_path}" - - # Debug to help diagnose issues - print(f"UpdateScript - Original path: {script_path}") - - # Parse script path (for potential creation) - script_name = script_path.split("/")[-1] - if not script_name.endswith(".cs"): - script_name += ".cs" - script_path = f"{script_path}.cs" - - if create_if_missing: - # When create_if_missing is true, we'll just try to update directly, - # and let Unity handle the creation if needed - params = { - "script_path": script_path, - "content": content, - "create_if_missing": True, - } - - # Add folder creation flag if requested - if create_folder_if_missing: - params["create_folder_if_missing"] = True - - # Send command to Unity to update/create the script - response = unity.send_command("UPDATE_SCRIPT", params) - return response.get("message", "Script updated successfully") - else: - # Standard update without creation flags - response = unity.send_command( - "UPDATE_SCRIPT", {"script_path": script_path, "content": content} - ) - return response.get("message", "Script updated successfully") - except Exception as e: - return f"Error updating script: {str(e)}" - - @mcp.tool() - def list_scripts(ctx: Context, folder_path: str = "Assets") -> str: - """List all script files in a specified folder. - - Args: - ctx: The MCP context - folder_path: Path to the folder to search (default: Assets) - - Returns: - str: List of script files or error message - """ - try: - # Send command to Unity to list scripts - response = get_unity_connection().send_command( - "LIST_SCRIPTS", {"folder_path": folder_path} - ) - scripts = response.get("scripts", []) - if not scripts: - return "No scripts found in the specified folder" - return "\n".join(scripts) - except Exception as e: - return f"Error listing scripts: {str(e)}" - - @mcp.tool() - def attach_script( - ctx: Context, object_name: str, script_name: str, script_path: str = None - ) -> str: - """Attach a script component to a GameObject. - - Args: - ctx: The MCP context - object_name: Name of the target GameObject in the scene - script_name: Name of the script to attach (with or without .cs extension) - script_path: Optional full path to the script (if not in the default Scripts folder) - - Returns: - str: Success message or error details - """ - try: - unity = get_unity_connection() - - # Check if the object exists - object_response = unity.send_command( - "FIND_OBJECTS_BY_NAME", {"name": object_name} - ) - - objects = object_response.get("objects", []) - if not objects: - return f"GameObject '{object_name}' not found in the scene." - - # Ensure script_name has .cs extension - if not script_name.lower().endswith(".cs"): - script_name = f"{script_name}.cs" - - # Remove any path information from script_name if it contains slashes - script_basename = script_name.split("/")[-1] - - # Determine the full script path if provided - if script_path is not None: - # Ensure script_path starts with Assets/ - if not script_path.startswith("Assets/"): - script_path = f"Assets/{script_path}" - - # If path is just a directory, append the script name - if not script_path.endswith(script_basename): - if script_path.endswith("/"): - script_path = f"{script_path}{script_basename}" - else: - script_path = f"{script_path}/{script_basename}" - - # Check if the script is already attached - object_props = unity.send_command( - "GET_OBJECT_PROPERTIES", {"name": object_name} - ) - - # Extract script name without .cs and without path for component type checking - script_class_name = script_basename.replace(".cs", "") - - # Check if component is already attached - components = object_props.get("components", []) - for component in components: - if component.get("type") == script_class_name: - return f"Script '{script_class_name}' is already attached to '{object_name}'." - - # Send command to Unity to attach the script - params = {"object_name": object_name, "script_name": script_basename} - - # Add script_path if provided - if script_path: - params["script_path"] = script_path - - response = unity.send_command("ATTACH_SCRIPT", params) - return response.get("message", "Script attached successfully") - except Exception as e: - return f"Error attaching script: {str(e)}" diff --git a/Python/tools/script_tools.py.meta b/Python/tools/script_tools.py.meta deleted file mode 100644 index 32052734..00000000 --- a/Python/tools/script_tools.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: bce1a207771693f4ba78d880688360d4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/example-prompt-v2.md b/example-prompt-v2.md new file mode 100644 index 00000000..33698d5e --- /dev/null +++ b/example-prompt-v2.md @@ -0,0 +1,20 @@ +# Create a "Collect the Cubes" game + Objective: The player controls a simple 3D character (like a sphere or capsule) that moves around a flat 3D environment to collect floating cubes before a timer runs out. + Win Condition: Collect all the cubes (e.g., 5–10) to win. + Lose Condition: Timer runs out before all cubes are collected. + +## Steps + Create a 3D plane in the scene and position it as the ground. + Add a 3D sphere to the scene as the player object. + Attach a Rigidbody component to the sphere. + Create a new script called "PlayerMovement" and attach it to the sphere. + Add five 3D cubes to the scene, positioning them at different spots above the ground. + Add a Collider component to each cube and set it as a trigger. + Create a new script called "Collectible" and attach it to each cube. + Create an empty GameObject called "GameManager" in the scene. + Create a new script called "GameController" and attach it to the GameManager. + Add a UI Text element to the scene for displaying the score. + Add a second UI Text element to the scene for displaying the timer. + Create a UI Text element for a win message and set it to be invisible by default. + Create a UI Text element for a lose message and set it to be invisible by default. + Save the scene. \ No newline at end of file diff --git a/example-prompt-v2.md.meta b/example-prompt-v2.md.meta new file mode 100644 index 00000000..19c75d96 --- /dev/null +++ b/example-prompt-v2.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 870319fa77a9f444ea4b604112bc761e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/example-prompt.md b/example-prompt.md new file mode 100644 index 00000000..83103628 --- /dev/null +++ b/example-prompt.md @@ -0,0 +1,83 @@ +Create an endless runner game based on the Google Dinosaur game concept. + + Scene Setup: + + Create a new 2D scene named "EndlessRunner". + + Configure the Main Camera for 2D orthographic view. + + Player Character: + + Create a GameObject for the Player (e.g., a simple sprite or 2D shape like a square). + + Position the Player towards the left side of the screen, slightly above the ground level. + + Add appropriate 2D physics components (Rigidbody2D, Collider2D) to the Player. Configure gravity. + + Create a PlayerController script and attach it to the Player. + + Implement jump functionality triggered by player input (e.g., Spacebar, mouse click, or screen tap). This should apply an upward force. + + Prevent double-jumping unless intended (check if grounded). + + Detect collisions, specifically with objects tagged as "Obstacle". + + Ground: + + Create at least two Ground GameObjects (e.g., long thin sprites or shapes) that can be placed end-to-end. + + Add Collider2D components to the Ground GameObjects so the player can stand on them. + + Create a script (e.g., GroundScroller) to manage ground movement. + + Implement continuous scrolling movement from right to left for the ground segments. + + Implement logic to reposition ground segments that move off-screen to the left back to the right side, creating an infinite loop. + + Obstacles: + + Create at least one Obstacle prefab (e.g., a different sprite or shape representing a cactus). + + Add a Collider2D component to the Obstacle prefab. + + Assign a specific tag (e.g., "Obstacle") to the Obstacle prefab. + + Create an empty GameObject named ObstacleSpawner. + + Create an ObstacleSpawner script and attach it. + + Implement logic to periodically spawn Obstacle prefabs at a set position off-screen to the right. + + Introduce random variation in the time between spawns. + + (Optional) Implement logic to choose randomly between different obstacle types if more than one prefab is created. + + Create an ObstacleMover script and attach it to the Obstacle prefab(s). + + Implement movement for spawned obstacles from right to left at the game's current speed. + + Implement logic to destroy obstacles once they move off-screen to the left. + + Game Management: + + Create an empty GameObject named GameManager. + + Create a GameManager script and attach it. + + Manage the overall game state (e.g., Initializing, Playing, GameOver). + + Track the player's score, increasing it over time while the game state is "Playing". + + Control the game's speed, gradually increasing the scrolling speed of the ground and obstacles over time. + + Implement Game Over logic: triggered when the Player collides with an "Obstacle". This should stop all movement (player, ground, obstacles) and change the game state. + + Implement Restart logic: allow the player to restart the game (e.g., by pressing a key or button) after a Game Over, resetting the score, speed, and scene elements. + + User Interface (UI): + + Create a UI Canvas. + + Add a UI Text element to display the current score, updated by the GameManager. + + Add UI elements for the Game Over screen (e.g., "Game Over" text, final score display, restart instructions). These should be hidden initially and shown when the game state changes to "GameOver". \ No newline at end of file diff --git a/example-prompt.md.meta b/example-prompt.md.meta new file mode 100644 index 00000000..fdc4b599 --- /dev/null +++ b/example-prompt.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 22513ccfdc5b6134f8d582d3d8869c5c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tool-refactor-plan.md b/tool-refactor-plan.md new file mode 100644 index 00000000..09365c71 --- /dev/null +++ b/tool-refactor-plan.md @@ -0,0 +1,98 @@ +# Tool Refactor Plan +The purpose of this refactor is to minimize the amount of tools in use. Right now we have around 35 tool available to the LLM. Most research I've seen says the ideal amount of tools is 10-30 total. This includes when using multiple MCP servers. So to help the LLM make the best tool choice for the job, we're going to narrow down the number of tools we are using from 35 to 8-ish. + +## Project Structure +We are building a Unity plugin under the folder and name UnityMCP. Within this folder are two projects. One is the MCP server under Python/ and the other is the Unity bridge and tool implementations under Editor/ + +## Steps + +1. Remove all existing tools except for execute_command under editor_tools.py and for HandleExecuteCommand in EditorControlHandler.cs. This will be the only tool reused. All other files should be deleted. Rename editor_tools.py to execute_command.py. Rename EditorControllerHandler.cs to ExecuteCommand.cs. + +2. Create Python/tools/manage_script.py and Editor/Tools/ManageScript.cs + - Implement all CRUD operations. Specify the action with an 'action' parameter. + - Add required parameter 'name' + - Add optional parameters 'path', 'contents', and 'script_type' (MonoBehaviour, ScriptableObject, Editor, etc.) + - Include validation for script syntax + - Add optional 'namespace' parameter for organizing scripts + +3. Create Python/tools/manage_scene.py and Editor/Tools/ManageScene.cs + - Implement scene operations like loading, saving, creating new scenes. + - Add required parameter 'action' to specify operation (load, save, create, get_hierarchy, etc.) + - Add optional parameters 'name', 'path', and 'build_index' + - Handle scene hierarchy queries with 'get_hierarchy' action + +4. Create Python/tools/manage_editor.py and Editor/Tools/ManageEditor.cs + - Control editor state (play mode, pause, stop). Query editor state + - Add required parameter 'action' to specify the operation ('play', 'pause', 'stop', 'get_state', etc.) + - Add optional parameters for specific settings ('resolution', 'quality', 'target_framerate') + - Include operations for managing editor windows and layouts + - Add optional 'wait_for_completion' boolean parameter for operations that take time + - Support querying current active tool and selection + +5. Create Python/tools/manage_gameobject.py and Editor/Tools/ManageGameObject.cs + - Handle GameObject creation, modification, deletion + - Add required parameters 'action' ('create', 'modify', 'delete', 'find', 'get_components', etc.) + - Add required parameter 'target' for operations on existing objects (path, name, or ID) + - Add optional parameters 'parent', 'position', 'rotation', 'scale', 'components' + - Support component-specific operations with 'component_name' and 'component_properties' + - Add 'search_method' parameter ('by_name', 'by_tag', 'by_layer', 'by_component') + - Return standardized GameObject data structure with transforms and components + +6. Create Python/tools/manage_asset.py and Editor/Tools/ManageAsset.cs + - Implement asset operations ('import', 'create', 'modify', 'delete', 'duplicate', 'search') + - Add required parameters 'action' and 'path' + - Add optional parameters 'asset_type', 'properties', 'destination' (for duplicate/move) + - Support asset-specific parameters based on asset_type + - Include preview generation with optional 'generate_preview' parameter + - Add pagination support with 'page_size' and 'page_number' for search results + - Support filtering assets by type, name pattern, or creation date + +7. Create Python/tools/read_console.py and Editor/Tools/ReadConsole.cs + - Retrieve Unity console output (errors, warnings, logs) + - Add optional parameters 'type' (array of 'error', 'warning', 'log', 'all') + - Add optional 'count', 'filter_text', 'since_timestamp' parameters + - Support 'clear' action to clear console + - Add 'format' parameter ('plain', 'detailed', 'json') for different output formats + - Include stack trace toggle with 'include_stacktrace' boolean + +8. Create Python/tools/execute_menu_item.py and Editor/Tools/ExecuteMenuItem.cs + - Execute Unity editor menu commands through script + - Add required parameter 'menu_path' for the menu item to execute + - Add optional 'parameters' object for menu items that accept parameters + - Support common menu operations with 'alias' parameter for simplified access + - Include validation to prevent execution of dangerous operations + - Add 'get_available_menus' action to list accessible menu items + - Support context-specific menu items with optional 'context' parameter + +## Implementation Guidelines + +1. Ensure consistent parameter naming and structure across all tools: + - Use 'action' parameter consistently for all operation-based tools + - Return standardized response format with 'success', 'data', and 'error' fields + - Use consistent error codes and messages + +2. Implement proper error handling and validation: + - Validate parameters before execution + - Provide detailed error messages with suggestions for resolution + - Add timeout handling for long-running operations + - Include parameter type checking in both Python and C# + +3. Use JSON for structured data exchange: + - Define clear schema for each tool's input and output + - Handle serialization edge cases (e.g., circular references) + - Optimize for large data transfers when necessary + +4. Minimize dependencies between tools: + - Design each tool to function independently + - Use common utility functions for shared functionality + - Document any required dependencies clearly + +5. Add performance considerations: + - Implement batching for multiple related operations + - Add optional asynchronous execution for long-running tasks + - Include optional progress reporting for time-consuming operations + +6. Improve documentation: + - Add detailed XML/JSDoc comments for all public methods + - Include example usage for common scenarios + - Document potential side effects of operations \ No newline at end of file diff --git a/tool-refactor-plan.md.meta b/tool-refactor-plan.md.meta new file mode 100644 index 00000000..842e7b98 --- /dev/null +++ b/tool-refactor-plan.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3ccc86868276bd4bba01c840f14e5bb +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 4f5a6a0014803fa2e150b38e24d56b37e42d0bea Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Sun, 30 Mar 2025 16:01:17 -0400 Subject: [PATCH 2/5] increasted timeout and buffer --- Python/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Python/config.py b/Python/config.py index 0f4adab1..58f6f846 100644 --- a/Python/config.py +++ b/Python/config.py @@ -15,8 +15,8 @@ class ServerConfig: mcp_port: int = 6500 # Connection settings - connection_timeout: float = 300.0 # 5 minutes timeout - buffer_size: int = 1024 * 1024 # 1MB buffer for localhost + connection_timeout: float = 86400.0 # 24 hours timeout + buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Logging settings log_level: str = "INFO" From ba4c2a85bf40a16e3f367bbb72595c96ef22893b Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Mon, 31 Mar 2025 10:49:35 -0400 Subject: [PATCH 3/5] fixed prefab creation/update errors --- Editor/Tools/ManageAsset.cs | 169 +++++++++++-- Editor/Tools/ManageGameObject.cs | 407 ++++++++++++++++++++++++------ Editor/Tools/ManageScene.cs | 9 +- Editor/Tools/ManageScript.cs | 7 +- Editor/Tools/ReadConsole.cs | 107 +++++--- Python/server.py | 16 +- Python/tools/execute_menu_item.py | 8 +- Python/tools/manage_asset.py | 32 ++- Python/tools/manage_editor.py | 6 - Python/tools/manage_gameobject.py | 58 +++-- Python/tools/manage_scene.py | 2 - Python/tools/manage_script.py | 1 + Python/tools/read_console.py | 28 +- 13 files changed, 642 insertions(+), 208 deletions(-) diff --git a/Editor/Tools/ManageAsset.cs b/Editor/Tools/ManageAsset.cs index 6e00314d..138b798c 100644 --- a/Editor/Tools/ManageAsset.cs +++ b/Editor/Tools/ManageAsset.cs @@ -17,6 +17,14 @@ 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(); @@ -25,6 +33,13 @@ public static object HandleCommand(JObject @params) 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(); @@ -52,9 +67,13 @@ public static object HandleCommand(JObject @params) 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: - return Response.Error($"Unknown action: '{action}'."); + // 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) @@ -239,27 +258,72 @@ private static object ModifyAsset(string path, JObject properties) UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(fullPath); if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); - bool modified = false; + 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 - if (asset is Material material) + else if (asset is Material material) { - modified = ApplyMaterialProperties(material, properties); + // 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 (more complex, needs reflection or specific interface) + // Example: Modifying a ScriptableObject else if (asset is ScriptableObject so) { - modified = ApplyObjectProperties(so, properties); // General helper + // 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) { - modified = ApplyObjectProperties(textureImporter, properties); - if (modified) { - // Importer settings need saving + 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 { @@ -267,25 +331,37 @@ private static object ModifyAsset(string path, JObject properties) } } // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) - else + else // Fallback for other asset types OR direct properties on non-GameObject assets { - Debug.LogWarning($"Modification for asset type '{asset.GetType().Name}' at '{fullPath}' is not fully implemented. Attempting generic property setting."); - modified = ApplyObjectProperties(asset, properties); + // 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) - { - EditorUtility.SetDirty(asset); // Mark the asset itself as dirty - AssetDatabase.SaveAssets(); // Save changes to disk - // AssetDatabase.Refresh(); // SaveAssets usually handles refresh + { + // 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 { - return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + // 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) { - return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + // 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}"); } } @@ -486,7 +562,62 @@ private static object GetAssetInfo(string path, bool generatePreview) 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 --- /// diff --git a/Editor/Tools/ManageGameObject.cs b/Editor/Tools/ManageGameObject.cs index 1af13ebc..d85c09b0 100644 --- a/Editor/Tools/ManageGameObject.cs +++ b/Editor/Tools/ManageGameObject.cs @@ -32,6 +32,54 @@ public static object HandleCommand(JObject @params) string searchMethod = @params["searchMethod"]?.ToString().ToLower(); string name = @params["name"]?.ToString(); + // --- 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) @@ -80,48 +128,141 @@ private static object CreateGameObject(JObject @params) 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 - if (saveAsPrefab && string.IsNullOrEmpty(prefabPath)) + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; // Keep original for messages + if (!string.IsNullOrEmpty(prefabPath)) { - return Response.Error("'prefabPath' is required when 'saveAsPrefab' is true."); - } - if (saveAsPrefab && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - return Response.Error($"'prefabPath' must end with '.prefab'. Provided: '{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. + } - string primitiveType = @params["primitiveType"]?.ToString(); - GameObject newGo; + // Removed the early return error for missing .prefab ending. + // The logic above now handles finding or assuming the .prefab extension. - // Create primitive or empty GameObject - if (!string.IsNullOrEmpty(primitiveType)) - { - try + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) { - PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); - newGo = GameObject.CreatePrimitive(type); - newGo.name = name; // Set name after creation + 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}"); + } } - catch (ArgumentException) + else { - return Response.Error($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}"); + // 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 } - catch (Exception e) - { - return Response.Error($"Failed to create primitive '{primitiveType}': {e.Message}"); - } } - else + + // --- 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 { - newGo = new GameObject(name); + 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}'"); + } } - // Record creation for Undo (initial object) - // Note: Prefab saving might have its own Undo implications or require different handling. - // PrefabUtility operations often handle their own Undo steps. - Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{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 (before potentially making it a prefab root) + // Set Parent JToken parentToken = @params["parent"]; if (parentToken != null) { @@ -202,52 +343,85 @@ private static object CreateGameObject(JObject @params) } } - // Save as Prefab if requested - GameObject prefabInstance = newGo; // Keep track of the instance potentially linked to the prefab - if (saveAsPrefab) + // 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 - string directoryPath = System.IO.Path.GetDirectoryName(prefabPath); - if (!System.IO.Directory.Exists(directoryPath)) + // 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}"); } - // Save the GameObject as a prefab asset and connect the instance - // Use SaveAsPrefabAssetAndConnect to keep the instance in the scene linked - prefabInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, prefabPath, InteractionMode.UserAction); + // Use SaveAsPrefabAssetAndConnect with the final saving path + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction); - if (prefabInstance == null) + 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 '{prefabPath}'. Check path and permissions."); + 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 '{prefabPath}' and instance connected."); + 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(prefabInstance); // Instance is handled by SaveAsPrefabAssetAndConnect + // 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 '{prefabPath}': {e.Message}"); + return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } - // Select the instance in the scene (which might now be a prefab instance) - Selection.activeGameObject = prefabInstance; + // Select the instance in the scene (either prefab instance or newly created/saved one) + Selection.activeGameObject = finalInstance; - string successMessage = saveAsPrefab - ? $"GameObject '{name}' created and saved as prefab to '{prefabPath}'." - : $"GameObject '{name}' created successfully in scene."; + // 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(prefabInstance)); + return Response.Success(successMessage, GetGameObjectData(finalInstance)); } private static object ModifyGameObject(JObject @params, JToken targetToken, string searchMethod) @@ -967,10 +1141,13 @@ private static object ConvertJTokenToType(JToken token, Type targetType) { try { + // 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) @@ -981,47 +1158,121 @@ private static object ConvertJTokenToType(JToken token, Type targetType) 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) + + // Enum types + if (targetType.IsEnum) return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing - // Handle assigning Unity Objects (like Prefabs, Materials, Textures) using their asset path + // Handle assigning Unity Objects (Assets, Scene Objects, Components) if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) { - // Check if the input token is a string, which we'll assume is the asset path - 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) + // 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) { - return loadedAsset; // Return the loaded asset if successful + Debug.LogWarning($"[ConvertJTokenToType] Could not find component type '{componentTypeName}' specified in reference object: {token}"); + return null; } - else + + // Ensure the targetType is assignable from the found component type + if (!targetType.IsAssignableFrom(compType)) { - // 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."); + 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; - } - } - else - { - // Handle cases where an empty string might be intended to clear the reference - return null; // Assign null if the path is empty - } - } - else + } + 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())) { - // Log a warning if the input token is not a string (path) for a Unity Object assignment - Debug.LogWarning($"[ConvertJTokenToType] Expected a string asset path to assign Unity Object of type '{targetType.Name}', but received token type '{token.Type}'. Value: {token}"); - return null; + 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 simple value types) - return token.ToObject(targetType); + // 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) { diff --git a/Editor/Tools/ManageScene.cs b/Editor/Tools/ManageScene.cs index aae70cef..7ef8d02c 100644 --- a/Editor/Tools/ManageScene.cs +++ b/Editor/Tools/ManageScene.cs @@ -317,7 +317,6 @@ private static object GetGameObjectDataRecursive(GameObject go) childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); } - // Basic info var gameObjectData = new Dictionary { { "name", go.name }, @@ -328,14 +327,12 @@ private static object GetGameObjectDataRecursive(GameObject go) { "isStatic", go.isStatic }, { "instanceID", go.GetInstanceID() }, // Useful unique identifier { "transform", new { - position = go.transform.localPosition, - rotation = go.transform.localRotation.eulerAngles, // Euler for simplicity - scale = go.transform.localScale + 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 } - // Add components if needed - potentially large data - // { "components", go.GetComponents().Select(c => c.GetType().FullName).ToList() } }; return gameObjectData; diff --git a/Editor/Tools/ManageScript.cs b/Editor/Tools/ManageScript.cs index 6c049d38..ef732106 100644 --- a/Editor/Tools/ManageScript.cs +++ b/Editor/Tools/ManageScript.cs @@ -43,7 +43,8 @@ public static object HandleCommand(JObject @params) } // Ensure path is relative to Assets/, removing any leading "Assets/" - string relativeDir = path ?? string.Empty; + // Set default directory to "Scripts" if path is not provided + string relativeDir = path ?? "Scripts"; // Default to "Scripts" if path is null if (!string.IsNullOrEmpty(relativeDir)) { relativeDir = relativeDir.Replace('\\', '/').Trim('/'); @@ -52,6 +53,10 @@ public static object HandleCommand(JObject @params) relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); } } + // Handle empty string case explicitly after processing + if (string.IsNullOrEmpty(relativeDir)) { + relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" + } // Construct paths string scriptFileName = $"{name}.cs"; diff --git a/Editor/Tools/ReadConsole.cs b/Editor/Tools/ReadConsole.cs index cc1353a5..f58a0979 100644 --- a/Editor/Tools/ReadConsole.cs +++ b/Editor/Tools/ReadConsole.cs @@ -18,9 +18,9 @@ namespace UnityMCP.Editor.Tools public static class ReadConsole { // Reflection members for accessing internal LogEntry data - private static MethodInfo _getEntriesMethod; + // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection private static MethodInfo _startGettingEntriesMethod; - private static MethodInfo _stopGettingEntriesMethod; + private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... private static MethodInfo _clearMethod; private static MethodInfo _getCountMethod; private static MethodInfo _getEntryMethod; @@ -38,33 +38,49 @@ static ReadConsole() Type logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries"); if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); - _getEntriesMethod = logEntriesType.GetMethod("GetEntries", BindingFlags.Static | BindingFlags.Public); - _startGettingEntriesMethod = logEntriesType.GetMethod("StartGettingEntries", BindingFlags.Static | BindingFlags.Public); - _stopGettingEntriesMethod = logEntriesType.GetMethod("StopGettingEntries", BindingFlags.Static | BindingFlags.Public); - _clearMethod = logEntriesType.GetMethod("Clear", BindingFlags.Static | BindingFlags.Public); - _getCountMethod = logEntriesType.GetMethod("GetCount", BindingFlags.Static | BindingFlags.Public); - _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public); + // 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", BindingFlags.Instance | BindingFlags.Public); - _messageField = logEntryType.GetField("message", BindingFlags.Instance | BindingFlags.Public); - _fileField = logEntryType.GetField("file", BindingFlags.Instance | BindingFlags.Public); - _lineField = logEntryType.GetField("line", BindingFlags.Instance | BindingFlags.Public); - _instanceIdField = logEntryType.GetField("instanceID", BindingFlags.Instance | BindingFlags.Public); + _modeField = logEntryType.GetField("mode", instanceFlags); + if (_modeField == null) throw new Exception("Failed to reflect LogEntry.mode"); - // Basic check if reflection worked - if (_getEntriesMethod == null || _clearMethod == null || _modeField == null || _messageField == null) - { - throw new Exception("Failed to get required reflection members for LogEntries/LogEntry."); - } + _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. Console reading/clearing will likely fail. Error: {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. - _getEntriesMethod = _startGettingEntriesMethod = _stopGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; + _startGettingEntriesMethod = _endGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; } } @@ -73,9 +89,13 @@ static ReadConsole() public static object HandleCommand(JObject @params) { - // Check if reflection setup failed in static constructor - if (_clearMethod == null || _getEntriesMethod == null || _startGettingEntriesMethod == null || _stopGettingEntriesMethod == null || _getCountMethod == null || _getEntryMethod == null || _modeField == null || _messageField == null) + // 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."); } @@ -162,6 +182,7 @@ private static object GetConsoleEntries(List types, int? count, string f 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); @@ -220,15 +241,15 @@ private static object GetConsoleEntries(List types, int? count, string f } catch (Exception e) { Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); - // Ensure StopGettingEntries is called even if there's an error during iteration - try { _stopGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ } + // 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 StopGettingEntries - try { _stopGettingEntriesMethod.Invoke(null, null); } catch (Exception e) { - Debug.LogError($"[ReadConsole] Failed to call StopGettingEntries: {e}"); + // 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. } } @@ -257,20 +278,30 @@ private static object GetConsoleEntries(List types, int? count, string f private static LogType GetLogTypeFromMode(int mode) { - // Check for specific error/exception/assert types first - // Combine general and scripting-specific bits for broader matching. - if ((mode & (ModeBitError | ModeBitScriptingError | ModeBitException | ModeBitScriptingException)) != 0) { - return LogType.Error; + // 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; } - if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) { - return LogType.Assert; + else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) { + initialType = LogType.Warning; } - if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) { - return 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 } - // If none of the above, assume it's a standard log message. - // This covers ModeBitLog and ModeBitScriptingLog. - return LogType.Log; } /// diff --git a/Python/server.py b/Python/server.py index ceca968a..fc11d99e 100644 --- a/Python/server.py +++ b/Python/server.py @@ -55,14 +55,16 @@ def asset_creation_strategy() -> str: """Guide for discovering and using Unity MCP tools effectively.""" return ( "Available Unity MCP Server Tools:\\n\\n" - "For detailed usage, please refer to the specific tool's documentation.\\n\\n" - "- `manage_editor`: Controls editor state (play/pause/stop) and queries info (state, selection).\\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path (e.g., 'File/Save Project').\\n" + "- `manage_editor`: Controls editor state and queries info.\\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path.\\n" "- `read_console`: Reads or clears Unity console messages, with filtering options.\\n" - "- `manage_scene`: Manages scenes (load, save, create, get hierarchy).\\n" - "- `manage_gameobject`: Manages GameObjects in the scene (CRUD, find, components, assign properties).\\n" - "- `manage_script`: Manages C# script files (CRUD).\\n" - "- `manage_asset`: Manages project assets (import, create, modify, delete, search).\\n\\n" + "- `manage_scene`: Manages scenes.\\n" + "- `manage_gameobject`: Manages GameObjects in the scene.\\n" + "- `manage_script`: Manages C# script files.\\n" + "- `manage_asset`: Manages prefabs and assets.\\n\\n" + "Tips:\\n" + "- Create prefabs for reusable GameObjects.\\n" + "- Always include a camera and main light in your scenes.\\n" ) # Run the server diff --git a/Python/tools/execute_menu_item.py b/Python/tools/execute_menu_item.py index 5efab12f..daa45b19 100644 --- a/Python/tools/execute_menu_item.py +++ b/Python/tools/execute_menu_item.py @@ -11,10 +11,8 @@ def register_execute_menu_item_tools(mcp: FastMCP): async def execute_menu_item( ctx: Context, menu_path: str, - action: Optional[str] = 'execute', # Allows extending later (e.g., 'validate', 'get_available') - parameters: Optional[Dict[str, Any]] = None, # For menu items that might accept parameters (less common) - # alias: Optional[str] = None, # Potential future addition for common commands - # context: Optional[Dict[str, Any]] = None # Potential future addition for context-specific menus + action: Optional[str] = 'execute', + parameters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Executes a Unity Editor menu item via its path (e.g., "File/Save Project"). @@ -35,8 +33,6 @@ async def execute_menu_item( "action": action, "menuPath": menu_path, "parameters": parameters if parameters else {}, - # "alias": alias, - # "context": context } # Remove None values diff --git a/Python/tools/manage_asset.py b/Python/tools/manage_asset.py index 56be50e2..492a4604 100644 --- a/Python/tools/manage_asset.py +++ b/Python/tools/manage_asset.py @@ -1,8 +1,11 @@ """ Defines the manage_asset tool for interacting with Unity assets. """ +import asyncio # Added: Import asyncio for running sync code in async from typing import Optional, Dict, Any, List from mcp.server.fastmcp import FastMCP, Context +# from ..unity_connection import get_unity_connection # Original line that caused error +from unity_connection import get_unity_connection # Use absolute import relative to Python dir def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" @@ -14,12 +17,11 @@ async def manage_asset( path: str, asset_type: Optional[str] = None, properties: Optional[Dict[str, Any]] = None, - destination: Optional[str] = None, # Used for move/duplicate + destination: Optional[str] = None, generate_preview: Optional[bool] = False, - # Search specific parameters - search_pattern: Optional[str] = None, # Replaces path for search action? Or use path as pattern? - filter_type: Optional[str] = None, # Redundant with asset_type? - filter_date_after: Optional[str] = None, # ISO 8601 format + search_pattern: Optional[str] = None, + filter_type: Optional[str] = None, + filter_date_after: Optional[str] = None, page_size: Optional[int] = None, page_number: Optional[int] = None ) -> Dict[str, Any]: @@ -27,7 +29,7 @@ async def manage_asset( Args: ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'search'). + action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. properties: Dictionary of properties for 'create'/'modify'. @@ -61,6 +63,18 @@ async def manage_asset( # Remove None values to avoid sending unnecessary nulls params_dict = {k: v for k, v in params_dict.items() if v is not None} - # Forward the command to the Unity editor handler using the send_command method - # The C# side expects a command type and parameters. - return await ctx.send_command("manage_asset", params_dict) \ No newline at end of file + # Get the current asyncio event loop + loop = asyncio.get_running_loop() + # Get the Unity connection instance + connection = get_unity_connection() + + # Run the synchronous send_command in the default executor (thread pool) + # This prevents blocking the main async event loop. + result = await loop.run_in_executor( + None, # Use default executor + connection.send_command, # The function to call + "manage_asset", # First argument for send_command + params_dict # Second argument for send_command + ) + # Return the result obtained from Unity + return result \ No newline at end of file diff --git a/Python/tools/manage_editor.py b/Python/tools/manage_editor.py index 2ff8de03..4ba65c10 100644 --- a/Python/tools/manage_editor.py +++ b/Python/tools/manage_editor.py @@ -11,15 +11,9 @@ def manage_editor( action: str, wait_for_completion: Optional[bool] = None, # --- Parameters for specific actions --- - # For 'set_active_tool' tool_name: Optional[str] = None, - # For 'add_tag', 'remove_tag' tag_name: Optional[str] = None, - # For 'add_layer', 'remove_layer' layer_name: Optional[str] = None, - # Example: width: Optional[int] = None, height: Optional[int] = None - # Example: window_name: Optional[str] = None - # context: Optional[Dict[str, Any]] = None # Additional context ) -> Dict[str, Any]: """Controls and queries the Unity editor's state and settings. diff --git a/Python/tools/manage_gameobject.py b/Python/tools/manage_gameobject.py index 732781b4..931f6d60 100644 --- a/Python/tools/manage_gameobject.py +++ b/Python/tools/manage_gameobject.py @@ -9,50 +9,60 @@ def register_manage_gameobject_tools(mcp: FastMCP): def manage_gameobject( ctx: Context, action: str, - target: Optional[Union[str, int]] = None, # Name, path, or instance ID - search_method: Optional[str] = None, # by_name, by_tag, by_layer, by_component, by_id + target: Optional[Union[str, int]] = None, + search_method: Optional[str] = None, # --- Parameters for 'create' --- - name: Optional[str] = None, # Required for 'create' - tag: Optional[str] = None, # Tag to assign during creation - parent: Optional[Union[str, int]] = None, # Name or ID of parent - position: Optional[List[float]] = None, # [x, y, z] - rotation: Optional[List[float]] = None, # [x, y, z] Euler angles - scale: Optional[List[float]] = None, # [x, y, z] - components_to_add: Optional[List[Union[str, Dict[str, Any]]]] = None, # List of component names or dicts with properties - primitive_type: Optional[str] = None, # Optional: create primitive (Cube, Sphere, etc.) instead of empty - save_as_prefab: Optional[bool] = False, # If True, save the created object as a prefab - prefab_path: Optional[str] = None, # Full path to save prefab (e.g., "Assets/Prefabs/MyObject.prefab"). Overrides prefab_folder. - prefab_folder: Optional[str] = "Assets/Prefabs", # Default folder if prefab_path not set (e.g., "Assets/Prefabs") + name: Optional[str] = None, + tag: Optional[str] = None, + parent: Optional[Union[str, int]] = None, + position: Optional[List[float]] = None, + rotation: Optional[List[float]] = None, + scale: Optional[List[float]] = None, + components_to_add: Optional[List[Union[str, Dict[str, Any]]]] = None, + primitive_type: Optional[str] = None, + save_as_prefab: Optional[bool] = False, + prefab_path: Optional[str] = None, + prefab_folder: Optional[str] = "Assets/Prefabs", # --- Parameters for 'modify' --- new_name: Optional[str] = None, new_parent: Optional[Union[str, int]] = None, set_active: Optional[bool] = None, new_tag: Optional[str] = None, - new_layer: Optional[Union[str, int]] = None, # Layer name or number + new_layer: Optional[Union[str, int]] = None, components_to_remove: Optional[List[str]] = None, - component_properties: Optional[Dict[str, Dict[str, Any]]] = None, # { "ComponentName": { "propName": value } } + component_properties: Optional[Dict[str, Dict[str, Any]]] = None, # --- Parameters for 'find' --- - search_term: Optional[str] = None, # Used with search_method (e.g., name, tag value, component type) - find_all: Optional[bool] = False, # Find all matches or just the first? - search_in_children: Optional[bool] = False, # Limit search scope - search_inactive: Optional[bool] = False, # Include inactive GameObjects? + search_term: Optional[str] = None, + find_all: Optional[bool] = False, + search_in_children: Optional[bool] = False, + search_inactive: Optional[bool] = False, # -- Component Management Arguments -- - component_name: Optional[str] = None, # Target component for component actions + component_name: Optional[str] = None, ) -> Dict[str, Any]: """Manages GameObjects: create, modify, delete, find, and component operations. Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component'). + action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property'). target: GameObject identifier (name, path, ID) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find'. - Action-specific arguments (e.g., name, parent, position for 'create'; - component_name, component_properties for component actions; + search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. + component_properties: Dict mapping Component names to their properties to set. + Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, + To set references: + - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} + - Use a dict for scene objects/components, e.g.: + {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) + {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) + Action-specific arguments (e.g., name, parent, position for 'create'; + component_name for component actions; search_term, find_all for 'find'). Returns: Dictionary with operation results ('success', 'message', 'data'). """ try: + # --- Early check for attempting to modify a prefab asset --- + # ---------------------------------------------------------- + # Prepare parameters, removing None values params = { "action": action, diff --git a/Python/tools/manage_scene.py b/Python/tools/manage_scene.py index af923c48..79e02d28 100644 --- a/Python/tools/manage_scene.py +++ b/Python/tools/manage_scene.py @@ -12,8 +12,6 @@ def manage_scene( name: Optional[str] = None, path: Optional[str] = None, build_index: Optional[int] = None, - # Add other potential parameters like load_additive, etc. if needed - # context: Optional[Dict[str, Any]] = None # Future: Contextual info (e.g., current project settings) ) -> Dict[str, Any]: """Manages Unity scenes (load, save, create, get hierarchy, etc.). diff --git a/Python/tools/manage_script.py b/Python/tools/manage_script.py index 1a04f372..c6f2744f 100644 --- a/Python/tools/manage_script.py +++ b/Python/tools/manage_script.py @@ -17,6 +17,7 @@ def manage_script( namespace: Optional[str] = None ) -> Dict[str, Any]: """Manages C# scripts in Unity (create, read, update, delete). + Make reference variables public for easier access in the Unity Editor. Args: action: Operation ('create', 'read', 'update', 'delete'). diff --git a/Python/tools/read_console.py b/Python/tools/read_console.py index 4409f315..0de9bacd 100644 --- a/Python/tools/read_console.py +++ b/Python/tools/read_console.py @@ -3,21 +3,21 @@ """ from typing import Optional, List, Dict, Any from mcp.server.fastmcp import FastMCP, Context +from unity_connection import get_unity_connection def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" @mcp.tool() - async def read_console( + def read_console( ctx: Context, - action: Optional[str] = 'get', # Default action is to get messages - types: Optional[List[str]] = ['error', 'warning', 'log'], # Default types to retrieve - count: Optional[int] = None, # Max number of messages to return (null for all matching) - filter_text: Optional[str] = None, # Text to filter messages by - since_timestamp: Optional[str] = None, # ISO 8601 timestamp to get messages since - format: Optional[str] = 'detailed', # 'plain', 'detailed', 'json' - include_stacktrace: Optional[bool] = True, # Whether to include stack traces in detailed/json formats - # context: Optional[Dict[str, Any]] = None # Future context + action: Optional[str] = 'get', + types: Optional[List[str]] = ['error', 'warning', 'log'], + count: Optional[int] = None, + filter_text: Optional[str] = None, + since_timestamp: Optional[str] = None, + format: Optional[str] = 'detailed', + include_stacktrace: Optional[bool] = True, ) -> Dict[str, Any]: """Gets messages from or clears the Unity Editor console. @@ -34,6 +34,9 @@ async def read_console( Dictionary with results. For 'get', includes 'data' (messages). """ + # Get the connection instance + bridge = get_unity_connection() + # Normalize action action = action.lower() if action else 'get' @@ -55,6 +58,7 @@ async def read_console( if 'count' not in params_dict: params_dict['count'] = None - # Forward the command to the Unity editor handler - # The C# handler name might need adjustment (e.g., CommandRegistry) - return await ctx.bridge.unity_editor.HandleReadConsole(params_dict) \ No newline at end of file + # Forward the command using the bridge's send_command method + # The command type is the name of the tool itself in this case + # No await needed as send_command is synchronous + return bridge.send_command("read_console", params_dict) \ No newline at end of file From 0b51ff50d57c7a92ca032f57ffde6722f45ca1cf Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Mon, 31 Mar 2025 16:34:24 -0400 Subject: [PATCH 4/5] remove all optional and union and any parameters for cursor --- Editor/Tools/ManageGameObject.cs | 382 +++++++++++++++++++++++++++--- Editor/Tools/ManageScript.cs | 52 +++- Python/tools/execute_menu_item.py | 20 +- Python/tools/manage_asset.py | 20 +- Python/tools/manage_editor.py | 22 +- Python/tools/manage_gameobject.py | 67 +++--- Python/tools/manage_scene.py | 17 +- Python/tools/manage_script.py | 48 ++-- Python/tools/read_console.py | 36 +-- Python/unity_connection.py | 23 +- climber-prompt.md | 66 ++++++ climber-prompt.md.meta | 7 + 12 files changed, 608 insertions(+), 152 deletions(-) create mode 100644 climber-prompt.md create mode 100644 climber-prompt.md.meta diff --git a/Editor/Tools/ManageGameObject.cs b/Editor/Tools/ManageGameObject.cs index d85c09b0..9c2e7a7f 100644 --- a/Editor/Tools/ManageGameObject.cs +++ b/Editor/Tools/ManageGameObject.cs @@ -30,7 +30,12 @@ public static object HandleCommand(JObject @params) // 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; @@ -308,6 +313,21 @@ private static object CreateGameObject(JObject @params) } } } + + // 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) @@ -438,22 +458,22 @@ private static object ModifyGameObject(JObject @params, JToken targetToken, stri bool modified = false; - // Rename - string newName = @params["newName"]?.ToString(); - if (!string.IsNullOrEmpty(newName) && targetGo.name != newName) + // Rename (using consolidated 'name' parameter) + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) { - targetGo.name = newName; - modified = true; + targetGo.name = name; + modified = true; } - // Change Parent - JToken newParentToken = @params["newParent"]; - if (newParentToken != null) + // Change Parent (using consolidated 'parent' parameter) + JToken parentToken = @params["parent"]; + if (parentToken != null) { - GameObject newParentGo = FindObjectInternal(newParentToken, "by_id_or_name_or_path"); - if (newParentGo == null && !(newParentToken.Type == JTokenType.Null || (newParentToken.Type == JTokenType.String && string.IsNullOrEmpty(newParentToken.ToString())))) + 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 ('{newParentToken}') not found."); + return Response.Error($"New parent ('{parentToken}') not found."); } // Check for hierarchy loops if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) @@ -475,14 +495,14 @@ private static object ModifyGameObject(JObject @params, JToken targetToken, stri modified = true; } - // Change Tag - string newTag = @params["newTag"]?.ToString(); + // 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 (newTag != null && targetGo.tag != newTag) + if (tag != null && targetGo.tag != tag) { // Ensure the tag is not empty, if empty, it means "Untagged" implicitly - string tagToSet = string.IsNullOrEmpty(newTag) ? "Untagged" : newTag; + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { // First attempt to set the tag @@ -522,28 +542,19 @@ private static object ModifyGameObject(JObject @params, JToken targetToken, stri } } - // Change Layer - JToken newLayerToken = @params["newLayer"]; - if (newLayerToken != null) + // Change Layer (using consolidated 'layer' parameter) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) { - int layer = -1; - if (newLayerToken.Type == JTokenType.Integer) - { - layer = newLayerToken.ToObject(); - } - else if (newLayerToken.Type == JTokenType.String) - { - layer = LayerMask.NameToLayer(newLayerToken.ToString()); - } - - if (layer == -1 && newLayerToken.ToString() != "Default") // LayerMask.NameToLayer returns -1 for invalid names + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1 && layerName != "Default") { - return Response.Error($"Invalid layer specified: '{newLayerToken}'. Use a valid layer name or index."); + return Response.Error($"Invalid layer specified: '{layerName}'. Use a valid layer name."); } - if (layer != -1 && targetGo.layer != layer) + if (layerId != -1 && targetGo.layer != layerId) { - targetGo.layer = layer; - modified = true; + targetGo.layer = layerId; + modified = true; } } @@ -999,6 +1010,13 @@ private static object AddComponentInternal(GameObject targetGo, string typeName, 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) { @@ -1104,6 +1122,13 @@ private static bool SetProperty(object target, string memberName, JToken value) 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) { @@ -1134,6 +1159,265 @@ private static bool SetProperty(object target, string memberName, JToken value) 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. /// @@ -1141,6 +1425,38 @@ 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(); diff --git a/Editor/Tools/ManageScript.cs b/Editor/Tools/ManageScript.cs index ef732106..f041d1c7 100644 --- a/Editor/Tools/ManageScript.cs +++ b/Editor/Tools/ManageScript.cs @@ -23,7 +23,26 @@ public static object HandleCommand(JObject @params) string action = @params["action"]?.ToString().ToLower(); string name = @params["name"]?.ToString(); string path = @params["path"]?.ToString(); // Relative to Assets/ - string contents = @params["contents"]?.ToString(); + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode script contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation string namespaceName = @params["namespace"]?.ToString(); // For organizing code @@ -93,6 +112,24 @@ public static object HandleCommand(JObject @params) } } + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + private static object CreateScript(string fullPath, string relativePath, string name, string contents, string scriptType, string namespaceName) { // Check if script already exists @@ -138,7 +175,18 @@ private static object ReadScript(string fullPath, string relativePath) try { string contents = File.ReadAllText(fullPath); - return Response.Success($"Script '{Path.GetFileName(relativePath)}' read successfully.", new { path = relativePath, contents = contents }); + + // 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, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge + }; + + return Response.Success($"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData); } catch (Exception e) { diff --git a/Python/tools/execute_menu_item.py b/Python/tools/execute_menu_item.py index daa45b19..a4ebc672 100644 --- a/Python/tools/execute_menu_item.py +++ b/Python/tools/execute_menu_item.py @@ -1,8 +1,9 @@ """ Defines the execute_menu_item tool for running Unity Editor menu commands. """ -from typing import Optional, Dict, Any +from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context +from unity_connection import get_unity_connection # Import unity_connection module def register_execute_menu_item_tools(mcp: FastMCP): """Registers the execute_menu_item tool with the MCP server.""" @@ -11,8 +12,8 @@ def register_execute_menu_item_tools(mcp: FastMCP): async def execute_menu_item( ctx: Context, menu_path: str, - action: Optional[str] = 'execute', - parameters: Optional[Dict[str, Any]] = None, + action: str = 'execute', + parameters: Dict[str, Any] = None, ) -> Dict[str, Any]: """Executes a Unity Editor menu item via its path (e.g., "File/Save Project"). @@ -41,9 +42,10 @@ async def execute_menu_item( if "parameters" not in params_dict: params_dict["parameters"] = {} # Ensure parameters dict exists - # Forward the command to the Unity editor handler - # The C# handler is the static method HandleCommand in the ExecuteMenuItem class. - # We assume ctx.call is the correct way to invoke it via FastMCP. - # Note: The exact target string might need adjustment based on FastMCP's specifics. - csharp_handler_target = "UnityMCP.Editor.Tools.ExecuteMenuItem.HandleCommand" - return await ctx.call(csharp_handler_target, params_dict) \ No newline at end of file + # Get Unity connection and send the command + # We use the unity_connection module to communicate with Unity + unity_conn = get_unity_connection() + + # Send command to the ExecuteMenuItem C# handler + # The command type should match what the Unity side expects + return unity_conn.send_command("execute_menu_item", params_dict) \ No newline at end of file diff --git a/Python/tools/manage_asset.py b/Python/tools/manage_asset.py index 492a4604..328b85a7 100644 --- a/Python/tools/manage_asset.py +++ b/Python/tools/manage_asset.py @@ -2,7 +2,7 @@ Defines the manage_asset tool for interacting with Unity assets. """ import asyncio # Added: Import asyncio for running sync code in async -from typing import Optional, Dict, Any, List +from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context # from ..unity_connection import get_unity_connection # Original line that caused error from unity_connection import get_unity_connection # Use absolute import relative to Python dir @@ -15,15 +15,15 @@ async def manage_asset( ctx: Context, action: str, path: str, - asset_type: Optional[str] = None, - properties: Optional[Dict[str, Any]] = None, - destination: Optional[str] = None, - generate_preview: Optional[bool] = False, - search_pattern: Optional[str] = None, - filter_type: Optional[str] = None, - filter_date_after: Optional[str] = None, - page_size: Optional[int] = None, - page_number: Optional[int] = None + asset_type: str = None, + properties: Dict[str, Any] = None, + destination: str = None, + generate_preview: bool = False, + search_pattern: str = None, + filter_type: str = None, + filter_date_after: str = None, + page_size: int = None, + page_number: int = None ) -> Dict[str, Any]: """Performs asset operations (import, create, modify, delete, etc.) in Unity. diff --git a/Python/tools/manage_editor.py b/Python/tools/manage_editor.py index 4ba65c10..b256e6cf 100644 --- a/Python/tools/manage_editor.py +++ b/Python/tools/manage_editor.py @@ -1,5 +1,5 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Optional, Dict, Any, Union +from typing import Dict, Any from unity_connection import get_unity_connection def register_manage_editor_tools(mcp: FastMCP): @@ -9,11 +9,11 @@ def register_manage_editor_tools(mcp: FastMCP): def manage_editor( ctx: Context, action: str, - wait_for_completion: Optional[bool] = None, + wait_for_completion: bool = None, # --- Parameters for specific actions --- - tool_name: Optional[str] = None, - tag_name: Optional[str] = None, - layer_name: Optional[str] = None, + tool_name: str = None, + tag_name: str = None, + layer_name: str = None, ) -> Dict[str, Any]: """Controls and queries the Unity editor's state and settings. @@ -50,14 +50,4 @@ def manage_editor( return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} - - # Example of potentially splitting into more specific tools: - # @mcp.tool() - # def get_editor_state(ctx: Context) -> Dict[str, Any]: ... - # @mcp.tool() - # def set_editor_playmode(ctx: Context, state: str) -> Dict[str, Any]: ... # state='play'/'pause'/'stop' - # @mcp.tool() - # def add_editor_tag(ctx: Context, tag_name: str) -> Dict[str, Any]: ... - # @mcp.tool() - # def add_editor_layer(ctx: Context, layer_name: str) -> Dict[str, Any]: ... \ No newline at end of file + return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/Python/tools/manage_gameobject.py b/Python/tools/manage_gameobject.py index 931f6d60..a65331fa 100644 --- a/Python/tools/manage_gameobject.py +++ b/Python/tools/manage_gameobject.py @@ -1,5 +1,5 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Optional, Dict, Any, List, Union +from typing import Dict, Any, List from unity_connection import get_unity_connection def register_manage_gameobject_tools(mcp: FastMCP): @@ -9,42 +9,43 @@ def register_manage_gameobject_tools(mcp: FastMCP): def manage_gameobject( ctx: Context, action: str, - target: Optional[Union[str, int]] = None, - search_method: Optional[str] = None, - # --- Parameters for 'create' --- - name: Optional[str] = None, - tag: Optional[str] = None, - parent: Optional[Union[str, int]] = None, - position: Optional[List[float]] = None, - rotation: Optional[List[float]] = None, - scale: Optional[List[float]] = None, - components_to_add: Optional[List[Union[str, Dict[str, Any]]]] = None, - primitive_type: Optional[str] = None, - save_as_prefab: Optional[bool] = False, - prefab_path: Optional[str] = None, - prefab_folder: Optional[str] = "Assets/Prefabs", + target: str = None, # GameObject identifier by name or path + search_method: str = None, + # --- Combined Parameters for Create/Modify --- + name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) + tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) + parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) + position: List[float] = None, + rotation: List[float] = None, + scale: List[float] = None, + components_to_add: List[str] = None, # List of component names to add + primitive_type: str = None, + save_as_prefab: bool = False, + prefab_path: str = None, + prefab_folder: str = "Assets/Prefabs", # --- Parameters for 'modify' --- - new_name: Optional[str] = None, - new_parent: Optional[Union[str, int]] = None, - set_active: Optional[bool] = None, - new_tag: Optional[str] = None, - new_layer: Optional[Union[str, int]] = None, - components_to_remove: Optional[List[str]] = None, - component_properties: Optional[Dict[str, Dict[str, Any]]] = None, + set_active: bool = None, + layer: str = None, # Layer name + components_to_remove: List[str] = None, + component_properties: Dict[str, Dict[str, Any]] = None, # --- Parameters for 'find' --- - search_term: Optional[str] = None, - find_all: Optional[bool] = False, - search_in_children: Optional[bool] = False, - search_inactive: Optional[bool] = False, + search_term: str = None, + find_all: bool = False, + search_in_children: bool = False, + search_inactive: bool = False, # -- Component Management Arguments -- - component_name: Optional[str] = None, + component_name: str = None, ) -> Dict[str, Any]: """Manages GameObjects: create, modify, delete, find, and component operations. Args: action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property'). - target: GameObject identifier (name, path, ID) for modify/delete/component actions. + target: GameObject identifier (name or path string) for modify/delete/component actions. search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. + name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). + tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). + parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). + layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). component_properties: Dict mapping Component names to their properties to set. Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, To set references: @@ -52,7 +53,10 @@ def manage_gameobject( - Use a dict for scene objects/components, e.g.: {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - Action-specific arguments (e.g., name, parent, position for 'create'; + Example set nested property: + - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} + components_to_add: List of component names to add. + Action-specific arguments (e.g., position, rotation, scale for create/modify; component_name for component actions; search_term, find_all for 'find'). @@ -79,11 +83,8 @@ def manage_gameobject( "saveAsPrefab": save_as_prefab, "prefabPath": prefab_path, "prefabFolder": prefab_folder, - "newName": new_name, - "newParent": new_parent, "setActive": set_active, - "newTag": new_tag, - "newLayer": new_layer, + "layer": layer, "componentsToRemove": components_to_remove, "componentProperties": component_properties, "searchTerm": search_term, diff --git a/Python/tools/manage_scene.py b/Python/tools/manage_scene.py index 79e02d28..44981f65 100644 --- a/Python/tools/manage_scene.py +++ b/Python/tools/manage_scene.py @@ -1,5 +1,5 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Optional, Dict, Any +from typing import Dict, Any from unity_connection import get_unity_connection def register_manage_scene_tools(mcp: FastMCP): @@ -9,9 +9,9 @@ def register_manage_scene_tools(mcp: FastMCP): def manage_scene( ctx: Context, action: str, - name: Optional[str] = None, - path: Optional[str] = None, - build_index: Optional[int] = None, + name: str, + path: str, + build_index: int, ) -> Dict[str, Any]: """Manages Unity scenes (load, save, create, get hierarchy, etc.). @@ -26,7 +26,6 @@ def manage_scene( Dictionary with results ('success', 'message', 'data'). """ try: - # Prepare parameters, removing None values params = { "action": action, "name": name, @@ -45,10 +44,4 @@ def manage_scene( return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} - - # Consider adding specific tools if the single 'manage_scene' becomes too complex: - # @mcp.tool() - # def load_scene(ctx: Context, name: str, path: Optional[str] = None, build_index: Optional[int] = None) -> Dict[str, Any]: ... - # @mcp.tool() - # def get_scene_hierarchy(ctx: Context) -> Dict[str, Any]: ... \ No newline at end of file + return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/Python/tools/manage_script.py b/Python/tools/manage_script.py index c6f2744f..22e09530 100644 --- a/Python/tools/manage_script.py +++ b/Python/tools/manage_script.py @@ -1,7 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Optional, Dict, Any +from typing import Dict, Any from unity_connection import get_unity_connection import os +import base64 def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" @@ -11,10 +12,10 @@ def manage_script( ctx: Context, action: str, name: str, - path: Optional[str] = None, - contents: Optional[str] = None, - script_type: Optional[str] = None, - namespace: Optional[str] = None + path: str, + contents: str, + script_type: str, + namespace: str ) -> Dict[str, Any]: """Manages C# scripts in Unity (create, read, update, delete). Make reference variables public for easier access in the Unity Editor. @@ -22,10 +23,10 @@ def manage_script( Args: action: Operation ('create', 'read', 'update', 'delete'). name: Script name (no .cs extension). - path: Asset path (optional, default: "Assets/"). + path: Asset path (default: "Assets/"). contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour', optional). - namespace: Script namespace (optional). + script_type: Type hint (e.g., 'MonoBehaviour'). + namespace: Script namespace. Returns: Dictionary with results ('success', 'message', 'data'). @@ -36,10 +37,19 @@ def manage_script( "action": action, "name": name, "path": path, - "contents": contents, - "scriptType": script_type, - "namespace": namespace + "namespace": namespace, + "scriptType": script_type } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} @@ -48,17 +58,17 @@ def manage_script( # Process response from Unity if response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} else: return {"success": False, "message": response.get("error", "An unknown error occurred.")} except Exception as e: # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} - - # Potentially add more specific helper tools if needed later, e.g.: - # @mcp.tool() - # def create_script(...): ... - # @mcp.tool() - # def read_script(...): ... - # etc. \ No newline at end of file + return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file diff --git a/Python/tools/read_console.py b/Python/tools/read_console.py index 0de9bacd..3d4bd121 100644 --- a/Python/tools/read_console.py +++ b/Python/tools/read_console.py @@ -1,9 +1,9 @@ """ Defines the read_console tool for accessing Unity Editor console messages. """ -from typing import Optional, List, Dict, Any +from typing import List, Dict, Any from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" @@ -11,17 +11,18 @@ def register_read_console_tools(mcp: FastMCP): @mcp.tool() def read_console( ctx: Context, - action: Optional[str] = 'get', - types: Optional[List[str]] = ['error', 'warning', 'log'], - count: Optional[int] = None, - filter_text: Optional[str] = None, - since_timestamp: Optional[str] = None, - format: Optional[str] = 'detailed', - include_stacktrace: Optional[bool] = True, + action: str = None, + types: List[str] = None, + count: int = None, + filter_text: str = None, + since_timestamp: str = None, + format: str = None, + include_stacktrace: bool = None ) -> Dict[str, Any]: """Gets messages from or clears the Unity Editor console. Args: + ctx: The MCP context. action: Operation ('get' or 'clear'). types: Message types to get ('error', 'warning', 'log', 'all'). count: Max messages to return. @@ -37,17 +38,24 @@ def read_console( # Get the connection instance bridge = get_unity_connection() - # Normalize action - action = action.lower() if action else 'get' + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + include_stacktrace = include_stacktrace if include_stacktrace is not None else True + + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() # Prepare parameters for the C# handler params_dict = { "action": action, - "types": types if types else ['error', 'warning', 'log'], # Ensure types is not None + "types": types, "count": count, "filterText": filter_text, "sinceTimestamp": since_timestamp, - "format": format.lower() if format else 'detailed', + "format": format.lower() if isinstance(format, str) else format, "includeStacktrace": include_stacktrace } @@ -59,6 +67,4 @@ def read_console( params_dict['count'] = None # Forward the command using the bridge's send_command method - # The command type is the name of the tool itself in this case - # No await needed as send_command is synchronous return bridge.send_command("read_console", params_dict) \ No newline at end of file diff --git a/Python/unity_connection.py b/Python/unity_connection.py index 582a6a84..ce30316d 100644 --- a/Python/unity_connection.py +++ b/Python/unity_connection.py @@ -125,10 +125,27 @@ def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict # Normal command handling command = {"type": command_type, "params": params or {}} try: - logger.info(f"Sending command: {command_type} with params: {params}") - self.sock.sendall(json.dumps(command).encode('utf-8')) + # Check for very large content that might cause JSON issues + command_size = len(json.dumps(command)) + + if command_size > config.buffer_size / 2: + logger.warning(f"Large command detected ({command_size} bytes). This might cause issues.") + + logger.info(f"Sending command: {command_type} with params size: {command_size} bytes") + + # Ensure we have a valid JSON string before sending + command_json = json.dumps(command, ensure_ascii=False) + self.sock.sendall(command_json.encode('utf-8')) + response_data = self.receive_full_response(self.sock) - response = json.loads(response_data.decode('utf-8')) + try: + response = json.loads(response_data.decode('utf-8')) + except json.JSONDecodeError as je: + logger.error(f"JSON decode error: {str(je)}") + # Log partial response for debugging + partial_response = response_data.decode('utf-8')[:500] + "..." if len(response_data) > 500 else response_data.decode('utf-8') + logger.error(f"Partial response: {partial_response}") + raise Exception(f"Invalid JSON response from Unity: {str(je)}") if response.get("status") == "error": error_message = response.get("error") or response.get("message", "Unknown Unity error") diff --git a/climber-prompt.md b/climber-prompt.md new file mode 100644 index 00000000..da07f840 --- /dev/null +++ b/climber-prompt.md @@ -0,0 +1,66 @@ +Follow this detailed step-by-step guide to build this **"Crystal Climber"** game. + +--- + +### Step 1: Set Up the Basic Scene +1. Create a new 3D project named "Crystal Climber." +2. Add a large flat plane as the starting ground (this can act as the base of the climb). +3. Add a simple 3D cube or capsule as the player character. +4. Position the player on the ground plane, slightly above it (to account for gravity). +5. Add a directional light to illuminate the scene evenly. + +--- + +### Step 2: Player Movement Basics +6. Implement basic WASD movement for the player (forward, backward, left, right). +7. Add a jump ability triggered by the spacebar. +8. Attach a third-person camera to follow the player (positioned slightly behind and above). + +--- + +### Step 3: Build the Platform Structure +9. Create a flat, square platform (e.g., a thin cube or plane) as a prefab. +10. Place 5 platforms manually in the scene, staggered vertically and slightly offset horizontally (forming a climbable path upward). +11. Add collision to the platforms so the player can land on them. +12. Test the player jumping from the ground plane to the first platform and up the sequence. + +--- + +### Step 4: Core Objective +13. Place a glowing cube or sphere at the topmost platform as the "crystal." +14. Make the crystal detectable so the game recognizes when the player reaches it. +15. Add a win condition (e.g., display "You Win!" text on screen when the player touches the crystal). + +--- + +### Step 5: Visual Polish +16. Apply a semi-transparent material to the platforms (e.g., light blue with a faint glow). +17. Add a pulsing effect to the platforms (e.g., slight scale increase/decrease or opacity shift). +18. Change the scene background to a starry skybox. +19. Add a particle effect (e.g., sparkles or glowing dots) around the crystal. + +--- + +### Step 6: Refine the Platforms +20. Adjust the spacing between platforms to ensure jumps are challenging but possible. +21. Add 5 more platforms (total 10) to extend the climb vertically. +22. Place a small floating orb or decorative object on one platform as a visual detail. + +--- + +### Step 7: Audio Enhancement +23. Add a looping ambient background sound (e.g., soft wind or ethereal hum). +24. Attach a jump sound to the player (e.g., a light tap or whoosh). +25. Add a short victory sound (e.g., a chime or jingle) when the player reaches the crystal. + +--- + +### Step 8: Final Touches for Devlog Appeal +26. Add a subtle camera zoom-in effect when the player touches the crystal. +27. Sprinkle a few particle effects (e.g., faint stars or mist) across the scene for atmosphere. + +--- + +### Extras +29. Add a double-jump ability (e.g., press space twice) to make platforming easier. +30. Place a slow-rotating spike ball on one platform as a hazard to jump over. \ No newline at end of file diff --git a/climber-prompt.md.meta b/climber-prompt.md.meta new file mode 100644 index 00000000..2a771625 --- /dev/null +++ b/climber-prompt.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 59f0a16c19ac31d48a5b294600c96873 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From a7750e4257fd0ff7a448278c748ee77620bd6be6 Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Mon, 31 Mar 2025 16:41:50 -0400 Subject: [PATCH 5/5] removed urp requirement --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 58e19796..244d2f5a 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,10 @@ { "name": "com.justinpbarnett.unity-mcp", - "version": "0.1.4", + "version": "1.0.0", "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", - "com.unity.render-pipelines.universal": "12.1.7" + "com.unity.nuget.newtonsoft-json": "3.0.2" } }