Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions MCPForUnity/Editor/Tools/BatchExecute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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);
invocationSuccessCount++;

commandResults.Add(new
{
tool = toolName,
callSucceeded = true,
result
});
}
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);
}

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();
}
}
}
11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Tools/BatchExecute.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion MCPForUnity/Editor/Tools/ManageAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ private static object ReimportAsset(string path, JObject properties)
private static object CreateAsset(JObject @params)
{
string path = @params["path"]?.ToString();
string assetType = @params["assetType"]?.ToString();
string assetType =
@params["assetType"]?.ToString()
?? @params["asset_type"]?.ToString(); // tolerate snake_case payloads from batched commands
JObject properties = @params["properties"] as JObject;

if (string.IsNullOrEmpty(path))
Expand Down
6 changes: 3 additions & 3 deletions Server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.