diff --git a/MCPForUnity/Editor/Tools/ManageAssetStore.cs b/MCPForUnity/Editor/Tools/ManageAssetStore.cs new file mode 100644 index 000000000..6b8f3b038 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageAssetStore.cs @@ -0,0 +1,1128 @@ +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; + if (page < 1) return new ErrorResponse("'page' must be >= 1."); + if (pageSize < 1) return new ErrorResponse("'page_size' must be >= 1."); + + try + { + await EnsureMyAssetsPageActiveAsync(); + + var (myAssetsPage, vsl, realTotal) = GetMyAssetsPageInfo(); + int loadedCount = GetVisualStateLoaded(vsl); + int needed = Math.Min(page * pageSize, realTotal > 0 ? realTotal : int.MaxValue); + + 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)); + + (_, vsl, realTotal) = GetMyAssetsPageInfo(); + loadedCount = GetVisualStateLoaded(vsl); + if (loadedCount < needed && loadedCount < realTotal) + return new ErrorResponse( + $"Timed out loading Asset Store purchases. Only {loadedCount}/{realTotal} loaded."); + } + } + + var result = ReadPackagesFromVisualStateList(vsl, page, pageSize, realTotal); + 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."); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to list purchases: {e.Message}"); + } + finally + { + DestroyHiddenWindow(); + } + } + + 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 (Exception e) { McpLog.Warn($"[ManageAssetStore] DestroyHiddenWindow: {e.Message}"); } + _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)); + + var (_, _, completed, downloadError) = GetDownloadState(productId); + string packagePath = GetCachedPackagePath(productId); + + 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 } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to start download: {e.Message}"); + } + finally + { + DestroyHiddenWindow(); + } + } + + 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."); + + 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}"); + } + finally + { + DestroyHiddenWindow(); + } + } + + // ── 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; + } + + 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."); + } + + // 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}, " + + $"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" + && m.GetParameters().Length == 1); + } + + if (_assetStoreCacheType != null) + { + _cacheGetLocalInfo = _assetStoreCacheType.GetMethods(all) + .FirstOrDefault(m => m.Name == "GetLocalInfo" + && m.GetParameters().Length == 1); + } + + 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) + { + 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; + } + } + 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; + object arg = paramType == typeof(string) ? (object)productId.ToString() : productId; + var op = _getDownloadOpMethod.Invoke(managerInstance, new[] { arg }); + + 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 (Exception e) + { + McpLog.Warn($"[ManageAssetStore] Failed to create hidden PM window: {e.Message}"); + } + + 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) + { + 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 (frameCount++ % 30 != 0) return; + + if ((DateTime.UtcNow - start) > timeout || !(bool)(prop.GetValue(listOp) ?? false)) + { + 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; + int frameCount = 0; + + void Tick() + { + if (tcs.Task.IsCompleted) { EditorApplication.update -= Tick; return; } + if (frameCount++ % 30 != 0) return; + + if ((DateTime.UtcNow - start) > timeout) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + return; + } + + var (_, _, completed, error) = GetDownloadState(productId); + if (completed || !string.IsNullOrEmpty(error)) + { + 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 +