From 318c824e1b78ca74701a1721a5a94f5dc567035f Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Tue, 2 Dec 2025 21:05:06 -0500
Subject: [PATCH] Update on Batch
Tested object generation/modification with batch and it works perfectly! We should push and let users test for a while and see
PS: I tried both VS Copilot and Claude Desktop. Claude Desktop works but VS Copilot does not due to the nested structure of batch. Will look into it more.
---
MCPForUnity/Editor/Tools/BatchExecute.cs | 205 ++++++++++++++++++
MCPForUnity/Editor/Tools/BatchExecute.cs.meta | 11 +
MCPForUnity/Editor/Tools/ManageAsset.cs | 4 +-
Server/uv.lock | 6 +-
4 files changed, 222 insertions(+), 4 deletions(-)
create mode 100644 MCPForUnity/Editor/Tools/BatchExecute.cs
create mode 100644 MCPForUnity/Editor/Tools/BatchExecute.cs.meta
diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs
new file mode 100644
index 000000000..fa46dd312
--- /dev/null
+++ b/MCPForUnity/Editor/Tools/BatchExecute.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ [McpForUnityTool("batch_execute", AutoRegister = false)]
+ public static class BatchExecute
+ {
+ private const int MaxCommandsPerBatch = 25;
+
+ public static async Task 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("failFast") ?? false;
+ bool parallelRequested = @params.Value("parallel") ?? false;
+ int? maxParallel = @params.Value("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(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();
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs.meta b/MCPForUnity/Editor/Tools/BatchExecute.cs.meta
new file mode 100644
index 000000000..491cc79a4
--- /dev/null
+++ b/MCPForUnity/Editor/Tools/BatchExecute.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4e1e2d8f3a454a37b18d06a7a7b6c3fb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs
index 36480a242..aa771c01e 100644
--- a/MCPForUnity/Editor/Tools/ManageAsset.cs
+++ b/MCPForUnity/Editor/Tools/ManageAsset.cs
@@ -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))
diff --git a/Server/uv.lock b/Server/uv.lock
index 26152be78..44a0ef88a 100644
--- a/Server/uv.lock
+++ b/Server/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.10"
[[package]]
@@ -694,7 +694,7 @@ wheels = [
[[package]]
name = "mcpforunityserver"
-version = "7.0.0"
+version = "8.1.4"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
@@ -715,7 +715,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.104.0" },
- { name = "fastmcp", specifier = ">=2.13.0" },
+ { name = "fastmcp", specifier = ">=2.13.0,<2.13.2" },
{ name = "httpx", specifier = ">=0.27.2" },
{ name = "mcp", specifier = ">=1.16.0" },
{ name = "pydantic", specifier = ">=2.12.0" },