From 113f0ed68cceebfb9830adf122f94d1c71ea05d5 Mon Sep 17 00:00:00 2001 From: djdcks12 <48234747+djdcks12@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:01:37 +0900 Subject: [PATCH 1/3] feat: Add event subscription system (event-subscribe, event-watch, event-list) --- .../Editor/Scripts/API/Tool/Event.List.cs | 59 ++++++ .../Scripts/API/Tool/Event.Subscribe.cs | 101 ++++++++++ .../Editor/Scripts/API/Tool/Event.Watch.cs | 147 ++++++++++++++ .../root/Editor/Scripts/API/Tool/Event.cs | 26 +++ .../root/Editor/Scripts/Event/McpEventBus.cs | 189 ++++++++++++++++++ .../root/Editor/Scripts/Event/McpEventData.cs | 67 +++++++ .../Editor/Scripts/Event/McpEventWatcher.cs | 168 ++++++++++++++++ .../Scripts/Skills/Skill_EventWorkflow.cs | 136 +++++++++++++ 8 files changed, 893 insertions(+) create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.List.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventData.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventWatcher.cs create mode 100644 Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs 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..66bfb5052 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs @@ -0,0 +1,101 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ 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" + + "IMPORTANT: This is a BLOCKING call. Use PARALLEL tool calls to subscribe and trigger simultaneously:\n" + + " [parallel] event-subscribe(type='my_event', timeoutMs=15000) + script-execute(trigger action)\n" + + " The subscribe waits while the action executes; when the event fires, subscribe returns immediately.\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" + + "BEST: 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:\n" + + " SomeManager.Instance.OnDataLoaded += () => McpEventBus.Push(\"data_loaded\");\n" + + " SomePopup.OnClosed += () => McpEventBus.Push(\"popup_closed\");\n" + + " Step 2 — Trigger action + subscribe in parallel:\n" + + " [parallel] event-subscribe(type='data_loaded') + script-execute(trigger action)\n" + + " Step 3 — 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. Dynamic hook + parallel subscribe (RECOMMENDED for playtesting):\n" + + " script-execute(hook game events to McpEventBus)\n" + + " [parallel] event-subscribe(type='my_event') + script-execute(trigger action)\n" + + "2. Parallel subscribe + trigger (for built-in events):\n" + + " [parallel] event-subscribe(type='compilation_finished') + script-update-or-create(...)\n" + + "3. Sequential drain (when event already happened):\n" + + " script-execute(action) → event-subscribe(type='x', timeoutMs=0) to drain pending\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.InvalidTimeout(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..5abaae867 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs @@ -0,0 +1,147 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ 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. Returns immediately with 'Processing' status, " + + "then sends a notification when a matching event fires. " + + "Unlike event-subscribe (which blocks), this tool lets you continue working while waiting.\n\n" + + "USE THIS when you want to be notified about events while doing other work:\n" + + " 1. event-watch(type='error_logged', timeoutMs=60000) → returns immediately\n" + + " 2. Continue editing code, running scripts, etc.\n" + + " 3. If an error occurs → you receive a notification with error details\n\n" + + "COMPARISON:\n" + + "- event-subscribe: BLOCKING — use with parallel tool calls for trigger+wait pattern\n" + + "- event-watch: NON-BLOCKING — use when you want background monitoring\n\n" + + "COMMON USE CASES:\n" + + "- Monitor for errors while editing: event-watch(type='error_logged', timeoutMs=120000)\n" + + "- Wait for compilation after script edit: event-watch(type='compilation_finished', timeoutMs=60000)\n" + + "- Watch for play mode entry: event-watch(type='play_mode_changed', timeoutMs=30000)\n" + + "- Watch for custom game event: event-watch(type='stage_cleared', timeoutMs=30000)\n\n" + + "CUSTOM EVENTS: Same as event-subscribe — add McpEventBus.Push() to game code,\n" + + "or use script-execute to push events. 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. For compilation events, prefer event-subscribe with parallel calls." + )] + 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.InvalidTimeout(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 result = await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token); + + ResponseCallTool response; + if (result.TimedOut) + { + response = ResponseCallTool.Success( + $"[event-watch] No '{watchType}' events occurred within {timeoutMs}ms timeout." + ).SetRequestID(requestId); + } + else + { + var eventSummaries = new System.Collections.Generic.List(); + foreach (var evt in result.Events) + { + var summary = $"[{evt.Type}] {evt.Message ?? "(no message)"}"; + if (evt.Source != null) + summary += $" (source: {evt.Source})"; + eventSummaries.Add(summary); + } + + response = ResponseCallTool.Success( + $"[event-watch] Caught {result.Events.Count} event(s):\n" + + string.Join("\n", eventSummaries) + ).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..4b00a0f5a --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs @@ -0,0 +1,26 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ 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 InvalidTimeout(int timeoutMs) + => $"Invalid timeout value '{timeoutMs}'. Must be between 1000 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..fd0259081 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs @@ -0,0 +1,189 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ 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.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 wait for them. + /// + public static class McpEventBus + { + static readonly ConcurrentQueue _queue = new(); + static readonly SemaphoreSlim _signal = new(0); + static readonly ConcurrentDictionary _registeredCustomTypes = new(); + + // 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.", + ["asset_imported"] = "Assets were imported/reimported.", + ["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; + + _queue.Enqueue(new McpEventData + { + Type = type, + Source = source, + Message = message, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0, + Payload = payload + }); + _signal.Release(); + + // 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. + /// + 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(); + + try + { + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + { + var remaining = Math.Max(0, (int)(deadline - DateTime.UtcNow).TotalMilliseconds); + if (remaining <= 0) + break; + + if (await _signal.WaitAsync(Math.Min(remaining, 500), ct)) + { + if (_queue.TryDequeue(out var evt)) + { + if (string.IsNullOrEmpty(typeFilter) || evt.Type == typeFilter) + { + result.Events.Add(evt); + if (!collectAll) + break; // Got one matching event, return immediately + } + else + { + skipped.Add(evt); + } + } + } + } + } + catch (OperationCanceledException) + { + // Cancelled — fall through with whatever we have + } + + // Re-enqueue events that didn't match the filter + foreach (var s in skipped) + { + _queue.Enqueue(s); + _signal.Release(); + } + + 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 (_queue.TryDequeue(out var evt)) + { + _signal.Wait(0); // Consume the signal for the dequeued item + + 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) + { + _queue.Enqueue(s); + _signal.Release(); + } + + 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. Useful for test cleanup. + /// + public static void Clear() + { + while (_queue.TryDequeue(out _)) + _signal.Wait(0); + } + } +} 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..671c0572c --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs @@ -0,0 +1,136 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ 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: + class Script { + public static object Main() { + SomeManager.Instance.OnDataLoaded += () => + McpEventBus.Push(""data_loaded"", source: ""SomeManager""); + return ""hook registered""; + } + } +``` + +### 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. +"; + } +} From e4747d89eacd485d49152f1af5915accffba6b59 Mon Sep 17 00:00:00 2001 From: djdcks12 <48234747+djdcks12@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:00:14 +0900 Subject: [PATCH 2/3] fix: Add missing using directive for McpEventBus in script-execute examples --- .../Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs | 2 +- .../Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 index 66bfb5052..efe378c31 100644 --- 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 @@ -42,7 +42,7 @@ public partial class Tool_Event "Do NOT use hierarchy_changed as a proxy for server responses — it fires on unrelated changes.\n\n" + "BEST: 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:\n" + + " script-execute (IMPORTANT: add 'using com.IvanMurzak.Unity.MCP.Editor.API;' for McpEventBus):\n" + " SomeManager.Instance.OnDataLoaded += () => McpEventBus.Push(\"data_loaded\");\n" + " SomePopup.OnClosed += () => McpEventBus.Push(\"popup_closed\");\n" + " Step 2 — Trigger action + subscribe in parallel:\n" + 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 index 671c0572c..bdb21d06e 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs @@ -82,6 +82,8 @@ These waste tokens and are unreliable. ### 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 += () => @@ -91,6 +93,8 @@ public static object Main() { } ``` +NOTE: `using com.IvanMurzak.Unity.MCP.Editor.API;` is required in script-execute for McpEventBus access. + ### Step 2: Subscribe + trigger in parallel ``` [parallel] From 3104046b83f82132cd74bdc7db1e71baf397a16c Mon Sep 17 00:00:00 2001 From: djdcks12 <48234747+djdcks12@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:52:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20Channel=20sync,=20error=20messages,=20structured?= =?UTF-8?q?=20response,=20update=20event-watch=20limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/API/Tool/Event.Subscribe.cs | 29 ++++---- .../Editor/Scripts/API/Tool/Event.Watch.cs | 64 ++++++----------- .../root/Editor/Scripts/API/Tool/Event.cs | 5 +- .../root/Editor/Scripts/Event/McpEventBus.cs | 69 ++++++++++--------- 4 files changed, 75 insertions(+), 92 deletions(-) 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 index efe378c31..b9a1ef3cc 100644 --- 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 @@ -26,9 +26,6 @@ public partial class Tool_Event )] [Description( "Waits for a Unity event and returns it. BLOCKING — holds response until event fires or timeout.\n\n" + - "IMPORTANT: This is a BLOCKING call. Use PARALLEL tool calls to subscribe and trigger simultaneously:\n" + - " [parallel] event-subscribe(type='my_event', timeoutMs=15000) + script-execute(trigger action)\n" + - " The subscribe waits while the action executes; when the event fires, subscribe returns immediately.\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" + @@ -40,27 +37,27 @@ public partial class Tool_Event "== 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" + - "BEST: Dynamic hook via script-execute (NO source code modification, auto-cleanup on play mode exit):\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" + - " SomePopup.OnClosed += () => McpEventBus.Push(\"popup_closed\");\n" + - " Step 2 — Trigger action + subscribe in parallel:\n" + - " [parallel] event-subscribe(type='data_loaded') + script-execute(trigger action)\n" + - " Step 3 — Hooks die automatically when play mode stops. No cleanup needed.\n\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. Dynamic hook + parallel subscribe (RECOMMENDED for playtesting):\n" + - " script-execute(hook game events to McpEventBus)\n" + - " [parallel] event-subscribe(type='my_event') + script-execute(trigger action)\n" + - "2. Parallel subscribe + trigger (for built-in events):\n" + - " [parallel] event-subscribe(type='compilation_finished') + script-update-or-create(...)\n" + - "3. Sequential drain (when event already happened):\n" + - " script-execute(action) → event-subscribe(type='x', timeoutMs=0) to drain pending\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 @@ -92,7 +89,7 @@ public async Task Subscribe return McpEventBus.DrainPending(type); if (timeoutMs < 1000 || timeoutMs > 120000) - throw new ArgumentException(Error.InvalidTimeout(timeoutMs)); + 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 index 5abaae867..413f40d6e 100644 --- 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 @@ -26,25 +26,20 @@ public partial class Tool_Event Title = "Event / Watch" )] [Description( - "NON-BLOCKING event watcher. Returns immediately with 'Processing' status, " + - "then sends a notification when a matching event fires. " + - "Unlike event-subscribe (which blocks), this tool lets you continue working while waiting.\n\n" + - "USE THIS when you want to be notified about events while doing other work:\n" + - " 1. event-watch(type='error_logged', timeoutMs=60000) → returns immediately\n" + - " 2. Continue editing code, running scripts, etc.\n" + - " 3. If an error occurs → you receive a notification with error details\n\n" + - "COMPARISON:\n" + - "- event-subscribe: BLOCKING — use with parallel tool calls for trigger+wait pattern\n" + - "- event-watch: NON-BLOCKING — use when you want background monitoring\n\n" + - "COMMON USE CASES:\n" + - "- Monitor for errors while editing: event-watch(type='error_logged', timeoutMs=120000)\n" + - "- Wait for compilation after script edit: event-watch(type='compilation_finished', timeoutMs=60000)\n" + - "- Watch for play mode entry: event-watch(type='play_mode_changed', timeoutMs=30000)\n" + - "- Watch for custom game event: event-watch(type='stage_cleared', timeoutMs=30000)\n\n" + - "CUSTOM EVENTS: Same as event-subscribe — add McpEventBus.Push() to game code,\n" + - "or use script-execute to push events. 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. For compilation events, prefer event-subscribe with parallel calls." + "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 ( @@ -77,7 +72,7 @@ public ResponseCallTool Watch return ResponseCallTool.Error("Original request with valid RequestID must be provided."); if (timeoutMs < 5000 || timeoutMs > 120000) - return ResponseCallTool.Error(Error.InvalidTimeout(timeoutMs)).SetRequestID(requestId); + return ResponseCallTool.Error(Error.InvalidWatchTimeout(timeoutMs)).SetRequestID(requestId); var watchType = string.IsNullOrEmpty(type) ? "any" : type; @@ -87,31 +82,12 @@ public ResponseCallTool Watch try { using var cts = new CancellationTokenSource(timeoutMs + 1000); - var result = await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token); + var watchResult = await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token); - ResponseCallTool response; - if (result.TimedOut) - { - response = ResponseCallTool.Success( - $"[event-watch] No '{watchType}' events occurred within {timeoutMs}ms timeout." - ).SetRequestID(requestId); - } - else - { - var eventSummaries = new System.Collections.Generic.List(); - foreach (var evt in result.Events) - { - var summary = $"[{evt.Type}] {evt.Message ?? "(no message)"}"; - if (evt.Source != null) - summary += $" (source: {evt.Source})"; - eventSummaries.Add(summary); - } - - response = ResponseCallTool.Success( - $"[event-watch] Caught {result.Events.Count} event(s):\n" + - string.Join("\n", eventSummaries) - ).SetRequestID(requestId); - } + var json = System.Text.Json.JsonSerializer.SerializeToNode(watchResult); + var response = ResponseCallValueTool + .SuccessStructured(json) + .SetRequestID(requestId); await UnityMcpPluginEditor.NotifyToolRequestCompleted(new RequestToolCompletedData { 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 index 4b00a0f5a..0b1bb0b76 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs @@ -16,9 +16,12 @@ public partial class Tool_Event { public static class Error { - public static string InvalidTimeout(int timeoutMs) + 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 index fd0259081..a2c937d98 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs @@ -12,20 +12,24 @@ 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 wait for them. + /// 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 readonly ConcurrentQueue _queue = new(); - static readonly SemaphoreSlim _signal = new(0); + 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() { @@ -37,7 +41,6 @@ public static class McpEventBus ["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.", - ["asset_imported"] = "Assets were imported/reimported.", ["hierarchy_changed"] = "The scene hierarchy changed (GameObject created/destroyed/reparented).", ["selection_changed"] = "Editor selection changed.", }; @@ -51,7 +54,7 @@ public static void Push(string type, string? source = null, string? message = nu if (string.IsNullOrEmpty(type)) return; - _queue.Enqueue(new McpEventData + _channel.Writer.TryWrite(new McpEventData { Type = type, Source = source, @@ -59,7 +62,6 @@ public static void Push(string type, string? source = null, string? message = nu Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0, Payload = payload }); - _signal.Release(); // Auto-register custom types if (!_builtInTypes.ContainsKey(type)) @@ -68,6 +70,7 @@ public static void Push(string type, string? source = null, string? message = nu /// /// 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) @@ -75,6 +78,7 @@ public static async Task WaitAsync( var result = new McpEventSubscribeResult(); var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); var skipped = new List(); + var reader = _channel.Reader; try { @@ -84,35 +88,42 @@ public static async Task WaitAsync( if (remaining <= 0) break; - if (await _signal.WaitAsync(Math.Min(remaining, 500), ct)) + using var chunkCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + chunkCts.CancelAfter(Math.Min(remaining, 500)); + + try { - if (_queue.TryDequeue(out var evt)) + if (await reader.WaitToReadAsync(chunkCts.Token)) { - if (string.IsNullOrEmpty(typeFilter) || evt.Type == typeFilter) - { - result.Events.Add(evt); - if (!collectAll) - break; // Got one matching event, return immediately - } - else + if (reader.TryRead(out var evt)) { - skipped.Add(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) { - // Cancelled — fall through with whatever we have + // Outer cancellation — fall through } // Re-enqueue events that didn't match the filter foreach (var s in skipped) - { - _queue.Enqueue(s); - _signal.Release(); - } + _channel.Writer.TryWrite(s); result.TimedOut = result.Events.Count == 0; return result; @@ -126,10 +137,8 @@ public static McpEventSubscribeResult DrainPending(string? typeFilter) var result = new McpEventSubscribeResult(); var skipped = new List(); - while (_queue.TryDequeue(out var evt)) + while (_channel.Reader.TryRead(out var evt)) { - _signal.Wait(0); // Consume the signal for the dequeued item - if (string.IsNullOrEmpty(typeFilter) || evt.Type == typeFilter) result.Events.Add(evt); else @@ -138,10 +147,7 @@ public static McpEventSubscribeResult DrainPending(string? typeFilter) // Re-enqueue non-matching events foreach (var s in skipped) - { - _queue.Enqueue(s); - _signal.Release(); - } + _channel.Writer.TryWrite(s); result.TimedOut = result.Events.Count == 0; return result; @@ -178,12 +184,13 @@ public static void RegisterCustomType(string type, string description) } /// - /// Clear all pending events. Useful for test cleanup. + /// Clear all pending events by replacing the channel. /// public static void Clear() { - while (_queue.TryDequeue(out _)) - _signal.Wait(0); + var old = _channel; + _channel = CreateChannel(); + old.Writer.Complete(); } } }