diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 8e3467b87..5d86260e5 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -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 | diff --git a/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs b/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs new file mode 100644 index 000000000..57a07ebac --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs @@ -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 +{ + /// Read-only exploration of built bundles; no static dependency on AssetBundleModule. + 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(); + 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 entries, int pageSize, int pageNumber) + { + long offset = ((long)pageNumber - 1) * pageSize; + return offset >= entries.Count ? Array.Empty() : 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; + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs.meta b/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs.meta new file mode 100644 index 000000000..fdc4b94c9 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetBundleExplorer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d9d817f3bff47ec8730d37b9b61506c diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 69aaf53cc..fcaa9abc0 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -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) @@ -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. diff --git a/Server/src/cli/commands/asset.py b/Server/src/cli/commands/asset.py index df7a2c075..e647ec0c7 100644 --- a/Server/src/cli/commands/asset.py +++ b/Server/src/cli/commands/asset.py @@ -14,7 +14,7 @@ @click.group() def asset(): - """Asset operations - search, import, create, delete assets.""" + """Asset operations and read-only AssetBundle exploration.""" pass @@ -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") diff --git a/Server/src/services/tools/manage_asset.py b/Server/src/services/tools/manage_asset.py index a0c7592d0..a6dfefaca 100644 --- a/Server/src/services/tools/manage_asset.py +++ b/Server/src/services/tools/manage_asset.py @@ -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)." ), @@ -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, @@ -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() @@ -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() diff --git a/Server/tests/integration/test_asset_bundle_explorer.py b/Server/tests/integration/test_asset_bundle_explorer.py new file mode 100644 index 000000000..11eb2c891 --- /dev/null +++ b/Server/tests/integration/test_asset_bundle_explorer.py @@ -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() diff --git a/Server/tests/test_asset_bundle_cli.py b/Server/tests/test_asset_bundle_cli.py new file mode 100644 index 000000000..35e083d4d --- /dev/null +++ b/Server/tests/test_asset_bundle_cli.py @@ -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() diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs new file mode 100644 index 000000000..19b95ea71 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs @@ -0,0 +1,265 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; +using UnityEditor; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using System.Text.RegularExpressions; + +namespace MCPForUnityTests.Editor.Tools +{ + public class AssetBundleExplorerTests + { + private string directory; + private string relativeDirectory; + private string sourceDirectory; + private string bundleDirectory; + private string relativeBundleDirectory; + + [OneTimeSetUp] + public void BuildFixtureBundles() + { + string id = Guid.NewGuid().ToString("N"); + sourceDirectory = "Assets/McpBundleExplorerFixture-" + id; + relativeBundleDirectory = "Temp/McpBundleExplorerBuild-" + id; + bundleDirectory = Path.Combine(Path.GetDirectoryName(Application.dataPath), relativeBundleDirectory); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(bundleDirectory); + File.WriteAllText(sourceDirectory + "/alpha.txt", "alpha fixture"); + File.WriteAllText(sourceDirectory + "/beta.txt", "beta fixture"); + AssetDatabase.Refresh(); + // Copy the repository fixture rather than replacing or saving the user's open scene. + File.Copy("Assets/Scenes/SampleScene.unity", sourceDirectory + "/fixture.unity"); + AssetDatabase.ImportAsset(sourceDirectory + "/fixture.unity"); + var manifest = BuildPipeline.BuildAssetBundles(bundleDirectory, new[] + { + new AssetBundleBuild { assetBundleName = "assets", assetNames = new[] { sourceDirectory + "/alpha.txt", sourceDirectory + "/beta.txt" } }, + new AssetBundleBuild { assetBundleName = "scenes", assetNames = new[] { sourceDirectory + "/fixture.unity" } } + }, BuildAssetBundleOptions.UncompressedAssetBundle | BuildAssetBundleOptions.ForceRebuildAssetBundle, + EditorUserBuildSettings.activeBuildTarget); + Assert.That(manifest, Is.Not.Null, "Fixture bundles must be built for this Editor's active build target."); + } + + [OneTimeTearDown] + public void RemoveFixtureBundles() + { + if (!string.IsNullOrEmpty(sourceDirectory)) AssetDatabase.DeleteAsset(sourceDirectory); + if (!string.IsNullOrEmpty(bundleDirectory) && Directory.Exists(bundleDirectory)) Directory.Delete(bundleDirectory, true); + } + + [SetUp] + public void SetUp() + { + relativeDirectory = "Temp/McpBundleExplorerTests-" + Guid.NewGuid().ToString("N"); + directory = Path.Combine(Path.GetDirectoryName(Application.dataPath), relativeDirectory); + Directory.CreateDirectory(directory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + private JObject Call(string action, string path, int pageSize = 25, int pageNumber = 1) + { + return JObject.FromObject(ManageAsset.HandleCommand(new JObject + { + ["action"] = action, ["path"] = path, + ["pageSize"] = pageSize, ["pageNumber"] = pageNumber + })); + } + + [Test] + public void ListAssetBundles_ReadsHeadersWithoutLoadingOrImportingFiles() + { + File.WriteAllBytes(Path.Combine(directory, "characters"), Encoding.ASCII.GetBytes("UnityFS\0fixture")); + File.WriteAllText(Path.Combine(directory, "characters.manifest"), "ordinary manifest"); + File.WriteAllText(Path.Combine(directory, "notes.txt"), "ordinary file"); + Directory.CreateDirectory(Path.Combine(directory, "nested")); + int loadedBefore = AssetBundle.GetAllLoadedAssetBundles().Count(); + + var response = Call("list_asset_bundles", relativeDirectory); + + Assert.That(response.Value("success"), Is.True, response.ToString()); + var entries = (JArray)response["data"]["entries"]; + Assert.That(entries.Count, Is.EqualTo(2)); + Assert.That(entries[0].Value("type"), Is.EqualTo("bundle")); + Assert.That(entries[0].Value("path"), Is.EqualTo(relativeDirectory + "/characters")); + Assert.That(entries[1].Value("type"), Is.EqualTo("directory")); + Assert.That(AssetBundle.GetAllLoadedAssetBundles().Count(), Is.EqualTo(loadedBefore)); + Assert.That(File.ReadAllText(Path.Combine(directory, "notes.txt")), Is.EqualTo("ordinary file")); + } + + [Test] + public void ListAssetBundles_PagesSortedEntriesAndHandlesAnOverflowingOffset() + { + foreach (string name in new[] { "c", "a", "b" }) + File.WriteAllBytes(Path.Combine(directory, name), Encoding.ASCII.GetBytes("UnityFS\0fixture")); + var first = Call("list_asset_bundles", relativeDirectory, 2, 1)["data"]; + var second = Call("list_asset_bundles", relativeDirectory, 2, 2)["data"]; + Assert.That(first.Value("totalEntries"), Is.EqualTo(3)); + Assert.That(first.Value("nextPageNumber"), Is.EqualTo(2)); + Assert.That(first["entries"].Select(e => e.Value("path")), Is.EqualTo(new[] { relativeDirectory + "/a", relativeDirectory + "/b" })); + Assert.That(second["entries"].Count(), Is.EqualTo(1)); + Assert.That(second["nextPageNumber"].Type, Is.EqualTo(JTokenType.Null)); + Assert.That(Call("list_asset_bundles", relativeDirectory, 200, int.MaxValue)["data"]["entries"].Count(), Is.Zero); + } + + [Test] + public void ListAssetBundles_DoesNotOpenAnEmptyNamedPipe() + { + if (Application.platform == RuntimePlatform.WindowsEditor) Assert.Ignore("Unix fixture uses mkfifo."); + string pipe = Path.Combine(directory, "pipe"); + using (var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/mkfifo", Arguments = "\"" + pipe + "\"", + UseShellExecute = false, CreateNoWindow = true + })) + { + Assert.That(process.WaitForExit(5000), Is.True); + Assert.That(process.ExitCode, Is.Zero); + } + Assert.That(new FileInfo(pipe).Length, Is.Zero); + File.WriteAllBytes(Path.Combine(directory, "short"), new byte[] { 1, 2 }); + var watch = System.Diagnostics.Stopwatch.StartNew(); + var response = Call("list_asset_bundles", relativeDirectory); + Assert.That(response.Value("success"), Is.True, response.ToString()); + Assert.That(response["data"]["entries"].Count(), Is.Zero); + Assert.That(watch.ElapsedMilliseconds, Is.LessThan(200)); + } + + [TestCase("UnityFS\0fixture")] + [TestCase("UnityRaw\0fixture")] + [TestCase("UnityWeb\0fixture")] + public void ListAssetBundles_RecognizesUnityBundleSignatures(string header) + { + File.WriteAllBytes(Path.Combine(directory, "bundle"), Encoding.ASCII.GetBytes(header)); + File.WriteAllBytes(Path.Combine(directory, "not-a-bundle"), Encoding.ASCII.GetBytes("UnityFS-without-null")); + Assert.That(Call("list_asset_bundles", relativeDirectory)["data"]["entries"].Count(), Is.EqualTo(1)); + } + + [TestCase("list_asset_bundles", 0, 1)] + [TestCase("list_asset_bundles", 201, 1)] + [TestCase("get_asset_bundle_info", 25, 0)] + public void BundleActions_RejectInvalidPaging(string action, int pageSize, int pageNumber) + { + Assert.That(Call(action, relativeDirectory, pageSize, pageNumber).Value("success"), Is.False); + } + + [TestCase("list_asset_bundles")] + [TestCase("get_asset_bundle_info")] + public void BundleActions_RejectOutsideProjectPaths(string action) + { + var response = Call(action, Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(Application.dataPath)), "outside")); + Assert.That(response.Value("success"), Is.False); + Assert.That(response.Value("error"), Does.Contain("inside the Unity project")); + } + + [Test] + public void ListAssetBundles_AcceptsAnAbsoluteProjectPathAndNormalizesDotSegments() + { + Directory.CreateDirectory(Path.Combine(directory, "nested")); + Assert.That(Call("list_asset_bundles", directory).Value("success"), Is.True); + Assert.That(Call("list_asset_bundles", relativeDirectory + "/nested/..").Value("success"), Is.True); + } + + [Test] + public void BundleActions_DoNotFollowSymbolicLinks() + { + if (Application.platform == RuntimePlatform.WindowsEditor) Assert.Ignore("Unix fixture uses ln; production rejects Windows reparse points too."); + string outside = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(Application.dataPath)), "McpBundleOutside-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outside); + try + { + File.WriteAllBytes(Path.Combine(outside, "bundle"), Encoding.ASCII.GetBytes("UnityFS\0fixture")); + using (var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/ln", Arguments = "-s \"" + outside + "\" \"" + Path.Combine(directory, "link") + "\"", + UseShellExecute = false, CreateNoWindow = true + })) + { + Assert.That(process.WaitForExit(5000), Is.True); + Assert.That(process.ExitCode, Is.Zero); + } + Assert.That(Call("list_asset_bundles", relativeDirectory)["data"]["entries"].Count(), Is.Zero); + Assert.That(Call("list_asset_bundles", relativeDirectory + "/link").Value("success"), Is.False); + Assert.That(Call("get_asset_bundle_info", relativeDirectory + "/link/bundle").Value("success"), Is.False); + } + finally { Directory.Delete(outside, true); } + } + + [Test] + public void GetAssetBundleInfo_RejectsOrdinaryFilesAndMissingPaths() + { + File.WriteAllText(Path.Combine(directory, "notes.txt"), "ordinary file"); + Assert.That(Call("get_asset_bundle_info", relativeDirectory + "/notes.txt").Value("success"), Is.False); + Assert.That(Call("get_asset_bundle_info", relativeDirectory + "/missing").Value("success"), Is.False); + Assert.That(Call("list_asset_bundles", relativeDirectory + "/notes.txt").Value("success"), Is.False); + Assert.That(Call("list_asset_bundles", "").Value("success"), Is.False); + } + + [Test] + public void GetAssetBundleInfo_InspectsRealAssetsAndReleasesItsHandleOnEveryPage() + { + int loadedBefore = AssetBundle.GetAllLoadedAssetBundles().Count(); + byte[] bytes = File.ReadAllBytes(Path.Combine(bundleDirectory, "assets")); + int sceneCount = SceneManager.sceneCount; + var first = Call("get_asset_bundle_info", relativeBundleDirectory + "/assets", 1, 1); + var second = Call("get_asset_bundle_info", relativeBundleDirectory + "/assets", 1, 2); + Assert.That(first.Value("success"), Is.True, first.ToString()); + Assert.That(second.Value("success"), Is.True, second.ToString()); + Assert.That(first["data"].Value("assetCount"), Is.EqualTo(2)); + Assert.That(first["data"].Value("sceneCount"), Is.Zero); + Assert.That(first["data"]["entries"][0].Value("name"), Does.EndWith("/alpha.txt")); + Assert.That(second["data"]["entries"][0].Value("name"), Does.EndWith("/beta.txt")); + Assert.That(AssetBundle.GetAllLoadedAssetBundles().Count(), Is.EqualTo(loadedBefore)); + Assert.That(SceneManager.sceneCount, Is.EqualTo(sceneCount)); + Assert.That(File.ReadAllBytes(Path.Combine(bundleDirectory, "assets")), Is.EqualTo(bytes)); + } + + [Test] + public void GetAssetBundleInfo_ListsScenesWithoutChangingLoadedAssetsOrScenes() + { + var existing = AssetBundle.LoadFromFile(Path.Combine(bundleDirectory, "assets")); + Assert.That(existing, Is.Not.Null); + try + { + var asset = existing.LoadAsset(existing.GetAllAssetNames()[0]); + int loadedBefore = AssetBundle.GetAllLoadedAssetBundles().Count(); + int scenesBefore = SceneManager.sceneCount; + var response = Call("get_asset_bundle_info", relativeBundleDirectory + "/scenes"); + Assert.That(response.Value("success"), Is.True, response.ToString()); + Assert.That(response["data"].Value("isStreamedSceneAssetBundle"), Is.True); + Assert.That(response["data"].Value("sceneCount"), Is.EqualTo(1)); + Assert.That(response["data"]["entries"][0].Value("type"), Is.EqualTo("scene")); + Assert.That(response["data"]["entries"][0].Value("name"), Does.EndWith("/fixture.unity")); + Assert.That(AssetBundle.GetAllLoadedAssetBundles().Count(), Is.EqualTo(loadedBefore)); + Assert.That(SceneManager.sceneCount, Is.EqualTo(scenesBefore)); + Assert.That(asset.text, Is.EqualTo("alpha fixture")); + } + finally { existing.Unload(true); } + } + + [Test] + public void GetAssetBundleInfo_DoesNotUnloadAnAlreadyLoadedSelectedBundle() + { + var existing = AssetBundle.LoadFromFile(Path.Combine(bundleDirectory, "assets")); + Assert.That(existing, Is.Not.Null); + try + { + LogAssert.Expect(LogType.Error, new Regex("already loaded", RegexOptions.IgnoreCase)); + var response = Call("get_asset_bundle_info", relativeBundleDirectory + "/assets"); + Assert.That(response.Value("success"), Is.False); + Assert.That(existing.GetAllAssetNames().Length, Is.EqualTo(2)); + Assert.That(AssetBundle.GetAllLoadedAssetBundles().Contains(existing), Is.True); + } + finally { existing.Unload(true); } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs.meta new file mode 100644 index 000000000..670b65fad --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AssetBundleExplorerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8d15daca084484c9fe2c19fdbbde3c0 diff --git a/manifest.json b/manifest.json index 98e2df4db..96ed33a0b 100644 --- a/manifest.json +++ b/manifest.json @@ -107,7 +107,7 @@ }, { "name": "manage_asset", - "description": "Create, modify, search, and organize Unity assets" + "description": "Create, modify, search, and organize Unity assets; explore built AssetBundle contents" }, { "name": "manage_build", diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 8e3467b87..5d86260e5 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -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 | diff --git a/website/docs/guides/cli.md b/website/docs/guides/cli.md index 9fac634e3..329948742 100644 --- a/website/docs/guides/cli.md +++ b/website/docs/guides/cli.md @@ -297,6 +297,10 @@ unity-mcp asset search "*.mat" --path "Assets/Materials" # Get asset info unity-mcp asset info "Assets/Materials/Red.mat" +# Browse built AssetBundles (read-only, project-local paths) +unity-mcp asset bundles "Builds/AssetBundles" --limit 25 +unity-mcp asset bundle-info "Builds/AssetBundles/characters" --page 1 + # Create folder unity-mcp asset mkdir "Assets/NewFolder" @@ -560,7 +564,7 @@ unity-mcp raw manage_packages '{"action": "list_packages"}' | `animation animator` | `crossfade`, `get-parameter`, `info`, `play`, `set-enabled`, `set-parameter`, `set-speed` | | `animation clip` | `add-curve`, `add-event`, `assign`, `create`, `create-preset`, `info`, `remove-event`, `set-curve`, `set-vector-curve` | | `animation controller` | `add-blend-tree-child`, `add-layer`, `add-parameter`, `add-state`, `add-transition`, `assign`, `create`, `create-blend-tree-1d`, `create-blend-tree-2d`, `info`, `remove-layer`, `set-layer-weight` | -| `asset` | `create`, `delete`, `duplicate`, `import`, `info`, `mkdir`, `move`, `rename`, `search` | +| `asset` | `bundle-info`, `bundles`, `create`, `delete`, `duplicate`, `import`, `info`, `mkdir`, `move`, `rename`, `search` | | `asset-gen` | `generate-audio`, `generate-image`, `generate-model`, `import-model`, `import-model-file`, `list-models`, `status` | | `audio` | `play`, `stop`, `volume` | | `batch` | `inline`, `run`, `template` | diff --git a/website/docs/reference/cli.md b/website/docs/reference/cli.md index b08b9b0d9..2b4602de1 100644 --- a/website/docs/reference/cli.md +++ b/website/docs/reference/cli.md @@ -57,7 +57,7 @@ The CLI mirrors the MCP tool catalog. Each command sends one or more MCP tool ca | `unity-mcp gameobject` | Find/create/modify/move/duplicate/delete GameObjects | [`manage_gameobject`](/reference/tools/core/manage_gameobject), [`find_gameobjects`](/reference/tools/core/find_gameobjects) (`find`), [`manage_components`](/reference/tools/core/manage_components) (`create --components`) | | `unity-mcp component` | Add/remove/configure components | [`manage_components`](/reference/tools/core/manage_components) | | `unity-mcp script` | Create/read/edit/validate/delete C# scripts | [`manage_script`](/reference/tools/core/manage_script) | -| `unity-mcp asset` | Asset import/create/modify/search | [`manage_asset`](/reference/tools/core/manage_asset) | +| `unity-mcp asset` | Asset import/create/modify/search; browse built AssetBundles | [`manage_asset`](/reference/tools/core/manage_asset) | | `unity-mcp asset-gen` | Generate images, 3D models and audio; import models | [`generate_image`](/reference/tools/asset_gen/generate_image), [`generate_model`](/reference/tools/asset_gen/generate_model), [`generate_audio`](/reference/tools/asset_gen/generate_audio), [`import_model`](/reference/tools/asset_gen/import_model), [`import_model_file`](/reference/tools/asset_gen/import_model_file) | | `unity-mcp blender` | Talk to a running Blender through the Blender Bridge | [`blender_bridge`](/reference/tools/asset_gen/blender_bridge) | | `unity-mcp material` | Material CRUD + shader props | [`manage_material`](/reference/tools/core/manage_material) | diff --git a/website/docs/reference/tools/core/manage_asset.md b/website/docs/reference/tools/core/manage_asset.md index ad1485f3f..fae78a2e0 100644 --- a/website/docs/reference/tools/core/manage_asset.md +++ b/website/docs/reference/tools/core/manage_asset.md @@ -12,7 +12,7 @@ description: "Performs asset operations (import, create, modify, delete, etc.) i ## Description -Performs asset operations (import, create, modify, delete, etc.) in Unity. +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. Tip (payload safety): for `action="search"`, prefer paging (`page_size`, `page_number`) and keep `generate_preview=false` (previews can add large base64 blobs). @@ -20,8 +20,8 @@ Tip (payload safety): for `action="search"`, prefer paging (`page_size`, `page_n | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components']` | yes | Perform CRUD operations on assets. | -| `path` | `str` | yes | Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets'). | +| `action` | `Literal['import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components', 'list_asset_bundles', 'get_asset_bundle_info']` | yes | Perform asset operations or read built AssetBundles. | +| `path` | `str` | yes | 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` | `str \| None` | — | Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object. | | `properties` | `dict[str, Any] \| str \| None` | — | Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values. | | `destination` | `str \| None` | — | Target path for 'duplicate'/'move'. | @@ -29,7 +29,7 @@ Tip (payload safety): for `action="search"`, prefer paging (`page_size`, `page_n | `search_pattern` | `str \| None` | — | Search pattern (e.g., '*.prefab' or AssetDatabase filters like 't:MonoScript'). Recommended: put queries like 't:MonoScript' here and set path='Assets'. | | `filter_type` | `str \| None` | — | Filter type for search | | `filter_date_after` | `str \| None` | — | Date after which to filter | -| `page_size` | `int \| float \| str \| None` | — | Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses). | +| `page_size` | `int \| float \| str \| None` | — | Page size for pagination. Recommended: 25. Bundle actions default to 25 and allow 1-200. | | `page_number` | `int \| float \| str \| None` | — | Page number for pagination (1-based). | ## Returns @@ -39,6 +39,29 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -*No examples yet. Add usage examples here — they will be preserved across regenerations.* +### Explore built AssetBundles + +List a project-local output directory without loading bundles: + +```json +{"action": "list_asset_bundles", "path": "Builds/AssetBundles", "page_size": 25, "page_number": 1} +``` + +The returned `entries` contain `type: "bundle"` or `type: "directory"`, project-relative paths, and file sizes. Follow directory paths to browse nested bundles. Header detection includes UnityFS, UnityRaw, and UnityWeb files, even when they have no extension; a recognized header does not guarantee a valid bundle. + +Inspect one built bundle's asset or scene names: + +```json +{"action": "get_asset_bundle_info", "path": "Builds/AssetBundles/characters", "page_size": 25, "page_number": 1} +``` + +Both actions return `totalEntries`, `pageSize`, `pageNumber`, and `nextPageNumber` (null on the last page). Inspection also returns `bundleName`, `sizeBytes`, `assetCount`, `sceneCount`, and `isStreamedSceneAssetBundle`. Each content entry has a `type` (`asset` or `scene`) and `name`. + +Paths must stay inside the project; symbolic links and reparse points are not traversed. Page sizes range from 1 to 200 and default to 25. Inspection requires the built-in AssetBundle module and a bundle compatible with the active Editor platform. It releases only its own temporary bundle handle, loads no assets or scenes, and leaves files unchanged. An already loaded selected bundle reports an error and stays loaded. + +```bash +unity-mcp asset bundles "Builds/AssetBundles" --limit 25 +unity-mcp asset bundle-info "Builds/AssetBundles/characters" --limit 25 --page 1 +```