-
Notifications
You must be signed in to change notification settings - Fork 953
feat: add command gateway for multi-agent concurrent access #826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
6c8d6cf
2891b10
3b8ee93
e9933f6
e7e3e56
13e2dca
09d40c9
504bdcf
15cd6c8
255ef84
7d93dcc
fd83cfa
8d66bbc
6079cc5
4215cd2
d08a017
a4842ff
8d9fcae
db95bd3
10cee37
9bc09a7
c652036
9f9cae2
6805bb6
c22369e
a87cc73
12f297e
7ff09e1
bed28a3
5e5cae7
aac1d8f
3c70b86
ff87441
1ee283a
ca2fe53
d6220a7
f8ecc01
895307f
7ee0bc8
86bd1a6
6151280
f1d6e4b
95b8981
940831f
c0f2802
8cd9468
996e36a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)); | ||
Lint111 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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; | ||
| } | ||
Lint111 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (anyUpdated) | ||
| AssetDatabase.Refresh(); | ||
| } | ||
Lint111 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <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; | ||
| } | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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; | ||
|
|
@@ -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."); | ||
| } | ||
|
Comment on lines
+36
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: Shortcut documentation mismatch. The XML comment says 📝 Suggested fix /// <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)
+ /// Shortcut: Ctrl+Shift+Alt+F5 (Cmd+Shift+Alt+F5 on Mac)
/// </summary>🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,7 +63,20 @@ 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(); | ||
| /// <summary> | ||
| /// Maximum age of a pending command that is still eligible for dedup matching. | ||
| /// If the original command hangs (stuck gateway job, lost transport, etc.) waiters | ||
| /// must not pile up indefinitely on a dead TaskCompletionSource — after this | ||
| /// window, new identical commands create a fresh pending entry instead. | ||
| /// </summary> | ||
| private static readonly TimeSpan DedupTtl = TimeSpan.FromSeconds(60); | ||
| private static bool updateHooked; | ||
| private static bool initialised; | ||
|
|
||
|
|
@@ -96,6 +109,50 @@ 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 | ||
| && (DateTime.UtcNow - existingPending.QueuedAt) < DedupTtl) | ||
| { | ||
| McpLog.Info($"[Dispatcher] Dedup: identical command already pending (id={existingId}). Sharing result."); | ||
| // Propagate the NEW caller's cancellation into a linked waiter so each dedup | ||
| // waiter can bail independently. Without this, a cancelled caller still blocks | ||
| // on the original command's TCS — which is the root cause of stall-under-load. | ||
| if (cancellationToken.CanBeCanceled) | ||
| { | ||
| var waiterTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| var waiterReg = cancellationToken.Register(() => waiterTcs.TrySetCanceled(cancellationToken)); | ||
| existingPending.CompletionSource.Task.ContinueWith(t => | ||
| { | ||
| waiterReg.Dispose(); | ||
| if (t.IsCanceled) waiterTcs.TrySetCanceled(); | ||
| else if (t.IsFaulted) waiterTcs.TrySetException(t.Exception!.InnerExceptions); | ||
| else waiterTcs.TrySetResult(t.Result); | ||
| }, TaskScheduler.Default); | ||
| return waiterTcs.Task; | ||
| } | ||
| return existingPending.CompletionSource.Task; | ||
| } | ||
| // Stale dedup entry (TTL exceeded or cancelled original): drop the mapping | ||
| // so the code below creates a fresh PendingCommand instead of piling onto | ||
| // a dead TCS. | ||
| if (contentHash != null | ||
| && ContentHashToPendingId.TryGetValue(contentHash, out var staleId) | ||
| && Pending.TryGetValue(staleId, out var stalePending) | ||
| && (stalePending.CancellationToken.IsCancellationRequested | ||
| || (DateTime.UtcNow - stalePending.QueuedAt) >= DedupTtl)) | ||
| { | ||
| McpLog.Warn($"[Dispatcher] Dedup entry stale for id={staleId} (age={(DateTime.UtcNow - stalePending.QueuedAt).TotalSeconds:F0}s). Releasing hash for fresh dispatch."); | ||
| ContentHashToPendingId.Remove(contentHash); | ||
| } | ||
| } | ||
|
|
||
| var id = Guid.NewGuid().ToString("N"); | ||
| var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
|
|
@@ -108,6 +165,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 | ||
|
|
@@ -362,6 +421,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; | ||
| } | ||
|
Comment on lines
+425
to
+469
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if AwaitJob has any CancellationToken integration
rg -nC5 'AwaitJob' MCPForUnity/Editor/Tools/CommandGatewayState.csRepository: CoplayDev/unity-mcp Length of output: 617 🏁 Script executed: cat -n MCPForUnity/Editor/Tools/CommandGatewayState.cs | sed -n '61,120p'Repository: CoplayDev/unity-mcp Length of output: 2250 🏁 Script executed: rg -n 'CancellationToken' MCPForUnity/Editor/Tools/CommandGatewayState.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'pending.CancellationToken\|job.Cts' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -nB10 'AwaitGateway\(\)' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | head -50Repository: CoplayDev/unity-mcp Length of output: 1424 🏁 Script executed: rg -n 'var pending\|TaskCompletionSource' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -nB30 'var job = CommandGatewayState.Queue.SubmitSingle' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | head -80Repository: CoplayDev/unity-mcp Length of output: 1939 🏁 Script executed: rg -n 'TaskCompletionSource\|pending =' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -nB50 'var resourceMeta = MCPServiceLocator.ResourceDiscovery' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | head -100Repository: CoplayDev/unity-mcp Length of output: 2284 🏁 Script executed: cat -n MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | sed -n '350,410p'Repository: CoplayDev/unity-mcp Length of output: 2992 🏁 Script executed: cat -n MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | sed -n '300,360p'Repository: CoplayDev/unity-mcp Length of output: 2368 🏁 Script executed: rg -n 'public.*void.*Dispatch\|private.*void.*Dispatch\|async.*Dispatch' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'class PendingCommand\|record PendingCommand\|struct PendingCommand' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: cat -n MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs | sed -n '1,100p'Repository: CoplayDev/unity-mcp Length of output: 4726 🏁 Script executed: rg -n 'job.Cts\|\.Cts' MCPForUnity/Editor/Tools/CommandGatewayState.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'SubmitSingle' MCPForUnity/Editor/Tools/CommandGatewayQueue.cs -A 15Repository: CoplayDev/unity-mcp Length of output: 150 🏁 Script executed: fd CommandGatewayQueue MCPForUnity/Repository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -l 'SubmitSingle' MCPForUnity/Repository: CoplayDev/unity-mcp Length of output: 171 🏁 Script executed: rg -n 'class.*BatchJob\|record.*BatchJob\|struct.*BatchJob' MCPForUnity/Repository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: cat -n MCPForUnity/Editor/Tools/CommandQueue.cs | head -150Repository: CoplayDev/unity-mcp Length of output: 7750 🏁 Script executed: rg -n 'SubmitSingle' MCPForUnity/Editor/Tools/CommandQueue.cs -A 20Repository: CoplayDev/unity-mcp Length of output: 1449 🏁 Script executed: rg -n 'class.*Job\|record.*Job\|struct.*Job' MCPForUnity/Editor/Tools/CommandQueue.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'class BatchJob' MCPForUnity/Repository: CoplayDev/unity-mcp Length of output: 200 🏁 Script executed: cat -n MCPForUnity/Editor/Tools/CommandQueue.cs | sed -n '150,200p'Repository: CoplayDev/unity-mcp Length of output: 2063 🏁 Script executed: cat -n MCPForUnity/Editor/Tools/BatchJob.csRepository: CoplayDev/unity-mcp Length of output: 2515 🏁 Script executed: rg -n 'job.Cts' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'CancellationRegistration\|registration =' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs -B 5 -A 5Repository: CoplayDev/unity-mcp Length of output: 45 🏁 Script executed: rg -n 'new PendingCommand' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs -B 10 -A 5Repository: CoplayDev/unity-mcp Length of output: 722 🏁 Script executed: rg -n 'public static.*Dispatch\|DispatchAsync' MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.csRepository: CoplayDev/unity-mcp Length of output: 45
The early cancellation check (line 332) prevents issues for synchronous command validation, but once The comment at line 432 claiming "CancellationToken support" is misleading. Either pass 🤖 Prompt for AI Agents |
||
|
|
||
| // --- 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 +519,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 +538,7 @@ private static void CancelPending(string id, CancellationToken token) | |
| { | ||
| if (Pending.Remove(id, out pending)) | ||
| { | ||
| CleanContentHash(id); | ||
| UnhookUpdateIfIdle(); | ||
| } | ||
| } | ||
|
|
@@ -444,12 +552,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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.