diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
index a513aca9a..b5227bdfd 100644
--- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
+++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
@@ -68,6 +68,8 @@ internal static class EditorPrefKeys
internal const string AutoStartOnLoad = "MCPForUnity.AutoStartOnLoad";
internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands";
+ internal const string GatewayJobLogging = "MCPForUnity.Gateway.JobLogging";
+ internal const string GatewayJobLogPath = "MCPForUnity.Gateway.JobLogPath";
internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled";
}
}
diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
index 53610d965..062c755a5 100644
--- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
+++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
@@ -107,6 +107,14 @@ public static string GetMcpPackageRootPath()
{
try
{
+ // WSL workaround: when the package lives on a WSL UNC path and Unity
+ // runs on Windows, UXML/USS files cannot be parsed from the UNC path.
+ // UIAssetSync copies them to Assets/MCPForUnityUI/ on domain reload.
+ if (UIAssetSync.NeedsSync())
+ {
+ return UIAssetSync.SyncedBasePath;
+ }
+
// Try Package Manager first (registry and local installs)
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath))
diff --git a/MCPForUnity/Editor/Helpers/UIAssetSync.cs b/MCPForUnity/Editor/Helpers/UIAssetSync.cs
new file mode 100644
index 000000000..d3dba17b8
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/UIAssetSync.cs
@@ -0,0 +1,121 @@
+using UnityEditor;
+using UnityEngine;
+using System.IO;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Automatically copies UXML and USS files from WSL package directories to a local
+ /// Assets/MCPForUnityUI/ folder on every domain reload, preserving directory structure.
+ ///
+ ///
+ ///
+ /// Problem: Unity's UXML/USS importer on Windows cannot properly parse files
+ /// when packages live on a WSL2 filesystem (UNC paths like \\wsl$\...). The
+ /// VisualTreeAsset loads but CloneTree produces an empty tree.
+ ///
+ ///
+ /// Solution: On startup, this class copies all UI asset files to
+ /// Assets/MCPForUnityUI/ and
+ /// returns this fallback path when WSL is detected.
+ ///
+ ///
+ [InitializeOnLoad]
+ static class UIAssetSync
+ {
+ /// Destination folder under the Unity project for synced UI assets.
+ internal const string SyncedBasePath = "Assets/MCPForUnityUI";
+
+ ///
+ /// Relative paths from package root to UXML and USS files that need syncing.
+ ///
+ private static readonly string[] k_UIAssetPaths =
+ {
+ "Editor/Windows/MCPForUnityEditorWindow.uxml",
+ "Editor/Windows/MCPForUnityEditorWindow.uss",
+ "Editor/Windows/MCPSetupWindow.uxml",
+ "Editor/Windows/MCPSetupWindow.uss",
+ "Editor/Windows/EditorPrefs/EditorPrefItem.uxml",
+ "Editor/Windows/EditorPrefs/EditorPrefsWindow.uxml",
+ "Editor/Windows/EditorPrefs/EditorPrefsWindow.uss",
+ "Editor/Windows/Components/Common.uss",
+ "Editor/Windows/Components/Connection/McpConnectionSection.uxml",
+ "Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml",
+ "Editor/Windows/Components/Validation/McpValidationSection.uxml",
+ "Editor/Windows/Components/Advanced/McpAdvancedSection.uxml",
+ "Editor/Windows/Components/Tools/McpToolsSection.uxml",
+ "Editor/Windows/Components/Resources/McpResourcesSection.uxml",
+ "Editor/Windows/Components/Queue/McpQueueSection.uxml",
+ "Editor/Windows/Components/Queue/McpQueueSection.uss",
+ };
+
+ static UIAssetSync()
+ {
+ if (!NeedsSync())
+ return;
+
+ string packageRoot = GetPackagePhysicalRoot();
+ if (string.IsNullOrEmpty(packageRoot))
+ return;
+
+ bool anyUpdated = false;
+
+ foreach (string relativePath in k_UIAssetPaths)
+ {
+ string sourcePath = Path.Combine(packageRoot, relativePath);
+ if (!File.Exists(sourcePath))
+ continue;
+
+ string sourceContent = File.ReadAllText(sourcePath);
+
+ string destPath = Path.GetFullPath(Path.Combine(SyncedBasePath, relativePath));
+ string destDir = Path.GetDirectoryName(destPath);
+
+ if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
+ Directory.CreateDirectory(destDir);
+
+ if (File.Exists(destPath) && File.ReadAllText(destPath) == sourceContent)
+ continue;
+
+ File.WriteAllText(destPath, sourceContent);
+ Debug.Log($"[UIAssetSync] Updated {relativePath}");
+ anyUpdated = true;
+ }
+
+ if (anyUpdated)
+ AssetDatabase.Refresh();
+ }
+
+ ///
+ /// Returns true when the MCP package lives on a WSL UNC path and Unity runs on Windows.
+ ///
+ internal static bool NeedsSync()
+ {
+ if (Application.platform != RuntimePlatform.WindowsEditor)
+ return false;
+
+ string packageRoot = GetPackagePhysicalRoot();
+ if (string.IsNullOrEmpty(packageRoot))
+ return false;
+
+ return packageRoot.StartsWith(@"\\wsl", System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Gets the physical (filesystem) root path of the MCP package.
+ ///
+ private static string GetPackagePhysicalRoot()
+ {
+ var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
+ typeof(UIAssetSync).Assembly);
+ if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath))
+ return packageInfo.resolvedPath;
+
+ // Fallback: resolve the virtual asset path
+ if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath))
+ return Path.GetFullPath(packageInfo.assetPath);
+
+ return null;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta b/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta
new file mode 100644
index 000000000..382ba1f25
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f23b4c1bd875357488d70068da564267
\ No newline at end of file
diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs
index c280d9559..5d3fb8fbf 100644
--- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs
+++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs
@@ -1,4 +1,5 @@
using MCPForUnity.Editor.Setup;
+using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Windows;
using UnityEditor;
using UnityEngine;
@@ -26,11 +27,22 @@ public static void ShowSetupWindow()
SetupWindowService.ShowSetupWindow();
}
-
[MenuItem("Window/MCP For Unity/Edit EditorPrefs", priority = 3)]
public static void ShowEditorPrefsWindow()
{
EditorPrefsWindow.ShowWindow();
}
+
+ ///
+ /// Emergency flush: cancels all queued/running MCP commands and clears stuck test jobs.
+ /// Use when the editor appears frozen due to a stuck MCP queue.
+ /// Shortcut: Ctrl+Shift+F5 (Cmd+Shift+F5 on Mac)
+ ///
+ [MenuItem("Window/MCP For Unity/Emergency Flush Queue %#&F5", priority = 100)]
+ public static void EmergencyFlushQueue()
+ {
+ CommandGatewayState.EmergencyFlush();
+ Debug.LogWarning("[MCP] Emergency flush completed. Queue cleared, stuck test jobs removed.");
+ }
}
}
diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs
index bf2ffec4e..5e6bae05a 100644
--- a/MCPForUnity/Editor/Services/TestJobManager.cs
+++ b/MCPForUnity/Editor/Services/TestJobManager.cs
@@ -220,7 +220,7 @@ private static void TryRestoreFromSessionState()
if (currentJob.Status == TestJobStatus.Running)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
- long staleCutoffMs = 5 * 60 * 1000; // 5 minutes
+ long staleCutoffMs = 60 * 1000; // 60 seconds
if (now - currentJob.LastUpdateUnixMs > staleCutoffMs)
{
McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)");
diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
index becc3bd88..07f9b3bc2 100644
--- a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
+++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
@@ -63,6 +63,12 @@ public void TrySetCanceled()
}
private static readonly Dictionary Pending = new();
+ ///
+ /// Maps command JSON content hash → pending ID for deduplication.
+ /// When a duplicate command arrives while an identical one is still pending,
+ /// the duplicate shares the original's TaskCompletionSource instead of queueing again.
+ ///
+ private static readonly Dictionary ContentHashToPendingId = new();
private static readonly object PendingLock = new();
private static bool updateHooked;
private static bool initialised;
@@ -96,6 +102,22 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat
EnsureInitialised();
+ // --- Deduplication: if an identical command is already pending, share its result ---
+ var contentHash = ComputeContentHash(commandJson);
+
+ lock (PendingLock)
+ {
+ if (contentHash != null
+ && ContentHashToPendingId.TryGetValue(contentHash, out var existingId)
+ && Pending.TryGetValue(existingId, out var existingPending)
+ && !existingPending.CancellationToken.IsCancellationRequested)
+ {
+ McpLog.Info($"[Dispatcher] Dedup: identical command already pending (id={existingId}). Sharing result.");
+ // Return the existing task — caller gets the same result when the original completes.
+ return existingPending.CompletionSource.Task;
+ }
+ }
+
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -108,6 +130,8 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat
lock (PendingLock)
{
Pending[id] = pending;
+ if (contentHash != null)
+ ContentHashToPendingId[contentHash] = id;
}
// Proactively wake up the main thread execution loop. This improves responsiveness
@@ -362,6 +386,54 @@ private static void ProcessCommand(string id, PendingCommand pending)
}
var logType = resourceMeta != null ? "resource" : toolMeta != null ? "tool" : "unknown";
+
+ // --- Tier-aware dispatch ---
+ var declaredTier = CommandRegistry.GetToolTier(command.type);
+ var effectiveTier = CommandClassifier.Classify(command.type, declaredTier, parameters);
+
+ if (effectiveTier != ExecutionTier.Instant)
+ {
+ // Route Smooth/Heavy through the gateway queue for tier-aware scheduling,
+ // heavy exclusivity, domain-reload guards, and CancellationToken support.
+ var job = CommandGatewayState.Queue.SubmitSingle(command.type, parameters, "transport");
+ var gatewaySw = McpLogRecord.IsEnabled ? System.Diagnostics.Stopwatch.StartNew() : null;
+
+ async void AwaitGateway()
+ {
+ try
+ {
+ var gatewayResult = await CommandGatewayState.AwaitJob(job);
+ gatewaySw?.Stop();
+ if (gatewayResult is IMcpResponse mcpResp && !mcpResp.Success)
+ {
+ McpLogRecord.Log(command.type, parameters, logType, "ERROR", gatewaySw?.ElapsedMilliseconds ?? 0, (gatewayResult as ErrorResponse)?.Error);
+ var errResponse = new { status = "error", result = gatewayResult, _queue = new { ticket = job.Ticket, tier = job.Tier.ToString().ToLowerInvariant(), queued = true } };
+ pending.TrySetResult(JsonConvert.SerializeObject(errResponse));
+ }
+ else
+ {
+ McpLogRecord.Log(command.type, parameters, logType, "SUCCESS", gatewaySw?.ElapsedMilliseconds ?? 0, null);
+ var okResponse = new { status = "success", result = gatewayResult, _queue = new { ticket = job.Ticket, tier = job.Tier.ToString().ToLowerInvariant(), queued = true } };
+ pending.TrySetResult(JsonConvert.SerializeObject(okResponse));
+ }
+ }
+ catch (Exception ex)
+ {
+ gatewaySw?.Stop();
+ McpLogRecord.Log(command.type, parameters, logType, "ERROR", gatewaySw?.ElapsedMilliseconds ?? 0, ex.Message);
+ pending.TrySetResult(SerializeError(ex.Message, command.type, ex.StackTrace));
+ }
+ finally
+ {
+ EditorApplication.delayCall += () => RemovePending(id, pending);
+ }
+ }
+
+ AwaitGateway();
+ return;
+ }
+
+ // --- Instant tier: execute directly (existing path) ---
var sw = McpLogRecord.IsEnabled ? System.Diagnostics.Stopwatch.StartNew() : null;
var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource);
@@ -412,8 +484,8 @@ private static void ProcessCommand(string id, PendingCommand pending)
}
McpLogRecord.Log(command.type, parameters, logType, syncLogStatus, sw?.ElapsedMilliseconds ?? 0, syncLogError);
- var response = new { status = "success", result };
- pending.TrySetResult(JsonConvert.SerializeObject(response));
+ var directResponse = new { status = "success", result };
+ pending.TrySetResult(JsonConvert.SerializeObject(directResponse));
RemovePending(id, pending);
}
catch (Exception ex)
@@ -431,6 +503,7 @@ private static void CancelPending(string id, CancellationToken token)
{
if (Pending.Remove(id, out pending))
{
+ CleanContentHash(id);
UnhookUpdateIfIdle();
}
}
@@ -444,12 +517,48 @@ private static void RemovePending(string id, PendingCommand pending)
lock (PendingLock)
{
Pending.Remove(id);
+ CleanContentHash(id);
UnhookUpdateIfIdle();
}
pending.Dispose();
}
+ ///
+ /// Remove the content hash entry that points to the given pending ID.
+ /// Must be called under PendingLock.
+ ///
+ private static void CleanContentHash(string pendingId)
+ {
+ string hashToRemove = null;
+ foreach (var kvp in ContentHashToPendingId)
+ {
+ if (kvp.Value == pendingId)
+ {
+ hashToRemove = kvp.Key;
+ break;
+ }
+ }
+ if (hashToRemove != null)
+ ContentHashToPendingId.Remove(hashToRemove);
+ }
+
+ ///
+ /// Compute a stable content hash for command deduplication.
+ /// Returns null for non-JSON commands (e.g., "ping") which are cheap enough to not need dedup.
+ ///
+ private static string ComputeContentHash(string commandJson)
+ {
+ if (string.IsNullOrWhiteSpace(commandJson)) return null;
+ var trimmed = commandJson.Trim();
+ if (!trimmed.StartsWith("{")) return null; // Skip non-JSON (ping, etc.)
+
+ // Use the raw JSON string as the hash key. Retries from the same client produce
+ // byte-identical JSON, so this is both fast and correct for the dedup use case.
+ // For very large payloads, a proper hash could be used, but MCP commands are small.
+ return trimmed;
+ }
+
private static string SerializeError(string message, string commandType = null, string stackTrace = null)
{
var errorResponse = new
diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs
index aca4a1994..c4e09a249 100644
--- a/MCPForUnity/Editor/Tools/BatchExecute.cs
+++ b/MCPForUnity/Editor/Tools/BatchExecute.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
@@ -51,6 +52,14 @@ public static async Task