From cb8b2fb27eeec954b3be4ea606a9c8a1368cf271 Mon Sep 17 00:00:00 2001 From: Sebastian Muehr Date: Sat, 4 Apr 2026 00:48:10 +0200 Subject: [PATCH 1/5] feat: add manage_asset_store tool for Asset Store package operations Add a new MCP tool that enables AI assistants to list, download, and import Unity Asset Store packages programmatically. ## Actions - **check_auth**: Verify Unity account login status - **list_purchases**: List all purchased Asset Store packages with demand-driven pagination via MyAssetsPage.LoadMore - **download**: Download a package by product ID (awaits completion) - **import**: Import a downloaded .unitypackage into the project ## Architecture Uses reflection into Unity's internal Package Manager APIs (UnityEditor.PackageManager.UI.Internal.*), following the same pattern as the existing FrameDebuggerOps profiler tool. A hidden (never-shown) PackageManagerWindow instance provides access to all internal services without any visible UI side effects. Key internal types accessed: - AssetStoreDownloadManager (download via IEnumerable overload) - AssetStoreCache/AssetStoreLocalInfo (package path lookup) - AssetStoreListOperation (pagination progress tracking) - PackageDatabase (package metadata) - PaginatedVisualStateList (accurate purchase count/pagination) ## Implementation Details - Async handlers using TaskCompletionSource + EditorApplication.update (same pattern as RefreshUnity.cs) - Lazy type/method resolution cached across calls, cleared on domain reload - Graceful degradation: returns clear error messages if internal APIs are unavailable or changed in a different Unity version - Tested on Unity 6000.4.0f1 (Linux) ## Files - MCPForUnity/Editor/Tools/ManageAssetStore.cs (C# handler) - Server/src/services/tools/manage_asset_store.py (Python MCP tool) - Server/src/cli/commands/asset_store.py (CLI commands) - Server/src/cli/main.py (CLI registration, +1 line) - Server/tests/test_manage_asset_store.py (14 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ManageAssetStore.cs | 1136 +++++++++++++++++ .../Editor/Tools/ManageAssetStore.cs.meta | 2 + Server/src/cli/commands/asset_store.py | 84 ++ Server/src/cli/main.py | 1 + .../src/services/tools/manage_asset_store.py | 69 + Server/tests/test_manage_asset_store.py | 193 +++ 6 files changed, 1485 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/ManageAssetStore.cs create mode 100644 MCPForUnity/Editor/Tools/ManageAssetStore.cs.meta create mode 100644 Server/src/cli/commands/asset_store.py create mode 100644 Server/src/services/tools/manage_asset_store.py create mode 100644 Server/tests/test_manage_asset_store.py diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs new file mode 100644 index 000000000..85f3fef3a --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -0,0 +1,1136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("manage_asset_store", AutoRegister = false, Group = "core")] + public static class ManageAssetStore + { + // ── Reflection cache ────────────────────────────────────────────── + + private static bool _reflectionInitialized; + private static bool _reflectionAvailable; + + // UnityEditor.Connect.UnityConnect + private static Type _unityConnectType; + private static PropertyInfo _unityConnectInstance; + private static PropertyInfo _unityConnectLoggedIn; + + // AssetStoreDownloadManager (download packages) + private static Type _downloadManagerType; + private static MethodInfo _getDownloadOpMethod; + + // AssetStoreCache (local info for downloaded packages) + private static Type _assetStoreCacheType; + private static MethodInfo _cacheGetLocalInfo; + + // AssetStoreLocalInfo (package path extraction) + private static Type _assetStoreLocalInfoType; + private static MethodInfo _localInfoToDictionary; + + // AssetStoreListOperation (tracks LoadMore progress) + private static Type _listOperationType; + + // AssetStoreDownloadOperation (download state tracking) + private static Type _downloadOperationType; + private static PropertyInfo _downloadOpErrorMessage; + + [InitializeOnLoadMethod] + private static void OnLoad() + { + AssemblyReloadEvents.afterAssemblyReload += () => + { + _reflectionInitialized = false; + _reflectionAvailable = false; + DestroyHiddenWindow(); + _cachedRoot = null; + _serviceCache.Clear(); + }; + } + + // ── Entry point ─────────────────────────────────────────────────── + + public static async Task HandleCommand(JObject @params) + { + if (@params == null) + return new ErrorResponse("Parameters cannot be null."); + + var p = new ToolParams(@params); + + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + return new ErrorResponse(actionResult.ErrorMessage); + + string action = actionResult.Value.ToLowerInvariant(); + + try + { + switch (action) + { + case "check_auth": + return CheckAuth(); + case "list_purchases": + return await ListPurchasesAsync(p); + case "download": + return await DownloadAsync(p); + case "import": + return Import(p); + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Supported actions: check_auth, list_purchases, download, import."); + } + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace }); + } + } + + // ── Actions ─────────────────────────────────────────────────────── + + private static object CheckAuth() + { + if (!EnsureReflection(out var error)) + return error; + + try + { + var instance = _unityConnectInstance.GetValue(null); + bool loggedIn = (bool)_unityConnectLoggedIn.GetValue(instance); + + return new SuccessResponse( + loggedIn ? "User is logged into Unity account." : "User is NOT logged in. Log in via Edit > Preferences > Accounts or Unity Hub.", + new { logged_in = loggedIn, unity_version = Application.unityVersion } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to check auth status: {e.Message}"); + } + } + + private static async Task ListPurchasesAsync(ToolParams p) + { + if (!EnsureReflection(out var error)) + return error; + + if (!CheckLoggedIn(out var authError)) + return authError; + + int page = p.GetInt("page") ?? 1; + int pageSize = p.GetInt("page_size") ?? p.GetInt("pageSize") ?? 50; + + try + { + // Ensure My Assets page is active and initial load is complete + await EnsureMyAssetsPageActiveAsync(); + + var (myAssetsPage, vsl, realTotal) = GetMyAssetsPageInfo(); + int loadedCount = GetVisualStateLoaded(vsl); + int needed = Math.Min(page * pageSize, realTotal > 0 ? realTotal : int.MaxValue); + + // If we need more packages than currently loaded, trigger LoadMore and wait + if (needed > loadedCount && loadedCount < realTotal && myAssetsPage != null) + { + long toLoad = needed - loadedCount; + + var loadMoreMethod = myAssetsPage.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "LoadMore"); + + if (loadMoreMethod != null) + { + var paramType = loadMoreMethod.GetParameters().FirstOrDefault()?.ParameterType; + if (paramType == typeof(long)) + loadMoreMethod.Invoke(myAssetsPage, new object[] { toLoad }); + else if (paramType == typeof(int)) + loadMoreMethod.Invoke(myAssetsPage, new object[] { (int)toLoad }); + + object listOp = GetListOperation(myAssetsPage); + await WaitForOperationAsync(listOp, TimeSpan.FromSeconds(30)); + + // Re-read the visual state list after loading + (_, vsl, realTotal) = GetMyAssetsPageInfo(); + } + } + + var result = ReadPackagesFromVisualStateList(vsl, page, pageSize, realTotal); + + // Close the PM window if we auto-opened it + DestroyHiddenWindow(); + + if (result != null) + return result; + + return new ErrorResponse( + "Asset Store purchase listing is not available in this Unity version. " + + $"Unity {Application.unityVersion} may not expose the required internal APIs."); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to list purchases: {e.Message}"); + } + } + + private static async Task EnsureMyAssetsPageActiveAsync() + { + var root = GetPackageManagerRoot(); + if (root == null) return; + + var pmField = root.GetType().GetField("m_PageManager", + BindingFlags.NonPublic | BindingFlags.Instance); + var pm = pmField?.GetValue(root); + if (pm == null) return; + + // Check if My Assets page is the active page + var activePageProp = pm.GetType().GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(prop => prop.Name == "activePage"); + + var getPageMethod = pm.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "GetPage" && m.GetParameters().Length == 1); + + if (activePageProp == null || getPageMethod == null) return; + + var myAssetsPage = getPageMethod.Invoke(pm, new object[] { "MyAssets" }); + if (myAssetsPage == null) return; + + var activePage = activePageProp.GetValue(pm); + if (activePage != myAssetsPage) + { + // Set active page to My Assets — triggers OnActivated which calls ListPurchases + var setActiveMethod = pm.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "SetActivePage" || m.Name == "set_activePage"); + + if (setActiveMethod != null) + setActiveMethod.Invoke(pm, new[] { myAssetsPage }); + else if (activePageProp.CanWrite) + activePageProp.SetValue(pm, myAssetsPage); + } + + // Wait for initial load: poll until countTotal > 0 or operation finishes + var vslField = myAssetsPage.GetType().GetFields( + BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name == "m_VisualStateList"); + var vsl = vslField?.GetValue(myAssetsPage); + + int countTotal = GetVisualStatePropInt(vsl, "countTotal"); + if (countTotal > 0) return; // Already loaded + + // Wait for the initial ListPurchases triggered by OnActivated + object listOp = GetListOperation(myAssetsPage); + await WaitForOperationAsync(listOp, TimeSpan.FromSeconds(15)); + + // After operation completes, check if countTotal is populated + // If still 0, wait a bit more (REST response may need a frame to propagate) + vsl = vslField?.GetValue(myAssetsPage); + countTotal = GetVisualStatePropInt(vsl, "countTotal"); + if (countTotal == 0) + { + // Give it one more second for propagation + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int frames = 0; + void WaitFrames() + { + frames++; + var total = GetVisualStatePropInt(vslField?.GetValue(myAssetsPage), "countTotal"); + if (total > 0 || frames > 60) // ~1 second + { + EditorApplication.update -= WaitFrames; + tcs.TrySetResult(true); + } + } + EditorApplication.update += WaitFrames; + await tcs.Task; + } + } + + private static void DestroyHiddenWindow() + { + if (_hiddenWindowInstance != null) + { + try { UnityEngine.Object.DestroyImmediate(_hiddenWindowInstance); } + catch { } + _hiddenWindowInstance = null; + _cachedRoot = null; + _serviceCache.Clear(); + } + } + + private static (object myAssetsPage, object visualStateList, int total) GetMyAssetsPageInfo() + { + try + { + var root = GetPackageManagerRoot(); + if (root == null) return (null, null, 0); + + var pmField = root.GetType().GetField("m_PageManager", + BindingFlags.NonPublic | BindingFlags.Instance); + var pm = pmField?.GetValue(root); + if (pm == null) return (null, null, 0); + + var getPageMethod = pm.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "GetPage" && m.GetParameters().Length == 1); + if (getPageMethod == null) return (null, null, 0); + + var myAssetsPage = getPageMethod.Invoke(pm, new object[] { "MyAssets" }); + if (myAssetsPage == null) return (null, null, 0); + + var vslField = myAssetsPage.GetType().GetFields( + BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name == "m_VisualStateList"); + var vsl = vslField?.GetValue(myAssetsPage); + if (vsl == null) return (myAssetsPage, null, 0); + + int total = GetVisualStatePropInt(vsl, "countTotal"); + return (myAssetsPage, vsl, total); + } + catch { return (null, null, 0); } + } + + private static int GetVisualStateLoaded(object vsl) + { + return GetVisualStatePropInt(vsl, "countLoaded"); + } + + private static int GetVisualStatePropInt(object vsl, string propName) + { + if (vsl == null) return 0; + try + { + var prop = vsl.GetType().GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(p => p.Name == propName); + if (prop != null) + { + var val = prop.GetValue(vsl); + if (val is int i) return i; + if (val is long l) return (int)l; + } + } + catch { } + return 0; + } + + private static object GetListOperation(object myAssetsPage) + { + try + { + // myAssetsPage → m_AssetStoreClient → m_ListOperation + var clientField = myAssetsPage.GetType().GetFields( + BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name == "m_AssetStoreClient"); + var client = clientField?.GetValue(myAssetsPage); + if (client == null) return null; + + var listOpField = client.GetType().GetFields( + BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name == "m_ListOperation"); + return listOpField?.GetValue(client); + } + catch { return null; } + } + + private static object ReadPackagesFromVisualStateList(object vsl, int page, int pageSize, int realTotal) + { + if (vsl == null) return null; + + try + { + var root = GetPackageManagerRoot(); + if (root == null) return null; + + var dbField = root.GetType().GetField("m_PackageDatabase", + BindingFlags.NonPublic | BindingFlags.Instance); + var db = dbField?.GetValue(root); + if (db == null) return null; + + // Get GetPackage method on PackageDatabase + var getPackageMethod = db.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "GetPackage" + && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType == typeof(string)); + + // Enumerate visual state list to get unique IDs + var packages = new List(); + int skip = (page - 1) * pageSize; + int count = 0; + int index = 0; + + if (vsl is System.Collections.IEnumerable enumerable) + { + foreach (var state in enumerable) + { + if (state == null) continue; + + // packageUniqueId is a field, not a property + string uniqueId = state.GetType().GetField("packageUniqueId", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(state)?.ToString(); + + if (string.IsNullOrEmpty(uniqueId)) continue; + + index++; + if (index <= skip) continue; + if (count >= pageSize) break; + + // Look up package in database for metadata + string displayName = uniqueId; + string productId = uniqueId; + string publisherName = null; + string category = null; + + if (getPackageMethod != null) + { + try + { + var pkg = getPackageMethod.Invoke(db, new object[] { uniqueId }); + if (pkg != null) + { + var props = pkg.GetType().GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + displayName = props.FirstOrDefault(p => p.Name == "displayName")?.GetValue(pkg)?.ToString() ?? uniqueId; + + var product = props.FirstOrDefault(p => p.Name == "product")?.GetValue(pkg); + if (product != null) + { + var productProps = product.GetType().GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + productId = productProps.FirstOrDefault(p => p.Name == "id")?.GetValue(product)?.ToString() ?? uniqueId; + publisherName = productProps.FirstOrDefault(p => p.Name == "publisherName")?.GetValue(product)?.ToString(); + category = productProps.FirstOrDefault(p => p.Name == "category")?.GetValue(product)?.ToString(); + } + } + } + catch { } + } + + packages.Add(new + { + unique_id = uniqueId, + display_name = displayName, + product_id = productId, + publisher = publisherName, + category, + }); + count++; + } + } + + return new SuccessResponse( + $"Found {packages.Count} Asset Store package(s) (page {page}, {realTotal} total).", + new + { + packages, + total = realTotal, + page, + page_size = pageSize, + has_more = page * pageSize < realTotal, + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to read packages: {e.Message}"); + } + } + + private static async Task DownloadAsync(ToolParams p) + { + if (!EnsureReflection(out var error)) + return error; + + if (!CheckLoggedIn(out var authError)) + return authError; + + var productIdResult = p.GetRequired("product_id", "'product_id' parameter is required for download."); + if (!productIdResult.IsSuccess) + return new ErrorResponse(productIdResult.ErrorMessage); + + if (!long.TryParse(productIdResult.Value, out long productId)) + return new ErrorResponse($"'product_id' must be a number, got '{productIdResult.Value}'."); + + try + { + var startResult = StartDownload(productId); + if (startResult is ErrorResponse) + return startResult; + + await WaitForDownloadAsync(productId, TimeSpan.FromMinutes(5)); + + string packagePath = GetCachedPackagePath(productId); + + DestroyHiddenWindow(); + + return new SuccessResponse( + $"Download completed for product {productId}.", + new { product_id = productId, package_path = packagePath } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to start download: {e.Message}"); + } + } + + private static object Import(ToolParams p) + { + if (!EnsureReflection(out var error)) + return error; + + var productIdResult = p.GetRequired("product_id", "'product_id' parameter is required for import."); + if (!productIdResult.IsSuccess) + return new ErrorResponse(productIdResult.ErrorMessage); + + if (!long.TryParse(productIdResult.Value, out long productId)) + return new ErrorResponse($"'product_id' must be a number, got '{productIdResult.Value}'."); + + try + { + string packagePath = GetCachedPackagePath(productId); + if (string.IsNullOrEmpty(packagePath)) + return new ErrorResponse($"No downloaded package found for product ID {productId}. Use 'download' first."); + + if (!System.IO.File.Exists(packagePath)) + return new ErrorResponse($"Package file not found at '{packagePath}'. The cached download may be corrupt. Re-download with 'download' action."); + + DestroyHiddenWindow(); + + AssetDatabase.ImportPackage(packagePath, false); + + return new SuccessResponse( + $"Package imported from '{packagePath}'.", + new { product_id = productId, package_path = packagePath } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to import package: {e.Message}"); + } + } + + // ── Reflection initialization ───────────────────────────────────── + + private static bool EnsureReflection(out object error) + { + error = null; + if (_reflectionInitialized) + { + if (!_reflectionAvailable) + { + error = new ErrorResponse( + "Asset Store internal APIs are not available in this Unity version. " + + $"Unity {Application.unityVersion} may not expose the required types. " + + "Try opening Window > Package Manager first, then retry."); + } + return _reflectionAvailable; + } + + _reflectionInitialized = true; + + try + { + // Find UnityConnect for auth check + _unityConnectType = FindType("UnityEditor.Connect.UnityConnect"); + if (_unityConnectType != null) + { + // Use GetProperties to avoid AmbiguousMatchException + _unityConnectInstance = _unityConnectType + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .FirstOrDefault(p => p.Name == "instance"); + _unityConnectLoggedIn = _unityConnectType + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(p => p.Name == "loggedIn"); + } + + // Scan ALL assemblies for internal Asset Store types using GetTypes() + // (GetExportedTypes only returns public types; Asset Store types are internal) + var assetStoreTypes = new Dictionary(); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + foreach (var type in asm.GetTypes()) + { + if (type.FullName != null && type.FullName.Contains("AssetStore")) + { + assetStoreTypes[type.FullName] = type; + } + } + } + catch (ReflectionTypeLoadException) + { + // Some assemblies fail to load types + } + catch + { + // Skip problematic assemblies + } + } + + McpLog.Info($"[ManageAssetStore] Found {assetStoreTypes.Count} AssetStore-related types."); + + // Match types by name suffix to handle different namespaces across Unity versions + _listOperationType = FindByNameSuffix(assetStoreTypes, "AssetStoreListOperation"); + _downloadManagerType = FindByNameSuffix(assetStoreTypes, "AssetStoreDownloadManager"); + _assetStoreCacheType = FindByNameSuffix(assetStoreTypes, "AssetStoreCache"); + _assetStoreLocalInfoType = FindByNameSuffix(assetStoreTypes, "AssetStoreLocalInfo"); + _downloadOperationType = FindByNameSuffix(assetStoreTypes, "AssetStoreDownloadOperation"); + + // Resolve methods on discovered types + ResolveReflectionMembers(); + + _reflectionAvailable = _unityConnectType != null + && _unityConnectInstance != null + && _unityConnectLoggedIn != null; + + if (!_reflectionAvailable) + { + error = new ErrorResponse( + "Asset Store internal APIs are not available. " + + $"Unity {Application.unityVersion} may not expose the required types. " + + "Try opening Window > Package Manager first, then retry."); + } + + McpLog.Info($"[ManageAssetStore] Reflection init: available={_reflectionAvailable}, " + + $"downloadMgr={_downloadManagerType != null}, " + + $"cache={_assetStoreCacheType != null}, " + + $"listOp={_listOperationType != null}"); + + return _reflectionAvailable; + } + catch (Exception e) + { + _reflectionAvailable = false; + error = new ErrorResponse($"Failed to initialize Asset Store reflection: {e.Message}"); + McpLog.Warn($"[ManageAssetStore] Reflection init failed: {e}"); + return false; + } + } + + private static void ResolveReflectionMembers() + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + if (_downloadManagerType != null) + { + _getDownloadOpMethod = _downloadManagerType.GetMethods(all) + .FirstOrDefault(m => m.Name == "GetDownloadOperation"); + } + + if (_assetStoreCacheType != null) + { + _cacheGetLocalInfo = _assetStoreCacheType.GetMethods(all) + .FirstOrDefault(m => m.Name == "GetLocalInfo"); + } + + if (_assetStoreLocalInfoType != null) + { + _localInfoToDictionary = _assetStoreLocalInfoType.GetMethods(all) + .FirstOrDefault(m => m.Name == "ToDictionary"); + } + + if (_downloadOperationType != null) + { + _downloadOpErrorMessage = _downloadOperationType.GetProperties(all) + .FirstOrDefault(p => p.Name == "errorMessage"); + } + } + + private static Type FindType(string fullName) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var type = asm.GetType(fullName); + if (type != null) return type; + } + catch + { + // Some assemblies throw on GetType + } + } + return null; + } + + private static Type FindByNameSuffix(Dictionary types, string suffix) + { + // Exact match on type name (last segment of FullName) + foreach (var kvp in types) + { + if (kvp.Value.Name == suffix) + return kvp.Value; + } + return null; + } + + // ── Auth helper ─────────────────────────────────────────────────── + + private static bool CheckLoggedIn(out object error) + { + error = null; + try + { + var instance = _unityConnectInstance.GetValue(null); + bool loggedIn = (bool)_unityConnectLoggedIn.GetValue(instance); + if (!loggedIn) + { + error = new ErrorResponse( + "Not logged into Unity account. Log in via Edit > Preferences > Accounts or Unity Hub, then retry."); + } + return loggedIn; + } + catch (Exception e) + { + error = new ErrorResponse($"Failed to check login status: {e.Message}"); + return false; + } + } + + // ── Download logic ──────────────────────────────────────────────── + + private static object StartDownload(long productId) + { + // Try to use AssetStoreDownloadManager.Download via reflection + if (_downloadManagerType == null) + { + return new ErrorResponse( + "Asset Store download API is not available in this Unity version. " + + $"Unity {Application.unityVersion} may not expose AssetStoreDownloadManager."); + } + + var managerInstance = GetServiceInstance(_downloadManagerType); + if (managerInstance == null) + { + return new ErrorResponse( + "Could not access Asset Store download manager. " + + "Try opening Window > Package Manager > My Assets first.", + new { manager_type = _downloadManagerType?.FullName ?? "null" }); + } + + var downloadMethods = managerInstance.GetType().GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(m => m.Name == "Download") + .ToArray(); + + if (downloadMethods.Length == 0) + { + return new ErrorResponse("No Download method found on AssetStoreDownloadManager.", + new { manager_type = managerInstance.GetType().FullName }); + } + + try + { + // Try each overload with appropriate parameter conversion + bool invoked = false; + string invokedOverload = null; + + // Prefer the IEnumerable overload — the single-arg long overload is a no-op + var batchOverload = downloadMethods.FirstOrDefault(dm => + { + var ps = dm.GetParameters(); + return ps.Length == 1 && typeof(System.Collections.IEnumerable).IsAssignableFrom(ps[0].ParameterType) + && ps[0].ParameterType != typeof(long) && ps[0].ParameterType != typeof(string); + }); + + if (batchOverload != null) + { + try + { + batchOverload.Invoke(managerInstance, new object[] { new List { productId } }); + invoked = true; + invokedOverload = "IEnumerable"; + } + catch (Exception ex) + { + McpLog.Warn($"[ManageAssetStore] Batch Download failed: {ex.InnerException?.Message ?? ex.Message}"); + } + } + + // Fallback to other overloads + if (!invoked) + { + foreach (var dm in downloadMethods) + { + var ps = dm.GetParameters(); + try + { + if (ps.Length == 1) + { + dm.Invoke(managerInstance, new object[] { productId }); + invoked = true; + invokedOverload = ps[0].ParameterType.Name; + break; + } + } + catch (Exception ex) + { + McpLog.Warn($"[ManageAssetStore] Download overload failed: {ex.InnerException?.Message ?? ex.Message}"); + } + } + } + + if (!invoked && downloadMethods.Length > 0) + { + return new ErrorResponse( + "Download method exists but no overload matched.", + new { product_id = productId }); + } + + McpLog.Info($"[ManageAssetStore] Download invoked via {invokedOverload} overload for product {productId}"); + + return new SuccessResponse($"Download started for product {productId}."); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to start download for product {productId}: {e.Message}"); + } + } + + private static (string phase, float progress, bool completed, string error) GetDownloadState(long productId) + { + try + { + var managerInstance = GetServiceInstance(_downloadManagerType); + if (managerInstance != null && _getDownloadOpMethod != null) + { + var paramType = _getDownloadOpMethod.GetParameters().FirstOrDefault()?.ParameterType; + var op = _getDownloadOpMethod.Invoke(managerInstance, + new object[] { paramType == typeof(long) ? (object)productId : productId }); + + if (op != null) + { + // Download still in progress — check for errors + string errorMsg = _downloadOpErrorMessage?.GetValue(op) as string; + if (!string.IsNullOrEmpty(errorMsg)) + return ("error", 0f, false, errorMsg); + + return ("downloading", 0f, false, null); + } + + // Operation is null — download finished (operation removed after completion) + string packagePath = GetCachedPackagePath(productId); + if (!string.IsNullOrEmpty(packagePath)) + return ("completed", 1f, true, null); + } + } + catch (Exception e) + { + McpLog.Warn($"[ManageAssetStore] GetDownloadState error: {e.Message}"); + } + + return ("downloading", 0f, false, null); + } + + // ── Service instance access via PM window root ──────────────────── + + private static object _cachedRoot; + private static readonly Dictionary _serviceCache = new(); + + private static object GetServiceInstance(Type serviceType) + { + if (serviceType == null) + return null; + + // Check cache first + string cacheKey = serviceType.FullName; + if (_serviceCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var root = GetPackageManagerRoot(); + if (root == null) + return null; + + // Use FindFieldOfTypeAssignable to search up to 3 levels deep + // This handles: root → m_DropdownHandler → m_AssetStoreDownloadManager + var result = FindFieldOfTypeAssignable(root, serviceType, 3); + if (result != null) + { + _serviceCache[cacheKey] = result; + return result; + } + } + catch (Exception e) + { + McpLog.Warn($"[ManageAssetStore] GetServiceInstance({serviceType.Name}) failed: {e.Message}"); + } + + return null; + } + + private static object FindFieldOfTypeAssignable(object obj, Type targetType, int maxDepth, int depth = 0) + { + if (obj == null || targetType == null || depth > maxDepth) + return null; + + var objType = obj.GetType(); + var fields = objType.GetFields( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + // Direct match on this object's fields + foreach (var field in fields) + { + try + { + var value = field.GetValue(obj); + if (value != null && targetType.IsAssignableFrom(value.GetType())) + return value; + } + catch { } + } + + // Recurse into non-primitive, non-System fields + foreach (var field in fields) + { + if (field.FieldType.IsPrimitive || field.FieldType == typeof(string) || field.FieldType.IsEnum) + continue; + if (field.FieldType.IsValueType) + continue; + // Only recurse into PM-related types + var ns = field.FieldType.Namespace; + if (ns == null || (!ns.Contains("PackageManager") && !ns.Contains("AssetStore"))) + { + // Also check by runtime type + try + { + var value = field.GetValue(obj); + if (value == null) continue; + var runtimeNs = value.GetType().Namespace; + if (runtimeNs == null || (!runtimeNs.Contains("PackageManager") && !runtimeNs.Contains("AssetStore"))) + continue; + var found = FindFieldOfTypeAssignable(value, targetType, maxDepth, depth + 1); + if (found != null) + return found; + } + catch { } + continue; + } + + try + { + var value = field.GetValue(obj); + var found = FindFieldOfTypeAssignable(value, targetType, maxDepth, depth + 1); + if (found != null) + return found; + } + catch { } + } + + return null; + } + + private static UnityEngine.Object _hiddenWindowInstance; + + private static object GetPackageManagerRoot() + { + if (_cachedRoot != null) + return _cachedRoot; + + var windowType = FindType("UnityEditor.PackageManager.UI.PackageManagerWindow"); + if (windowType == null) + return null; + + // Reuse an existing visible window if one is open + var windows = UnityEngine.Resources.FindObjectsOfTypeAll(windowType); + + if (windows == null || windows.Length == 0) + { + // Create a hidden (never shown) instance — services are fully functional + try + { + _hiddenWindowInstance = ScriptableObject.CreateInstance(windowType); + windows = new[] { _hiddenWindowInstance }; + } + catch { } + + if (windows == null || windows.Length == 0) + return null; + } + + var rootField = windowType.GetField("m_Root", + BindingFlags.NonPublic | BindingFlags.Instance); + if (rootField == null) + return null; + + _cachedRoot = rootField.GetValue(windows[0]); + return _cachedRoot; + } + + // ── Async wait helper ────────────────────────────────────────────── + + private static Task WaitForOperationAsync(object listOp, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var start = DateTime.UtcNow; + + void Tick() + { + if (tcs.Task.IsCompleted) + { + EditorApplication.update -= Tick; + return; + } + + if ((DateTime.UtcNow - start) > timeout) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); // Timeout — return what we have + return; + } + + try + { + bool isInProgress = false; + if (listOp != null && _listOperationType != null) + { + var prop = _listOperationType.GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(p => p.Name == "isInProgress"); + if (prop != null) + isInProgress = (bool)(prop.GetValue(listOp) ?? false); + } + + if (!isInProgress) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + } + catch + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + } + + EditorApplication.update += Tick; + return tcs.Task; + } + + private static Task WaitForDownloadAsync(long productId, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var start = DateTime.UtcNow; + + void Tick() + { + if (tcs.Task.IsCompleted) + { + EditorApplication.update -= Tick; + return; + } + + if ((DateTime.UtcNow - start) > timeout) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + return; + } + + try + { + var (_, _, completed, error) = GetDownloadState(productId); + if (completed || !string.IsNullOrEmpty(error)) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + } + catch + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + } + + EditorApplication.update += Tick; + return tcs.Task; + } + + // ── Cache path lookup ───────────────────────────────────────────── + + private static string GetCachedPackagePath(long productId) + { + try + { + // Try AssetStoreCache.GetLocalInfo(productId) + if (_cacheGetLocalInfo != null) + { + var cacheInstance = GetServiceInstance(_assetStoreCacheType); + if (cacheInstance != null) + { + // GetLocalInfo may take long or string + object localInfo = null; + var paramType = _cacheGetLocalInfo.GetParameters().FirstOrDefault()?.ParameterType; + if (paramType == typeof(long)) + localInfo = _cacheGetLocalInfo.Invoke(cacheInstance, new object[] { productId }); + else if (paramType == typeof(string)) + localInfo = _cacheGetLocalInfo.Invoke(cacheInstance, new object[] { productId.ToString() }); + else + localInfo = _cacheGetLocalInfo.Invoke(cacheInstance, new object[] { productId }); + + if (localInfo != null) + { + // Try ToDictionary first + if (_localInfoToDictionary != null) + { + var dict = _localInfoToDictionary.Invoke(localInfo, Array.Empty()); + if (dict is System.Collections.IDictionary d) + { + foreach (var key in new[] { "packagePath", "PackagePath", "packagepath" }) + { + if (d.Contains(key)) + { + var path = d[key] as string; + if (!string.IsNullOrEmpty(path)) + return path; + } + } + } + } + + // Try reading fields directly on AssetStoreLocalInfo + var infoFields = localInfo.GetType().GetFields( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + foreach (var f in infoFields) + { + if (f.Name.IndexOf("packagePath", StringComparison.OrdinalIgnoreCase) >= 0 + || f.Name.IndexOf("path", StringComparison.OrdinalIgnoreCase) >= 0) + { + var path = f.GetValue(localInfo) as string; + if (!string.IsNullOrEmpty(path)) + return path; + } + } + + McpLog.Warn($"[ManageAssetStore] LocalInfo found for {productId} but no package path could be extracted."); + } + } + } + + return null; + } + catch (Exception e) + { + McpLog.Warn($"[ManageAssetStore] GetCachedPackagePath error: {e.Message}"); + return null; + } + } + + } +} diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs.meta b/MCPForUnity/Editor/Tools/ManageAssetStore.cs.meta new file mode 100644 index 000000000..1938cac52 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36b478bf1faa66e7d8384c724da0a64c \ No newline at end of file diff --git a/Server/src/cli/commands/asset_store.py b/Server/src/cli/commands/asset_store.py new file mode 100644 index 000000000..c6a6286da --- /dev/null +++ b/Server/src/cli/commands/asset_store.py @@ -0,0 +1,84 @@ +"""Asset Store package management CLI commands.""" + +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_success +from cli.utils.connection import run_command, handle_unity_errors + + +@click.group("asset-store") +def asset_store(): + """Asset Store packages - list purchases, download, import.""" + pass + + +@asset_store.command("auth") +@handle_unity_errors +def check_auth(): + """Check Unity account login status. + + \b + Examples: + unity-mcp asset-store auth + """ + config = get_config() + result = run_command("manage_asset_store", {"action": "check_auth"}, config) + click.echo(format_output(result, config.format)) + + +@asset_store.command("list") +@click.option("--page", type=int, default=None, help="Page number (1-based).") +@click.option("--page-size", type=int, default=None, help="Results per page.") +@handle_unity_errors +def list_purchases(page: Optional[int], page_size: Optional[int]): + """List purchased Asset Store packages. + + \b + Examples: + unity-mcp asset-store list + unity-mcp asset-store list --page 1 --page-size 20 + """ + config = get_config() + params: dict[str, Any] = {"action": "list_purchases"} + if page is not None: + params["page"] = page + if page_size is not None: + params["page_size"] = page_size + result = run_command("manage_asset_store", params, config) + click.echo(format_output(result, config.format)) + + +@asset_store.command("download") +@click.argument("product_id", type=int) +@handle_unity_errors +def download(product_id: int): + """Download an Asset Store package. + + \b + Examples: + unity-mcp asset-store download 12345 + """ + config = get_config() + result = run_command("manage_asset_store", {"action": "download", "product_id": product_id}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Download complete.") + + +@asset_store.command("import") +@click.argument("product_id", type=int) +@handle_unity_errors +def import_package(product_id: int): + """Import an already-downloaded Asset Store package. + + \b + Examples: + unity-mcp asset-store import 12345 + """ + config = get_config() + result = run_command("manage_asset_store", {"action": "import", "product_id": product_id}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Package imported.") diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 44afce32c..04ba78fcf 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -271,6 +271,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.camera", "camera"), ("cli.commands.graphics", "graphics"), ("cli.commands.packages", "packages"), + ("cli.commands.asset_store", "asset_store"), ("cli.commands.reflect", "reflect"), ("cli.commands.docs", "docs"), ("cli.commands.physics", "physics"), diff --git a/Server/src/services/tools/manage_asset_store.py b/Server/src/services/tools/manage_asset_store.py new file mode 100644 index 000000000..9a242d78a --- /dev/null +++ b/Server/src/services/tools/manage_asset_store.py @@ -0,0 +1,69 @@ +from typing import Annotated, Any, Optional + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + +ALL_ACTIONS = [ + "check_auth", "list_purchases", "download", "import", +] + + +async def _send_asset_store_command( + ctx: Context, + params_dict: dict[str, Any], +) -> dict[str, Any]: + unity_instance = await get_unity_instance_from_context(ctx) + result = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_asset_store", params_dict + ) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} + + +@mcp_for_unity_tool( + group="core", + description=( + "Manage Unity Asset Store packages: list purchases, download, and import.\n\n" + "AUTH:\n" + "- check_auth: Check if user is logged into their Unity account\n\n" + "QUERY:\n" + "- list_purchases: List purchased Asset Store packages (My Assets)\n\n" + "INSTALL:\n" + "- download: Download an Asset Store package by product ID\n" + "- import: Import an already-downloaded Asset Store package" + ), + annotations=ToolAnnotations( + title="Manage Asset Store", + destructiveHint=True, + readOnlyHint=False, + ), +) +async def manage_asset_store( + ctx: Context, + action: Annotated[str, "The asset store action to perform."], + product_id: Annotated[Optional[int], "Asset Store product ID (for download/import actions)."] = None, + page: Annotated[Optional[int], "Page number for list_purchases (1-based)."] = None, + page_size: Annotated[Optional[int], "Results per page for list_purchases."] = None, +) -> dict[str, Any]: + action_lower = action.lower() + if action_lower not in ALL_ACTIONS: + return { + "success": False, + "message": f"Unknown action '{action}'. Valid actions: {', '.join(ALL_ACTIONS)}", + } + + params_dict: dict[str, Any] = {"action": action_lower} + param_map = { + "product_id": product_id, + "page": page, + "page_size": page_size, + } + for key, val in param_map.items(): + if val is not None: + params_dict[key] = val + + return await _send_asset_store_command(ctx, params_dict) diff --git a/Server/tests/test_manage_asset_store.py b/Server/tests/test_manage_asset_store.py new file mode 100644 index 000000000..9ff88d7e3 --- /dev/null +++ b/Server/tests/test_manage_asset_store.py @@ -0,0 +1,193 @@ +"""Tests for manage_asset_store tool and CLI commands.""" + +import asyncio +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from click.testing import CliRunner + +from cli.commands.asset_store import asset_store +from cli.utils.config import CLIConfig +from services.tools.manage_asset_store import ALL_ACTIONS + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def runner(): + """Return a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_config(): + """Return a default CLIConfig for testing.""" + return CLIConfig( + host="127.0.0.1", + port=8080, + timeout=30, + format="text", + unity_instance=None, + ) + + +@pytest.fixture +def mock_success(): + """Return a generic success response.""" + return {"success": True, "message": "OK", "data": {}} + + +@pytest.fixture +def cli_runner(runner, mock_config, mock_success): + """Invoke an asset-store CLI command with run_command mocked out. + + Usage:: + + def test_something(cli_runner): + result, mock_run = cli_runner(["list"]) + assert result.exit_code == 0 + params = mock_run.call_args.args[1] + assert params["action"] == "list_purchases" + """ + def _invoke(args): + with patch("cli.commands.asset_store.get_config", return_value=mock_config): + with patch("cli.commands.asset_store.run_command", return_value=mock_success) as mock_run: + result = runner.invoke(asset_store, args) + return result, mock_run + return _invoke + + +# ============================================================================= +# Action Lists +# ============================================================================= + +class TestActionLists: + """Verify action list completeness and consistency.""" + + def test_all_actions_is_not_empty(self): + assert len(ALL_ACTIONS) > 0 + + def test_no_duplicate_actions(self): + assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS)) + + def test_expected_auth_actions_present(self): + expected = {"check_auth"} + assert expected.issubset(set(ALL_ACTIONS)) + + def test_expected_query_actions_present(self): + expected = {"list_purchases"} + assert expected.issubset(set(ALL_ACTIONS)) + + def test_expected_install_actions_present(self): + expected = {"download", "import"} + assert expected.issubset(set(ALL_ACTIONS)) + + +# ============================================================================= +# Tool Validation (Python-side, no Unity) +# ============================================================================= + +class TestManageAssetStoreToolValidation: + """Test action validation in the manage_asset_store tool function.""" + + def test_unknown_action_returns_error(self): + from services.tools.manage_asset_store import manage_asset_store + + ctx = MagicMock() + ctx.get_state = AsyncMock(return_value=None) + + result = asyncio.run(manage_asset_store(ctx, action="invalid_action")) + assert result["success"] is False + assert "Unknown action" in result["message"] + + def test_unknown_action_lists_valid_actions(self): + from services.tools.manage_asset_store import manage_asset_store + + ctx = MagicMock() + ctx.get_state = AsyncMock(return_value=None) + + result = asyncio.run(manage_asset_store(ctx, action="bogus")) + assert result["success"] is False + assert "Valid actions" in result["message"] + + def test_unknown_action_does_not_call_unity(self): + from services.tools.manage_asset_store import manage_asset_store + + ctx = MagicMock() + ctx.get_state = AsyncMock(return_value=None) + + with patch( + "services.tools.manage_asset_store._send_asset_store_command", + new_callable=AsyncMock, + ) as mock_send: + asyncio.run(manage_asset_store(ctx, action="bogus")) + mock_send.assert_not_called() + + def test_action_matching_is_case_insensitive(self): + from services.tools.manage_asset_store import manage_asset_store + + ctx = MagicMock() + ctx.get_state = AsyncMock(return_value=None) + + with patch( + "services.tools.manage_asset_store._send_asset_store_command", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = {"success": True, "message": "OK"} + result = asyncio.run(manage_asset_store(ctx, action="CHECK_AUTH")) + + assert result["success"] is True + sent_params = mock_send.call_args.args[1] + assert sent_params["action"] == "check_auth" + + +# ============================================================================= +# CLI Command Parameter Building +# ============================================================================= + +class TestAssetStoreQueryCLICommands: + """Verify query CLI commands build correct parameter dicts.""" + + def test_auth_builds_correct_params(self, cli_runner): + result, mock_run = cli_runner(["auth"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + params = mock_run.call_args.args[1] + assert params["action"] == "check_auth" + + def test_list_builds_correct_params(self, cli_runner): + result, mock_run = cli_runner(["list"]) + assert result.exit_code == 0 + params = mock_run.call_args.args[1] + assert params["action"] == "list_purchases" + assert "page" not in params + assert "page_size" not in params + + def test_list_with_pagination(self, cli_runner): + result, mock_run = cli_runner(["list", "--page", "2", "--page-size", "10"]) + assert result.exit_code == 0 + params = mock_run.call_args.args[1] + assert params["action"] == "list_purchases" + assert params["page"] == 2 + assert params["page_size"] == 10 + + + +class TestAssetStoreInstallCLICommands: + """Verify download/import CLI commands build correct parameter dicts.""" + + def test_download_builds_correct_params(self, cli_runner): + result, mock_run = cli_runner(["download", "12345"]) + assert result.exit_code == 0 + params = mock_run.call_args.args[1] + assert params["action"] == "download" + assert params["product_id"] == 12345 + + def test_import_builds_correct_params(self, cli_runner): + result, mock_run = cli_runner(["import", "12345"]) + assert result.exit_code == 0 + params = mock_run.call_args.args[1] + assert params["action"] == "import" + assert params["product_id"] == 12345 + From 0b109cd26f21fe6bbda54072cbf7368850bdc8c9 Mon Sep 17 00:00:00 2001 From: Sebastian Muehr Date: Sat, 4 Apr 2026 10:21:07 +0200 Subject: [PATCH 2/5] fix: address PR review feedback - Verify download completion after wait: check GetDownloadState and return ErrorResponse on failure/timeout instead of always reporting success - Handle string parameter type in GetDownloadOperation for Unity version compatibility - Throttle async wait polling to every ~0.5s instead of every frame Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ManageAssetStore.cs | 76 ++++++++------------ 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs index 85f3fef3a..2d07943b2 100644 --- a/MCPForUnity/Editor/Tools/ManageAssetStore.cs +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -469,10 +469,18 @@ private static async Task DownloadAsync(ToolParams p) await WaitForDownloadAsync(productId, TimeSpan.FromMinutes(5)); + // Verify download actually completed + var (_, _, completed, downloadError) = GetDownloadState(productId); string packagePath = GetCachedPackagePath(productId); DestroyHiddenWindow(); + if (!string.IsNullOrEmpty(downloadError)) + return new ErrorResponse($"Download failed for product {productId}: {downloadError}"); + + if (!completed && string.IsNullOrEmpty(packagePath)) + return new ErrorResponse($"Download timed out for product {productId}. The package may still be downloading — try again later."); + return new SuccessResponse( $"Download completed for product {productId}.", new { product_id = productId, package_path = packagePath } @@ -808,8 +816,8 @@ private static (string phase, float progress, bool completed, string error) GetD if (managerInstance != null && _getDownloadOpMethod != null) { var paramType = _getDownloadOpMethod.GetParameters().FirstOrDefault()?.ParameterType; - var op = _getDownloadOpMethod.Invoke(managerInstance, - new object[] { paramType == typeof(long) ? (object)productId : productId }); + object arg = paramType == typeof(string) ? (object)productId.ToString() : productId; + var op = _getDownloadOpMethod.Invoke(managerInstance, new[] { arg }); if (op != null) { @@ -975,43 +983,25 @@ private static object GetPackageManagerRoot() private static Task WaitForOperationAsync(object listOp, TimeSpan timeout) { + if (listOp == null || _listOperationType == null) + return Task.CompletedTask; + + var prop = _listOperationType.GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(p => p.Name == "isInProgress"); + if (prop == null) + return Task.CompletedTask; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var start = DateTime.UtcNow; + int frameCount = 0; void Tick() { - if (tcs.Task.IsCompleted) - { - EditorApplication.update -= Tick; - return; - } + if (tcs.Task.IsCompleted) { EditorApplication.update -= Tick; return; } + if (frameCount++ % 30 != 0) return; - if ((DateTime.UtcNow - start) > timeout) - { - EditorApplication.update -= Tick; - tcs.TrySetResult(true); // Timeout — return what we have - return; - } - - try - { - bool isInProgress = false; - if (listOp != null && _listOperationType != null) - { - var prop = _listOperationType.GetProperties( - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(p => p.Name == "isInProgress"); - if (prop != null) - isInProgress = (bool)(prop.GetValue(listOp) ?? false); - } - - if (!isInProgress) - { - EditorApplication.update -= Tick; - tcs.TrySetResult(true); - } - } - catch + if ((DateTime.UtcNow - start) > timeout || !(bool)(prop.GetValue(listOp) ?? false)) { EditorApplication.update -= Tick; tcs.TrySetResult(true); @@ -1026,14 +1016,12 @@ private static Task WaitForDownloadAsync(long productId, TimeSpan timeout) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var start = DateTime.UtcNow; + int frameCount = 0; void Tick() { - if (tcs.Task.IsCompleted) - { - EditorApplication.update -= Tick; - return; - } + if (tcs.Task.IsCompleted) { EditorApplication.update -= Tick; return; } + if (frameCount++ % 30 != 0) return; if ((DateTime.UtcNow - start) > timeout) { @@ -1042,16 +1030,8 @@ void Tick() return; } - try - { - var (_, _, completed, error) = GetDownloadState(productId); - if (completed || !string.IsNullOrEmpty(error)) - { - EditorApplication.update -= Tick; - tcs.TrySetResult(true); - } - } - catch + var (_, _, completed, error) = GetDownloadState(productId); + if (completed || !string.IsNullOrEmpty(error)) { EditorApplication.update -= Tick; tcs.TrySetResult(true); From 30d74706b1d57cb433a033187abb5c11972bd7fc Mon Sep 17 00:00:00 2001 From: Sebastian Muehr Date: Sat, 4 Apr 2026 10:29:44 +0200 Subject: [PATCH 3/5] fix: address CodeRabbit nitpicks - Adapt param type in Download fallback loop (string conversion) - Log exceptions in DestroyHiddenWindow and CreateInstance instead of silently swallowing them Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ManageAssetStore.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs index 2d07943b2..aabdff49d 100644 --- a/MCPForUnity/Editor/Tools/ManageAssetStore.cs +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -259,7 +259,7 @@ private static void DestroyHiddenWindow() if (_hiddenWindowInstance != null) { try { UnityEngine.Object.DestroyImmediate(_hiddenWindowInstance); } - catch { } + catch (Exception e) { McpLog.Warn($"[ManageAssetStore] DestroyHiddenWindow: {e.Message}"); } _hiddenWindowInstance = null; _cachedRoot = null; _serviceCache.Clear(); @@ -778,7 +778,9 @@ private static object StartDownload(long productId) { if (ps.Length == 1) { - dm.Invoke(managerInstance, new object[] { productId }); + object arg = ps[0].ParameterType == typeof(string) + ? (object)productId.ToString() : productId; + dm.Invoke(managerInstance, new object[] { arg }); invoked = true; invokedOverload = ps[0].ParameterType.Name; break; @@ -964,7 +966,10 @@ private static object GetPackageManagerRoot() _hiddenWindowInstance = ScriptableObject.CreateInstance(windowType); windows = new[] { _hiddenWindowInstance }; } - catch { } + catch (Exception e) + { + McpLog.Warn($"[ManageAssetStore] Failed to create hidden PM window: {e.Message}"); + } if (windows == null || windows.Length == 0) return null; From f7b93d2df5824a822c9d7fa0747eb0c4d50ecf8a Mon Sep 17 00:00:00 2001 From: Sebastian Muehr Date: Sun, 5 Apr 2026 11:21:13 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20page=20verification,=20reflection=20retry,=20signat?= =?UTF-8?q?ure=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verify loaded count after LoadMore wait, return error if page wasn't fully loaded instead of returning truncated results - Only memoize reflection init on success — allow retry if types weren't found (e.g. PM window not yet initialized) - Filter GetDownloadOperation and GetLocalInfo by parameter count to prevent caching wrong overload Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ManageAssetStore.cs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs index aabdff49d..95f82e35a 100644 --- a/MCPForUnity/Editor/Tools/ManageAssetStore.cs +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -156,8 +156,15 @@ private static async Task ListPurchasesAsync(ToolParams p) object listOp = GetListOperation(myAssetsPage); await WaitForOperationAsync(listOp, TimeSpan.FromSeconds(30)); - // Re-read the visual state list after loading + // Re-read and verify enough items were loaded (_, vsl, realTotal) = GetMyAssetsPageInfo(); + loadedCount = GetVisualStateLoaded(vsl); + if (loadedCount < needed && loadedCount < realTotal) + { + DestroyHiddenWindow(); + return new ErrorResponse( + $"Timed out loading Asset Store purchases. Only {loadedCount}/{realTotal} loaded."); + } } } @@ -545,8 +552,6 @@ private static bool EnsureReflection(out object error) return _reflectionAvailable; } - _reflectionInitialized = true; - try { // Find UnityConnect for auth check @@ -611,6 +616,9 @@ private static bool EnsureReflection(out object error) "Try opening Window > Package Manager first, then retry."); } + // Only memoize on success — allow retry if types weren't found + _reflectionInitialized = _reflectionAvailable; + McpLog.Info($"[ManageAssetStore] Reflection init: available={_reflectionAvailable}, " + $"downloadMgr={_downloadManagerType != null}, " + $"cache={_assetStoreCacheType != null}, " + @@ -635,13 +643,15 @@ private static void ResolveReflectionMembers() if (_downloadManagerType != null) { _getDownloadOpMethod = _downloadManagerType.GetMethods(all) - .FirstOrDefault(m => m.Name == "GetDownloadOperation"); + .FirstOrDefault(m => m.Name == "GetDownloadOperation" + && m.GetParameters().Length == 1); } if (_assetStoreCacheType != null) { _cacheGetLocalInfo = _assetStoreCacheType.GetMethods(all) - .FirstOrDefault(m => m.Name == "GetLocalInfo"); + .FirstOrDefault(m => m.Name == "GetLocalInfo" + && m.GetParameters().Length == 1); } if (_assetStoreLocalInfoType != null) From 0f2af29c9b2b921e1e7b35c58907cae502550d42 Mon Sep 17 00:00:00 2001 From: Sebastian Muehr Date: Sun, 5 Apr 2026 17:01:40 +0200 Subject: [PATCH 5/5] fix: pagination validation, cache invalidation, and finally cleanup - Reject page < 1 and page_size < 1 with clear error messages - Always clear _cachedRoot and _serviceCache in DestroyHiddenWindow, not just when a hidden instance exists (prevents stale refs after user closes a visible PM window) - Move DestroyHiddenWindow into finally blocks so cleanup runs on all exit paths including early returns and exceptions Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ManageAssetStore.cs | 39 +++++++++----------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs index 95f82e35a..6b8f3b038 100644 --- a/MCPForUnity/Editor/Tools/ManageAssetStore.cs +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -126,17 +126,17 @@ private static async Task ListPurchasesAsync(ToolParams p) int page = p.GetInt("page") ?? 1; int pageSize = p.GetInt("page_size") ?? p.GetInt("pageSize") ?? 50; + if (page < 1) return new ErrorResponse("'page' must be >= 1."); + if (pageSize < 1) return new ErrorResponse("'page_size' must be >= 1."); try { - // Ensure My Assets page is active and initial load is complete await EnsureMyAssetsPageActiveAsync(); var (myAssetsPage, vsl, realTotal) = GetMyAssetsPageInfo(); int loadedCount = GetVisualStateLoaded(vsl); int needed = Math.Min(page * pageSize, realTotal > 0 ? realTotal : int.MaxValue); - // If we need more packages than currently loaded, trigger LoadMore and wait if (needed > loadedCount && loadedCount < realTotal && myAssetsPage != null) { long toLoad = needed - loadedCount; @@ -156,27 +156,16 @@ private static async Task ListPurchasesAsync(ToolParams p) object listOp = GetListOperation(myAssetsPage); await WaitForOperationAsync(listOp, TimeSpan.FromSeconds(30)); - // Re-read and verify enough items were loaded (_, vsl, realTotal) = GetMyAssetsPageInfo(); loadedCount = GetVisualStateLoaded(vsl); if (loadedCount < needed && loadedCount < realTotal) - { - DestroyHiddenWindow(); return new ErrorResponse( $"Timed out loading Asset Store purchases. Only {loadedCount}/{realTotal} loaded."); - } } } var result = ReadPackagesFromVisualStateList(vsl, page, pageSize, realTotal); - - // Close the PM window if we auto-opened it - DestroyHiddenWindow(); - - if (result != null) - return result; - - return new ErrorResponse( + return result ?? new ErrorResponse( "Asset Store purchase listing is not available in this Unity version. " + $"Unity {Application.unityVersion} may not expose the required internal APIs."); } @@ -184,6 +173,10 @@ private static async Task ListPurchasesAsync(ToolParams p) { return new ErrorResponse($"Failed to list purchases: {e.Message}"); } + finally + { + DestroyHiddenWindow(); + } } private static async Task EnsureMyAssetsPageActiveAsync() @@ -268,9 +261,10 @@ private static void DestroyHiddenWindow() try { UnityEngine.Object.DestroyImmediate(_hiddenWindowInstance); } catch (Exception e) { McpLog.Warn($"[ManageAssetStore] DestroyHiddenWindow: {e.Message}"); } _hiddenWindowInstance = null; - _cachedRoot = null; - _serviceCache.Clear(); } + + _cachedRoot = null; + _serviceCache.Clear(); } private static (object myAssetsPage, object visualStateList, int total) GetMyAssetsPageInfo() @@ -476,12 +470,9 @@ private static async Task DownloadAsync(ToolParams p) await WaitForDownloadAsync(productId, TimeSpan.FromMinutes(5)); - // Verify download actually completed var (_, _, completed, downloadError) = GetDownloadState(productId); string packagePath = GetCachedPackagePath(productId); - DestroyHiddenWindow(); - if (!string.IsNullOrEmpty(downloadError)) return new ErrorResponse($"Download failed for product {productId}: {downloadError}"); @@ -497,6 +488,10 @@ private static async Task DownloadAsync(ToolParams p) { return new ErrorResponse($"Failed to start download: {e.Message}"); } + finally + { + DestroyHiddenWindow(); + } } private static object Import(ToolParams p) @@ -520,8 +515,6 @@ private static object Import(ToolParams p) if (!System.IO.File.Exists(packagePath)) return new ErrorResponse($"Package file not found at '{packagePath}'. The cached download may be corrupt. Re-download with 'download' action."); - DestroyHiddenWindow(); - AssetDatabase.ImportPackage(packagePath, false); return new SuccessResponse( @@ -533,6 +526,10 @@ private static object Import(ToolParams p) { return new ErrorResponse($"Failed to import package: {e.Message}"); } + finally + { + DestroyHiddenWindow(); + } } // ── Reflection initialization ─────────────────────────────────────