diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs new file mode 100644 index 00000000..fa46dd31 --- /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 00000000..491cc79a --- /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 36480a24..aa771c01 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 26152be7..44a0ef88 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" },