Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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 @@ -66,5 +66,7 @@ internal static class EditorPrefKeys
internal const string ApiKey = "MCPForUnity.ApiKey";

internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands";
internal const string GatewayJobLogging = "MCPForUnity.Gateway.JobLogging";
internal const string GatewayJobLogPath = "MCPForUnity.Gateway.JobLogPath";
}
}
8 changes: 8 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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.

88 changes: 88 additions & 0 deletions MCPForUnity/Editor/Tools/BatchExecute.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -51,6 +52,14 @@ public static async Task<object> HandleCommand(JObject @params)
$"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch}).");
}

// --- Async gateway path ---
bool isAsync = @params.Value<bool?>("async") ?? false;
if (isAsync)
{
return HandleAsyncSubmit(@params, commandsToken);
}

// --- Legacy synchronous path (unchanged) ---
bool failFast = @params.Value<bool?>("failFast") ?? false;
bool parallelRequested = @params.Value<bool?>("parallel") ?? false;
int? maxParallel = @params.Value<int?>("maxParallelism");
Expand Down Expand Up @@ -231,5 +240,84 @@ private static JObject NormalizeParameterKeys(JObject source)
}

private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key);

/// <summary>
/// Handle async batch submission. Queues commands via CommandGateway and returns
/// a ticket (for non-instant batches) or results inline (for instant batches).
/// </summary>
private static object HandleAsyncSubmit(JObject @params, JArray commandsToken)
{
bool atomic = @params.Value<bool?>("atomic") ?? false;
string agent = @params.Value<string>("agent") ?? "anonymous";
string label = @params.Value<string>("label") ?? "";

var commands = new List<BatchCommand>();
foreach (var token in commandsToken)
{
if (token is not JObject cmdObj) continue;
string toolName = cmdObj["tool"]?.ToString();
if (string.IsNullOrWhiteSpace(toolName)) continue;

var rawParams = cmdObj["params"] as JObject ?? new JObject();
var cmdParams = NormalizeParameterKeys(rawParams);

var toolTier = CommandRegistry.GetToolTier(toolName);
var effectiveTier = CommandClassifier.Classify(toolName, toolTier, cmdParams);

commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier, CausesDomainReload = CommandClassifier.CausesDomainReload(toolName, cmdParams) });
}

if (commands.Count == 0)
{
return new ErrorResponse("No valid commands in async batch.");
}

var job = CommandGatewayState.Queue.Submit(agent, label, atomic, commands);

if (job.Tier == ExecutionTier.Instant)
{
// Execute inline, return results directly
foreach (var cmd in commands)
{
try
{
var result = CommandRegistry.InvokeCommandAsync(cmd.Tool, cmd.Params)
.ConfigureAwait(true).GetAwaiter().GetResult();
job.Results.Add(result);
}
catch (Exception ex)
{
job.Results.Add(new ErrorResponse(ex.Message));
if (atomic)
{
job.Status = JobStatus.Failed;
job.Error = ex.Message;
job.CompletedAt = DateTime.UtcNow;
return new ErrorResponse($"Instant batch failed at command '{cmd.Tool}': {ex.Message}",
new { ticket = job.Ticket, results = job.Results });
}
}
}
job.Status = JobStatus.Done;
job.CompletedAt = DateTime.UtcNow;
return new SuccessResponse("Batch completed (instant).",
new { ticket = job.Ticket, results = job.Results });
}

// Non-instant: return ticket for polling
return new PendingResponse(
$"Batch queued as {job.Ticket}. Poll with poll_job.",
pollIntervalSeconds: 2.0,
data: new
{
ticket = job.Ticket,
status = job.Status.ToString().ToLowerInvariant(),
position = CommandGatewayState.Queue.GetAheadOf(job.Ticket).Count,
tier = job.Tier.ToString().ToLowerInvariant(),
agent,
label,
atomic
});
}
}
}
47 changes: 47 additions & 0 deletions MCPForUnity/Editor/Tools/BatchJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;

namespace MCPForUnity.Editor.Tools
{
public enum JobStatus { Queued, Running, Done, Failed, Cancelled }

/// <summary>
/// Represents a queued batch of MCP commands with ticket tracking.
/// </summary>
public class BatchJob
{
public string Ticket { get; set; }
public string Agent { get; set; }
public string Label { get; set; }
public bool Atomic { get; set; }
public ExecutionTier Tier { get; set; }
public bool CausesDomainReload { get; set; }
public JobStatus Status { get; set; } = JobStatus.Queued;

public List<BatchCommand> Commands { get; set; } = new();
public List<object> Results { get; set; } = new();
public int CurrentIndex { get; set; }
public string Error { get; set; }

public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }

public int UndoGroup { get; set; } = -1;
}

public class BatchCommand
{
public string Tool { get; set; }
public JObject Params { get; set; }
public ExecutionTier Tier { get; set; }
public bool CausesDomainReload { get; set; }
}

public class AgentStats
{
public int Active { get; set; }
public int Queued { get; set; }
public int Completed { get; set; }
}
}
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Tools/BatchJob.cs.meta

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

89 changes: 89 additions & 0 deletions MCPForUnity/Editor/Tools/CommandClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Newtonsoft.Json.Linq;

namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Refines a tool's ExecutionTier based on action-level parameters.
/// Tools declare a base tier via [McpForUnityTool(Tier=...)]; this classifier
/// can promote or demote based on specific action strings or param values.
/// </summary>
public static class CommandClassifier
{
/// <summary>
/// Classify a single command. Returns the effective tier after action-level overrides.
/// </summary>
public static ExecutionTier Classify(string toolName, ExecutionTier attributeTier, JObject @params)
{
if (@params == null) return attributeTier;

string action = @params.Value<string>("action");

return toolName switch
{
"manage_scene" => ClassifyManageScene(action, attributeTier),
"refresh_unity" => ClassifyRefreshUnity(@params, attributeTier),
"manage_editor" => ClassifyManageEditor(action, attributeTier),
_ => attributeTier
};
}

/// <summary>
/// Classify a batch of commands. Returns the highest (most restrictive) tier.
/// </summary>
public static ExecutionTier ClassifyBatch(
(string toolName, ExecutionTier attributeTier, JObject @params)[] commands)
{
var max = ExecutionTier.Instant;
foreach (var (toolName, attributeTier, @params) in commands)
{
var tier = Classify(toolName, attributeTier, @params);
if (tier > max) max = tier;
}
return max;
}

/// <summary>
/// Returns true if the given command would trigger a domain reload (compilation or play mode entry).
/// </summary>
public static bool CausesDomainReload(string toolName, JObject @params)
{
if (@params == null) return false;

return toolName switch
{
"refresh_unity" => @params.Value<string>("compile") != "none",
"manage_editor" => @params.Value<string>("action") == "play",
_ => false
};
}

static ExecutionTier ClassifyManageScene(string action, ExecutionTier fallback)
{
return action switch
{
"get_hierarchy" or "get_active" or "get_build_settings" or "screenshot"
=> ExecutionTier.Instant,
"create" or "load" or "save"
=> ExecutionTier.Heavy,
_ => fallback
};
}

static ExecutionTier ClassifyRefreshUnity(JObject @params, ExecutionTier fallback)
{
string compile = @params.Value<string>("compile");
if (compile == "none") return ExecutionTier.Smooth;
return fallback; // Heavy by default
}

static ExecutionTier ClassifyManageEditor(string action, ExecutionTier fallback)
{
return action switch
{
"telemetry_status" or "telemetry_ping" => ExecutionTier.Instant,
"play" or "pause" or "stop" => ExecutionTier.Heavy,
_ => fallback
};
}
}
}
Loading