315 lines
10 KiB
C#
315 lines
10 KiB
C#
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|