From 73ea045c20502cac3c06ff412d763b065815211d Mon Sep 17 00:00:00 2001 From: Justin Barnett Date: Wed, 19 Mar 2025 08:03:12 -0400 Subject: [PATCH] added read_console tool --- Editor/Commands/EditorControlHandler.cs | 437 ++++++++++++++++++++++++ Python/server.py | 3 + Python/tools/editor_tools.py | 96 +++++- 3 files changed, 534 insertions(+), 2 deletions(-) diff --git a/Editor/Commands/EditorControlHandler.cs b/Editor/Commands/EditorControlHandler.cs index 1090049..7525711 100644 --- a/Editor/Commands/EditorControlHandler.cs +++ b/Editor/Commands/EditorControlHandler.cs @@ -2,6 +2,11 @@ using UnityEngine; using UnityEditor; using UnityEditor.Build.Reporting; using Newtonsoft.Json.Linq; +using System; +using System.Reflection; +using System.Collections.Generic; +using System.Linq; // Add LINQ namespace for Select extension method +using System.Globalization; /// /// Handles editor control commands like undo, redo, play, pause, stop, and build operations. @@ -32,6 +37,8 @@ public static class EditorControlHandler return HandleBuild(commandParams); case "EXECUTE_COMMAND": return HandleExecuteCommand(commandParams); + case "READ_CONSOLE": + return ReadConsole(commandParams); default: return new { error = $"Unknown editor control command: {command}" }; } @@ -124,6 +131,303 @@ public static class EditorControlHandler } } + /// + /// 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 = message, + stackTrace = stackTrace + }); + } + catch (Exception) + { + // Skip entries that cause errors + continue; + } + } + + // Return filtered results + return new + { + message = "Console logs retrieved successfully", + entries = 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; @@ -152,4 +456,137 @@ public static class EditorControlHandler } 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; + } } \ No newline at end of file diff --git a/Python/server.py b/Python/server.py index a54c8b4..17d3b4e 100644 --- a/Python/server.py +++ b/Python/server.py @@ -55,6 +55,7 @@ def asset_creation_strategy() -> str: "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" @@ -97,6 +98,8 @@ def asset_creation_strategy() -> str: " - 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" ) # Run the server diff --git a/Python/tools/editor_tools.py b/Python/tools/editor_tools.py index e5b2e42..38ab274 100644 --- a/Python/tools/editor_tools.py +++ b/Python/tools/editor_tools.py @@ -1,5 +1,5 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Optional +from typing import Optional, List, Dict, Any from unity_connection import get_unity_connection def register_editor_tools(mcp: FastMCP): @@ -174,4 +174,96 @@ def register_editor_tools(mcp: FastMCP): }) return response.get("message", f"Executed command: {command_name}") except Exception as e: - return f"Error executing command: {str(e)}" \ No newline at end of file + 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": "" + }] \ No newline at end of file