diff --git a/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs b/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs
new file mode 100644
index 000000000..8996db744
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Describes a native modal dialog blocking the Editor's main thread.
+ ///
+ internal sealed class ModalDialogInfo
+ {
+ public bool Supported { get; set; }
+ public bool Blocked { get; set; }
+
+ ///
+ /// "dialog" for a native EditorUtility.DisplayDialog box, whose buttons are real OS
+ /// controls; "editor_window" for a Unity-drawn modal (EditorWindow.ShowModal), which
+ /// blocks identically but paints its own buttons with IMGUI.
+ ///
+ public string Kind { get; set; }
+
+ /// Whether a specific button can actually be pressed programmatically.
+ public bool Answerable { get; set; }
+
+ public long Handle { get; set; }
+ public string Title { get; set; }
+ public string Body { get; set; }
+ public List Buttons { get; } = new List();
+ }
+
+ ///
+ /// Reads (and can answer) the native modal dialog that blocks Unity's main thread.
+ ///
+ /// Windows only. EditorUtility.DisplayDialog raises a standard Win32 dialog (class
+ /// #32770) owned by the Editor process, whose title, body and button labels are all
+ /// readable, and whose buttons are real Button controls that accept BM_CLICK.
+ ///
+ /// Every text read goes through : GetWindowText on a
+ /// window owned by the calling process is a synchronous WM_GETTEXT, which hangs when the
+ /// owning thread is not pumping messages — exactly the case this probe reports on.
+ ///
+ internal static class ModalDialogProbe
+ {
+ private const string DialogWindowClass = "#32770";
+ private const string ButtonWindowClass = "Button";
+ private const uint WM_GETTEXT = 0x000D;
+ private const uint BM_CLICK = 0x00F5;
+ private const uint SMTO_ABORTIFHUNG = 0x0002;
+ private const int TextReadTimeoutMs = 400;
+ private const int MaxTopLevelScan = 5000;
+ private const int MaxChildScan = 200;
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr FindWindowExW(IntPtr parent, IntPtr childAfter, IntPtr className, IntPtr windowName);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindowVisible(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindowEnabled(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassNameW(IntPtr hWnd, StringBuilder buffer, int maxCount);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr SendMessageTimeoutW(
+ IntPtr hWnd, uint msg, IntPtr wParam, StringBuilder lParam, uint flags, uint timeoutMs, out IntPtr result);
+
+ [DllImport("user32.dll")]
+ private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+
+ internal static bool IsSupported =>
+ Application.platform == RuntimePlatform.WindowsEditor;
+
+ ///
+ /// Look for a modal dialog owned by this process. Safe to call from a background thread.
+ ///
+ internal static ModalDialogInfo Capture()
+ {
+ var info = new ModalDialogInfo { Supported = IsSupported };
+ if (!info.Supported)
+ {
+ return info;
+ }
+
+ try
+ {
+ uint myPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
+
+ // Windows disables a modal's owner while the modal is up. That — not the window
+ // class — is what "a modal is blocking the Editor" means, and it is what makes a
+ // Unity-drawn EditorWindow.ShowModal visible here too.
+ var enabled = new List();
+ bool anyDisabled = false;
+
+ IntPtr window = IntPtr.Zero;
+ for (int i = 0; i < MaxTopLevelScan; i++)
+ {
+ window = FindWindowExW(IntPtr.Zero, window, IntPtr.Zero, IntPtr.Zero);
+ if (window == IntPtr.Zero)
+ {
+ break;
+ }
+
+ GetWindowThreadProcessId(window, out uint pid);
+ if (pid != myPid || !IsWindowVisible(window))
+ {
+ continue;
+ }
+
+ if (IsWindowEnabled(window))
+ {
+ enabled.Add(window);
+ }
+ else
+ {
+ anyDisabled = true;
+ }
+ }
+
+ if (!anyDisabled || enabled.Count == 0)
+ {
+ return info;
+ }
+
+ // Prefer a native dialog when one is up: it is the only kind whose buttons can be
+ // read and pressed.
+ IntPtr modal = IntPtr.Zero;
+ foreach (var candidate in enabled)
+ {
+ if (string.Equals(ClassOf(candidate), DialogWindowClass, StringComparison.Ordinal))
+ {
+ modal = candidate;
+ break;
+ }
+ }
+
+ bool isNativeDialog = modal != IntPtr.Zero;
+ if (!isNativeDialog)
+ {
+ modal = enabled[0];
+ }
+
+ info.Blocked = true;
+ info.Handle = modal.ToInt64();
+ info.Title = TextOf(modal);
+ info.Kind = isNativeDialog ? "dialog" : "editor_window";
+
+ if (!isNativeDialog)
+ {
+ // EditorWindow.ShowModal paints its buttons with IMGUI, so there are no child
+ // controls to enumerate. Measured: it keeps repainting while blocked yet ignores
+ // synthetic WM_LBUTTONDOWN/UP from both PostMessage and SendMessage, activated or
+ // not. Reported as blocked, but not answerable.
+ return info;
+ }
+
+ var bodyParts = new List();
+ foreach (var child in ChildrenOf(modal))
+ {
+ string cls = ClassOf(child);
+ string text = TextOf(child);
+ if (string.IsNullOrEmpty(text))
+ {
+ continue;
+ }
+
+ if (string.Equals(cls, ButtonWindowClass, StringComparison.Ordinal))
+ {
+ info.Buttons.Add(StripAccelerator(text));
+ }
+ else
+ {
+ bodyParts.Add(text);
+ }
+ }
+
+ info.Body = bodyParts.Count > 0 ? string.Join("\n", bodyParts.ToArray()) : null;
+ info.Answerable = info.Buttons.Count > 0;
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Modal dialog probe failed: {ex.Message}");
+ }
+
+ return info;
+ }
+
+ ///
+ /// Press a button on the currently open modal dialog. Safe to call from a background thread.
+ /// The dialog is re-identified and its title re-checked so the press cannot land on a
+ /// different dialog than the caller was shown.
+ ///
+ internal static bool TryAnswer(string expectedTitle, string buttonLabel, out string error, out ModalDialogInfo observed)
+ {
+ observed = Capture();
+ error = null;
+
+ if (!observed.Supported)
+ {
+ error = "Answering modal dialogs is only supported in the Windows Editor.";
+ return false;
+ }
+
+ if (!observed.Blocked)
+ {
+ error = "No modal dialog is currently open in the Unity Editor.";
+ return false;
+ }
+
+ if (!observed.Answerable)
+ {
+ error = $"Modal window '{observed.Title}' can't be answered programmatically; dismiss it in the Editor.";
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(expectedTitle)
+ && !string.Equals(expectedTitle, observed.Title, StringComparison.Ordinal))
+ {
+ error = $"Dialog changed: expected '{expectedTitle}' but '{observed.Title}' is open.";
+ return false;
+ }
+
+ var handle = new IntPtr(observed.Handle);
+ if (!IsWindow(handle))
+ {
+ error = "The dialog closed before it could be answered.";
+ return false;
+ }
+
+ foreach (var child in ChildrenOf(handle))
+ {
+ if (!string.Equals(ClassOf(child), ButtonWindowClass, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!string.Equals(StripAccelerator(TextOf(child)), buttonLabel, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ PostMessageW(child, BM_CLICK, IntPtr.Zero, IntPtr.Zero);
+ return true;
+ }
+
+ error = $"Dialog '{observed.Title}' has no button labelled '{buttonLabel}'. Available: {string.Join(", ", observed.Buttons.ToArray())}";
+ return false;
+ }
+
+ private static List ChildrenOf(IntPtr parent)
+ {
+ var children = new List();
+ IntPtr child = IntPtr.Zero;
+ for (int i = 0; i < MaxChildScan; i++)
+ {
+ child = FindWindowExW(parent, child, IntPtr.Zero, IntPtr.Zero);
+ if (child == IntPtr.Zero)
+ {
+ break;
+ }
+
+ children.Add(child);
+ }
+
+ return children;
+ }
+
+ private static string ClassOf(IntPtr hWnd)
+ {
+ var buffer = new StringBuilder(256);
+ GetClassNameW(hWnd, buffer, buffer.Capacity);
+ return buffer.ToString();
+ }
+
+ private static string TextOf(IntPtr hWnd)
+ {
+ var buffer = new StringBuilder(2048);
+ IntPtr sent = SendMessageTimeoutW(
+ hWnd, WM_GETTEXT, new IntPtr(buffer.Capacity), buffer, SMTO_ABORTIFHUNG, TextReadTimeoutMs, out _);
+ return sent == IntPtr.Zero ? string.Empty : buffer.ToString();
+ }
+
+ private static string StripAccelerator(string label)
+ {
+ return string.IsNullOrEmpty(label) ? label : label.Replace("&", string.Empty);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs.meta b/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs.meta
new file mode 100644
index 000000000..82e6d6845
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/ModalDialogProbe.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 44b3e79f88f8ffa4bbf455951116e2c7
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs b/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs
new file mode 100644
index 000000000..62b3fcb9c
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs
@@ -0,0 +1,273 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Newtonsoft.Json;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine.SceneManagement;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Detects that a scene open in the Editor was modified on disk behind Unity's back — the usual
+ /// cause being an agent editing the .unity YAML directly — and resolves it before an
+ /// asset refresh can raise Unity's blocking "Scene(s) Have Been Modified" prompt.
+ ///
+ /// The prompt is modal, so it stalls the Editor's main thread and with it every MCP command
+ /// until a human dismisses it. This guard removes the condition that raises it instead of
+ /// answering it: the on-disk and in-memory copies are reconciled first, in whichever direction
+ /// the caller asked for.
+ ///
+ [InitializeOnLoad]
+ internal static class SceneExternalChangeGuard
+ {
+ internal const string ModeAuto = "auto";
+ internal const string ModeReload = "reload";
+ internal const string ModeKeepEditor = "keep_editor";
+
+ private const string BaselineKey = "MCPForUnity.OpenSceneMtimeBaseline";
+
+ static SceneExternalChangeGuard()
+ {
+ // Without a baseline from scene-open time the first refresh of a session has nothing to
+ // compare against, so the edit reaches AssetDatabase.Refresh — which is where the modal
+ // comes from. Each callback updates only the scene it names: rewriting the whole map
+ // would stamp the current mtime onto an unrelated open scene that was edited but not yet
+ // reconciled, erasing the change this guard exists to catch.
+ EditorSceneManager.sceneOpened += (scene, _) => UpsertBaseline(scene.path);
+ EditorSceneManager.sceneSaved += scene => UpsertBaseline(scene.path);
+ EditorApplication.delayCall += FillMissingBaselines;
+ }
+
+ internal sealed class Outcome
+ {
+ public bool Blocked { get; set; }
+ public string Error { get; set; }
+ public List ChangedScenes { get; } = new List();
+ public List ReloadedScenes { get; } = new List();
+ public List OverwrittenScenes { get; } = new List();
+ }
+
+ ///
+ /// Reconcile any open scene whose file changed on disk since we last recorded it.
+ /// Returns an outcome whose flag means the caller must not
+ /// refresh: doing so would raise the modal prompt.
+ ///
+ internal static Outcome Reconcile(string mode)
+ {
+ var outcome = new Outcome();
+ mode = string.IsNullOrWhiteSpace(mode) ? ModeAuto : mode.Trim().ToLowerInvariant();
+
+ var baseline = LoadBaseline();
+ var openScenes = OpenScenes();
+
+ var changed = new List();
+ foreach (var scene in openScenes)
+ {
+ if (string.IsNullOrEmpty(scene.path) || !File.Exists(scene.path))
+ {
+ continue;
+ }
+
+ long diskMtime = MtimeOf(scene.path);
+ if (baseline.TryGetValue(scene.path, out long known) && diskMtime > known)
+ {
+ changed.Add(scene);
+ outcome.ChangedScenes.Add(scene.path);
+ }
+ }
+
+ if (changed.Count == 0)
+ {
+ RecordBaseline(openScenes);
+ return outcome;
+ }
+
+ if (mode == ModeKeepEditor)
+ {
+ foreach (var scene in changed)
+ {
+ // Capture before saving: the path is read back off the Scene struct, and
+ // anything that reopens or replaces the scene invalidates that handle.
+ string path = scene.path;
+ EditorSceneManager.SaveScene(scene);
+ outcome.OverwrittenScenes.Add(path);
+ }
+
+ RecordBaseline(OpenScenes());
+ return outcome;
+ }
+
+ bool anyDirty = changed.Any(s => s.isDirty);
+ bool multiScene = openScenes.Count > 1;
+
+ if (mode == ModeAuto && (anyDirty || multiScene))
+ {
+ outcome.Blocked = true;
+ outcome.Error = BuildBlockedMessage(changed, anyDirty, multiScene);
+ return outcome;
+ }
+
+ // mode == reload, or auto with a single clean scene: take the on-disk version.
+ if (multiScene)
+ {
+ // Reopening one scene of a multi-scene setup as Single would unload the others, and
+ // reopening additively would reorder them. Refuse rather than rearrange the setup.
+ outcome.Blocked = true;
+ outcome.Error = BuildBlockedMessage(changed, anyDirty, true);
+ return outcome;
+ }
+
+ // Reopening a scene invalidates every Scene struct describing it, so the paths are read
+ // out before any of them are used.
+ var reloadPaths = changed.Select(s => s.path).ToList();
+ foreach (var path in reloadPaths)
+ {
+ EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
+ outcome.ReloadedScenes.Add(path);
+ }
+
+ RecordBaseline(OpenScenes());
+ return outcome;
+ }
+
+ ///
+ /// Record the current on-disk timestamps as the known-good baseline. Called after any
+ /// operation that syncs Unity with disk, so the next check only sees genuinely new edits.
+ ///
+ internal static void RecordBaseline()
+ {
+ RecordBaseline(OpenScenes());
+ }
+
+ private static string BuildBlockedMessage(List changed, bool anyDirty, bool multiScene)
+ {
+ string names = string.Join(", ", changed.Select(s => s.path).ToArray());
+
+ // Reloading one scene of a multi-scene setup would unload or reorder the others, so
+ // only keep_editor is offered there.
+ return multiScene
+ ? $"Scene(s) changed on disk ({names}) and several scenes are open. "
+ + $"Set on_external_scene_change to \"{ModeKeepEditor}\", or close the other scenes."
+ : $"Scene(s) changed on disk ({names}) with unsaved Editor changes. "
+ + $"Set on_external_scene_change to \"{ModeReload}\" or \"{ModeKeepEditor}\".";
+ }
+
+ private static List OpenScenes()
+ {
+ var scenes = new List();
+ for (int i = 0; i < SceneManager.sceneCount; i++)
+ {
+ var scene = SceneManager.GetSceneAt(i);
+ if (scene.IsValid())
+ {
+ scenes.Add(scene);
+ }
+ }
+
+ return scenes;
+ }
+
+ private static long MtimeOf(string path)
+ {
+ try
+ {
+ return File.GetLastWriteTimeUtc(path).Ticks;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private static Dictionary LoadBaseline()
+ {
+ try
+ {
+ string raw = SessionState.GetString(BaselineKey, null);
+ if (!string.IsNullOrEmpty(raw))
+ {
+ var parsed = JsonConvert.DeserializeObject>(raw);
+ if (parsed != null)
+ {
+ return parsed;
+ }
+ }
+ }
+ catch
+ {
+ // Corrupt baseline is not worth failing a refresh over; rebuild it below.
+ }
+
+ return new Dictionary(StringComparer.Ordinal);
+ }
+
+ /// Record one scene's current mtime, leaving every other entry alone.
+ private static void UpsertBaseline(string path)
+ {
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ return;
+ }
+
+ var map = LoadBaseline();
+ map[path] = MtimeOf(path);
+ SaveBaseline(map);
+ }
+
+ ///
+ /// Seed baselines for open scenes that have none, without overwriting the ones already
+ /// recorded — an existing entry may be the only evidence of an unreconciled edit.
+ ///
+ private static void FillMissingBaselines()
+ {
+ var map = LoadBaseline();
+ bool changed = false;
+ foreach (var scene in OpenScenes())
+ {
+ if (string.IsNullOrEmpty(scene.path) || !File.Exists(scene.path))
+ {
+ continue;
+ }
+
+ if (!map.ContainsKey(scene.path))
+ {
+ map[scene.path] = MtimeOf(scene.path);
+ changed = true;
+ }
+ }
+
+ if (changed)
+ {
+ SaveBaseline(map);
+ }
+ }
+
+ private static void SaveBaseline(Dictionary map)
+ {
+ try
+ {
+ SessionState.SetString(BaselineKey, JsonConvert.SerializeObject(map));
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to record open-scene baseline: {ex.Message}");
+ }
+ }
+
+ private static void RecordBaseline(List scenes)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var scene in scenes)
+ {
+ if (!string.IsNullOrEmpty(scene.path) && File.Exists(scene.path))
+ {
+ map[scene.path] = MtimeOf(scene.path);
+ }
+ }
+
+ SaveBaseline(map);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs.meta b/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs.meta
new file mode 100644
index 000000000..3002b0941
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/SceneExternalChangeGuard.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7be37280f70b37a4396636d662954cdc
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Services/EditorLivenessProbe.cs b/MCPForUnity/Editor/Services/EditorLivenessProbe.cs
new file mode 100644
index 000000000..f592cf85a
--- /dev/null
+++ b/MCPForUnity/Editor/Services/EditorLivenessProbe.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Services.Transport;
+using Newtonsoft.Json.Linq;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.Services
+{
+ ///
+ /// Answers "is Unity's main thread alive, and if not, why" without touching the main thread.
+ ///
+ /// The modal scan runs on a dedicated sampler thread, never on the request path: reading window
+ /// text can block when the main thread is not pumping, which would stall the very answer that
+ /// reports the stall. Requests read the last snapshot plus its age, and a snapshot that has
+ /// stopped advancing is itself evidence of a non-pumping block.
+ ///
+ [InitializeOnLoad]
+ internal static class EditorLivenessProbe
+ {
+ private const int SampleIntervalMs = 500;
+
+ ///
+ /// Only scan windows once the main thread has actually missed ticks. In the healthy case
+ /// this keeps the sampler down to one interlocked read per interval.
+ ///
+ private const int ScanThresholdMs = 1000;
+
+ private static readonly Stopwatch Clock = Stopwatch.StartNew();
+ private static readonly object SnapshotLock = new object();
+
+ private static ModalDialogInfo _snapshot = new ModalDialogInfo { Supported = ModalDialogProbe.IsSupported };
+ private static long _snapshotAtMs;
+ private static Thread _sampler;
+ private static volatile bool _stopRequested;
+
+ static EditorLivenessProbe()
+ {
+ _snapshotAtMs = Clock.ElapsedMilliseconds;
+
+ AssemblyReloadEvents.beforeAssemblyReload += StopSampler;
+ EditorApplication.quitting += StopSampler;
+
+ if (!ModalDialogProbe.IsSupported)
+ {
+ return;
+ }
+
+ _sampler = new Thread(SampleLoop)
+ {
+ IsBackground = true,
+ Name = "McpForUnity.LivenessSampler"
+ };
+ _sampler.Start();
+ }
+
+ private static void StopSampler()
+ {
+ _stopRequested = true;
+ }
+
+ private static void SampleLoop()
+ {
+ while (!_stopRequested)
+ {
+ try
+ {
+ ModalDialogInfo sample = MainThreadHeartbeat.StallMs >= ScanThresholdMs
+ ? ModalDialogProbe.Capture()
+ : new ModalDialogInfo { Supported = true };
+
+ lock (SnapshotLock)
+ {
+ _snapshot = sample;
+ _snapshotAtMs = Clock.ElapsedMilliseconds;
+ }
+ }
+ catch (Exception)
+ {
+ // A sampler failure must never take the transport down; the growing snapshot
+ // age tells the server the probe is not reporting.
+ }
+
+ Thread.Sleep(SampleIntervalMs);
+ }
+ }
+
+ ///
+ /// Build the liveness payload. Never blocks: reads interlocked counters and the last
+ /// sampler snapshot only.
+ ///
+ internal static JObject Capture()
+ {
+ ModalDialogInfo snapshot;
+ long ageMs;
+ lock (SnapshotLock)
+ {
+ snapshot = _snapshot;
+ ageMs = Math.Max(0, Clock.ElapsedMilliseconds - _snapshotAtMs);
+ }
+
+ var modal = new JObject
+ {
+ ["supported"] = snapshot.Supported,
+ ["blocked"] = snapshot.Blocked
+ };
+
+ if (snapshot.Blocked)
+ {
+ modal["kind"] = snapshot.Kind;
+ modal["answerable"] = snapshot.Answerable;
+ modal["title"] = snapshot.Title;
+ modal["body"] = snapshot.Body;
+ modal["handle"] = snapshot.Handle;
+ modal["buttons"] = new JArray(snapshot.Buttons.ToArray());
+ }
+
+ return new JObject
+ {
+ ["main_thread_stall_ms"] = MainThreadHeartbeat.StallMs,
+ ["main_thread_ticks"] = MainThreadHeartbeat.TickCount,
+ ["pending_commands"] = TransportCommandDispatcher.PendingCount,
+ ["sample_age_ms"] = ageMs,
+ ["modal"] = modal
+ };
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/EditorLivenessProbe.cs.meta b/MCPForUnity/Editor/Services/EditorLivenessProbe.cs.meta
new file mode 100644
index 000000000..18f20ab12
--- /dev/null
+++ b/MCPForUnity/Editor/Services/EditorLivenessProbe.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 27113905d1cb8a74abc6d533ac23d06d
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs b/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs
new file mode 100644
index 000000000..3d600ebd3
--- /dev/null
+++ b/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.Services
+{
+ ///
+ /// Records that Unity's main thread is alive by stamping a timestamp from
+ /// . Read from background threads (websocket receive loop,
+ /// stdio socket thread) to tell "main thread is stalled" apart from "the process is gone".
+ ///
+ /// The stamp MUST be written by the main thread. A heartbeat written by whichever thread happens
+ /// to answer the liveness request only proves that thread ran, and reports a healthy Editor while
+ /// the main thread is frozen behind a modal dialog.
+ ///
+ [InitializeOnLoad]
+ internal static class MainThreadHeartbeat
+ {
+ private static readonly Stopwatch Clock = Stopwatch.StartNew();
+ private static long _lastTickMs;
+ private static long _tickCount;
+
+ static MainThreadHeartbeat()
+ {
+ Interlocked.Exchange(ref _lastTickMs, Clock.ElapsedMilliseconds);
+ EditorApplication.update += Beat;
+ }
+
+ private static void Beat()
+ {
+ Interlocked.Exchange(ref _lastTickMs, Clock.ElapsedMilliseconds);
+ Interlocked.Increment(ref _tickCount);
+ }
+
+ /// Milliseconds since the main thread last ran an editor update tick.
+ internal static long StallMs
+ {
+ get
+ {
+ long last = Interlocked.Read(ref _lastTickMs);
+ return Math.Max(0, Clock.ElapsedMilliseconds - last);
+ }
+ }
+
+ /// Total editor update ticks observed since this domain loaded.
+ internal static long TickCount => Interlocked.Read(ref _tickCount);
+ }
+}
diff --git a/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs.meta b/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs.meta
new file mode 100644
index 000000000..2213ee9f7
--- /dev/null
+++ b/MCPForUnity/Editor/Services/MainThreadHeartbeat.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4d34bfbca9a5c0146b069afe643c6a14
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs b/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs
new file mode 100644
index 000000000..8ecfacca8
--- /dev/null
+++ b/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs
@@ -0,0 +1,121 @@
+using System;
+using MCPForUnity.Editor.Helpers;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace MCPForUnity.Editor.Services.Transport
+{
+ ///
+ /// Commands answered directly on the transport's receive thread, bypassing
+ /// and therefore Unity's main thread.
+ ///
+ /// These exist precisely for the case where the main thread is blocked: routing them through
+ /// the normal dispatcher would queue them behind the very stall they are meant to report on or
+ /// clear, so answer_dialog in particular would deadlock against the dialog it is trying
+ /// to dismiss.
+ ///
+ internal static class OffMainThreadCommands
+ {
+ internal const string Liveness = "liveness";
+ internal const string AnswerDialog = "answer_dialog";
+
+ internal static bool IsOffMainThreadCommand(string commandType)
+ {
+ return string.Equals(commandType, Liveness, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(commandType, AnswerDialog, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Handle an off-main-thread command. Returns the serialized transport response envelope.
+ ///
+ internal static string Handle(string commandType, JObject parameters)
+ {
+ object result;
+ try
+ {
+ result = string.Equals(commandType, AnswerDialog, StringComparison.OrdinalIgnoreCase)
+ ? HandleAnswerDialog(parameters ?? new JObject())
+ : HandleLiveness();
+ }
+ catch (Exception ex)
+ {
+ result = new ErrorResponse($"{commandType}_failed: {ex.Message}");
+ }
+
+ return JsonConvert.SerializeObject(new { status = "success", result });
+ }
+
+ ///
+ /// Parse a raw transport frame and handle it when it names an off-main-thread command.
+ ///
+ internal static bool TryHandleRaw(string commandText, out string responseJson)
+ {
+ responseJson = null;
+ if (string.IsNullOrWhiteSpace(commandText))
+ {
+ return false;
+ }
+
+ string trimmed = commandText.Trim();
+ if (!trimmed.StartsWith("{", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ string commandType;
+ JObject parameters;
+ try
+ {
+ var parsed = JObject.Parse(trimmed);
+ commandType = parsed.Value("type");
+ parameters = parsed["params"] as JObject;
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(commandType) || !IsOffMainThreadCommand(commandType))
+ {
+ return false;
+ }
+
+ responseJson = Handle(commandType, parameters);
+ return true;
+ }
+
+ private static object HandleLiveness()
+ {
+ return new SuccessResponse("Liveness snapshot.", EditorLivenessProbe.Capture());
+ }
+
+ private static object HandleAnswerDialog(JObject parameters)
+ {
+ string button = parameters.Value("button");
+ if (string.IsNullOrWhiteSpace(button))
+ {
+ return new ErrorResponse("Required parameter 'button' is missing.");
+ }
+
+ string expectedTitle = parameters.Value("expect_title");
+
+ if (ModalDialogProbe.TryAnswer(expectedTitle, button, out string error, out var observed))
+ {
+ return new SuccessResponse($"Answered dialog '{observed.Title}' with '{button}'.", new
+ {
+ title = observed.Title,
+ button,
+ buttons = observed.Buttons
+ });
+ }
+
+ return new ErrorResponse(error, new
+ {
+ reason = observed.Blocked ? "answer_rejected" : "no_dialog_open",
+ title = observed.Title,
+ buttons = observed.Buttons,
+ supported = observed.Supported
+ });
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs.meta b/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs.meta
new file mode 100644
index 000000000..c753d103f
--- /dev/null
+++ b/MCPForUnity/Editor/Services/Transport/OffMainThreadCommands.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5b3393be4ed8ca241a430002ae5ee739
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
index becc3bd88..b708ce34a 100644
--- a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
+++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
@@ -84,6 +84,21 @@ static TransportCommandDispatcher()
}
}
+ ///
+ /// Number of commands queued for the main thread. Read from transport threads to report how
+ /// much work is stuck behind a main-thread stall.
+ ///
+ internal static int PendingCount
+ {
+ get
+ {
+ lock (PendingLock)
+ {
+ return Pending.Count;
+ }
+ }
+ }
+
///
/// Schedule a command for execution on the Unity main thread and await its JSON response.
///
diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
index 4c0ff0b87..c6833f84f 100644
--- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
+++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
@@ -600,6 +600,14 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
continue;
}
+ // Answered on this socket thread for the same reason ping is: they
+ // report on (or clear) a blocked main thread and must not queue behind it.
+ if (OffMainThreadCommands.TryHandleRaw(commandText, out string offMainThreadResponse))
+ {
+ await WriteFrameAsync(stream, System.Text.Encoding.UTF8.GetBytes(offMainThreadResponse));
+ continue;
+ }
+
lock (lockObj)
{
commandQueue[commandId] = new QueuedCommand
diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
index 8aaf2249e..4387a1b2e 100644
--- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
+++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
@@ -47,6 +47,14 @@ public class WebSocketTransportClient : IMcpTransportClient, IDisposable
private Task _keepAliveTask;
private readonly SemaphoreSlim _sendLock = new(1, 1);
+ ///
+ /// Caps how many dispatcher-bound commands may be in flight at once. The main thread runs
+ /// them one at a time regardless, so this only bounds how much work piles up waiting for it.
+ /// Control commands (liveness, answer_dialog) bypass this gate.
+ ///
+ private const int MaxConcurrentExecutes = 16;
+ private readonly SemaphoreSlim _executeGate = new(MaxConcurrentExecutes, MaxConcurrentExecutes);
+
private Uri _endpointUri;
private string _sessionId;
private string _projectHash;
@@ -387,7 +395,14 @@ private async Task ReceiveLoopAsync(CancellationToken token)
{
continue;
}
- await HandleMessageAsync(message, token).ConfigureAwait(false);
+
+ // Handled detached so the loop keeps reading while a command is in flight.
+ // Awaiting here meant one command waiting on the Unity main thread stopped every
+ // later frame from even being read — including the liveness probe that exists to
+ // report the stall, and the server's pings. Command execution itself is still
+ // ordered by TransportCommandDispatcher's queue, and sends are serialised by
+ // _sendLock, so only the read side becomes concurrent.
+ _ = HandleMessageSafeAsync(message, token);
}
catch (OperationCanceledException)
{
@@ -455,6 +470,27 @@ private async Task ReceiveMessageAsync(CancellationToken token)
}
}
+ ///
+ /// Run without letting a failure escape onto the detached
+ /// task, where it would surface as an unobserved exception rather than a log line. A single
+ /// bad message must not take down the receive loop.
+ ///
+ private async Task HandleMessageSafeAsync(string message, CancellationToken token)
+ {
+ try
+ {
+ await HandleMessageAsync(message, token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"[WebSocket] Message handling failed: {ex.Message}");
+ }
+ }
+
private async Task HandleMessageAsync(string message, CancellationToken token)
{
JObject payload;
@@ -619,9 +655,42 @@ private async Task HandleExecuteAsync(JObject payload, CancellationToken token)
string responseJson;
try
{
- using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
- timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
- responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false);
+ if (OffMainThreadCommands.IsOffMainThreadCommand(commandName))
+ {
+ // Answered off the main thread: these report on (or clear) a blocked main thread,
+ // so they must not queue behind it. Deliberately outside _executeGate — a
+ // saturated gate is one of the states they exist to report on. Run on the pool
+ // rather than inline so the dialog probe's window scan does not hold the
+ // receive loop either.
+ responseJson = await Task.Run(
+ () => OffMainThreadCommands.Handle(commandName, parameters),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ else
+ {
+ // Detaching the receive loop removed the backpressure that awaiting it used to
+ // provide, so admission is bounded here instead: without it a burst queues
+ // unboundedly into the dispatcher while the main thread works through it one
+ // command at a time.
+ //
+ // The timeout is armed before waiting for admission and covers both, so a
+ // command cannot sit in the queue past its budget and then execute for a caller
+ // that already gave up — which would apply side effects nobody is waiting for.
+ // Expiring here is also what bounds the queue: a waiting handler releases its
+ // payload at its own deadline rather than accumulating.
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
+
+ await _executeGate.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+ try
+ {
+ responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ _executeGate.Release();
+ }
+ }
}
catch (OperationCanceledException)
{
diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs
index e35237d80..47368ade1 100644
--- a/MCPForUnity/Editor/Tools/RefreshUnity.cs
+++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs
@@ -37,6 +37,30 @@ public static async Task