Skip to content
Original file line number Diff line number Diff line change
@@ -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
};
}
}
}
Original file line number Diff line number Diff line change
@@ -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<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.InvalidSubscribeTimeout(timeoutMs));

using var cts = new CancellationTokenSource(timeoutMs + 1000); // Grace period
return await McpEventBus.WaitAsync(type, timeoutMs, collectAll, cts.Token);
}
Comment on lines +63 to +96
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.
}
}
123 changes: 123 additions & 0 deletions Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.Watch.cs
Original file line number Diff line number Diff line change
@@ -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<McpEventSubscribeResult>
.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);
}
}
}
29 changes: 29 additions & 0 deletions Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Event.cs
Original file line number Diff line number Diff line change
@@ -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.";
}
}
}
Loading
Loading