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
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 @@ -186,7 +186,7 @@ uri="file:///full/path/to/file.cs"
| **Scene** | `manage_scene`, `find_gameobjects` | Scene operations, finding objects. Multi-scene editing (additive load, close, set active, move GO between scenes), scene templates (`3d_basic`, `2d_basic`, `empty`, `default`), scene validation with `auto_repair`. For build settings, use `manage_build(action="scenes")`. |
| **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects |
| **Scripts** | `create_script`, `script_apply_edits`, `validate_script` | C# code management (auto-refreshes on create/edit) |
| **Assets** | `manage_asset`, `manage_prefabs` | Asset operations. **Prefab instantiation** is done via `manage_gameobject(action="create", prefab_path="...")`, not `manage_prefabs`. |
| **Assets** | `manage_asset`, `manage_prefabs` | Asset operations and read-only built-bundle exploration: `list_asset_bundles` takes a project directory; `get_asset_bundle_info` takes one bundle file. Both page results (default 25, max 200); details load no assets/scenes. **Prefab instantiation** is done via `manage_gameobject(action="create", prefab_path="...")`, not `manage_prefabs`. |
| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package`), undo/redo (`undo`/`redo` actions) |
| **Testing** | `run_tests`, `get_test_job` | Unity Test Framework |
| **Batch** | `batch_execute` | Parallel/bulk operations |
Expand Down
137 changes: 137 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace MCPForUnity.Editor.Helpers
{
/// <summary>Read-only exploration of built bundles; no static dependency on AssetBundleModule.</summary>
internal static class AssetBundleExplorer
{
internal static object Handle(JObject parameters, bool inspect)
{
try
{
string root = Path.GetFullPath(Path.GetDirectoryName(Application.dataPath));
string requested = parameters["path"]?.ToString();
if (string.IsNullOrWhiteSpace(requested)) return new ErrorResponse("'path' is required for AssetBundle exploration.");
string path = Path.GetFullPath(Path.IsPathRooted(requested) ? requested : Path.Combine(root, requested));
var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (!path.Equals(root, comparison) && !path.StartsWith(root + Path.DirectorySeparatorChar, comparison))
return new ErrorResponse("AssetBundle paths must be inside the Unity project.");
if (HasLink(root, path)) return new ErrorResponse("AssetBundle paths cannot traverse symbolic links or reparse points.");
int pageSize = ParamCoercion.CoerceInt(parameters["pageSize"], 25);
int pageNumber = ParamCoercion.CoerceInt(parameters["pageNumber"], 1);
if (pageSize < 1 || pageSize > 200 || pageNumber < 1)
return new ErrorResponse("Use pageSize from 1 to 200 and a pageNumber of at least 1.");
return inspect ? Inspect(root, path, pageSize, pageNumber) : List(root, path, pageSize, pageNumber);
}
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException || e is NotSupportedException)
{
return new ErrorResponse("Could not read the selected AssetBundle path. Check that it exists and is accessible.");
}
}

private static bool HasLink(string root, string path)
{
string current = root;
foreach (string segment in path.Substring(root.Length).Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, segment);
try
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return true;
}
catch (FileNotFoundException) { break; }
catch (DirectoryNotFoundException) { break; }
}
return false;
}

private static string Relative(string root, string path) => path.Substring(root.Length).TrimStart(Path.DirectorySeparatorChar).Replace('\\', '/');

private static bool HasBundleHeader(string path)
{
// Stat before opening. Empty/short files and Unix FIFOs/sockets have no
// bundle header; opening a FIFO would wait for a writer on the Editor thread.
if (new FileInfo(path).Length < 8) return false;
var bytes = new byte[9];
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
if (stream.Read(bytes, 0, bytes.Length) < 8) return false;
}
string header = Encoding.ASCII.GetString(bytes);
return header.StartsWith("UnityFS\0", StringComparison.Ordinal) ||
header.StartsWith("UnityRaw\0", StringComparison.Ordinal) ||
header.StartsWith("UnityWeb\0", StringComparison.Ordinal);
}

private static object List(string root, string path, int pageSize, int pageNumber)
{
if (!Directory.Exists(path)) return new ErrorResponse("'path' must identify an existing project directory for list_asset_bundles.");
var entries = new List<object>();
foreach (string entry in Directory.EnumerateFileSystemEntries(path).OrderBy(p => p, StringComparer.Ordinal))
{
var attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0) continue;
bool directory = (attributes & FileAttributes.Directory) != 0;
if (directory) entries.Add(new { type = "directory", path = Relative(root, entry), sizeBytes = (long?)null });
else if (HasBundleHeader(entry)) entries.Add(new { type = "bundle", path = Relative(root, entry), sizeBytes = (long?)new FileInfo(entry).Length });
}
return new SuccessResponse("AssetBundle directory read successfully.", new
{
path = Relative(root, path), entries = Page(entries, pageSize, pageNumber),
totalEntries = entries.Count, pageSize, pageNumber,
nextPageNumber = NextPage(entries.Count, pageSize, pageNumber)
});
}

private static object Inspect(string root, string path, int pageSize, int pageNumber)
{
if (!File.Exists(path)) return new ErrorResponse("'path' must identify an existing bundle file for get_asset_bundle_info.");
if (!HasBundleHeader(path)) return new ErrorResponse("The selected file does not have a Unity AssetBundle header.");
Type type = Type.GetType("UnityEngine.AssetBundle, UnityEngine.AssetBundleModule", false);
if (type == null) return new ErrorResponse("AssetBundle inspection requires the built-in com.unity.modules.assetbundle package.");
object bundle = null;
try
{
bundle = type.GetMethod("LoadFromFile", new[] { typeof(string) }).Invoke(null, new object[] { path });
if (bundle == null) return new ErrorResponse("Unity could not load this AssetBundle. Check its build target, file integrity, and whether it is already loaded.");
string[] assets = (string[])type.GetMethod("GetAllAssetNames").Invoke(bundle, null);
string[] scenes = (string[])type.GetMethod("GetAllScenePaths").Invoke(bundle, null);
var entries = assets.OrderBy(s => s, StringComparer.Ordinal).Select(s => (object)new { type = "asset", name = s })
.Concat(scenes.OrderBy(s => s, StringComparer.Ordinal).Select(s => (object)new { type = "scene", name = s })).ToList();
return new SuccessResponse("AssetBundle contents read successfully.", new
{
path = Relative(root, path), bundleName = type.GetProperty("name").GetValue(bundle),
sizeBytes = new FileInfo(path).Length,
isStreamedSceneAssetBundle = type.GetProperty("isStreamedSceneAssetBundle").GetValue(bundle),
assetCount = assets.Length, sceneCount = scenes.Length,
entries = Page(entries, pageSize, pageNumber), totalEntries = entries.Count,
pageSize, pageNumber, nextPageNumber = NextPage(entries.Count, pageSize, pageNumber)
});
}
catch (TargetInvocationException)
{
return new ErrorResponse("Unity could not inspect this AssetBundle. Check its build target and file integrity.");
}
finally
{
// Metadata inspection never loads assets. Release only the handle opened here.
if (bundle != null) type.GetMethod("Unload", new[] { typeof(bool) }).Invoke(bundle, new object[] { false });
}
}

private static object[] Page(List<object> entries, int pageSize, int pageNumber)
{
long offset = ((long)pageNumber - 1) * pageSize;
return offset >= entries.Count ? Array.Empty<object>() : entries.Skip((int)offset).Take(pageSize).ToArray();
}

private static int? NextPage(int count, int pageSize, int pageNumber) => (long)pageNumber * pageSize < count ? (int?)(pageNumber + 1) : null;
}
}
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs.meta

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

6 changes: 6 additions & 0 deletions MCPForUnity/Editor/Tools/ManageAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public static class ManageAsset
"get_info",
"create_folder",
"get_components",
"list_asset_bundles",
"get_asset_bundle_info",
};

public static object HandleCommand(JObject @params)
Expand Down Expand Up @@ -109,6 +111,10 @@ public static object HandleCommand(JObject @params)
return CreateFolder(path);
case "get_components":
return GetComponentsFromAsset(path);
case "list_asset_bundles":
return AssetBundleExplorer.Handle(@params, inspect: false);
case "get_asset_bundle_info":
return AssetBundleExplorer.Handle(@params, inspect: true);

default:
// This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications.
Expand Down
38 changes: 37 additions & 1 deletion Server/src/cli/commands/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

@click.group()
def asset():
"""Asset operations - search, import, create, delete assets."""
"""Asset operations and read-only AssetBundle exploration."""
pass


Expand Down Expand Up @@ -99,6 +99,42 @@ def info(path: str, preview: bool):
click.echo(format_output(result, config.format))


@asset.command("bundles")
@click.argument("path")
@click.option("--limit", "-l", default=25, type=click.IntRange(1, 200), help="Entries per page (1-200).")
@click.option("--page", default=1, type=click.IntRange(min=1), help="Page number (1-based).")
@handle_unity_errors
def bundles(path: str, limit: int, page: int):
"""List built bundles and subdirectories in a project directory.

Example: unity-mcp asset bundles "Builds/AssetBundles"
"""
config = get_config()
result = run_command("manage_asset", {
"action": "list_asset_bundles", "path": path,
"pageSize": limit, "pageNumber": page,
}, config)
click.echo(format_output(result, config.format))


@asset.command("bundle-info")
@click.argument("path")
@click.option("--limit", "-l", default=25, type=click.IntRange(1, 200), help="Asset/scene names per page (1-200).")
@click.option("--page", default=1, type=click.IntRange(min=1), help="Page number (1-based).")
@handle_unity_errors
def bundle_info(path: str, limit: int, page: int):
"""Inspect a built bundle's asset/scene names without loading assets.

Example: unity-mcp asset bundle-info "Builds/AssetBundles/characters"
"""
config = get_config()
result = run_command("manage_asset", {
"action": "get_asset_bundle_info", "path": path,
"pageSize": limit, "pageNumber": page,
}, config)
click.echo(format_output(result, config.format))


@asset.command("create")
@click.argument("path")
@click.argument("asset_type")
Expand Down
16 changes: 10 additions & 6 deletions Server/src/services/tools/manage_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@

@mcp_for_unity_tool(
description=(
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
"Performs asset operations (import, create, modify, delete, etc.) in Unity. "
"Read built AssetBundles with list_asset_bundles (directory navigation) or "
"get_asset_bundle_info (asset/scene names without loading assets). Both use "
"project-local paths and bounded page_size/page_number output.\n\n"
"Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
"`generate_preview=false` (previews can add large base64 blobs)."
),
Expand All @@ -29,8 +32,8 @@
)
async def manage_asset(
ctx: Context,
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components", "list_asset_bundles", "get_asset_bundle_info"], "Perform asset operations or read built AssetBundles."],
path: Annotated[str, "Asset path or search scope. For bundles: a project-relative directory/file, e.g., 'Builds/AssetBundles' or 'Builds/AssetBundles/characters'. Symbolic links are not traversed."],
asset_type: Annotated[str,
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
properties: Annotated[dict[str, Any] | str,
Expand All @@ -47,15 +50,17 @@ async def manage_asset(
filter_date_after: Annotated[str,
"Date after which to filter"] | None = None,
page_size: Annotated[int | float | str,
"Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses)."] | None = None,
"Page size for pagination. Recommended: 25. Bundle actions default to 25 and allow 1-200."] | None = None,
page_number: Annotated[int | float | str,
"Page number for pagination (1-based)."] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
action_l = (action or "").lower()

# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
# wait/refresh to avoid stale reads and flaky timeouts.
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
gate = await preflight(ctx, wait_for_no_compile=True,
refresh_if_dirty=action_l not in {"list_asset_bundles", "get_asset_bundle_info"})
if gate is not None:
return gate.model_dump()

Expand All @@ -72,7 +77,6 @@ async def manage_asset(
# Unity's C# handler treats `path` as a folder scope. If a model mistakenly puts a query like
# "t:MonoScript" into `path`, Unity will consider it an invalid folder and fall back to searching
# the entire project, which is token-heavy. Normalize such cases into search_pattern + Assets scope.
action_l = (action or "").lower()
if action_l == "search":
try:
raw_path = (path or "").strip()
Expand Down
56 changes: 56 additions & 0 deletions Server/tests/integration/test_asset_bundle_explorer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import asyncio
from unittest.mock import AsyncMock

import pytest
from pydantic import TypeAdapter, ValidationError
from typing import get_type_hints

import services.tools.manage_asset as module
from .test_helpers import DummyContext


@pytest.mark.parametrize("action", ["list_asset_bundles", "get_asset_bundle_info"])
def test_bundle_actions_forward_paging_without_refresh_or_search_normalization(monkeypatch, action):
captured = {}

async def send(command, params, **kwargs):
captured.update(command=command, params=params)
return {"success": True, "data": {"entries": []}}

gate = AsyncMock(return_value=None)
monkeypatch.setattr(module, "preflight", gate)
monkeypatch.setattr(module, "async_send_command_with_retry", send)
response = asyncio.run(module.manage_asset(
DummyContext(), action, "Builds/AssetBundles/characters",
page_size="2", page_number="3",
))

assert response["success"]
assert captured["command"] == "manage_asset"
assert captured["params"]["action"] == action
assert captured["params"]["path"] == "Builds/AssetBundles/characters"
assert captured["params"]["pageSize"] == 2
assert captured["params"]["pageNumber"] == 3
assert gate.await_args.kwargs == {"wait_for_no_compile": True, "refresh_if_dirty": False}


def test_bundle_actions_are_exposed_by_the_declared_schema():
# The repository's test fixtures stub FastMCP; validate the actual annotation.
action = TypeAdapter(get_type_hints(module.manage_asset, include_extras=True)["action"])
assert action.validate_python("list_asset_bundles") == "list_asset_bundles"
assert action.validate_python("get_asset_bundle_info") == "get_asset_bundle_info"
with pytest.raises(ValidationError):
action.validate_python("build_asset_bundles")


def test_bundle_actions_preserve_preflight_errors(monkeypatch):
from models import MCPResponse
gate = MCPResponse(success=False, error="compiling")
monkeypatch.setattr(module, "preflight", AsyncMock(return_value=gate))
send = AsyncMock()
monkeypatch.setattr(module, "send_with_unity_instance", send)

response = asyncio.run(module.manage_asset(DummyContext(), "list_asset_bundles", "Builds"))

assert response["success"] is False
send.assert_not_awaited()
40 changes: 40 additions & 0 deletions Server/tests/test_asset_bundle_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import json
from types import SimpleNamespace
from unittest.mock import Mock

import pytest
from click.testing import CliRunner

import cli.commands.asset as module


@pytest.mark.parametrize("command,action", [
("bundles", "list_asset_bundles"), ("bundle-info", "get_asset_bundle_info"),
])
@pytest.mark.parametrize("paging,limit,page", [([], 25, 1), (["--limit", "2", "--page", "3"], 2, 3)])
def test_bundle_cli_sends_the_read_only_action(monkeypatch, command, action, paging, limit, page):
config = SimpleNamespace(format="json")
monkeypatch.setattr(module, "get_config", lambda: config)
send = Mock(return_value={"success": True, "data": {"entries": []}})
monkeypatch.setattr(module, "run_command", send)

result = CliRunner().invoke(module.asset, [command, "Builds/AssetBundles", *paging])

assert result.exit_code == 0, result.output
assert json.loads(result.output)["success"]
send.assert_called_once_with("manage_asset", {
"action": action, "path": "Builds/AssetBundles",
"pageSize": limit, "pageNumber": page,
}, config)


@pytest.mark.parametrize("command", ["bundles", "bundle-info"])
@pytest.mark.parametrize("option", [["--limit", "0"], ["--limit", "201"], ["--page", "0"]])
def test_invalid_bundle_cli_paging_never_sends_a_command(monkeypatch, command, option):
send = Mock()
monkeypatch.setattr(module, "run_command", send)

result = CliRunner().invoke(module.asset, [command, "Builds", *option])

assert result.exit_code == 2
send.assert_not_called()
Loading