diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index cd12f7fc9..bda21f49b 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -53,7 +53,7 @@ batch_execute( ) ``` -**Max 25 commands per batch.** Use `fail_fast=True` for dependent operations. Batches are not transactional (no rollback on partial failure). +**Max 25 commands per batch by default (configurable in Unity MCP Tools window, max 100).** Use `fail_fast=True` for dependent operations. ### 3. Use `screenshot` in manage_scene to Verify Visual Results diff --git a/.claude/skills/unity-mcp-skill/references/workflows.md b/.claude/skills/unity-mcp-skill/references/workflows.md index 631c4a0e4..545b5c518 100644 --- a/.claude/skills/unity-mcp-skill/references/workflows.md +++ b/.claude/skills/unity-mcp-skill/references/workflows.md @@ -829,7 +829,7 @@ batch_execute(fail_fast=True, commands=[ ### Complete Example: Main Menu Screen -Combines multiple templates into a full menu screen in two batch calls (25 command limit per batch). +Combines multiple templates into a full menu screen in two batch calls (default 25 command limit per batch, configurable in Unity MCP Tools window up to 100). ```python # Batch 1: Canvas + EventSystem + Panel + Title diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 5f99d3e5a..f1360984b 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -63,5 +63,7 @@ internal static class EditorPrefKeys internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; internal const string ApiKey = "MCPForUnity.ApiKey"; + + internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands"; } } diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index fc450bada..c45469413 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -201,6 +201,8 @@ public static JObject GetPackageJson() /// Gets the package source for the MCP server (used with uvx --from). /// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.), /// then falls back to PyPI package reference. + /// When the override is a local path, auto-corrects to the "Server" subdirectory + /// if the path doesn't contain pyproject.toml but Server/pyproject.toml exists. /// /// Package source string for uvx --from argument public static string GetMcpServerPackageSource() @@ -209,7 +211,14 @@ public static string GetMcpServerPackageSource() string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); if (!string.IsNullOrEmpty(sourceOverride)) { - return sourceOverride; + string resolved = ResolveLocalServerPath(sourceOverride); + // Persist the corrected path so future reads are consistent + if (resolved != sourceOverride) + { + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, resolved); + McpLog.Info($"Auto-corrected server source override from '{sourceOverride}' to '{resolved}'"); + } + return resolved; } // Default to PyPI package (avoids Windows long path issues with git clone) @@ -223,6 +232,59 @@ public static string GetMcpServerPackageSource() return $"mcpforunityserver=={version}"; } + /// + /// Validates and auto-corrects a local server source path to ensure it points to the + /// directory containing pyproject.toml. If the path points to a parent directory + /// (e.g. the repo root "unity-mcp") instead of the Python package directory ("Server"), + /// this checks for a "Server" subdirectory with pyproject.toml and returns that path. + /// Non-local paths (URLs, PyPI references) are returned unchanged. + /// + internal static string ResolveLocalServerPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + // Skip non-local paths (git URLs, PyPI package names, etc.) + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + // If it looks like a PyPI package reference (no path separators), skip + if (!path.Contains('/') && !path.Contains('\\') && !path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + // Strip file:// prefix for filesystem checks, preserve for return value + string checkPath = path; + string prefix = string.Empty; + if (checkPath.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + prefix = checkPath.Substring(0, 7); // preserve original casing + checkPath = checkPath.Substring(7); + } + + // Already correct — pyproject.toml exists at this path + if (System.IO.File.Exists(System.IO.Path.Combine(checkPath, "pyproject.toml"))) + { + return path; + } + + // Check if "Server" subdirectory contains pyproject.toml + string serverSubDir = System.IO.Path.Combine(checkPath, "Server"); + if (System.IO.File.Exists(System.IO.Path.Combine(serverSubDir, "pyproject.toml"))) + { + return prefix + serverSubDir; + } + + // Return as-is; uvx will report the error if the path is truly invalid + return path; + } + /// /// Deprecated: Use GetMcpServerPackageSource() instead. /// Kept for backwards compatibility. diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs index 24fec0f14..9dd85d529 100644 --- a/MCPForUnity/Editor/Services/EditorStateCache.cs +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -75,6 +75,9 @@ private sealed class EditorStateSnapshot [JsonProperty("transport")] public EditorStateTransport Transport { get; set; } + + [JsonProperty("settings")] + public EditorStateSettings Settings { get; set; } } private sealed class EditorStateUnity @@ -239,6 +242,12 @@ private sealed class EditorStateTransport public long? LastMessageUnixMs { get; set; } } + private sealed class EditorStateSettings + { + [JsonProperty("batch_execute_max_commands")] + public int BatchExecuteMaxCommands { get; set; } + } + static EditorStateCache() { try @@ -482,6 +491,10 @@ private static JObject BuildSnapshot(string reason) { UnityBridgeConnected = null, LastMessageUnixMs = null + }, + Settings = new EditorStateSettings + { + BatchExecuteMaxCommands = Tools.BatchExecute.GetMaxCommandsPerBatch() } }; diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index d9df336d6..66dc2b39b 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; +using UnityEditor; namespace MCPForUnity.Editor.Tools { @@ -13,7 +15,20 @@ namespace MCPForUnity.Editor.Tools [McpForUnityTool("batch_execute", AutoRegister = false)] public static class BatchExecute { - private const int MaxCommandsPerBatch = 25; + /// Default limit when no EditorPrefs override is set. + internal const int DefaultMaxCommandsPerBatch = 25; + + /// Hard ceiling to prevent extreme editor freezes regardless of user setting. + internal const int AbsoluteMaxCommandsPerBatch = 100; + + /// + /// Returns the user-configured max commands per batch, clamped between 1 and . + /// + internal static int GetMaxCommandsPerBatch() + { + int configured = EditorPrefs.GetInt(EditorPrefKeys.BatchExecuteMaxCommands, DefaultMaxCommandsPerBatch); + return Math.Clamp(configured, 1, AbsoluteMaxCommandsPerBatch); + } public static async Task HandleCommand(JObject @params) { @@ -28,9 +43,11 @@ public static async Task HandleCommand(JObject @params) return new ErrorResponse("Provide at least one command entry in 'commands'."); } - if (commandsToken.Count > MaxCommandsPerBatch) + int maxCommands = GetMaxCommandsPerBatch(); + if (commandsToken.Count > maxCommands) { - return new ErrorResponse($"A maximum of {MaxCommandsPerBatch} commands are allowed per batch."); + return new ErrorResponse( + $"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch})."); } bool failFast = @params.Value("failFast") ?? false; diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 4957e7da8..4ec8b07f8 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -158,6 +158,12 @@ private void RegisterCallbacks() } else { + url = ResolveServerPath(url); + // Update the text field if the path was auto-corrected, without re-triggering the callback + if (url != evt.newValue?.Trim()) + { + gitUrlOverride.SetValueWithoutNotify(url); + } EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, url); } OnGitUrlChanged?.Invoke(); @@ -326,9 +332,10 @@ private void OnClearUvxClicked() private void OnBrowseGitUrlClicked() { - string picked = EditorUtility.OpenFolderPanel("Select Server folder", string.Empty, string.Empty); + string picked = EditorUtility.OpenFolderPanel("Select Server folder (containing pyproject.toml)", string.Empty, string.Empty); if (!string.IsNullOrEmpty(picked)) { + picked = ResolveServerPath(picked); gitUrlOverride.value = picked; EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, picked); OnGitUrlChanged?.Invoke(); @@ -337,6 +344,54 @@ private void OnBrowseGitUrlClicked() } } + /// + /// Validates and auto-corrects a local server path to ensure it points to the directory + /// containing pyproject.toml (the Python package root). If the user selects a parent + /// directory (e.g. the repo root), this checks for a "Server" subdirectory with + /// pyproject.toml and returns that instead. + /// + private static string ResolveServerPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + // If path is not a local filesystem path, return as-is (git URLs, PyPI refs, etc.) + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + // Strip file:// prefix for filesystem checks, but preserve it for the return value + string checkPath = path; + string prefix = string.Empty; + if (checkPath.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + prefix = "file://"; + checkPath = checkPath.Substring(7); + } + + // Already points to a directory with pyproject.toml — correct path + if (File.Exists(Path.Combine(checkPath, "pyproject.toml"))) + { + return path; + } + + // Check if "Server" subdirectory contains pyproject.toml (common repo structure) + string serverSubDir = Path.Combine(checkPath, "Server"); + if (File.Exists(Path.Combine(serverSubDir, "pyproject.toml"))) + { + string corrected = prefix + serverSubDir; + McpLog.Info($"Auto-corrected server path to 'Server' subdirectory: {corrected}"); + return corrected; + } + + // Return as-is; uvx will report the error if the path is invalid + return path; + } + private void UpdateDeploymentSection() { var deployService = MCPServiceLocator.Deployment; diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs index dc5a3eabf..21513b5db 100644 --- a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs @@ -220,6 +220,11 @@ private VisualElement CreateToolRow(ToolMetadata tool) row.Add(CreateManageSceneActions()); } + if (IsBatchExecuteTool(tool)) + { + row.Add(CreateBatchExecuteSettings()); + } + return row; } @@ -296,6 +301,52 @@ private VisualElement CreateManageSceneActions() return actions; } + private VisualElement CreateBatchExecuteSettings() + { + var container = new VisualElement(); + container.AddToClassList("tool-item-actions"); + container.style.flexDirection = FlexDirection.Row; + container.style.alignItems = Align.Center; + container.style.marginTop = 4; + + var label = new Label("Max commands per batch:"); + label.style.marginRight = 8; + label.style.unityFontStyleAndWeight = UnityEngine.FontStyle.Normal; + container.Add(label); + + int currentValue = EditorPrefs.GetInt( + EditorPrefKeys.BatchExecuteMaxCommands, + BatchExecute.DefaultMaxCommandsPerBatch + ); + + var field = new IntegerField + { + value = Math.Clamp(currentValue, 1, BatchExecute.AbsoluteMaxCommandsPerBatch), + style = { width = 60 } + }; + field.tooltip = $"Number of commands allowed per batch_execute call (1–{BatchExecute.AbsoluteMaxCommandsPerBatch}). Default: {BatchExecute.DefaultMaxCommandsPerBatch}."; + + field.RegisterValueChangedCallback(evt => + { + int clamped = Math.Clamp(evt.newValue, 1, BatchExecute.AbsoluteMaxCommandsPerBatch); + if (clamped != evt.newValue) + { + field.SetValueWithoutNotify(clamped); + } + EditorPrefs.SetInt(EditorPrefKeys.BatchExecuteMaxCommands, clamped); + }); + + container.Add(field); + + var hint = new Label($"(max {BatchExecute.AbsoluteMaxCommandsPerBatch})"); + hint.style.marginLeft = 4; + hint.style.color = new UnityEngine.Color(0.5f, 0.5f, 0.5f); + hint.style.fontSize = 10; + container.Add(hint); + + return container; + } + private void OnManageSceneScreenshotClicked() { try @@ -329,6 +380,8 @@ private static Label CreateTag(string text) private static bool IsManageSceneTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_scene", StringComparison.OrdinalIgnoreCase); + private static bool IsBatchExecuteTool(ToolMetadata tool) => string.Equals(tool?.Name, "batch_execute", StringComparison.OrdinalIgnoreCase); + private static bool IsBuiltIn(ToolMetadata tool) => tool?.IsBuiltIn ?? false; } } diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 4de79429e..f14146fac 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -91,6 +91,10 @@ class EditorStateTransport(BaseModel): last_message_unix_ms: int | None = None +class EditorStateSettings(BaseModel): + batch_execute_max_commands: int | None = None + + class EditorStateAdvice(BaseModel): ready_for_tools: bool | None = None blocking_reasons: list[str] | None = None @@ -114,6 +118,7 @@ class EditorStateData(BaseModel): assets: EditorStateAssets | None = None tests: EditorStateTests | None = None transport: EditorStateTransport | None = None + settings: EditorStateSettings | None = None advice: EditorStateAdvice | None = None staleness: EditorStateStaleness | None = None diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index bd480e3d1..4e41da364 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -1,6 +1,7 @@ """Defines the batch_execute tool for orchestrating multiple Unity MCP commands.""" from __future__ import annotations +import logging from typing import Annotated, Any from fastmcp import Context @@ -11,7 +12,51 @@ from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry -MAX_COMMANDS_PER_BATCH = 25 +logger = logging.getLogger(__name__) + +# Fallback used when the Unity-side configured limit is not yet known. +DEFAULT_MAX_COMMANDS_PER_BATCH = 25 + +# Hard ceiling matching the C# AbsoluteMaxCommandsPerBatch. +ABSOLUTE_MAX_COMMANDS_PER_BATCH = 100 + +# Module-level cache for the Unity-configured limit (populated from editor state). +_cached_max_commands: int | None = None + + +async def _get_max_commands_from_editor_state(ctx: Context) -> int: + """ + Attempt to read the configured batch limit from the Unity editor state. + Falls back to DEFAULT_MAX_COMMANDS_PER_BATCH if unavailable. + """ + global _cached_max_commands + if _cached_max_commands is not None: + return _cached_max_commands + + try: + from services.resources.editor_state import get_editor_state + + state_resp = await get_editor_state(ctx) + data = state_resp.data if hasattr(state_resp, "data") else ( + state_resp.get("data") if isinstance(state_resp, dict) else None + ) + if isinstance(data, dict): + settings = data.get("settings") + if isinstance(settings, dict): + limit = settings.get("batch_execute_max_commands") + if isinstance(limit, int) and 1 <= limit <= ABSOLUTE_MAX_COMMANDS_PER_BATCH: + _cached_max_commands = limit + return limit + except Exception as exc: + logger.debug("Could not read batch limit from editor state: %s", exc) + + return DEFAULT_MAX_COMMANDS_PER_BATCH + + +def invalidate_cached_max_commands() -> None: + """Reset the cached limit so the next call re-reads from editor state.""" + global _cached_max_commands + _cached_max_commands = None @mcp_for_unity_tool( @@ -20,7 +65,8 @@ "Executes multiple MCP commands in a single batch for dramatically better performance. " "STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, " "or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to " - "sequential tool calls. Supports up to 25 commands per batch. " + "sequential tool calls. The max commands per batch is configurable in the Unity MCP Tools window " + f"(default {DEFAULT_MAX_COMMANDS_PER_BATCH}, hard max {ABSOLUTE_MAX_COMMANDS_PER_BATCH}). " "Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls." ), annotations=ToolAnnotations( @@ -45,9 +91,10 @@ async def batch_execute( raise ValueError( "'commands' must be a non-empty list of command specifications") - if len(commands) > MAX_COMMANDS_PER_BATCH: + max_commands = await _get_max_commands_from_editor_state(ctx) + if len(commands) > max_commands: raise ValueError( - f"batch_execute currently supports up to {MAX_COMMANDS_PER_BATCH} commands; received {len(commands)}" + f"batch_execute supports up to {max_commands} commands (configured in Unity); received {len(commands)}" ) normalized_commands: list[dict[str, Any]] = [] diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 0097d7a1a..986a40fe5 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -53,7 +53,7 @@ batch_execute( ) ``` -**Max 25 commands per batch.** Use `fail_fast=True` for dependent operations. Batches are not transactional (no rollback on partial failure). +**Max 25 commands per batch by default (configurable in Unity MCP Tools window, max 100).** Use `fail_fast=True` for dependent operations. ### 3. Use `screenshot` in manage_scene to Verify Visual Results diff --git a/unity-mcp-skill/references/workflows.md b/unity-mcp-skill/references/workflows.md index 631c4a0e4..545b5c518 100644 --- a/unity-mcp-skill/references/workflows.md +++ b/unity-mcp-skill/references/workflows.md @@ -829,7 +829,7 @@ batch_execute(fail_fast=True, commands=[ ### Complete Example: Main Menu Screen -Combines multiple templates into a full menu screen in two batch calls (25 command limit per batch). +Combines multiple templates into a full menu screen in two batch calls (default 25 command limit per batch, configurable in Unity MCP Tools window up to 100). ```python # Batch 1: Canvas + EventSystem + Panel + Title