Skip to content

feat: Add event subscription system (event-subscribe, event-watch, event-list)#639

Open
djdcks12 wants to merge 6 commits into
IvanMurzak:mainfrom
djdcks12:feature/event-system
Open

feat: Add event subscription system (event-subscribe, event-watch, event-list)#639
djdcks12 wants to merge 6 commits into
IvanMurzak:mainfrom
djdcks12:feature/event-system

Conversation

@djdcks12
Copy link
Copy Markdown

@djdcks12 djdcks12 commented Apr 3, 2026

Summary

Adds an event-driven alternative to polling for Unity state changes. Instead of repeatedly calling console-get-logs or capturing screenshots to detect changes, AI agents can now subscribe to events and receive results instantly when they fire.

New Tools

Tool Behavior Use case
event-subscribe Blocking — waits until event fires or timeout (default: 30s, max: 120s) Parallel with trigger: [parallel] event-subscribe + script-execute
event-watch Non-blocking — returns immediately, notifies via NotifyToolRequestCompleted Background error monitoring while doing other work
event-list Instant List available built-in and custom event types

Why parallel? event-subscribe is blocking — if called sequentially before the trigger, the event fires with no listener yet. If called after, the event is already gone. Parallel tool calls ensure the subscriber is waiting before the trigger executes.

Built-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_changed

Custom Events

Users can bridge game-specific events to the event bus without modifying source code — via script-execute dynamic hooks that are cleaned up automatically on play mode exit:

// script-execute at runtime
SomeManager.Instance.OnDataLoaded += () => McpEventBus.Push("data_loaded");

Or permanently in game code:

#if UNITY_EDITOR
McpEventBus.Push("server_response_done", source: "NetworkHelper");
#endif

Skill

Includes Skill_EventWorkflow (unity-event-workflow) — a playtesting best-practices guide that is auto-generated by setup-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 helpers
  • Event.Subscribe.cs — blocking event wait
  • Event.List.cs — list available event types
  • Event.Watch.cs — non-blocking background watcher

Infrastructure (Editor/Scripts/Event/ — new folder):

  • McpEventBus.cs — thread-safe event queue (ConcurrentQueue + SemaphoreSlim)
  • McpEventData.cs — typed event/result models
  • McpEventWatcher.cs[InitializeOnLoad] auto-captures built-in Unity events

Skill (Editor/Scripts/Skills/):

  • Skill_EventWorkflow.cs — playtesting workflow guide

Design Decisions

  • event-subscribe blocks with a default timeout of 30s (max 120s). Also supports timeoutMs=0 drain mode to collect already-pending events without waiting.
  • event-watch uses the existing NotifyToolRequestCompleted pattern (same as tests-run, package-add) — proven, no new infrastructure needed. Each call spawns one background Task tied to a unique requestId; multiple concurrent watches are independent and do not interfere with each other.
  • McpEventBus is 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).
  • McpEventWatcher uses [InitializeOnLoad] — zero configuration, events are captured from the moment Unity Editor starts.
  • Editor-only — all files live in Editor folder, excluded from builds.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Test Results

   12 files    546 suites   43m 24s ⏱️
  919 tests   918 ✅ 1 💤 0 ❌
5 514 runs  5 508 ✅ 6 💤 0 ❌

Results for commit f6524d1.

♻️ This comment has been updated with latest results.

@IvanMurzak IvanMurzak requested a review from Copilot April 3, 2026 21:25
@IvanMurzak IvanMurzak added the enhancement New feature or request label Apr 3, 2026
@IvanMurzak
Copy link
Copy Markdown
Owner

Hi @djdcks12 , thanks for the contribution. I will review this pull request in a few days.
Until that, could you please explain the exact use case? How do you use it in your workflow?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), and event-list (discovery) tools.
  • Adds McpEventBus + models and an [InitializeOnLoad] watcher that publishes built-in Unity Editor events.
  • Adds unity-event-workflow skill 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.

Comment on lines +87 to +101
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);
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +137
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);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +187
public static void Clear()
{
while (_queue.TryDequeue(out _))
_signal.Wait(0);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
["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.",
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
["asset_imported"] = "Assets were imported/reimported.",

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +80
if (timeoutMs < 5000 || timeoutMs > 120000)
return ResponseCallTool.Error(Error.InvalidTimeout(timeoutMs)).SetRequestID(requestId);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +114
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);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +99
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);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@djdcks12
Copy link
Copy Markdown
Author

djdcks12 commented Apr 5, 2026

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
system in my workflow:

Real workflow example — testing if a friend request is sent correctly to a recommended friend:

  1. editor-application-set-state(isPlaying=true)
    event-subscribe(type='play_mode_changed') → wait for EnteredPlayMode

  2. event-subscribe(type='scene_loaded') → wait for WorldMap

  3. script-execute: register custom events dynamically (no source code modification)
    LobbyFriendManager.it.OnFriendListUpdated += () => McpEventBus.Push("friend_data_loaded");
    LobbyFriendManager.it.OnRecommendUpdated += (list) => McpEventBus.Push("recommend_loaded");
    LobbyFriendManager.it.OnRequestListUpdated += () => McpEventBus.Push("request_list_updated");

  4. script-execute: open friend page (triggers FetchAll server API)
    event-subscribe(type='friend_data_loaded') → wait for server response callback

  5. script-execute: fetch recommended friends
    event-subscribe(type='recommend_loaded') → wait for server response callback

  6. script-execute: send friend request to recommended user
    event-subscribe(type='request_list_updated') → wait for server response callback

  7. script-execute: verify SentRequests.Count == 1 ✓

The key value is async server response handling. Game managers fire C# events on server callback completion. By
hooking McpEventBus.Push() to these events via script-execute, the agent can await exact server response timing —
replacing polling with event-driven flow. Hooks are registered dynamically at runtime with no source code
modification, and auto-cleaned up on play mode exit.

…ured response, update event-watch limitations
@djdcks12
Copy link
Copy Markdown
Author

djdcks12 commented Apr 6, 2026

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants