Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude/skills/unity-mcp-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/unity-mcp-skill/references/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
64 changes: 63 additions & 1 deletion MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <returns>Package source string for uvx --from argument</returns>
public static string GetMcpServerPackageSource()
Expand All @@ -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)
Expand All @@ -223,6 +232,59 @@ public static string GetMcpServerPackageSource()
return $"mcpforunityserver=={version}";
}

/// <summary>
/// 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.
/// </summary>
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))
Comment on lines +256 to +257
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Relative local paths without separators are treated as PyPI references and skipped.

This logic treats any string without /, \, or file: as a PyPI reference, so relative directories like Server or my_package won’t be auto-corrected even if they contain a valid pyproject.toml. If users are likely to enter such relative paths, consider tightening the heuristic (e.g. require a more PyPI-specific pattern or explicitly distinguish absolute vs relative paths) so these local directories can still be handled.

{
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;
}

/// <summary>
/// Deprecated: Use GetMcpServerPackageSource() instead.
/// Kept for backwards compatibility.
Expand Down
13 changes: 13 additions & 0 deletions MCPForUnity/Editor/Services/EditorStateCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -482,6 +491,10 @@ private static JObject BuildSnapshot(string reason)
{
UnityBridgeConnected = null,
LastMessageUnixMs = null
},
Settings = new EditorStateSettings
{
BatchExecuteMaxCommands = Tools.BatchExecute.GetMaxCommandsPerBatch()
}
};

Expand Down
23 changes: 20 additions & 3 deletions MCPForUnity/Editor/Tools/BatchExecute.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -13,7 +15,20 @@ namespace MCPForUnity.Editor.Tools
[McpForUnityTool("batch_execute", AutoRegister = false)]
public static class BatchExecute
{
private const int MaxCommandsPerBatch = 25;
/// <summary>Default limit when no EditorPrefs override is set.</summary>
internal const int DefaultMaxCommandsPerBatch = 25;

/// <summary>Hard ceiling to prevent extreme editor freezes regardless of user setting.</summary>
internal const int AbsoluteMaxCommandsPerBatch = 100;

/// <summary>
/// Returns the user-configured max commands per batch, clamped between 1 and <see cref="AbsoluteMaxCommandsPerBatch"/>.
/// </summary>
internal static int GetMaxCommandsPerBatch()
{
int configured = EditorPrefs.GetInt(EditorPrefKeys.BatchExecuteMaxCommands, DefaultMaxCommandsPerBatch);
return Math.Clamp(configured, 1, AbsoluteMaxCommandsPerBatch);
}

public static async Task<object> HandleCommand(JObject @params)
{
Expand All @@ -28,9 +43,11 @@ public static async Task<object> 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<bool?>("failFast") ?? false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -337,6 +344,54 @@ private void OnBrowseGitUrlClicked()
}
}

/// <summary>
/// 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.
/// </summary>
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))
Comment on lines +353 to +362
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Path resolution logic is duplicated between ResolveServerPath and ResolveLocalServerPath and may drift over time.

These two methods share most of the same concerns (local vs URL detection, file:// handling, Server subdirectory probing) but differ in details like the PyPI-style heuristic and prefix preservation. Consider extracting a shared helper for the common resolution logic that both methods delegate to, so any future changes are made in one place and behavior stays consistent across the UI, editor prefs, and GetMcpServerPackageSource.

Suggested implementation:

        /// <summary>
        /// Shared normalization logic for resolving server paths (both local and remote).
        /// Handles scheme detection, `file://` URIs, and normalization of local filesystem
        /// paths before any call-site specific heuristics are applied.
        /// </summary>
        /// <param name="path">Raw user-provided path or URL.</param>
        /// <param name="normalizedPath">
        /// Normalized local filesystem path (if applicable). When this returns false,
        /// this will be null and the original <paramref name="path" /> should be used.
        /// </param>
        /// <returns>
        /// True when <paramref name="path" /> represents a local filesystem location that
        /// should be further resolved (e.g. probing for a "Server" subdirectory); false
        /// when the caller should treat the original string as-is (URLs, PyPI-style refs, etc.).
        /// </returns>
        private static bool TryNormalizeLocalServerPath(string path, out string normalizedPath)
        {
            normalizedPath = null;

            if (string.IsNullOrEmpty(path))
                return false;

            // Non-local sources (git URLs, PyPI refs, etc.) are returned as-is at call sites.
            if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
                path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
                path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) ||
                path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            // Support `file://` style URIs, which can come from Unity file pickers on some platforms.
            if (path.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
            {
                if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.IsFile)
                    normalizedPath = uri.LocalPath;
                else
                    normalizedPath = path;

                return true;
            }

            // For plain paths, just normalize separators; call sites can perform additional
            // probing (e.g. for "Server" subdirectories or pyproject.toml detection).
            normalizedPath = path.Replace('\\', Path.DirectorySeparatorChar)
                                 .Replace('/', Path.DirectorySeparatorChar);

            return true;
        }

        /// <summary>
        /// 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.
        /// </summary>
        private static string ResolveServerPath(string path)

To complete the refactor and fully address the duplication / drift risk, you should:

  1. Update ResolveServerPath to:
    • Call TryNormalizeLocalServerPath(path, out var normalizedPath).
    • If it returns false, immediately return the original path (covers URLs, PyPI-style refs, etc.).
    • Use normalizedPath for all subsequent filesystem probing logic (including the "Server" subdirectory and pyproject.toml detection) instead of re-implementing scheme checks.
  2. Update ResolveLocalServerPath in the same file to:
    • Call TryNormalizeLocalServerPath up-front and branch the same way.
    • Remove any duplicated scheme / file:// / separator normalization logic that is now handled centrally.
  3. If there are other call sites that need consistent behavior (e.g. GetMcpServerPackageSource or editor prefs code that performs similar path handling), consider routing them through either TryNormalizeLocalServerPath directly or one of the two higher-level resolvers, rather than re-implementing normalization in-line.

{
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;
Comment on lines +355 to +392
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveServerPath() duplicates the local-path auto-correction logic that was also added to AssetPathUtility.ResolveLocalServerPath(). Keeping two implementations increases the chance of drift (e.g., handling of file:/// URIs, future supported schemes). Prefer reusing the helper from AssetPathUtility (and/or extracting a single shared utility) so both the UI and command-building paths behave identically.

Suggested change
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;
// Delegate to shared helper to keep path resolution logic centralized
return AssetPathUtility.ResolveLocalServerPath(path);

Copilot uses AI. Check for mistakes.
}

private void UpdateDeploymentSection()
{
var deployService = MCPServiceLocator.Deployment;
Expand Down
53 changes: 53 additions & 0 deletions MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ private VisualElement CreateToolRow(ToolMetadata tool)
row.Add(CreateManageSceneActions());
}

if (IsBatchExecuteTool(tool))
{
row.Add(CreateBatchExecuteSettings());
}

return row;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
5 changes: 5 additions & 0 deletions Server/src/services/resources/editor_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading