feat: Add event subscription system (event-subscribe, event-watch, event-list)#639
feat: Add event subscription system (event-subscribe, event-watch, event-list)#639djdcks12 wants to merge 6 commits into
Conversation
Test Results 12 files 546 suites 43m 24s ⏱️ Results for commit f6524d1. ♻️ This comment has been updated with latest results. |
|
Hi @djdcks12 , thanks for the contribution. I will review this pull request in a few days. |
There was a problem hiding this comment.
Pull request overview
Adds an event-driven alternative to polling Unity state by introducing an Editor-only event bus and three new MCP tools for subscribing, watching, and listing event types, plus an agent-facing workflow skill.
Changes:
- Introduces
event-subscribe(blocking),event-watch(non-blocking + notification), andevent-list(discovery) tools. - Adds
McpEventBus+ models and an[InitializeOnLoad]watcher that publishes built-in Unity Editor events. - Adds
unity-event-workflowskill markdown to guide agents toward event-driven workflows.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/Skills/Skill_EventWorkflow.cs | New skill guide documenting event tool usage patterns. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventWatcher.cs | Captures Unity Editor/runtime events and pushes them into the event bus. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventData.cs | Event payload/type/result data models for JSON tool responses. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/Event/McpEventBus.cs | Core concurrent queue + wait/drain APIs and event type registry. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs | Implements non-blocking watcher tool with completion notifications. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Subscribe.cs | Implements blocking subscription tool with timeout/drain support. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.List.cs | Implements tool to list built-in and custom event types. |
| Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs | Adds the Tool_Event tool type and shared error helpers. |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
McpEventBus.WaitAsync decrements _signal (via WaitAsync) and then attempts _queue.TryDequeue. With multiple concurrent consumers (e.g., event-watch plus event-subscribe(timeoutMs=0)), it’s possible for the semaphore/queue to get out of sync and TryDequeue to fail after the semaphore permit was consumed, causing missed events or hangs. Consider making dequeue+signal consumption atomic (e.g., single lock around both, or replacing the queue+semaphore pair with Channel<McpEventData> / BlockingCollection).
| 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); | ||
| } |
There was a problem hiding this comment.
DrainPending dequeues from _queue first and then calls _signal.Wait(0) without checking its return value. If another waiter already acquired the semaphore but hasn’t dequeued yet, this can desynchronize _signal vs _queue (queue has fewer items than the semaphore count or vice versa), leading to deadlocks or spurious wakeups. Drain/clear operations should be synchronized with waiters (lock) or implemented via a single concurrency primitive (e.g., Channel).
| public static void Clear() | ||
| { | ||
| while (_queue.TryDequeue(out _)) | ||
| _signal.Wait(0); | ||
| } |
There was a problem hiding this comment.
Clear() has the same semaphore/queue consistency issue as DrainPending: it dequeues without guaranteeing a corresponding _signal permit exists and ignores the Wait(0) result. This can leave queued events with no semaphore count (or vice versa), making future WaitAsync calls block even when events are present. Consider synchronizing all dequeue paths or switching to a Channel-based design.
| ["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.", |
There was a problem hiding this comment.
asset_imported is advertised as a built-in event type, but there is no corresponding watcher publishing it (no AssetPostprocessor/AssetDatabase hook in this PR), so event-list will show an event that can never fire. Either implement publishing for asset_imported or remove it from _builtInTypes until it’s supported.
| ["asset_imported"] = "Assets were imported/reimported.", |
| if (timeoutMs < 5000 || timeoutMs > 120000) | ||
| return ResponseCallTool.Error(Error.InvalidTimeout(timeoutMs)).SetRequestID(requestId); |
There was a problem hiding this comment.
event-watch enforces timeoutMs in the 5000–120000 range, but it reuses Error.InvalidTimeout(...) whose message says “between 1000 and 120000 milliseconds.” This will produce incorrect guidance when users pass values between 1000 and 4999. Suggest adding a watcher-specific error (or parameterizing the min/max in the helper) so the error message matches the validation.
| 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<string>(); | ||
| 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); | ||
| } |
There was a problem hiding this comment.
The background notification result is returned as a formatted string summary (ResponseCallTool.Success("[event-watch] Caught ...")). The repository constitution requires structured tool returns to avoid fragile string parsing by agents (see .specify/memory/constitution.md, Principle III). Consider sending a structured result (e.g., ResponseCallValueTool<McpEventSubscribeResult> or equivalent) containing the captured McpEventData[] so clients can reliably consume event details.
| public async Task<McpEventSubscribeResult> 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); | ||
| } |
There was a problem hiding this comment.
This PR introduces new event tools (event-subscribe, event-watch, event-list) and core infra (McpEventBus), but there are no corresponding Editor tests (no matches for event-subscribe under Assets/root/Tests). Given the repo’s existing tool test harness (BaseTest.RunTool(...)) and the constitution’s TDD/coverage requirement, please add tests covering at least: timeout vs success, timeoutMs=0 drain behavior, and filtering by type.
|
Hi @IvanMurzak, I'm using Claude Code (AI agent) with Unity-MCP for a production mobile game project. Here's how I use the event Real workflow example — testing if a friend request is sent correctly to a recommended friend:
The key value is async server response handling. Game managers fire C# events on server callback completion. By |
…ured response, update event-watch limitations
|
Update on event-watch: During testing, we found that MCP clients (including Claude Code) block on the Processing response until the notification arrives, making event-watch effectively blocking from the agent's perspective. The tool description has been updated to note this limitation. event-subscribe with sequential pattern (trigger → subscribe) remains the recommended approach for most use cases. |
Summary
Adds an event-driven alternative to polling for Unity state changes. Instead of repeatedly calling
console-get-logsor capturing screenshots to detect changes, AI agents can now subscribe to events and receive results instantly when they fire.New Tools
event-subscribe[parallel] event-subscribe + script-executeevent-watchNotifyToolRequestCompletedevent-listBuilt-in Events (auto-captured, no setup needed)
play_mode_changed,scene_loaded,scene_opened,compilation_started,compilation_finished,error_logged,warning_logged,pause_state_changed,hierarchy_changed,selection_changedCustom Events
Users can bridge game-specific events to the event bus without modifying source code — via
script-executedynamic hooks that are cleaned up automatically on play mode exit:Or permanently in game code:
Skill
Includes
Skill_EventWorkflow(unity-event-workflow) — a playtesting best-practices guide that is auto-generated bysetup-skills, teaching agents when and how to use event tools instead of polling.Files Added
Tools (
Editor/Scripts/API/Tool/):Event.cs— partial class base + error helpersEvent.Subscribe.cs— blocking event waitEvent.List.cs— list available event typesEvent.Watch.cs— non-blocking background watcherInfrastructure (
Editor/Scripts/Event/— new folder):McpEventBus.cs— thread-safe event queue (ConcurrentQueue+SemaphoreSlim)McpEventData.cs— typed event/result modelsMcpEventWatcher.cs—[InitializeOnLoad]auto-captures built-in Unity eventsSkill (
Editor/Scripts/Skills/):Skill_EventWorkflow.cs— playtesting workflow guideDesign Decisions
event-subscribeblocks with a default timeout of 30s (max 120s). Also supportstimeoutMs=0drain mode to collect already-pending events without waiting.event-watchuses the existingNotifyToolRequestCompletedpattern (same astests-run,package-add) — proven, no new infrastructure needed. Each call spawns one backgroundTasktied to a uniquerequestId; multiple concurrent watches are independent and do not interfere with each other.McpEventBusis a static class — thread-safe, survives across tool calls within a Unity Editor session. State resets on domain reload (e.g. script recompilation, play mode transitions).McpEventWatcheruses[InitializeOnLoad]— zero configuration, events are captured from the moment Unity Editor starts.