diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.List.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.List.cs new file mode 100644 index 000000000..3b1da4c56 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.List.cs @@ -0,0 +1,59 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System.ComponentModel; +using System.Linq; +using com.IvanMurzak.McpPlugin; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + public partial class Tool_Event + { + public const string EventListToolId = "event-list"; + + [McpPluginTool + ( + EventListToolId, + Title = "Event / List", + ReadOnlyHint = true, + IdempotentHint = true + )] + [Description( + "Lists all event types available for event-subscribe.\n\n" + + "Shows built-in Unity Editor events AND custom game events.\n" + + "Custom events are registered automatically when McpEventBus.Push() is called from game code,\n" + + "or manually via script-execute: McpEventBus.RegisterCustomType(\"event_name\", \"description\").\n\n" + + "If only built-in events appear, the project may need custom events added to game code:\n" + + " #if UNITY_EDITOR\n" + + " McpEventBus.Push(\"server_response_done\", source: \"NetworkHelper\");\n" + + " #endif\n" + + "See event-subscribe tool description for full custom event guide." + )] + public McpEventTypeInfo[] ListEvents + ( + [Description( + "Filter event types. " + + "'all' = show everything (default), " + + "'builtin' = only built-in Unity events, " + + "'custom' = only user-registered custom events." + )] + string? filter = "all" + ) + { + var allTypes = McpEventBus.GetAllEventTypes(); + + return (filter?.ToLowerInvariant()) switch + { + "builtin" => allTypes.Where(t => t.IsBuiltIn).ToArray(), + "custom" => allTypes.Where(t => !t.IsBuiltIn).ToArray(), + _ => allTypes + }; + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs new file mode 100644 index 000000000..b9a1ef3cc --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs @@ -0,0 +1,98 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using com.IvanMurzak.McpPlugin; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + public partial class Tool_Event + { + public const string EventSubscribeToolId = "event-subscribe"; + + [McpPluginTool + ( + EventSubscribeToolId, + Title = "Event / Subscribe" + )] + [Description( + "Waits for a Unity event and returns it. BLOCKING — holds response until event fires or timeout.\n\n" + + "== BUILT-IN EVENTS (no code changes needed) ==\n" + + "- play_mode_changed: Play/Pause/Stop transitions\n" + + "- scene_loaded / scene_opened: Scene loading\n" + + "- compilation_started / compilation_finished: Script compilation (hasErrors flag)\n" + + "- error_logged / warning_logged: Console messages\n" + + "- pause_state_changed: Editor pause toggle\n" + + "- hierarchy_changed: GO created/destroyed/reparented (NOTE: fires on ANY hierarchy change, not just yours)\n" + + "- selection_changed: Editor selection\n\n" + + "== CUSTOM EVENTS (for game logic — RECOMMENDED) ==\n" + + "Built-in events CANNOT detect game-specific moments like 'server callback done' or 'popup closed'.\n" + + "Do NOT use hierarchy_changed as a proxy for server responses — it fires on unrelated changes.\n\n" + + "Dynamic hook via script-execute (NO source code modification, auto-cleanup on play mode exit):\n" + + " Step 1 — Hook game event to McpEventBus (run once before test):\n" + + " script-execute (IMPORTANT: add 'using com.IvanMurzak.Unity.MCP.Editor.API;' for McpEventBus):\n" + + " SomeManager.Instance.OnDataLoaded += () => McpEventBus.Push(\"data_loaded\");\n" + + " Step 2 — Trigger async action (e.g. server request, UI navigation):\n" + + " script-execute(trigger the action that will eventually fire the callback)\n" + + " Step 3 — Subscribe and wait for the callback event:\n" + + " event-subscribe(type='data_loaded', timeoutMs=30000)\n" + + " Step 4 — Hooks die automatically when play mode stops. No cleanup needed.\n\n" + + "ALTERNATIVE: Add McpEventBus.Push() directly in game code (persistent, survives sessions):\n" + + " #if UNITY_EDITOR\n" + + " McpEventBus.Push(\"friend_data_loaded\", source: \"FriendManager\");\n" + + " #endif\n\n" + + "Custom events appear in event-list(filter='custom') after first Push.\n\n" + + "== USAGE PATTERNS ==\n" + + "1. Custom game events (RECOMMENDED — sequential, callback is async):\n" + + " script-execute(hook + trigger async action) → event-subscribe(type='custom_event')\n" + + "2. Built-in events (parallel subscribe + trigger):\n" + + " [parallel] event-subscribe(type='play_mode_changed') + editor-application-set-state(...)\n" + + "3. Compilation wait:\n" + + " script-update-or-create(...) → event-subscribe(type='compilation_finished')\n" + + "4. Background monitoring (non-blocking): use event-watch instead" + )] + public async Task Subscribe + ( + [Description( + "Event type to filter for. " + + "Use a specific type like 'error_logged' or 'compilation_finished'. " + + "Empty string or null matches ANY event type. " + + "Use 'event-list' tool to see all available types." + )] + string? type = null, + + [Description( + "Maximum wait time in milliseconds. " + + "Range: 0-120000. Default: 30000 (30 seconds). " + + "Set to 0 to drain pending events without waiting." + )] + int timeoutMs = 30000, + + [Description( + "If true, collects ALL matching events until timeout. " + + "If false (default), returns immediately after the FIRST matching event." + )] + bool collectAll = false + ) + { + // Special case: timeoutMs=0 means drain pending, no wait + if (timeoutMs == 0) + return McpEventBus.DrainPending(type); + + if (timeoutMs < 1000 || timeoutMs > 120000) + throw new ArgumentException(Error.InvalidSubscribeTimeout(timeoutMs)); + + using var cts = new CancellationTokenSource(timeoutMs + 1000); // Grace period + return await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token); + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs new file mode 100644 index 000000000..413f40d6e --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs @@ -0,0 +1,123 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using com.IvanMurzak.McpPlugin; +using com.IvanMurzak.McpPlugin.Common.Model; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + public partial class Tool_Event + { + public const string EventWatchToolId = "event-watch"; + + [McpPluginTool + ( + EventWatchToolId, + Title = "Event / Watch" + )] + [Description( + "NON-BLOCKING event watcher at MCP server level. Returns 'Processing' status immediately, " + + "then sends a notification via NotifyToolRequestCompleted when a matching event fires.\n\n" + + "IMPORTANT LIMITATION: Some MCP clients (including Claude Code) wait for the notification " + + "before proceeding to the next turn, making this effectively blocking from the AI agent's perspective. " + + "If your client blocks on Processing responses, use event-subscribe instead.\n\n" + + "This tool is useful for MCP clients that can handle Processing responses asynchronously, " + + "or for scenarios where you want the server to notify you without keeping a blocking tool call open.\n\n" + + "USE CASES:\n" + + "- Clients that support async Processing: background error monitoring\n" + + "- Long-running watches where you want server-side timeout management\n\n" + + "For most use cases, prefer event-subscribe:\n" + + " script-execute(trigger async action) → event-subscribe(type='my_event', timeoutMs=30000)\n\n" + + "CUSTOM EVENTS: Same as event-subscribe — see event-subscribe description for full guide.\n\n" + + "NOTE: Watch is ephemeral — if Unity domain reloads (e.g. script recompile), the watcher is lost." + )] + public ResponseCallTool Watch + ( + [Description( + "Event type to watch for. " + + "Use a specific type like 'error_logged' or 'compilation_finished'. " + + "Empty string or null matches ANY event type. " + + "Use 'event-list' tool to see all available types." + )] + string? type = null, + + [Description( + "Maximum watch time in milliseconds. " + + "Range: 5000-120000. Default: 60000 (60 seconds). " + + "The watcher is automatically cancelled after this timeout." + )] + int timeoutMs = 60000, + + [Description( + "If true, collects ALL matching events until timeout, then notifies once. " + + "If false (default), notifies immediately on the FIRST matching event." + )] + bool collectAll = false, + + [RequestID] + string? requestId = null + ) + { + if (requestId == null || string.IsNullOrWhiteSpace(requestId)) + return ResponseCallTool.Error("Original request with valid RequestID must be provided."); + + if (timeoutMs < 5000 || timeoutMs > 120000) + return ResponseCallTool.Error(Error.InvalidWatchTimeout(timeoutMs)).SetRequestID(requestId); + + var watchType = string.IsNullOrEmpty(type) ? "any" : type; + + // Start background watcher — does NOT block the tool response + _ = Task.Run(async () => + { + try + { + using var cts = new CancellationTokenSource(timeoutMs + 1000); + var watchResult = await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token); + + var json = System.Text.Json.JsonSerializer.SerializeToNode(watchResult); + var response = ResponseCallValueTool + .SuccessStructured(json) + .SetRequestID(requestId); + + await UnityMcpPluginEditor.NotifyToolRequestCompleted(new RequestToolCompletedData + { + RequestId = requestId, + Result = response + }); + } + catch (Exception ex) + { + try + { + await UnityMcpPluginEditor.NotifyToolRequestCompleted(new RequestToolCompletedData + { + RequestId = requestId, + Result = ResponseCallTool.Error( + $"[event-watch] Watcher failed: {ex.Message}" + ).SetRequestID(requestId) + }); + } + catch + { + // Connection lost — nothing we can do + } + } + }); + + return ResponseCallTool.Processing( + $"Watching for '{watchType}' events (timeout: {timeoutMs}ms). " + + "You will be notified when a matching event occurs. Continue working." + ).SetRequestID(requestId); + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs new file mode 100644 index 000000000..0b1bb0b76 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs @@ -0,0 +1,29 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using com.IvanMurzak.McpPlugin; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + [McpPluginToolType] + public partial class Tool_Event + { + public static class Error + { + public static string InvalidSubscribeTimeout(int timeoutMs) + => $"Invalid timeout value '{timeoutMs}'. Must be between 1000 and 120000 milliseconds."; + + public static string InvalidWatchTimeout(int timeoutMs) + => $"Invalid timeout value '{timeoutMs}'. Must be between 5000 and 120000 milliseconds."; + + public static string EventBusNotAvailable() + => "McpEventBus is not available."; + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs new file mode 100644 index 000000000..a2c937d98 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs @@ -0,0 +1,196 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + /// + /// Thread-safe event bus for MCP event subscription system. + /// Unity main thread pushes events, MCP tool background threads consume them. + /// Uses System.Threading.Channels for lock-free, multi-consumer-safe async I/O. + /// + public static class McpEventBus + { + static Channel _channel = CreateChannel(); + static readonly ConcurrentDictionary _registeredCustomTypes = new(); + + static Channel CreateChannel() => Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleWriter = false, SingleReader = false }); + + // Built-in event type definitions + static readonly Dictionary _builtInTypes = new() + { + ["play_mode_changed"] = "Editor play mode state changed (Playing, Paused, Stopped).", + ["scene_loaded"] = "A scene finished loading.", + ["scene_opened"] = "A scene was opened in the Editor.", + ["compilation_started"] = "Script compilation started.", + ["compilation_finished"] = "Script compilation finished.", + ["error_logged"] = "An error or exception was logged to the console.", + ["warning_logged"] = "A warning was logged to the console.", + ["pause_state_changed"] = "Editor pause state toggled.", + ["hierarchy_changed"] = "The scene hierarchy changed (GameObject created/destroyed/reparented).", + ["selection_changed"] = "Editor selection changed.", + }; + + /// + /// Push an event onto the bus. Call from Unity main thread or any thread. + /// + public static void Push(string type, string? source = null, string? message = null, + Dictionary? payload = null) + { + if (string.IsNullOrEmpty(type)) + return; + + _channel.Writer.TryWrite(new McpEventData + { + Type = type, + Source = source, + Message = message, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0, + Payload = payload + }); + + // Auto-register custom types + if (!_builtInTypes.ContainsKey(type)) + _registeredCustomTypes.TryAdd(type, $"Custom event: {type}"); + } + + /// + /// Wait for a matching event. Blocks until event arrives or timeout. + /// Safe for multiple concurrent consumers (e.g. event-watch + event-subscribe). + /// + public static async Task WaitAsync( + string? typeFilter, int timeoutMs, bool collectAll, CancellationToken ct) + { + var result = new McpEventSubscribeResult(); + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + var skipped = new List(); + var reader = _channel.Reader; + + try + { + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + { + var remaining = Math.Max(0, (int)(deadline - DateTime.UtcNow).TotalMilliseconds); + if (remaining <= 0) + break; + + using var chunkCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + chunkCts.CancelAfter(Math.Min(remaining, 500)); + + try + { + if (await reader.WaitToReadAsync(chunkCts.Token)) + { + if (reader.TryRead(out var evt)) + { + if (string.IsNullOrEmpty(typeFilter) || evt.Type == typeFilter) + { + result.Events.Add(evt); + if (!collectAll) + break; + } + else + { + skipped.Add(evt); + } + } + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // 500ms chunk timeout — continue loop to check deadline + } + } + } + catch (OperationCanceledException) + { + // Outer cancellation — fall through + } + + // Re-enqueue events that didn't match the filter + foreach (var s in skipped) + _channel.Writer.TryWrite(s); + + result.TimedOut = result.Events.Count == 0; + return result; + } + + /// + /// Drain all pending events matching a filter without waiting. + /// + public static McpEventSubscribeResult DrainPending(string? typeFilter) + { + var result = new McpEventSubscribeResult(); + var skipped = new List(); + + while (_channel.Reader.TryRead(out var evt)) + { + if (string.IsNullOrEmpty(typeFilter) || evt.Type == typeFilter) + result.Events.Add(evt); + else + skipped.Add(evt); + } + + // Re-enqueue non-matching events + foreach (var s in skipped) + _channel.Writer.TryWrite(s); + + result.TimedOut = result.Events.Count == 0; + return result; + } + + public static McpEventTypeInfo[] GetAllEventTypes() + { + var list = _builtInTypes.Select(kv => new McpEventTypeInfo + { + Type = kv.Key, + Description = kv.Value, + IsBuiltIn = true + }).ToList(); + + foreach (var kv in _registeredCustomTypes) + { + list.Add(new McpEventTypeInfo + { + Type = kv.Key, + Description = kv.Value, + IsBuiltIn = false + }); + } + + return list.ToArray(); + } + + /// + /// Register a custom event type with description (optional, for discoverability). + /// + public static void RegisterCustomType(string type, string description) + { + _registeredCustomTypes[type] = description; + } + + /// + /// Clear all pending events by replacing the channel. + /// + public static void Clear() + { + var old = _channel; + _channel = CreateChannel(); + old.Writer.Complete(); + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventData.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventData.cs new file mode 100644 index 000000000..500db425f --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventData.cs @@ -0,0 +1,67 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System.Collections.Generic; +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + [Description("Represents a Unity event captured by the MCP Event System.")] + public class McpEventData + { + [JsonInclude, JsonPropertyName("type")] + [Description("Event type identifier (e.g. 'play_mode_changed', 'error_logged', 'scene_loaded').")] + public string Type { get; set; } = string.Empty; + + [JsonInclude, JsonPropertyName("source")] + [Description("Origin of the event (e.g. GameObject name, script name, scene name).")] + public string? Source { get; set; } + + [JsonInclude, JsonPropertyName("message")] + [Description("Human-readable event description.")] + public string? Message { get; set; } + + [JsonInclude, JsonPropertyName("timestamp")] + [Description("UTC epoch seconds when the event was captured.")] + public double Timestamp { get; set; } + + [JsonInclude, JsonPropertyName("payload")] + [Description("Additional event-specific data as key-value pairs.")] + public Dictionary? Payload { get; set; } + } + + [Description("Describes an available event type that can be subscribed to.")] + public class McpEventTypeInfo + { + [JsonInclude, JsonPropertyName("type")] + [Description("Event type identifier used in event-subscribe filter.")] + public string Type { get; set; } = string.Empty; + + [JsonInclude, JsonPropertyName("description")] + [Description("What triggers this event.")] + public string? Description { get; set; } + + [JsonInclude, JsonPropertyName("isBuiltIn")] + [Description("True if this is a built-in event, false if registered by user code.")] + public bool IsBuiltIn { get; set; } + } + + [Description("Result of an event subscription wait.")] + public class McpEventSubscribeResult + { + [JsonInclude, JsonPropertyName("timedOut")] + [Description("True if no matching event occurred within the timeout period.")] + public bool TimedOut { get; set; } + + [JsonInclude, JsonPropertyName("events")] + [Description("List of captured events matching the filter. Empty if timed out.")] + public List Events { get; set; } = new(); + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventWatcher.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventWatcher.cs new file mode 100644 index 000000000..f48700ad4 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventWatcher.cs @@ -0,0 +1,168 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + /// + /// Automatically collects built-in Unity Editor events and pushes them to McpEventBus. + /// Lives in Editor folder — excluded from builds. + /// + [InitializeOnLoad] + static class McpEventWatcher + { + static McpEventWatcher() + { + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + EditorApplication.pauseStateChanged += OnPauseStateChanged; + SceneManager.sceneLoaded += OnSceneLoaded; + EditorSceneManager.sceneOpened += OnSceneOpened; + CompilationPipeline.compilationStarted += OnCompilationStarted; + CompilationPipeline.compilationFinished += OnCompilationFinished; + Application.logMessageReceived += OnLogMessageReceived; + EditorApplication.hierarchyChanged += OnHierarchyChanged; + Selection.selectionChanged += OnSelectionChanged; + } + + static void OnPlayModeStateChanged(PlayModeStateChange state) + { + McpEventBus.Push( + type: "play_mode_changed", + source: "EditorApplication", + message: state.ToString(), + payload: new Dictionary + { + ["state"] = state.ToString(), + ["isPlaying"] = EditorApplication.isPlaying, + ["isPaused"] = EditorApplication.isPaused + } + ); + } + + static void OnPauseStateChanged(PauseState state) + { + McpEventBus.Push( + type: "pause_state_changed", + source: "EditorApplication", + message: state == PauseState.Paused ? "Paused" : "Unpaused" + ); + } + + static void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + McpEventBus.Push( + type: "scene_loaded", + source: scene.name, + message: $"Scene '{scene.name}' loaded ({mode}).", + payload: new Dictionary + { + ["sceneName"] = scene.name, + ["scenePath"] = scene.path, + ["buildIndex"] = scene.buildIndex, + ["loadMode"] = mode.ToString() + } + ); + } + + static void OnSceneOpened(Scene scene, OpenSceneMode mode) + { + McpEventBus.Push( + type: "scene_opened", + source: scene.name, + message: $"Scene '{scene.name}' opened in Editor ({mode}).", + payload: new Dictionary + { + ["sceneName"] = scene.name, + ["scenePath"] = scene.path, + ["openMode"] = mode.ToString() + } + ); + } + + static void OnCompilationStarted(object context) + { + McpEventBus.Push( + type: "compilation_started", + source: "CompilationPipeline", + message: "Script compilation started." + ); + } + + static void OnCompilationFinished(object context) + { + var hasErrors = EditorUtility.scriptCompilationFailed; + McpEventBus.Push( + type: "compilation_finished", + source: "CompilationPipeline", + message: hasErrors + ? "Script compilation finished with errors." + : "Script compilation finished successfully.", + payload: new Dictionary + { + ["hasErrors"] = hasErrors + } + ); + } + + static void OnLogMessageReceived(string condition, string stackTrace, LogType type) + { + if (type is LogType.Error or LogType.Exception) + { + McpEventBus.Push( + type: "error_logged", + source: "Console", + message: condition, + payload: new Dictionary + { + ["logType"] = type.ToString(), + ["stackTrace"] = stackTrace + } + ); + } + else if (type is LogType.Warning) + { + McpEventBus.Push( + type: "warning_logged", + source: "Console", + message: condition + ); + } + } + + static void OnHierarchyChanged() + { + McpEventBus.Push( + type: "hierarchy_changed", + source: "EditorApplication", + message: "Scene hierarchy changed." + ); + } + + static void OnSelectionChanged() + { + var selected = Selection.activeGameObject; + McpEventBus.Push( + type: "selection_changed", + source: "Selection", + message: selected != null ? $"Selected: {selected.name}" : "Selection cleared.", + payload: new Dictionary + { + ["selectedName"] = selected != null ? selected.name : null, + ["selectedCount"] = Selection.gameObjects.Length + } + ); + } + } +} diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs new file mode 100644 index 000000000..bdb21d06e --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs @@ -0,0 +1,140 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using com.IvanMurzak.McpPlugin; + +namespace com.IvanMurzak.Unity.MCP.Editor.API +{ + [McpPluginSkillType] + public static class Skill_EventWorkflow + { + public const string SkillId = "unity-event-workflow"; + + [McpPluginSkill(SkillId, +@"Best practices for waiting on Unity events, monitoring errors, and playtesting +with the MCP event system. Read this BEFORE using sleep, polling, or screenshots +to wait for state changes.")] + public static string Markdown => @" +# Unity Event Workflow — Stop Polling, Start Subscribing + +## The Problem +When you need to wait for something in Unity (play mode entered, scene loaded, +server callback done, compilation finished), do NOT use: +- sleep/delay loops +- Repeated screenshot captures to check state +- Polling console-get-logs or editor-application-get-state in a loop + +These waste tokens and are unreliable. + +## The Solution: Event Tools + +### Three tools, three use cases: + +| Tool | Blocking? | Use when | +|------|-----------|----------| +| `event-subscribe` | YES — blocks until event | You need to wait for a specific event before continuing | +| `event-watch` | NO — returns immediately, notifies later | You want background monitoring while doing other work | +| `event-list` | NO — instant | You want to see what events are available | + +--- + +## Quick Start + +### 1. Wait for play mode (instead of polling editor-application-get-state) +``` +editor-application-set-state(playMode: true) +event-subscribe(type='play_mode_changed', timeoutMs=30000) +``` + +### 2. Monitor errors in background (instead of polling console-get-logs) +``` +event-watch(type='error_logged', timeoutMs=120000) +// ... continue other work ... notification arrives if error occurs +``` + +### 3. Wait for compilation (instead of sleep + assets-refresh loop) +``` +[parallel] + event-subscribe(type='compilation_finished', timeoutMs=60000) + script-update-or-create(...) // triggers compilation +``` + +### 4. Wait for scene load +``` +[parallel] + event-subscribe(type='scene_loaded', timeoutMs=30000) + scene-open(scenePath='...') +``` + +--- + +## Custom Events (Game Logic) + +Built-in events cover Unity Editor events only. For game-specific events +(server response, popup close, data load), use **dynamic hooks**: + +### Step 1: Hook game event to McpEventBus (via script-execute, no code changes needed) +``` +script-execute: + using com.IvanMurzak.Unity.MCP.Editor.API; + + class Script { + public static object Main() { + SomeManager.Instance.OnDataLoaded += () => + McpEventBus.Push(""data_loaded"", source: ""SomeManager""); + return ""hook registered""; + } + } +``` + +NOTE: `using com.IvanMurzak.Unity.MCP.Editor.API;` is required in script-execute for McpEventBus access. + +### Step 2: Subscribe + trigger in parallel +``` +[parallel] + event-subscribe(type='data_loaded', timeoutMs=15000) + script-execute(trigger the game action) +``` + +### Step 3: No cleanup needed +Hooks die automatically when play mode stops. + +--- + +## Playtesting Checklist + +When asked to playtest or run the game: + +1. **First**: `event-watch(type='error_logged', timeoutMs=120000)` — background error monitoring +2. **Enter play mode**: `editor-application-set-state` + `event-subscribe(type='play_mode_changed')` +3. **Wait for scene**: `event-subscribe(type='scene_loaded')` — NOT screenshot polling +4. **Game events**: Hook with `script-execute` then `event-subscribe` for precise timing +5. **Check results**: Errors arrive via event-watch notification automatically + +--- + +## Built-in Event Types + +| Event | Fires when | +|-------|-----------| +| `play_mode_changed` | Play/Pause/Stop transitions | +| `scene_loaded` | Runtime scene load complete | +| `scene_opened` | Editor scene open complete | +| `compilation_started` | Script compilation begins | +| `compilation_finished` | Script compilation ends (has `hasErrors` flag) | +| `error_logged` | Error or exception in console | +| `warning_logged` | Warning in console | +| `pause_state_changed` | Editor pause toggled | +| `hierarchy_changed` | Any GO created/destroyed/reparented | +| `selection_changed` | Editor selection changed | + +Run `event-list` for the full list including any custom events. +"; + } +}