Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6c8d6cf
feat(tools): add ExecutionTier enum and annotate built-in tools
Lint111 Feb 25, 2026
2891b10
feat(tools): add CommandClassifier for action-level tier overrides
Lint111 Feb 25, 2026
3b8ee93
feat(tools): add BatchJob model and TicketStore for job lifecycle
Lint111 Feb 25, 2026
e9933f6
feat(tools): add tier-aware CommandQueue and gateway state
Lint111 Feb 25, 2026
e7e3e56
feat(tools): extend BatchExecute with async gateway path
Lint111 Feb 25, 2026
13e2dca
feat(tools): add PollJob and QueueStatus tools
Lint111 Feb 25, 2026
09d40c9
test(tools): add unit tests for command gateway components
Lint111 Feb 25, 2026
504bdcf
fix(tools): add missing using directive for IMcpResponse in CommandQueue
Lint111 Feb 25, 2026
15cd6c8
chore(tools): add Unity meta files for command gateway scripts
Lint111 Feb 25, 2026
255ef84
docs: add gateway async-aware blocking design
Lint111 Feb 25, 2026
7d93dcc
docs: add gateway async-awareness implementation plan
Lint111 Feb 25, 2026
fd83cfa
feat(tools): add CausesDomainReload to CommandClassifier
Lint111 Feb 25, 2026
8d66bbc
feat(tools): add CausesDomainReload property to BatchCommand and Batc…
Lint111 Feb 25, 2026
6079cc5
feat(tools): propagate CausesDomainReload through queue submission
Lint111 Feb 25, 2026
4215cd2
feat(tools): add domain-reload guard to ProcessTick
Lint111 Feb 25, 2026
d08a017
feat(tools): add blocked_by reason to poll_job response
Lint111 Feb 25, 2026
a4842ff
feat(tools): persist gateway queue state across domain reloads
Lint111 Feb 25, 2026
8d9fcae
fix(gateway): hold heavy slot while editor busy from async side-effects
Lint111 Feb 25, 2026
db95bd3
feat(gateway): add compressed queue_status tool for batch status checks
Lint111 Feb 25, 2026
10cee37
docs: add queue summary UI design doc
Lint111 Feb 25, 2026
9bc09a7
docs: add queue summary UI implementation plan
Lint111 Feb 25, 2026
c652036
feat(gateway): expose GetAllJobs on CommandQueue for UI consumption
Lint111 Feb 25, 2026
9f9cae2
feat(gateway-ui): add McpQueueSection UXML layout
Lint111 Feb 25, 2026
6805bb6
feat(gateway-ui): add McpQueueSection USS styles
Lint111 Feb 25, 2026
c22369e
feat(gateway-ui): add McpQueueSection controller with status bar and …
Lint111 Feb 25, 2026
a87cc73
feat(gateway-ui): wire Queue tab into MCP editor window with 1s auto-…
Lint111 Feb 25, 2026
12f297e
fix(gateway-ui): add Queue UXML/USS to UIAssetSync for WSL compatibility
Lint111 Feb 25, 2026
7ff09e1
feat(gateway): auto-cleanup terminal jobs on poll with optional logging
Lint111 Feb 25, 2026
bed28a3
chore(gateway): stage meta files and WSL asset path workaround
Lint111 Feb 25, 2026
5e5cae7
feat(gateway): add logging settings box to Queue tab
Lint111 Feb 25, 2026
aac1d8f
fix(gateway): keep heavy jobs Running while async side-effects active
Lint111 Feb 25, 2026
3c70b86
fix(gateway): restore domain-reload jobs as Done after successful reload
Lint111 Feb 25, 2026
ff87441
Update docs/plans/2026-02-25-queue-summary-ui-design.md
Lint111 Feb 25, 2026
1ee283a
Remove executable bit from repo scripts
Lint111 Mar 6, 2026
ca2fe53
fix: add queue watchdog and reduce stale job threshold to prevent Uni…
Lint111 Mar 14, 2026
d6220a7
feat(gateway): add SubmitSingle() convenience method to CommandQueue
Lint111 Mar 14, 2026
f8ecc01
feat(gateway): add AwaitJob() to bridge queue completion to Task<object>
Lint111 Mar 14, 2026
895307f
feat(gateway): route Smooth/Heavy transport commands through CommandQ…
Lint111 Mar 14, 2026
7ee0bc8
fix(sdf-graph): fix Export reflection for optional FlowDirection param
Lint111 Mar 14, 2026
86bd1a6
feat(gateway): add command deduplication to prevent queue flooding
Lint111 Mar 16, 2026
6151280
Merge remote-tracking branch 'origin/beta' into feature/command-gateway
Lint111 Mar 16, 2026
f1d6e4b
Merge remote-tracking branch 'fork/feature/command-gateway' into feat…
Lint111 Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
8 changes: 8 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
121 changes: 121 additions & 0 deletions MCPForUnity/Editor/Helpers/UIAssetSync.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using UnityEditor;
using UnityEngine;
using System.IO;

namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Automatically copies UXML and USS files from WSL package directories to a local
/// <c>Assets/MCPForUnityUI/</c> folder on every domain reload, preserving directory structure.
/// </summary>
/// <remarks>
/// <para>
/// <b>Problem:</b> Unity's UXML/USS importer on Windows cannot properly parse files
/// when packages live on a WSL2 filesystem (UNC paths like <c>\\wsl$\...</c>). The
/// VisualTreeAsset loads but CloneTree produces an empty tree.
/// </para>
/// <para>
/// <b>Solution:</b> On startup, this class copies all UI asset files to
/// <c>Assets/MCPForUnityUI/</c> and <see cref="AssetPathUtility.GetMcpPackageRootPath"/>
/// returns this fallback path when WSL is detected.
/// </para>
/// </remarks>
[InitializeOnLoad]
static class UIAssetSync
{
/// <summary>Destination folder under the Unity project for synced UI assets.</summary>
internal const string SyncedBasePath = "Assets/MCPForUnityUI";

/// <summary>
/// Relative paths from package root to UXML and USS files that need syncing.
/// </summary>
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();
}

/// <summary>
/// Returns true when the MCP package lives on a WSL UNC path and Unity runs on Windows.
/// </summary>
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);
}

/// <summary>
/// Gets the physical (filesystem) root path of the MCP package.
/// </summary>
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;
}
}
}
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MCPForUnity.Editor.Setup;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Windows;
using UnityEditor;
using UnityEngine;
Expand Down Expand Up @@ -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();
}

/// <summary>
/// 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)
/// </summary>
[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.");
}
}
}
2 changes: 1 addition & 1 deletion MCPForUnity/Editor/Services/TestJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down
113 changes: 111 additions & 2 deletions MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ public void TrySetCanceled()
}

private static readonly Dictionary<string, PendingCommand> Pending = new();
/// <summary>
/// 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.
/// </summary>
private static readonly Dictionary<string, string> ContentHashToPendingId = new();
private static readonly object PendingLock = new();
private static bool updateHooked;
private static bool initialised;
Expand Down Expand Up @@ -96,6 +102,22 @@ public static Task<string> 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<string>(TaskCreationOptions.RunContinuationsAsynchronously);

Expand All @@ -108,6 +130,8 @@ public static Task<string> 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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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)
Expand All @@ -431,6 +503,7 @@ private static void CancelPending(string id, CancellationToken token)
{
if (Pending.Remove(id, out pending))
{
CleanContentHash(id);
UnhookUpdateIfIdle();
}
}
Expand All @@ -444,12 +517,48 @@ private static void RemovePending(string id, PendingCommand pending)
lock (PendingLock)
{
Pending.Remove(id);
CleanContentHash(id);
UnhookUpdateIfIdle();
}

pending.Dispose();
}

/// <summary>
/// Remove the content hash entry that points to the given pending ID.
/// Must be called under PendingLock.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
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
Expand Down
Loading