unity-mcp/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs

315 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
{
/// <summary>
/// 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.
/// </summary>
internal static class TransportCommandDispatcher
{
private sealed class PendingCommand
{
public PendingCommand(
string commandJson,
TaskCompletionSource<string> completionSource,
CancellationToken cancellationToken,
CancellationTokenRegistration registration)
{
CommandJson = commandJson;
CompletionSource = completionSource;
CancellationToken = cancellationToken;
CancellationRegistration = registration;
}
public string CommandJson { get; }
public TaskCompletionSource<string> 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<string, PendingCommand> Pending = new();
private static readonly object PendingLock = new();
private static bool updateHooked;
private static bool initialised;
/// <summary>
/// Schedule a command for execution on the Unity main thread and await its JSON response.
/// </summary>
public static Task<string> 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<string>(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<Command>(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;
}
}
}