diff --git a/.gitignore b/.gitignore index fd0d7207..ce4b2f33 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,6 @@ log.txt log.txt.meta Server~/build/* Server~/node_modules/* + +# Claude settings +.claude/settings.local.json diff --git a/Editor/Services/ConsoleLogsService.cs b/Editor/Services/ConsoleLogsService.cs index 1dbbf01d..d7179cf8 100644 --- a/Editor/Services/ConsoleLogsService.cs +++ b/Editor/Services/ConsoleLogsService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; @@ -256,6 +257,143 @@ private void OnLogMessageReceived(string logString, string stackTrace, LogType t } } + /// + /// Search logs with keyword or regex pattern + /// + public JObject SearchLogsAsJson(string keyword = null, string regex = null, string logType = null, + bool includeStackTrace = true, bool caseSensitive = false, int offset = 0, int limit = 50) + { + // Prepare search criteria + bool hasSearchCriteria = !string.IsNullOrEmpty(keyword) || !string.IsNullOrEmpty(regex); + Regex searchRegex = null; + string searchKeyword = keyword; + + // If regex is provided, use it instead of keyword + if (!string.IsNullOrEmpty(regex)) + { + try + { + searchRegex = new Regex(regex, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); + } + catch (ArgumentException ex) + { + return new JObject + { + ["logs"] = new JArray(), + ["error"] = $"Invalid regex pattern: {ex.Message}", + ["success"] = false + }; + } + } + else if (!string.IsNullOrEmpty(keyword) && !caseSensitive) + { + searchKeyword = keyword.ToLower(); + } + + // Map MCP log types to Unity log types + HashSet unityLogTypes = null; + if (!string.IsNullOrEmpty(logType)) + { + if (LogTypeMapping.TryGetValue(logType, out var mapped)) + { + unityLogTypes = mapped; + } + else + { + unityLogTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { logType }; + } + } + + JArray logsArray = new JArray(); + int totalCount = 0; + int filteredCount = 0; + int matchedCount = 0; + int currentIndex = 0; + + lock (_logEntries) + { + totalCount = _logEntries.Count; + + // Search through logs (newest first) + for (int i = _logEntries.Count - 1; i >= 0; i--) + { + var entry = _logEntries[i]; + + // Skip if filtering by log type and entry doesn't match + if (unityLogTypes != null && !unityLogTypes.Contains(entry.Type.ToString())) + continue; + + filteredCount++; + + // Check if entry matches search criteria + bool matches = true; + if (hasSearchCriteria) + { + matches = false; + + // Search in message + if (searchRegex != null) + { + matches = searchRegex.IsMatch(entry.Message); + if (!matches && includeStackTrace && !string.IsNullOrEmpty(entry.StackTrace)) + { + matches = searchRegex.IsMatch(entry.StackTrace); + } + } + else if (!string.IsNullOrEmpty(searchKeyword)) + { + string messageToSearch = caseSensitive ? entry.Message : entry.Message.ToLower(); + matches = messageToSearch.Contains(searchKeyword); + + if (!matches && includeStackTrace && !string.IsNullOrEmpty(entry.StackTrace)) + { + string stackTraceToSearch = caseSensitive ? entry.StackTrace : entry.StackTrace.ToLower(); + matches = stackTraceToSearch.Contains(searchKeyword); + } + } + } + + if (!matches) continue; + + matchedCount++; + + // Check if we're in the offset range and haven't reached the limit yet + if (currentIndex >= offset && logsArray.Count < limit) + { + var logObject = new JObject + { + ["message"] = entry.Message, + ["type"] = entry.Type.ToString(), + ["timestamp"] = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff") + }; + + // Only include stack trace if requested + if (includeStackTrace) + { + logObject["stackTrace"] = entry.StackTrace; + } + + logsArray.Add(logObject); + } + + currentIndex++; + + // Early exit if we've collected enough logs + if (currentIndex >= offset + limit) break; + } + } + + return new JObject + { + ["logs"] = logsArray, + ["_totalCount"] = totalCount, + ["_filteredCount"] = filteredCount, + ["_matchedCount"] = matchedCount, + ["_returnedCount"] = logsArray.Count, + ["success"] = true + }; + } + #if UNITY_6000_0_OR_NEWER /// /// Called when the console logs count changes diff --git a/Editor/Services/ConsoleLogsServiceUnity6.cs b/Editor/Services/ConsoleLogsServiceUnity6.cs new file mode 100644 index 00000000..2beab34b --- /dev/null +++ b/Editor/Services/ConsoleLogsServiceUnity6.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace McpUnity.Services +{ + /// + /// Unity 6 specific implementation using ConsoleWindowUtility API + /// This implementation uses Unity's internal console APIs for more reliable log retrieval + /// + public class ConsoleLogsServiceUnity6 : IConsoleLogsService + { + // Static mapping for MCP log types to Unity log types + private static readonly Dictionary> LogTypeMapping = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "info", new HashSet(StringComparer.OrdinalIgnoreCase) { "Log" } }, + { "error", new HashSet(StringComparer.OrdinalIgnoreCase) { "Error", "Exception", "Assert" } }, + { "warning", new HashSet(StringComparer.OrdinalIgnoreCase) { "Warning" } } + }; + + // Reflection cache for internal Unity APIs + private static Type _logEntriesType; + private static MethodInfo _getCountMethod; + private static MethodInfo _getEntryInternalMethod; + private static MethodInfo _startGettingEntriesMethod; + private static MethodInfo _endGettingEntriesMethod; + private static Type _logEntryType; + private static FieldInfo _messageField; + private static FieldInfo _fileField; + private static FieldInfo _lineField; + private static FieldInfo _modeField; + private static FieldInfo _callstackTextStartUTF8Field; + private static FieldInfo _callstackTextStartUTF16Field; + + static ConsoleLogsServiceUnity6() + { + InitializeReflection(); + } + + private static void InitializeReflection() + { + // Get LogEntries type + _logEntriesType = Type.GetType("UnityEditor.LogEntries,UnityEditor"); + if (_logEntriesType == null) + { + Debug.LogError("[MCP Unity] Failed to find LogEntries type"); + return; + } + + // Get LogEntry type + _logEntryType = Type.GetType("UnityEditor.LogEntry,UnityEditor"); + if (_logEntryType == null) + { + Debug.LogError("[MCP Unity] Failed to find LogEntry type"); + return; + } + + // Get methods + _getCountMethod = _logEntriesType.GetMethod("GetCount", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + + _startGettingEntriesMethod = _logEntriesType.GetMethod("StartGettingEntries", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + + _endGettingEntriesMethod = _logEntriesType.GetMethod("EndGettingEntries", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + + _getEntryInternalMethod = _logEntriesType.GetMethod("GetEntryInternal", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + + // Get fields + _messageField = _logEntryType.GetField("message", BindingFlags.Public | BindingFlags.Instance); + _fileField = _logEntryType.GetField("file", BindingFlags.Public | BindingFlags.Instance); + _lineField = _logEntryType.GetField("line", BindingFlags.Public | BindingFlags.Instance); + _modeField = _logEntryType.GetField("mode", BindingFlags.Public | BindingFlags.Instance); + _callstackTextStartUTF8Field = _logEntryType.GetField("callstackTextStartUTF8", BindingFlags.Public | BindingFlags.Instance); + _callstackTextStartUTF16Field = _logEntryType.GetField("callstackTextStartUTF16", BindingFlags.Public | BindingFlags.Instance); + } + + public void StartListening() + { + // Unity 6: Register for console changes + ConsoleWindowUtility.consoleLogsChanged += OnConsoleLogsChanged; + } + + public void StopListening() + { + // Unity 6: Unregister from console changes + ConsoleWindowUtility.consoleLogsChanged -= OnConsoleLogsChanged; + } + + private void OnConsoleLogsChanged() + { + // This is called whenever console logs change: + // - New logs added + // - Console cleared + // - Logs filtered/collapsed + + ConsoleWindowUtility.GetConsoleLogCounts(out int error, out int warning, out int log); + int totalLogs = error + warning + log; + + if (totalLogs == 0) + { + Debug.Log("[MCP Unity] Console cleared detected via Unity 6 API"); + } + + // Since we query Unity directly, we don't need to maintain our own cache + // This event just helps us know when to notify clients that log state changed + } + + public JObject GetLogsAsJson(string logType = "", int offset = 0, int limit = 100, bool includeStackTrace = true) + { + if (_logEntriesType == null || _getCountMethod == null || _getEntryInternalMethod == null) + { + return new JObject + { + ["logs"] = new JArray(), + ["message"] = "LogEntries API not available", + ["success"] = false + }; + } + + JArray logsArray = new JArray(); + + try + { + // Get console log counts using Unity 6 API + ConsoleWindowUtility.GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount); + int totalCount = errorCount + warningCount + logCount; + + // Map MCP log types to Unity log types + HashSet unityLogTypes = null; + bool filter = !string.IsNullOrEmpty(logType); + if (filter) + { + if (LogTypeMapping.TryGetValue(logType, out var mapped)) + { + unityLogTypes = mapped; + } + else + { + unityLogTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { logType }; + } + } + + // Start getting entries + _startGettingEntriesMethod?.Invoke(null, null); + + int currentCount = (int)_getCountMethod.Invoke(null, null); + var collectedLogs = new List(); + + // Iterate through all logs (newest first) + for (int i = currentCount - 1; i >= 0; i--) + { + // Create LogEntry instance + var logEntry = Activator.CreateInstance(_logEntryType); + + // GetEntryInternal(int row, LogEntry outputEntry) + bool success = (bool)_getEntryInternalMethod.Invoke(null, new object[] { i, logEntry }); + + if (!success) continue; + + // Extract fields (see Unity6InternalAPIReference.md for field details) + string fullMessage = _messageField?.GetValue(logEntry) as string ?? ""; + string file = _fileField?.GetValue(logEntry) as string ?? ""; + int line = _lineField?.GetValue(logEntry) as int? ?? 0; + int mode = _modeField?.GetValue(logEntry) as int? ?? 0; + int callstackStartUTF8 = _callstackTextStartUTF8Field?.GetValue(logEntry) as int? ?? 0; + int callstackStartUTF16 = _callstackTextStartUTF16Field?.GetValue(logEntry) as int? ?? 0; + + // Debug: Write mode values to file for analysis (disabled by default) + #if MCP_UNITY_DEBUG_MODE_VALUES + if (fullMessage.Contains("error") || fullMessage.Contains("Error") || + fullMessage.Contains("warning") || fullMessage.Contains("Warning") || + fullMessage.Contains("failed") || fullMessage.Contains("Failed") || + fullMessage.Contains("exception") || fullMessage.Contains("Exception")) + { + WriteDebugInfo(fullMessage, mode); + } + #endif + + // Parse message and stack trace using Unity's internal callstack position (prefer UTF-16) + var (actualMessage, stackTrace) = ParseMessageAndStackTrace(fullMessage, callstackStartUTF16, callstackStartUTF8); + + // Determine log type from mode and stack trace + string entryType = DetermineLogTypeFromModeAndContent(mode, stackTrace); + + // Skip if filtering and doesn't match + if (filter && !unityLogTypes.Contains(entryType)) + continue; + + // Create log object + var logObject = new JObject + { + ["message"] = actualMessage, + ["type"] = entryType, + ["timestamp"] = DateTime.Now.AddSeconds(-(currentCount - i)).ToString("yyyy-MM-dd HH:mm:ss.fff") + }; + + // Include stack trace if requested + if (includeStackTrace && !string.IsNullOrEmpty(stackTrace)) + { + logObject["stackTrace"] = stackTrace; + } + + collectedLogs.Add(logObject); + } + + // End getting entries + _endGettingEntriesMethod?.Invoke(null, null); + + // Apply pagination + var paginatedLogs = collectedLogs + .Skip(offset) + .Take(limit) + .ToList(); + + foreach (var log in paginatedLogs) + { + logsArray.Add(log); + } + + return new JObject + { + ["logs"] = logsArray, + ["_totalCount"] = totalCount, + ["_filteredCount"] = collectedLogs.Count, + ["_returnedCount"] = paginatedLogs.Count, + ["message"] = $"Retrieved {paginatedLogs.Count} of {collectedLogs.Count} log entries (offset: {offset}, limit: {limit}, total: {totalCount})", + ["success"] = true + }; + } + catch (Exception ex) + { + Debug.LogError($"[MCP Unity] Error getting logs: {ex.Message}"); + return new JObject + { + ["logs"] = new JArray(), + ["message"] = $"Error retrieving logs: {ex.Message}", + ["success"] = false + }; + } + } + + private (string message, string stackTrace) ParseMessageAndStackTrace(string fullMessage, int callstackStartUTF16, int callstackStartUTF8) + { + if (string.IsNullOrEmpty(fullMessage)) + return ("", ""); + + // Try UTF-16 position first (C# strings are UTF-16) + if (callstackStartUTF16 > 0 && callstackStartUTF16 < fullMessage.Length) + { + try + { + string message = fullMessage.Substring(0, callstackStartUTF16).TrimEnd('\n', '\r'); + string stackTrace = fullMessage.Substring(callstackStartUTF16); + return (message, stackTrace); + } + catch + { + // Continue to next attempt + } + } + + // Fallback to UTF-8 position + if (callstackStartUTF8 > 0 && callstackStartUTF8 < fullMessage.Length) + { + try + { + string message = fullMessage.Substring(0, callstackStartUTF8).TrimEnd('\n', '\r'); + string stackTrace = fullMessage.Substring(callstackStartUTF8); + return (message, stackTrace); + } + catch + { + // Continue to heuristic parsing + } + } + + // Fallback: heuristic parsing (previous method) + var lines = fullMessage.Split(new[] { '\n' }, StringSplitOptions.None); + + if (lines.Length == 1) + { + return (fullMessage, ""); + } + + // Find the first line that looks like a stack trace + int stackTraceStartIndex = -1; + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i]; + if (line.StartsWith("UnityEngine.") || + line.StartsWith("System.") || + line.Contains(" (at ") || + line.Contains(":") && (line.Contains("(") && line.Contains(")"))) + { + stackTraceStartIndex = i; + break; + } + } + + if (stackTraceStartIndex == -1) + { + return (fullMessage, ""); + } + + string fallbackMessage = string.Join("\n", lines.Take(stackTraceStartIndex)); + string fallbackStackTrace = string.Join("\n", lines.Skip(stackTraceStartIndex)); + + return (fallbackMessage, fallbackStackTrace); + } + + private string DetermineLogTypeFromModeAndContent(int mode, string stackTrace) + { + // First try to determine from stack trace content + if (!string.IsNullOrEmpty(stackTrace)) + { + if (stackTrace.Contains("UnityEngine.Debug:LogError") || + stackTrace.Contains("UnityEngine.Logger:LogError")) + return "Error"; + + if (stackTrace.Contains("UnityEngine.Debug:LogWarning") || + stackTrace.Contains("UnityEngine.Logger:LogWarning")) + return "Warning"; + + if (stackTrace.Contains("UnityEngine.Debug:LogException") || + stackTrace.Contains("UnityEngine.Logger:LogException")) + return "Exception"; + + if (stackTrace.Contains("UnityEngine.Debug:LogAssertion") || + stackTrace.Contains("UnityEngine.Assertions.Assert")) + return "Assert"; + + if (stackTrace.Contains("UnityEngine.Debug:Log")) + return "Log"; + } + + // Fallback to mode flags + return GetLogTypeFromMode(mode); + } + + private string GetLogTypeFromMode(int mode) + { + // Use centralized mode flags logic + return LogEntryModeFlags.GetLogTypeFromMode(mode); + } + + public void CleanupOldLogs(int keepCount = 500) + { + // Not needed for Unity 6 implementation as we query directly from Unity + } + + public int GetLogCount() + { + ConsoleWindowUtility.GetConsoleLogCounts(out int error, out int warning, out int log); + return error + warning + log; + } + + /// + /// Search logs with keyword or regex pattern + /// + public JObject SearchLogsAsJson(string keyword = null, string regex = null, string logType = null, + bool includeStackTrace = true, bool caseSensitive = false, int offset = 0, int limit = 50) + { + // Prepare search criteria + bool hasSearchCriteria = !string.IsNullOrEmpty(keyword) || !string.IsNullOrEmpty(regex); + Regex searchRegex = null; + string searchKeyword = keyword; + + // If regex is provided, use it instead of keyword + if (!string.IsNullOrEmpty(regex)) + { + try + { + searchRegex = new Regex(regex, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); + } + catch (ArgumentException ex) + { + return new JObject + { + ["logs"] = new JArray(), + ["error"] = $"Invalid regex pattern: {ex.Message}", + ["success"] = false + }; + } + } + else if (!string.IsNullOrEmpty(keyword) && !caseSensitive) + { + searchKeyword = keyword.ToLower(); + } + + // Map MCP log types to Unity log types + HashSet unityLogTypes = null; + if (!string.IsNullOrEmpty(logType)) + { + if (LogTypeMapping.TryGetValue(logType, out var mapped)) + { + unityLogTypes = mapped; + } + else + { + unityLogTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { logType }; + } + } + + JArray logsArray = new JArray(); + int totalCount = 0; + int filteredCount = 0; + int matchedCount = 0; + int currentIndex = 0; + + // Get total count using reflection + try + { + totalCount = (int)_getCountMethod.Invoke(null, null); + } + catch (Exception ex) + { + Debug.LogError($"[MCP Unity] Error getting log count: {ex.Message}"); + return new JObject + { + ["logs"] = logsArray, + ["error"] = "Failed to access Unity console logs", + ["success"] = false + }; + } + + if (totalCount == 0) + { + return new JObject + { + ["logs"] = logsArray, + ["_totalCount"] = 0, + ["_filteredCount"] = 0, + ["_matchedCount"] = 0, + ["_returnedCount"] = 0, + ["success"] = true + }; + } + + try + { + // Start getting entries + _startGettingEntriesMethod?.Invoke(null, null); + + // Search through logs (newest first) + for (int i = totalCount - 1; i >= 0; i--) + { + // Create LogEntry instance + var logEntry = Activator.CreateInstance(_logEntryType); + + // GetEntryInternal(int row, LogEntry outputEntry) + bool success = (bool)_getEntryInternalMethod.Invoke(null, new object[] { i, logEntry }); + + if (!success) continue; + + // Extract fields + string fullMessage = _messageField?.GetValue(logEntry) as string ?? ""; + string file = _fileField?.GetValue(logEntry) as string ?? ""; + int line = _lineField?.GetValue(logEntry) as int? ?? 0; + int mode = _modeField?.GetValue(logEntry) as int? ?? 0; + int callstackStartUTF8 = _callstackTextStartUTF8Field?.GetValue(logEntry) as int? ?? 0; + int callstackStartUTF16 = _callstackTextStartUTF16Field?.GetValue(logEntry) as int? ?? 0; + + // Parse message and stack trace + var (actualMessage, stackTrace) = ParseMessageAndStackTrace(fullMessage, callstackStartUTF16, callstackStartUTF8); + + // Determine log type + string entryLogType = DetermineLogTypeFromModeAndContent(mode, stackTrace); + + // Skip if filtering by log type and entry doesn't match + if (unityLogTypes != null && !unityLogTypes.Contains(entryLogType)) + continue; + + filteredCount++; + + // Check if entry matches search criteria + bool matches = true; + if (hasSearchCriteria) + { + matches = false; + + // Search in message + if (searchRegex != null) + { + matches = searchRegex.IsMatch(actualMessage); + if (!matches && includeStackTrace && !string.IsNullOrEmpty(stackTrace)) + { + matches = searchRegex.IsMatch(stackTrace); + } + } + else if (!string.IsNullOrEmpty(searchKeyword)) + { + string messageToSearch = caseSensitive ? actualMessage : actualMessage.ToLower(); + matches = messageToSearch.Contains(searchKeyword); + + if (!matches && includeStackTrace && !string.IsNullOrEmpty(stackTrace)) + { + string stackTraceToSearch = caseSensitive ? stackTrace : stackTrace.ToLower(); + matches = stackTraceToSearch.Contains(searchKeyword); + } + } + } + + if (!matches) continue; + + matchedCount++; + + // Check if we're in the offset range and haven't reached the limit yet + if (currentIndex >= offset && logsArray.Count < limit) + { + var logObject = new JObject + { + ["message"] = actualMessage, + ["type"] = entryLogType, + ["timestamp"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + }; + + // Only include stack trace if requested + if (includeStackTrace) + { + logObject["stackTrace"] = stackTrace; + } + + logsArray.Add(logObject); + } + + currentIndex++; + + // Early exit if we've collected enough logs + if (currentIndex >= offset + limit) break; + } + } + finally + { + // End getting entries + _endGettingEntriesMethod?.Invoke(null, null); + } + + return new JObject + { + ["logs"] = logsArray, + ["_totalCount"] = totalCount, + ["_filteredCount"] = filteredCount, + ["_matchedCount"] = matchedCount, + ["_returnedCount"] = logsArray.Count, + ["success"] = true + }; + } + + #if MCP_UNITY_DEBUG_MODE_VALUES + /// + /// Debug method to write mode values to file for analysis + /// Enable by adding MCP_UNITY_DEBUG_MODE_VALUES to Project Settings > Player > Scripting Define Symbols + /// + private void WriteDebugInfo(string message, int mode) + { + try + { + string debugPath = Path.Combine(Application.dataPath, "..", "mcp-unity-debug.log"); + string debugLine = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} | Mode: {mode} (0x{mode:X}) Binary: {Convert.ToString(mode, 2)} | Message: {message.Substring(0, Math.Min(100, message.Length))}...\n"; + File.AppendAllText(debugPath, debugLine); + } + catch + { + // Ignore debug write errors + } + } + #endif + } +} \ No newline at end of file diff --git a/Editor/Services/ConsoleLogsServiceUnity6.cs.meta b/Editor/Services/ConsoleLogsServiceUnity6.cs.meta new file mode 100644 index 00000000..a9326b0d --- /dev/null +++ b/Editor/Services/ConsoleLogsServiceUnity6.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4534e9a288ddd411891ebe758d3bb332 \ No newline at end of file diff --git a/Editor/Services/IConsoleLogsService.cs b/Editor/Services/IConsoleLogsService.cs index 23ce313b..a0f2de99 100644 --- a/Editor/Services/IConsoleLogsService.cs +++ b/Editor/Services/IConsoleLogsService.cs @@ -41,5 +41,19 @@ public interface IConsoleLogsService /// /// Number of stored log entries int GetLogCount(); + + /// + /// Search logs with keyword or regex pattern + /// + /// Keyword to search for (partial match) + /// Regular expression pattern (overrides keyword if provided) + /// Filter by log type (empty for all) + /// Whether to include stack trace in search (default: true) + /// Whether the search is case sensitive (default: false) + /// Starting index (0-based) + /// Maximum number of logs to return (default: 50) + /// JObject containing matching logs array and pagination info + JObject SearchLogsAsJson(string keyword = null, string regex = null, string logType = null, + bool includeStackTrace = true, bool caseSensitive = false, int offset = 0, int limit = 50); } } diff --git a/Editor/Services/LogEntryModeFlags.cs b/Editor/Services/LogEntryModeFlags.cs new file mode 100644 index 00000000..ea3fc1d1 --- /dev/null +++ b/Editor/Services/LogEntryModeFlags.cs @@ -0,0 +1,60 @@ +namespace McpUnity.Services +{ + /// + /// Unity LogEntry mode flags constants + /// Based on Unity's internal LogMessageFlags from UnityCsReference + /// https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/LogEntries.bindings.cs + /// + public static class LogEntryModeFlags + { + // Basic log type flags (bits 0-4) + public const int kModeError = 1 << 0; // 1 (0x1) + public const int kModeAssert = 1 << 1; // 2 (0x2) + public const int kModeLog = 1 << 2; // 4 (0x4) + public const int kModeWarning = 1 << 3; // 8 (0x8) + public const int kModeException = 1 << 4; // 16 (0x10) + + // Scripting related flags (bits 8-12) + public const int kScriptingError = 1 << 8; // 256 (0x100) + public const int kScriptingWarning = 1 << 9; // 512 (0x200) + public const int kScriptingLog = 1 << 10; // 1024 (0x400) + public const int kScriptCompileError = 1 << 11; // 2048 (0x800) + public const int kScriptCompileWarning = 1 << 12; // 4096 (0x1000) + + // Observed composite values from debugging + // These include additional undocumented high bits + public const int ObservedCompilerWarning = 266240; // 0x41000 (bits: 18, 10) + public const int ObservedCompilerError = 272384; // 0x42800 (bits: 18, 14, 10) + public const int ObservedShaderError = 262212; // 0x40044 (bits: 18, 6, 2) + public const int ObservedRuntimeWarning = 8405504; // 0x804200 (bits: 23, 18, 9) + public const int ObservedRuntimeError = 8405248; // 0x804100 (bits: 23, 18, 8) + + /// + /// Determine log type from mode flags + /// + public static string GetLogTypeFromMode(int mode) + { + // Check for observed compiler/shader message patterns first + if (mode == ObservedCompilerError) return "Error"; + if (mode == ObservedCompilerWarning) return "Warning"; + if (mode == ObservedShaderError) return "Error"; + + // Check for script compile errors/warnings + if ((mode & kScriptCompileError) != 0) return "Error"; + if ((mode & kScriptCompileWarning) != 0) return "Warning"; + + // Check for scripting errors/warnings + if ((mode & kScriptingError) != 0) return "Error"; + if ((mode & kScriptingWarning) != 0) return "Warning"; + + // Then check standard flags + if ((mode & kModeError) != 0) return "Error"; + if ((mode & kModeAssert) != 0) return "Assert"; + if ((mode & kModeException) != 0) return "Exception"; + if ((mode & kModeWarning) != 0) return "Warning"; + if ((mode & kModeLog) != 0) return "Log"; + + return "Log"; // Default to Log instead of Unknown + } + } +} \ No newline at end of file diff --git a/Editor/Services/LogEntryModeFlags.cs.meta b/Editor/Services/LogEntryModeFlags.cs.meta new file mode 100644 index 00000000..62d5b163 --- /dev/null +++ b/Editor/Services/LogEntryModeFlags.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2b3c4d5e6f7890123456789abcdef01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Editor/Services/Unity6InternalAPIReference.md b/Editor/Services/Unity6InternalAPIReference.md new file mode 100644 index 00000000..2fa665de --- /dev/null +++ b/Editor/Services/Unity6InternalAPIReference.md @@ -0,0 +1,84 @@ +# Unity 6 Internal API Reference + +## LogEntry Structure + +Unity's internal `LogEntry` structure contains the following fields: + +### Fields +| Field Name | Type | Description | +|------------|------|-------------| +| message | String | Full message including stack trace | +| file | String | Source file path | +| line | Int32 | Line number | +| column | Int32 | Column number (-1 if not available) | +| mode | Int32 | Log type flags (see Mode Flags section) | +| instanceID | Int32 | Instance ID of the object | +| identifier | Int32 | Unique identifier | +| globalLineIndex | Int32 | Global line index in console | +| callstackTextStartUTF8 | Int32 | UTF-8 byte position where stack trace starts | +| callstackTextStartUTF16 | Int32 | UTF-16 character position where stack trace starts | + +## Mode Flags + +### Standard Unity Log Flags (from UnityCsReference) +```csharp +// Basic log types +const int kModeError = 1 << 0; // 1 (0x1) +const int kModeAssert = 1 << 1; // 2 (0x2) +const int kModeLog = 1 << 2; // 4 (0x4) +const int kModeWarning = 1 << 3; // 8 (0x8) +const int kModeException = 1 << 4; // 16 (0x10) + +// Scripting related flags +const int kScriptingError = 1 << 8; // 256 (0x100) +const int kScriptingWarning = 1 << 9; // 512 (0x200) +const int kScriptingLog = 1 << 10; // 1024 (0x400) +const int kScriptCompileError = 1 << 11; // 2048 (0x800) +const int kScriptCompileWarning = 1 << 12; // 4096 (0x1000) +``` + +### Observed Compiler/Shader Message Values (from debugging) +```csharp +// These are composite values with multiple flags set +const int ObservedCompilerWarning = 266240; // 0x41000 (bits: 18, 10) +const int ObservedCompilerError = 272384; // 0x42800 (bits: 18, 14, 10) +const int ObservedShaderError = 262212; // 0x40044 (bits: 18, 6, 2) +``` + +Note: The observed values include additional high bits (2, 6, 10, 14, 18) beyond the documented flags, suggesting Unity may be using undocumented internal flags. + +## Important Findings + +1. **Compiler messages use special mode values** + - C# compilation errors (e.g., `error CS0103`) have mode = 272384 + - C# compilation warnings (e.g., `warning CS0414`) have mode = 266240 + - These do NOT use the standard error/warning flags! + +2. **Shader errors** + - Shader compilation errors appear to use standard Log mode (4) + - Need message content analysis to properly classify + +3. **Stack trace separation** + - Use `callstackTextStartUTF16` for C# strings (preferred) + - Fallback to `callstackTextStartUTF8` if needed + - Unity provides exact position where stack trace begins + +## Usage Notes + +- Always check special compiler flags before standard flags +- Message content analysis may still be needed for some error types +- The mode field alone is not sufficient for all classification needs + +## Example Mode Analysis + +``` +Warning CS0414: mode = 266240 = 0x41000 +Binary: 1000001000000000000 +Bits set: 18, 10 + +Error CS0103: mode = 272384 = 0x42800 +Binary: 1000010100000000000 +Bits set: 18, 14, 10 +``` + +The high bits (10, 14, 18) appear to indicate compiler-related messages. \ No newline at end of file diff --git a/Editor/Services/Unity6InternalAPIReference.md.meta b/Editor/Services/Unity6InternalAPIReference.md.meta new file mode 100644 index 00000000..2b7e18e3 --- /dev/null +++ b/Editor/Services/Unity6InternalAPIReference.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9f8e7d6c5b4a3214fa9b8c7d6e5f4321 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Editor/Tools/SearchConsoleLogsTool.cs b/Editor/Tools/SearchConsoleLogsTool.cs new file mode 100644 index 00000000..82d93060 --- /dev/null +++ b/Editor/Tools/SearchConsoleLogsTool.cs @@ -0,0 +1,96 @@ +using System; +using Newtonsoft.Json.Linq; +using McpUnity.Services; + +namespace McpUnity.Tools +{ + /// + /// Tool for searching Unity console logs with keyword or regex pattern + /// + public class SearchConsoleLogsTool : McpToolBase + { + private readonly IConsoleLogsService _consoleLogsService; + + public SearchConsoleLogsTool(IConsoleLogsService consoleLogsService) + { + Name = "search_console_logs"; + Description = "Search Unity console logs with keyword or regex pattern. Supports case-sensitive/insensitive search, optional stack trace inclusion, and pagination. Regex takes precedence over keyword if both are provided."; + + _consoleLogsService = consoleLogsService; + } + + /// + /// Execute the search console logs tool + /// + /// Tool parameters + /// JObject containing search results + public override JObject Execute(JObject parameters) + { + // Extract search parameters + string keyword = parameters?["keyword"]?.ToString(); + string regex = parameters?["regex"]?.ToString(); + string logType = parameters?["logType"]?.ToString(); + + bool includeStackTrace = GetBoolParameter(parameters, "includeStackTrace", true); + bool caseSensitive = GetBoolParameter(parameters, "caseSensitive", false); + int offset = Math.Max(0, GetIntParameter(parameters, "offset", 0)); + int limit = Math.Max(1, Math.Min(1000, GetIntParameter(parameters, "limit", 50))); + + // At least one search criteria must be provided + if (string.IsNullOrEmpty(keyword) && string.IsNullOrEmpty(regex)) + { + return new JObject + { + ["logs"] = new JArray(), + ["error"] = "Either 'keyword' or 'regex' parameter must be provided", + ["success"] = false + }; + } + + // Call the search method + JObject result = _consoleLogsService.SearchLogsAsJson( + keyword, regex, logType, includeStackTrace, caseSensitive, offset, limit); + + // Add formatted message if search was successful + if (result["success"]?.Value() == true) + { + string searchTerm = !string.IsNullOrEmpty(regex) ? $"regex '{regex}'" : $"keyword '{keyword}'"; + string typeFilter = !string.IsNullOrEmpty(logType) ? $" of type '{logType}'" : ""; + int returnedCount = result["_returnedCount"]?.Value() ?? 0; + int matchedCount = result["_matchedCount"]?.Value() ?? 0; + int filteredCount = result["_filteredCount"]?.Value() ?? 0; + int totalCount = result["_totalCount"]?.Value() ?? 0; + + result["message"] = $"Found {matchedCount} logs matching {searchTerm}{typeFilter} (returned: {returnedCount}, filtered by type: {filteredCount}, total: {totalCount})"; + + // Remove internal count fields (they're now in the message) + result.Remove("_totalCount"); + result.Remove("_filteredCount"); + result.Remove("_matchedCount"); + result.Remove("_returnedCount"); + } + + return result; + } + + /// + /// Helper method to safely extract integer parameters with default values + /// + private static int GetIntParameter(JObject parameters, string key, int defaultValue) + { + if (parameters?[key] != null && int.TryParse(parameters[key].ToString(), out int value)) + return value; + return defaultValue; + } + + /// + /// Helper method to safely extract boolean parameters with default values + /// + private static bool GetBoolParameter(JObject parameters, string key, bool defaultValue) + { + if (parameters?[key] != null && bool.TryParse(parameters[key].ToString(), out bool value)) + return value; + return defaultValue; + } + } +} \ No newline at end of file diff --git a/Editor/Tools/SearchConsoleLogsTool.cs.meta b/Editor/Tools/SearchConsoleLogsTool.cs.meta new file mode 100644 index 00000000..4f8dfbb0 --- /dev/null +++ b/Editor/Tools/SearchConsoleLogsTool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b51f73e4710a04aafa4146a0deb9f77a \ No newline at end of file diff --git a/Editor/UnityBridge/McpUnityEditorWindow.cs b/Editor/UnityBridge/McpUnityEditorWindow.cs index 4ddcf463..02c6579e 100644 --- a/Editor/UnityBridge/McpUnityEditorWindow.cs +++ b/Editor/UnityBridge/McpUnityEditorWindow.cs @@ -141,6 +141,30 @@ private void DrawServerTab() settings.SaveSettings(); } + EditorGUILayout.Space(); + + // Console log service selection + ConsoleLogServiceType newConsoleLogService = (ConsoleLogServiceType)EditorGUILayout.EnumPopup( + new GUIContent("Console Log Service", "Select console log service implementation. EventBased is safe but may miss some logs. Unity6Enhanced uses internal APIs for better reliability but requires Unity 6+. Server restart required when changed."), + settings.ConsoleLogService); + if (newConsoleLogService != settings.ConsoleLogService) + { + settings.ConsoleLogService = newConsoleLogService; + settings.SaveSettings(); + + // Show warning for Unity6Enhanced + if (newConsoleLogService == ConsoleLogServiceType.Unity6Enhanced) + { +#if UNITY_6000_0_OR_NEWER + EditorUtility.DisplayDialog("Console Log Service Changed", + "Unity 6 enhanced console log service selected. This uses internal Unity APIs for better reliability but may break in future Unity versions.\n\nRestart the MCP server for changes to take effect.", "OK"); +#else + EditorUtility.DisplayDialog("Console Log Service Warning", + $"Unity 6 enhanced console log service selected but current Unity version is {Application.unityVersion}. The service will fall back to event-based implementation.\n\nRestart the MCP server for changes to take effect.", "OK"); +#endif + } + } + EditorGUILayout.Space(); // Server control buttons diff --git a/Editor/UnityBridge/McpUnityServer.cs b/Editor/UnityBridge/McpUnityServer.cs index dfabf2be..3eccafc0 100644 --- a/Editor/UnityBridge/McpUnityServer.cs +++ b/Editor/UnityBridge/McpUnityServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using UnityEngine; using UnityEditor; @@ -28,7 +29,7 @@ public class McpUnityServer private WebSocketServer _webSocketServer; private CancellationTokenSource _cts; private TestRunnerService _testRunnerService; - private ConsoleLogsService _consoleLogsService; + private IConsoleLogsService _consoleLogsService; /// /// Static constructor that gets called when Unity loads due to InitializeOnLoad attribute @@ -141,6 +142,14 @@ public bool TryGetResource(string name, out McpResourceBase resource) { return _resources.TryGetValue(name, out resource); } + + /// + /// Get all resource names for debugging + /// + public string[] GetResourceNames() + { + return _resources.Keys.ToArray(); + } /// /// Installs the MCP Node.js server by running 'npm install' and 'npm run build' @@ -205,6 +214,10 @@ private void RegisterTools() // Register AddAssetToSceneTool AddAssetToSceneTool addAssetToSceneTool = new AddAssetToSceneTool(); _tools.Add(addAssetToSceneTool.Name, addAssetToSceneTool); + + // Register SearchConsoleLogsTool + SearchConsoleLogsTool searchConsoleLogsTool = new SearchConsoleLogsTool(_consoleLogsService); + _tools.Add(searchConsoleLogsTool.Name, searchConsoleLogsTool); } /// @@ -249,8 +262,26 @@ private void InitializeServices() // Initialize the test runner service _testRunnerService = new TestRunnerService(); - // Initialize the console logs service - _consoleLogsService = new ConsoleLogsService(); + // Initialize the console logs service based on settings and Unity version + // Default to safe event-based implementation + var consoleLogService = McpUnitySettings.Instance.ConsoleLogService; + + if (consoleLogService == ConsoleLogServiceType.Unity6Enhanced) + { +#if UNITY_6000_0_OR_NEWER + _consoleLogsService = new ConsoleLogsServiceUnity6(); + McpLogger.LogInfo($"[MCP Unity] Console Log Service: Unity 6 Enhanced (experimental, uses internal APIs)"); +#else + McpLogger.LogWarning($"[MCP Unity] Console Log Service: Unity 6 Enhanced requested but Unity version is {Application.unityVersion}. Falling back to Event-Based service."); + _consoleLogsService = new ConsoleLogsService(); +#endif + } + else + { + // Default to safe event-based implementation + _consoleLogsService = new ConsoleLogsService(); + McpLogger.LogInfo($"[MCP Unity] Console Log Service: Event-Based (default, safe)"); + } } } } diff --git a/Editor/UnityBridge/McpUnitySettings.cs b/Editor/UnityBridge/McpUnitySettings.cs index 47dd6624..c9aac1db 100644 --- a/Editor/UnityBridge/McpUnitySettings.cs +++ b/Editor/UnityBridge/McpUnitySettings.cs @@ -6,6 +6,18 @@ namespace McpUnity.Unity { + /// + /// Console log service implementation options + /// + public enum ConsoleLogServiceType + { + [Tooltip("Event-based implementation (default, safe) - may occasionally miss logs")] + EventBased, + + [Tooltip("Unity 6 enhanced implementation (experimental) - uses internal APIs for better reliability")] + Unity6Enhanced + } + /// /// Handles persistence of MCP Unity settings /// @@ -36,6 +48,9 @@ public class McpUnitySettings [Tooltip("Optional: Full path to the npm executable (e.g., /Users/user/.asdf/shims/npm or C:\\path\\to\\npm.cmd). If not set, 'npm' from the system PATH will be used.")] public string NpmExecutablePath = string.Empty; + + [Tooltip("Console log service implementation to use. EventBased (default, safe) may occasionally miss logs. Unity6Enhanced uses internal APIs for better reliability but requires Unity 6+ and should only be used when explicitly needed. Server restart required when changed.")] + public ConsoleLogServiceType ConsoleLogService = ConsoleLogServiceType.EventBased; /// /// Singleton instance of settings diff --git a/Editor/UnityBridge/McpUnitySocketHandler.cs b/Editor/UnityBridge/McpUnitySocketHandler.cs index 63c429f0..b5bcd944 100644 --- a/Editor/UnityBridge/McpUnitySocketHandler.cs +++ b/Editor/UnityBridge/McpUnitySocketHandler.cs @@ -74,10 +74,12 @@ protected override async void OnMessage(MessageEventArgs e) } else if (_server.TryGetResource(method, out var resource)) { + McpLogger.LogInfo($"[DEBUG] Found resource: {resource.Name} for method: {method}"); EditorCoroutineUtility.StartCoroutineOwnerless(FetchResourceCoroutine(resource, parameters, tcs)); } else { + McpLogger.LogWarning($"[DEBUG] Unknown method: {method}. Available resources: {string.Join(", ", _server.GetResourceNames())}"); tcs.SetResult(CreateErrorResponse($"Unknown method: {method}", "unknown_method")); } diff --git a/Server~/build/index.js b/Server~/build/index.js index cf0cc708..35baa814 100644 --- a/Server~/build/index.js +++ b/Server~/build/index.js @@ -20,6 +20,7 @@ import { registerGetAssetsResource } from './resources/getAssetsResource.js'; import { registerGetTestsResource } from './resources/getTestsResource.js'; import { registerGetGameObjectResource } from './resources/getGameObjectResource.js'; import { registerGameObjectHandlingPrompt } from './prompts/gameobjectHandlingPrompt.js'; +import { registerSearchConsoleLogsTool } from './tools/searchConsoleLogsTool.js'; // Initialize loggers const serverLogger = new Logger('Server', LogLevel.INFO); const unityLogger = new Logger('Unity', LogLevel.INFO); @@ -48,6 +49,7 @@ registerGetConsoleLogsTool(server, mcpUnity, toolLogger); registerUpdateComponentTool(server, mcpUnity, toolLogger); registerAddAssetToSceneTool(server, mcpUnity, toolLogger); registerUpdateGameObjectTool(server, mcpUnity, toolLogger); +registerSearchConsoleLogsTool(server, mcpUnity, toolLogger); // Register all resources into the MCP server registerGetTestsResource(server, mcpUnity, resourceLogger); registerGetGameObjectResource(server, mcpUnity, resourceLogger); diff --git a/Server~/build/utils/errors.js b/Server~/build/utils/errors.js index 7382c65f..429a4d72 100644 --- a/Server~/build/utils/errors.js +++ b/Server~/build/utils/errors.js @@ -6,7 +6,7 @@ export var ErrorType; ErrorType["VALIDATION"] = "validation_error"; ErrorType["INTERNAL"] = "internal_error"; ErrorType["TIMEOUT"] = "timeout_error"; -})(ErrorType || (ErrorType = {})); +})(ErrorType = ErrorType || (ErrorType = {})); export class McpUnityError extends Error { type; details; diff --git a/Server~/build/utils/logger.js b/Server~/build/utils/logger.js index ec6b4c13..212c2829 100644 --- a/Server~/build/utils/logger.js +++ b/Server~/build/utils/logger.js @@ -5,7 +5,7 @@ export var LogLevel; LogLevel[LogLevel["INFO"] = 1] = "INFO"; LogLevel[LogLevel["WARN"] = 2] = "WARN"; LogLevel[LogLevel["ERROR"] = 3] = "ERROR"; -})(LogLevel || (LogLevel = {})); +})(LogLevel = LogLevel || (LogLevel = {})); // Check environment variable for logging const isLoggingEnabled = process.env.LOGGING === 'true'; // Check environment variable for logging in a file diff --git a/Server~/src/index.ts b/Server~/src/index.ts index d8d67e10..40714cce 100644 --- a/Server~/src/index.ts +++ b/Server~/src/index.ts @@ -20,6 +20,7 @@ import { registerGetAssetsResource } from './resources/getAssetsResource.js'; import { registerGetTestsResource } from './resources/getTestsResource.js'; import { registerGetGameObjectResource } from './resources/getGameObjectResource.js'; import { registerGameObjectHandlingPrompt } from './prompts/gameobjectHandlingPrompt.js'; +import { registerSearchConsoleLogsTool } from './tools/searchConsoleLogsTool.js'; // Initialize loggers const serverLogger = new Logger('Server', LogLevel.INFO); @@ -55,6 +56,7 @@ registerGetConsoleLogsTool(server, mcpUnity, toolLogger); registerUpdateComponentTool(server, mcpUnity, toolLogger); registerAddAssetToSceneTool(server, mcpUnity, toolLogger); registerUpdateGameObjectTool(server, mcpUnity, toolLogger); +registerSearchConsoleLogsTool(server, mcpUnity, toolLogger); // Register all resources into the MCP server registerGetTestsResource(server, mcpUnity, resourceLogger);