2025-12-08 08:36:44 +08:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using MCPForUnity.Editor.Helpers;
|
|
|
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
|
|
|
|
|
|
namespace MCPForUnity.Editor.Tools
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Executes multiple MCP commands within a single Unity-side handler. Commands are executed sequentially
|
|
|
|
|
/// on the main thread to preserve determinism and Unity API safety.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[McpForUnityTool("batch_execute", AutoRegister = false)]
|
|
|
|
|
public static class BatchExecute
|
|
|
|
|
{
|
|
|
|
|
private const int MaxCommandsPerBatch = 25;
|
|
|
|
|
|
|
|
|
|
public static async Task<object> HandleCommand(JObject @params)
|
|
|
|
|
{
|
|
|
|
|
if (@params == null)
|
|
|
|
|
{
|
|
|
|
|
return new ErrorResponse("'commands' payload is required.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var commandsToken = @params["commands"] as JArray;
|
|
|
|
|
if (commandsToken == null || commandsToken.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return new ErrorResponse("Provide at least one command entry in 'commands'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandsToken.Count > MaxCommandsPerBatch)
|
|
|
|
|
{
|
|
|
|
|
return new ErrorResponse($"A maximum of {MaxCommandsPerBatch} commands are allowed per batch.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool failFast = @params.Value<bool?>("failFast") ?? false;
|
|
|
|
|
bool parallelRequested = @params.Value<bool?>("parallel") ?? false;
|
|
|
|
|
int? maxParallel = @params.Value<int?>("maxParallelism");
|
|
|
|
|
|
|
|
|
|
if (parallelRequested)
|
|
|
|
|
{
|
|
|
|
|
McpLog.Warn("batch_execute parallel mode requested, but commands will run sequentially on the main thread for safety.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var commandResults = new List<object>(commandsToken.Count);
|
|
|
|
|
int invocationSuccessCount = 0;
|
|
|
|
|
int invocationFailureCount = 0;
|
|
|
|
|
bool anyCommandFailed = false;
|
|
|
|
|
|
|
|
|
|
foreach (var token in commandsToken)
|
|
|
|
|
{
|
|
|
|
|
if (token is not JObject commandObj)
|
|
|
|
|
{
|
|
|
|
|
invocationFailureCount++;
|
|
|
|
|
anyCommandFailed = true;
|
|
|
|
|
commandResults.Add(new
|
|
|
|
|
{
|
|
|
|
|
tool = (string)null,
|
|
|
|
|
callSucceeded = false,
|
|
|
|
|
error = "Command entries must be JSON objects."
|
|
|
|
|
});
|
|
|
|
|
if (failFast)
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
string toolName = commandObj["tool"]?.ToString();
|
|
|
|
|
var rawParams = commandObj["params"] as JObject ?? new JObject();
|
|
|
|
|
var commandParams = NormalizeParameterKeys(rawParams);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(toolName))
|
|
|
|
|
{
|
|
|
|
|
invocationFailureCount++;
|
|
|
|
|
anyCommandFailed = true;
|
|
|
|
|
commandResults.Add(new
|
|
|
|
|
{
|
|
|
|
|
tool = toolName,
|
|
|
|
|
callSucceeded = false,
|
|
|
|
|
error = "Each command must include a non-empty 'tool' field."
|
|
|
|
|
});
|
|
|
|
|
if (failFast)
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var result = await CommandRegistry.InvokeCommandAsync(toolName, commandParams).ConfigureAwait(true);
|
2026-01-08 13:44:32 +08:00
|
|
|
bool callSucceeded = DetermineCallSucceeded(result);
|
|
|
|
|
if (callSucceeded)
|
|
|
|
|
{
|
|
|
|
|
invocationSuccessCount++;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
invocationFailureCount++;
|
|
|
|
|
anyCommandFailed = true;
|
|
|
|
|
}
|
2025-12-08 08:36:44 +08:00
|
|
|
|
|
|
|
|
commandResults.Add(new
|
|
|
|
|
{
|
|
|
|
|
tool = toolName,
|
2026-01-08 13:44:32 +08:00
|
|
|
callSucceeded,
|
2025-12-08 08:36:44 +08:00
|
|
|
result
|
|
|
|
|
});
|
2026-01-08 13:44:32 +08:00
|
|
|
|
|
|
|
|
if (!callSucceeded && failFast)
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
2025-12-08 08:36:44 +08:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
invocationFailureCount++;
|
|
|
|
|
anyCommandFailed = true;
|
|
|
|
|
commandResults.Add(new
|
|
|
|
|
{
|
|
|
|
|
tool = toolName,
|
|
|
|
|
callSucceeded = false,
|
|
|
|
|
error = ex.Message
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (failFast)
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool overallSuccess = !anyCommandFailed;
|
|
|
|
|
var data = new
|
|
|
|
|
{
|
|
|
|
|
results = commandResults,
|
|
|
|
|
callSuccessCount = invocationSuccessCount,
|
|
|
|
|
callFailureCount = invocationFailureCount,
|
|
|
|
|
parallelRequested,
|
|
|
|
|
parallelApplied = false,
|
|
|
|
|
maxParallelism = maxParallel
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return overallSuccess
|
|
|
|
|
? new SuccessResponse("Batch execution completed.", data)
|
|
|
|
|
: new ErrorResponse("One or more commands failed.", data);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 13:44:32 +08:00
|
|
|
private static bool DetermineCallSucceeded(object result)
|
|
|
|
|
{
|
|
|
|
|
if (result == null)
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result is IMcpResponse response)
|
|
|
|
|
{
|
|
|
|
|
return response.Success;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result is JObject obj)
|
|
|
|
|
{
|
|
|
|
|
var successToken = obj["success"];
|
|
|
|
|
if (successToken != null && successToken.Type == JTokenType.Boolean)
|
|
|
|
|
{
|
|
|
|
|
return successToken.Value<bool>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result is JToken token)
|
|
|
|
|
{
|
|
|
|
|
var successToken = token["success"];
|
|
|
|
|
if (successToken != null && successToken.Type == JTokenType.Boolean)
|
|
|
|
|
{
|
|
|
|
|
return successToken.Value<bool>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 08:36:44 +08:00
|
|
|
private static JObject NormalizeParameterKeys(JObject source)
|
|
|
|
|
{
|
|
|
|
|
if (source == null)
|
|
|
|
|
{
|
|
|
|
|
return new JObject();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var normalized = new JObject();
|
|
|
|
|
foreach (var property in source.Properties())
|
|
|
|
|
{
|
|
|
|
|
string normalizedName = ToCamelCase(property.Name);
|
|
|
|
|
normalized[normalizedName] = NormalizeToken(property.Value);
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static JArray NormalizeArray(JArray source)
|
|
|
|
|
{
|
|
|
|
|
var normalized = new JArray();
|
|
|
|
|
foreach (var token in source)
|
|
|
|
|
{
|
|
|
|
|
normalized.Add(NormalizeToken(token));
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static JToken NormalizeToken(JToken token)
|
|
|
|
|
{
|
|
|
|
|
return token switch
|
|
|
|
|
{
|
|
|
|
|
JObject obj => NormalizeParameterKeys(obj),
|
|
|
|
|
JArray arr => NormalizeArray(arr),
|
|
|
|
|
_ => token.DeepClone()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string ToCamelCase(string key)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(key) || key.IndexOf('_') < 0)
|
|
|
|
|
{
|
|
|
|
|
return key;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var parts = key.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (parts.Length == 0)
|
|
|
|
|
{
|
|
|
|
|
return key;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var builder = new StringBuilder(parts[0]);
|
|
|
|
|
for (int i = 1; i < parts.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
var part = parts[i];
|
|
|
|
|
if (string.IsNullOrEmpty(part))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
builder.Append(char.ToUpperInvariant(part[0]));
|
|
|
|
|
if (part.Length > 1)
|
|
|
|
|
{
|
|
|
|
|
builder.Append(part.AsSpan(1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return builder.ToString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|