using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; namespace MCPForUnity.Editor.Services.Transport { /// /// Centralised command execution pipeline shared by all transport implementations. /// Guarantees that MCP commands are executed on the Unity main thread while preserving /// the legacy response format expected by the server. /// internal static class TransportCommandDispatcher { private sealed class PendingCommand { public PendingCommand( string commandJson, TaskCompletionSource completionSource, CancellationToken cancellationToken, CancellationTokenRegistration registration) { CommandJson = commandJson; CompletionSource = completionSource; CancellationToken = cancellationToken; CancellationRegistration = registration; } public string CommandJson { get; } public TaskCompletionSource CompletionSource { get; } public CancellationToken CancellationToken { get; } public CancellationTokenRegistration CancellationRegistration { get; } public bool IsExecuting { get; set; } public void Dispose() { CancellationRegistration.Dispose(); } public void TrySetResult(string payload) { CompletionSource.TrySetResult(payload); } public void TrySetCanceled() { CompletionSource.TrySetCanceled(CancellationToken); } } private static readonly Dictionary Pending = new(); private static readonly object PendingLock = new(); private static bool updateHooked; private static bool initialised; /// /// Schedule a command for execution on the Unity main thread and await its JSON response. /// public static Task ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken) { if (commandJson is null) { throw new ArgumentNullException(nameof(commandJson)); } EnsureInitialised(); var id = Guid.NewGuid().ToString("N"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var registration = cancellationToken.CanBeCanceled ? cancellationToken.Register(() => CancelPending(id, cancellationToken)) : default; var pending = new PendingCommand(commandJson, tcs, cancellationToken, registration); lock (PendingLock) { Pending[id] = pending; HookUpdate(); } return tcs.Task; } private static void EnsureInitialised() { if (initialised) { return; } CommandRegistry.Initialize(); initialised = true; } private static void HookUpdate() { if (updateHooked) { return; } updateHooked = true; EditorApplication.update += ProcessQueue; } private static void UnhookUpdateIfIdle() { if (Pending.Count > 0 || !updateHooked) { return; } updateHooked = false; EditorApplication.update -= ProcessQueue; } private static void ProcessQueue() { List<(string id, PendingCommand pending)> ready; lock (PendingLock) { ready = new List<(string, PendingCommand)>(Pending.Count); foreach (var kvp in Pending) { if (kvp.Value.IsExecuting) { continue; } kvp.Value.IsExecuting = true; ready.Add((kvp.Key, kvp.Value)); } if (ready.Count == 0) { UnhookUpdateIfIdle(); return; } } foreach (var (id, pending) in ready) { ProcessCommand(id, pending); } } private static void ProcessCommand(string id, PendingCommand pending) { if (pending.CancellationToken.IsCancellationRequested) { RemovePending(id, pending); pending.TrySetCanceled(); return; } string commandText = pending.CommandJson?.Trim(); if (string.IsNullOrEmpty(commandText)) { pending.TrySetResult(SerializeError("Empty command received")); RemovePending(id, pending); return; } if (string.Equals(commandText, "ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = new { status = "success", result = new { message = "pong" } }; pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); RemovePending(id, pending); return; } if (!IsValidJson(commandText)) { var invalidJsonResponse = new { status = "error", error = "Invalid JSON format", receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText }; pending.TrySetResult(JsonConvert.SerializeObject(invalidJsonResponse)); RemovePending(id, pending); return; } try { var command = JsonConvert.DeserializeObject(commandText); if (command == null) { pending.TrySetResult(SerializeError("Command deserialized to null", "Unknown", commandText)); RemovePending(id, pending); return; } if (string.IsNullOrWhiteSpace(command.type)) { pending.TrySetResult(SerializeError("Command type cannot be empty")); RemovePending(id, pending); return; } if (string.Equals(command.type, "ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = new { status = "success", result = new { message = "pong" } }; pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); RemovePending(id, pending); return; } var parameters = command.@params ?? new JObject(); var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource); if (result == null) { // Async command – cleanup after completion on next editor frame to preserve order. pending.CompletionSource.Task.ContinueWith(_ => { EditorApplication.delayCall += () => RemovePending(id, pending); }, TaskScheduler.Default); return; } var response = new { status = "success", result }; pending.TrySetResult(JsonConvert.SerializeObject(response)); RemovePending(id, pending); } catch (Exception ex) { McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); pending.TrySetResult(SerializeError(ex.Message, "Unknown (error during processing)", ex.StackTrace)); RemovePending(id, pending); } } private static void CancelPending(string id, CancellationToken token) { PendingCommand pending = null; lock (PendingLock) { if (Pending.Remove(id, out pending)) { UnhookUpdateIfIdle(); } } pending?.TrySetCanceled(); pending?.Dispose(); } private static void RemovePending(string id, PendingCommand pending) { lock (PendingLock) { Pending.Remove(id); UnhookUpdateIfIdle(); } pending.Dispose(); } private static string SerializeError(string message, string commandType = null, string stackTrace = null) { var errorResponse = new { status = "error", error = message, command = commandType ?? "Unknown", stackTrace }; return JsonConvert.SerializeObject(errorResponse); } private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) { return false; } text = text.Trim(); if ((text.StartsWith("{") && text.EndsWith("}")) || (text.StartsWith("[") && text.EndsWith("]"))) { try { JToken.Parse(text); return true; } catch { return false; } } return false; } } }