diff --git a/.claude/skills/blender-to-unity/SKILL.md b/.claude/skills/blender-to-unity/SKILL.md new file mode 100644 index 000000000..46fc3dc05 --- /dev/null +++ b/.claude/skills/blender-to-unity/SKILL.md @@ -0,0 +1,66 @@ +--- +name: blender-to-unity +description: Hand off a model from Blender (via BlenderMCP) into Unity (via MCP for Unity) — export the current Blender model, import it through import_model_file, and place it in the open scene. Use when the user has BlenderMCP and MCP for Unity both connected and wants to bring a Blender model into Unity. Does NOT drive Blender's own generators; BlenderMCP owns how the model got into Blender. +--- + +# Blender → Unity Model Handoff + +Bring whatever model is currently in Blender into the open Unity scene. The seam is the +local filesystem: Blender exports a file, Unity imports it. The two servers never talk +directly. + +## Preconditions +- Both `mcp__blender__*` tools and MCP for Unity tools are connected. +- A model exists in the Blender scene (confirm with `mcp__blender__get_scene_info` / + `mcp__blender__get_object_info`). If empty, stop and tell the user — this skill does not generate models. + +## Steps +1. **Resolve the Unity project path.** Read `mcpforunity://editor/state` for the project + root (the editor dataPath's parent). Decide the export format: **FBX by default** + (built-in importer, zero extra dependencies). Use glTF/.glb only if glTFast is installed + and PBR fidelity matters. +2. **Export from Blender to a temp path** via `mcp__blender__execute_blender_code`: + ```python + import bpy, os, tempfile + out = os.path.join(tempfile.gettempdir(), "blender_to_unity.fbx") + # Export the selection if any, else the whole scene: + bpy.ops.export_scene.fbx(filepath=out, use_selection=bool(bpy.context.selected_objects), + apply_unit_scale=True, bake_space_transform=True) + print(out) + ``` + (glTF branch: `bpy.ops.export_scene.gltf(filepath=out_glb, export_format='GLB')`.) +3. **Import into Unity** with `import_model_file`: + `import_model_file(source_path=, name=, target_size=)`. + It returns `{ asset_path, asset_guid }`. Pass `target_size` as the intended final size, but treat + it only as a hint: it rescales at import solely when the project's **Auto-normalize** pref is on, + and even then is unreliable for Blender FBX (see the **Scale** note). Step 4 does the reliable + normalization. +4. **Place it in the scene, normalized to size.** Ensure the scene has a camera + directional + light (`manage_scene` / `manage_gameobject`). Instantiate the model at the chosen position via + `manage_gameobject(action="create", prefab_path=, name=, position=[x,y,z])`. + Then normalize its size deterministically — Blender FBX commonly imports ~100× too large — by + measuring the placed model's world bounds and scaling so its largest dimension equals your target + size. Run via `execute_code` (substitute your object name and target meters): + ```csharp + var go = GameObject.Find(""); + var rs = go.GetComponentsInChildren(); + var b = rs[0].bounds; for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds); + float maxDim = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z)); + float target = 2f; // intended size in meters + if (maxDim > 0.0001f) go.transform.localScale *= target / maxDim; + ``` +5. **Verify** with `manage_camera(action="screenshot", include_image=true)` and report the + asset path + a screenshot. + +## Notes +- **Scale.** Models from Blender almost always arrive at the wrong scale — its FBX unit handling + makes them land ~100× too large in Unity. `import_model_file`'s `target_size` only rescales at + import when the project's Auto-normalize pref is enabled, and its importer-level normalization is + unreliable for Blender FBX (it can over- or under-shoot, e.g. a `target_size=2` model measured 200 m). + The robust fix is the Step 4 measure-bounds-then-set-`localScale` routine, which hits the target + size deterministically regardless of the import scale or the Auto-normalize pref. +- FBX is the default because glTFast is optional in MCP for Unity. If the import errors with + "GLB import requires glTFast", re-export as FBX (or install glTFast from the Dependencies tab). +- Keep one model per handoff; for batches, repeat the loop with distinct names. +- This skill never sends API keys or file bytes over the MCP bridge — Unity reads the file from disk. +- `import_model_file` copies only the single source file; for multi-file exports (a text `.gltf` with an external `.bin`, or an `.obj` with a sibling `.mtl`/textures), zip them first and pass the `.zip` — a bare `.gltf`/`.obj` will lose its sidecars. diff --git a/.claude/skills/blender-to-unity/manual-verify.md b/.claude/skills/blender-to-unity/manual-verify.md new file mode 100644 index 000000000..acbb5ad8c --- /dev/null +++ b/.claude/skills/blender-to-unity/manual-verify.md @@ -0,0 +1,14 @@ +# Manual Verify — blender-to-unity + +Run against a live Blender (BlenderMCP) + Unity (MCP for Unity) pair. + +- [ ] A cube/model exists in Blender (`get_scene_info` shows it). +- [ ] FBX export writes a non-empty file to the temp path (printed path exists, size > 0). +- [ ] `import_model_file` returns `success: true` with `asset_path` under `Assets/` and a non-empty `asset_guid`. +- [ ] The imported model appears in the Project window at `asset_path`. +- [ ] The model is instantiated in the open scene and visible in a `manage_camera` screenshot. +- [ ] Scale: after the Step 4 measure-bounds-then-`localScale` routine, the placed model's largest + world dimension ≈ the target size (Blender FBX imports ~100× too large until normalized). +- [ ] glTF path: with glTFast installed, a `.glb` export imports successfully; without it, + the error names glTFast/the Dependencies tab (and FBX still works). +- [ ] No API keys or file bytes appear in any bridge payload (handoff is filesystem-only). diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index a876a27b6..def02c795 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -73,5 +73,14 @@ internal static class EditorPrefKeys internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled"; internal const string ExecuteCodeCompiler = "MCPForUnity.ExecuteCode.Compiler"; + + // AI Asset Generation — NON-SECRET config only. Provider API keys live in the OS + // secure store (MCPForUnity.Editor.Security.SecureKeyStore), never in EditorPrefs. + internal const string AssetGenSelectedModelProvider = "MCPForUnity.AssetGen.ModelProvider"; + internal const string AssetGenSelectedImageProvider = "MCPForUnity.AssetGen.ImageProvider"; + internal const string AssetGenDefaultFormat = "MCPForUnity.AssetGen.Format"; + internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; + internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; + internal const string AssetGenProviderEnabledPrefix = "MCPForUnity.AssetGen.Enabled."; } } diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs new file mode 100644 index 000000000..290cb8de0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Project-path conversions shared by the asset-gen import/write code: project-relative + /// ("Assets/...") ↔ absolute on-disk paths, with forward-slash normalization for + /// cross-platform consistency. + /// + public static class AssetGenPaths + { + /// Resolve a project-relative ("Assets/...") path to an absolute, forward-slashed path. + public static string ToAbsolute(string projectRelative) + { + if (string.IsNullOrWhiteSpace(projectRelative)) return projectRelative; + string p = projectRelative.Replace('\\', '/'); + string abs = Path.IsPathRooted(p) ? p : Path.Combine(ProjectRoot(), p); + return Path.GetFullPath(abs).Replace('\\', '/'); + } + + /// Convert an absolute (or already-relative) path to a project-relative ("Assets/...") path. + public static string ToProjectRelative(string path) + { + if (TryGetAssetsRelativePath(path, out string rel)) return rel; + return path?.Replace('\\', '/'); + } + + /// + /// Normalize an absolute or "Assets/..." path and verify it resolves inside the project's + /// Assets directory. Rejects traversal such as "Assets/../ProjectSettings". + /// + public static bool TryGetAssetsRelativePath(string path, out string projectRelative) + { + projectRelative = null; + if (string.IsNullOrWhiteSpace(path)) return false; + + try + { + string p = path.Replace('\\', '/'); + string abs; + if (p == "Assets" || p.StartsWith("Assets/", StringComparison.Ordinal)) + { + abs = Path.Combine(ProjectRoot(), p); + } + else if (Path.IsPathRooted(p)) + { + abs = p; + } + else + { + return false; + } + + string full = Path.GetFullPath(abs).Replace('\\', '/'); + string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/').TrimEnd('/'); + if (string.Equals(full, dataPath, StringComparison.Ordinal)) + { + projectRelative = "Assets"; + return true; + } + + string prefix = dataPath + "/"; + if (!full.StartsWith(prefix, StringComparison.Ordinal)) return false; + + projectRelative = "Assets/" + full.Substring(prefix.Length); + return true; + } + catch + { + return false; + } + } + + /// Normalize an Assets folder path, trimming trailing slashes. + public static bool TryGetAssetsFolder(string path, out string projectRelative) + { + if (!TryGetAssetsRelativePath(path, out projectRelative)) return false; + projectRelative = projectRelative.TrimEnd('/'); + return true; + } + + private static string ProjectRoot() + { + string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/'); + return dataPath.Substring(0, dataPath.Length - "Assets".Length); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta new file mode 100644 index 000000000..a17afd01e --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: afc641959f464b55911f6703a96b5f37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs new file mode 100644 index 000000000..b34ea47f0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs @@ -0,0 +1,73 @@ +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user, NON-SECRET configuration for the AI Asset Generation feature. + /// + /// Provider API keys are never stored here — they live in the OS secure store + /// (). Only UI/behavior + /// preferences (selected provider, default format, output folder, normalize toggle, + /// per-provider enable flags) are kept in EditorPrefs. + /// + public static class AssetGenPrefs + { + public const string DefaultOutputRoot = "Assets/Generated"; + public const string DefaultModelProvider = "tripo"; + public const string DefaultImageProvider = "fal"; + public const string DefaultFormatValue = "glb"; + + public static string ModelProvider + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedModelProvider, DefaultModelProvider); + set => SetOrDelete(EditorPrefKeys.AssetGenSelectedModelProvider, value); + } + + public static string ImageProvider + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedImageProvider, DefaultImageProvider); + set => SetOrDelete(EditorPrefKeys.AssetGenSelectedImageProvider, value); + } + + public static string DefaultFormat + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenDefaultFormat, DefaultFormatValue); + set => SetOrDelete(EditorPrefKeys.AssetGenDefaultFormat, value); + } + + /// Project-relative root under which generated assets are written. Defaults to Assets/Generated. + public static string OutputRoot + { + get + { + string v = EditorPrefs.GetString(EditorPrefKeys.AssetGenOutputRoot, string.Empty); + return string.IsNullOrWhiteSpace(v) ? DefaultOutputRoot : v; + } + set => SetOrDelete(EditorPrefKeys.AssetGenOutputRoot, value); + } + + /// When true, imported models are uniformly scaled to a target size on import. + public static bool AutoNormalize + { + get => EditorPrefs.GetBool(EditorPrefKeys.AssetGenAutoNormalize, true); + set => EditorPrefs.SetBool(EditorPrefKeys.AssetGenAutoNormalize, value); + } + + public static bool IsProviderEnabled(string providerId) => + !string.IsNullOrEmpty(providerId) + && EditorPrefs.GetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, false); + + public static void SetProviderEnabled(string providerId, bool enabled) + { + if (string.IsNullOrEmpty(providerId)) return; + EditorPrefs.SetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, enabled); + } + + private static void SetOrDelete(string key, string value) + { + if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key); + else EditorPrefs.SetString(key, value.Trim()); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta new file mode 100644 index 000000000..be588227f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7704064abf1412e890a066628bae30a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs b/MCPForUnity/Editor/Helpers/BlenderDetection.cs new file mode 100644 index 000000000..6af74e344 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's + /// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the + /// BlenderMCP server is configured in the user's AI client (that lives outside Unity). + /// + internal static class BlenderDetection + { + /// True if a Blender executable is found in a well-known location or on PATH. + public static bool IsInstalled() + { + try { return DetectIn(CandidatePaths(), File.Exists); } + catch { return false; } + } + + /// Pure core: true if reports any candidate present. Testable. + internal static bool DetectIn(IEnumerable candidates, Func exists) + { + if (candidates == null || exists == null) return false; + foreach (string c in candidates) + if (!string.IsNullOrEmpty(c) && exists(c)) return true; + return false; + } + + /// Well-known Blender executable paths for the current platform, plus PATH entries. + internal static IEnumerable CandidatePaths() + { + var list = new List(); + bool win = Application.platform == RuntimePlatform.WindowsEditor; + string exeName = win ? "blender.exe" : "blender"; + + // PATH entries: /blender(.exe) + string pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (string dir in pathVar.Split(win ? ';' : ':')) + if (!string.IsNullOrWhiteSpace(dir)) list.Add(Path.Combine(dir.Trim(), exeName)); + + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + list.Add("/Applications/Blender.app/Contents/MacOS/Blender"); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + list.Add(Path.Combine(home, "Applications/Blender.app/Contents/MacOS/Blender")); + break; + case RuntimePlatform.WindowsEditor: + foreach (string pf in new[] + { + Environment.GetEnvironmentVariable("ProgramFiles"), + Environment.GetEnvironmentVariable("ProgramFiles(x86)") + }) + { + if (string.IsNullOrEmpty(pf)) continue; + string foundation = Path.Combine(pf, "Blender Foundation"); + // Blender installs under a version subdir (Blender X.Y); enumerate them. + try + { + if (Directory.Exists(foundation)) + foreach (string d in Directory.GetDirectories(foundation)) + list.Add(Path.Combine(d, "blender.exe")); + } + catch { /* unreadable dir; ignore */ } + } + break; + case RuntimePlatform.LinuxEditor: + list.Add("/usr/bin/blender"); + list.Add("/usr/local/bin/blender"); + list.Add("/snap/bin/blender"); + list.Add("/var/lib/flatpak/exports/bin/org.blender.Blender"); + break; + } + return list; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta b/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta new file mode 100644 index 000000000..d5edc29e4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf9f02bd846b4e8db34827d949046f70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security.meta b/MCPForUnity/Editor/Security.meta new file mode 100644 index 000000000..981586a8f --- /dev/null +++ b/MCPForUnity/Editor/Security.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b6a5aa4def35486aa9c85c981cfa8d15 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore.meta b/MCPForUnity/Editor/Security/SecureKeyStore.meta new file mode 100644 index 000000000..342a41371 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e3d4c873dcc741b3be3791b36dcd31bd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs new file mode 100644 index 000000000..3774b3731 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Cross-platform fallback key store used when no OS secret service is available + /// (e.g. Linux without libsecret, or headless CI). Encrypts each key at rest with + /// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC). The derivation passphrase is a + /// per-user random master secret (persisted once, 0600) combined with a machine id, + /// run through PBKDF2. Ciphertext lives under the user app-data dir — never under the + /// repo / Assets. This is weaker than an OS keychain (the master secret sits on disk) + /// but far better than plaintext, and it never leaks keys to git or the bridge. + /// + internal sealed class EncryptedFileKeyStore : ISecureKeyStore + { + private const int Iterations = 200_000; + private readonly string _dir; + + public EncryptedFileKeyStore() : this(DefaultDir()) { } + + /// Test seam: point the store at a throwaway directory. + internal EncryptedFileKeyStore(string storageDir) + { + _dir = storageDir; + Directory.CreateDirectory(_dir); + TryChmod(_dir, "700"); + } + + internal static string DefaultDir() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(appData)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + appData = Path.Combine(home, ".config"); + } + return Path.Combine(appData, "MCPForUnity", "AssetGenKeys"); + } + + private string KeyFile(string providerId) => Path.Combine(_dir, "key_" + providerId + ".bin"); + + public bool Has(string providerId) + => !string.IsNullOrEmpty(providerId) && File.Exists(KeyFile(providerId)); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + string path = KeyFile(providerId); + if (!File.Exists(path)) return false; + try + { + byte[] blob = Convert.FromBase64String(File.ReadAllText(path).Trim()); + apiKey = Encoding.UTF8.GetString(Decrypt(blob)); + return true; + } + catch + { + return false; + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + byte[] blob = Encrypt(Encoding.UTF8.GetBytes(apiKey)); + string path = KeyFile(providerId); + File.WriteAllText(path, Convert.ToBase64String(blob)); + TryChmod(path, "600"); + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + try + { + string path = KeyFile(providerId); + if (File.Exists(path)) File.Delete(path); + } + catch { /* best effort */ } + } + + // ---------- crypto ---------- + + private void DeriveKeys(out byte[] encKey, out byte[] macKey) + { + byte[] master = LoadOrCreate(Path.Combine(_dir, "secret.bin"), 32); + byte[] salt = LoadOrCreate(Path.Combine(_dir, "salt.bin"), 16); + string password = Convert.ToBase64String(master) + "|" + MachineId(); + using (var kdf = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256)) + { + byte[] material = kdf.GetBytes(64); + encKey = new byte[32]; + macKey = new byte[32]; + Buffer.BlockCopy(material, 0, encKey, 0, 32); + Buffer.BlockCopy(material, 32, macKey, 0, 32); + } + } + + private byte[] Encrypt(byte[] plaintext) + { + DeriveKeys(out byte[] encKey, out byte[] macKey); + byte[] iv = RandomBytes(16); + byte[] ct; + using (var aes = Aes.Create()) + { + aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; + using (var enc = aes.CreateEncryptor()) + ct = enc.TransformFinalBlock(plaintext, 0, plaintext.Length); + } + byte[] mac; + using (var h = new HMACSHA256(macKey)) + mac = h.ComputeHash(Concat(iv, ct)); + return Concat(iv, mac, ct); + } + + private byte[] Decrypt(byte[] blob) + { + if (blob.Length < 48) throw new CryptographicException("ciphertext too short"); + DeriveKeys(out byte[] encKey, out byte[] macKey); + byte[] iv = new byte[16]; + byte[] mac = new byte[32]; + byte[] ct = new byte[blob.Length - 48]; + Buffer.BlockCopy(blob, 0, iv, 0, 16); + Buffer.BlockCopy(blob, 16, mac, 0, 32); + Buffer.BlockCopy(blob, 48, ct, 0, ct.Length); + using (var h = new HMACSHA256(macKey)) + { + byte[] expected = h.ComputeHash(Concat(iv, ct)); + if (!FixedTimeEquals(expected, mac)) + throw new CryptographicException("MAC verification failed"); + } + using (var aes = Aes.Create()) + { + aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; + using (var dec = aes.CreateDecryptor()) + return dec.TransformFinalBlock(ct, 0, ct.Length); + } + } + + // ---------- helpers ---------- + + private byte[] LoadOrCreate(string path, int len) + { + if (File.Exists(path)) + { + byte[] existing = File.ReadAllBytes(path); + if (existing.Length == len) return existing; + } + byte[] fresh = RandomBytes(len); + File.WriteAllBytes(path, fresh); + TryChmod(path, "600"); + return fresh; + } + + private static string MachineId() + { + try + { + string id = SystemInfo.deviceUniqueIdentifier; + if (!string.IsNullOrEmpty(id) && id != SystemInfo.unsupportedIdentifier) return id; + } + catch { /* not in a Unity runtime context */ } + return Environment.MachineName ?? "unknown-machine"; + } + + private static byte[] RandomBytes(int n) + { + byte[] b = new byte[n]; + using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(b); + return b; + } + + private static byte[] Concat(params byte[][] parts) + { + int total = 0; + foreach (byte[] p in parts) total += p.Length; + byte[] result = new byte[total]; + int offset = 0; + foreach (byte[] p in parts) + { + Buffer.BlockCopy(p, 0, result, offset, p.Length); + offset += p.Length; + } + return result; + } + + private static bool FixedTimeEquals(byte[] a, byte[] b) + { + if (a == null || b == null || a.Length != b.Length) return false; + int diff = 0; + for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + + private static void TryChmod(string path, string mode) + { + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) return; + var psi = new System.Diagnostics.ProcessStartInfo("/bin/chmod") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + psi.ArgumentList.Add(mode); + psi.ArgumentList.Add(path); + using (var p = System.Diagnostics.Process.Start(psi)) p?.WaitForExit(2000); + } + catch { /* hardening is best-effort */ } + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta new file mode 100644 index 000000000..86648aeb2 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be82d82467544ffa8bbc8c64a8020636 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs new file mode 100644 index 000000000..c4c296dde --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs @@ -0,0 +1,26 @@ +using System; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Read-only environment-variable override for provider keys, intended for CI/headless + /// and power users. Resolution order is env → secure store (see ). + /// Env values are never written back to any store. + /// + internal static class EnvKeyOverride + { + /// e.g. "tripo" → "MCPFORUNITY_TRIPO_API_KEY". + internal static string EnvVarName(string providerId) + => "MCPFORUNITY_" + (providerId ?? string.Empty).ToUpperInvariant() + "_API_KEY"; + + internal static bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + string v = Environment.GetEnvironmentVariable(EnvVarName(providerId)); + if (string.IsNullOrEmpty(v)) return false; + apiKey = v; + return true; + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta new file mode 100644 index 000000000..2cc8623ae --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12d89d2f3dd846d3975bd5beb190a741 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs new file mode 100644 index 000000000..3675368f4 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs @@ -0,0 +1,25 @@ +namespace MCPForUnity.Editor.Security +{ + /// + /// At-rest storage for AI asset-generation provider API keys. + /// + /// Keys are written ONLY to the OS secure store (Keychain / Credential Manager / + /// libsecret) or an encrypted-file fallback. They are never placed in EditorPrefs, + /// never sent over the MCP bridge, never returned by any MCP tool, and never logged. + /// reports existence only — there is no API that returns all keys. + /// + public interface ISecureKeyStore + { + /// Resolve the key for a provider id (e.g. "tripo"). Returns false when absent. + bool TryGet(string providerId, out string apiKey); + + /// Store (or overwrite) the key for a provider id. Empty/null deletes it. + void Set(string providerId, string apiKey); + + /// Remove the stored key for a provider id (no-op when absent). + void Delete(string providerId); + + /// True when a key exists for the provider id. Never returns the value. + bool Has(string providerId); + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta new file mode 100644 index 000000000..cc1994e71 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc35b98fa68548c5ab34badae87b99d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs new file mode 100644 index 000000000..96710e8ad --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Linux libsecret-backed key store via the `secret-tool` CLI. The secret value is + /// passed on stdin (never as a process argument). Falls back to + /// when secret-tool is not installed. + /// + internal sealed class LinuxSecretToolKeyStore : ISecureKeyStore + { + private const string Service = SecureKeyStoreConstants.ServiceName; + + internal static bool IsAvailable() + { + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("--version"); + using (var p = Process.Start(psi)) + { + p.WaitForExit(2000); + return p.ExitCode == 0; + } + } + catch { return false; } + } + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("lookup"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) + { + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + if (p.ExitCode != 0) return false; + apiKey = (outp ?? string.Empty).TrimEnd('\n', '\r'); + return !string.IsNullOrEmpty(apiKey); + } + } + catch { return false; } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + try + { + var psi = NewPsi(redirectIn: true); + psi.ArgumentList.Add("store"); + psi.ArgumentList.Add("--label=MCPForUnity AssetGen"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) + { + p.StandardInput.Write(apiKey); + p.StandardInput.Close(); + p.WaitForExit(5000); + } + } + catch { /* best effort */ } + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("clear"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) p.WaitForExit(5000); + } + catch { /* best effort */ } + } + + private static ProcessStartInfo NewPsi(bool redirectIn = false) + { + var psi = new ProcessStartInfo("/usr/bin/env") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = redirectIn, + }; + psi.ArgumentList.Add("secret-tool"); + return psi; + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta new file mode 100644 index 000000000..0dbc99cda --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eddf52a01df445629b408e5a7593356e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs new file mode 100644 index 000000000..9cf86249b --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs @@ -0,0 +1,67 @@ +using System; +using System.Diagnostics; + +namespace MCPForUnity.Editor.Security +{ + /// + /// macOS Keychain-backed key store via /usr/bin/security generic passwords. + /// Service = MCPForUnity.AssetGen, account = provider id. + /// + internal sealed class MacKeychainKeyStore : ISecureKeyStore + { + private const string Security = "/usr/bin/security"; + private const string Service = SecureKeyStoreConstants.ServiceName; + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + (int code, string stdout, _) = Run("find-generic-password", "-s", Service, "-a", providerId, "-w"); + if (code != 0) return false; + apiKey = (stdout ?? string.Empty).TrimEnd('\n', '\r'); + return !string.IsNullOrEmpty(apiKey); + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + // -U overwrites an existing item for this service/account. + Run("add-generic-password", "-U", "-s", Service, "-a", providerId, "-w", apiKey); + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + Run("delete-generic-password", "-s", Service, "-a", providerId); + } + + private static (int code, string stdout, string stderr) Run(params string[] args) + { + try + { + var psi = new ProcessStartInfo(Security) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string a in args) psi.ArgumentList.Add(a); + using (var p = Process.Start(psi)) + { + string outp = p.StandardOutput.ReadToEnd(); + string err = p.StandardError.ReadToEnd(); + p.WaitForExit(5000); + return (p.ExitCode, outp, err); + } + } + catch (Exception e) + { + return (-1, null, e.Message); + } + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta new file mode 100644 index 000000000..c9112d323 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fca6d32a6be24c138e8c32ae824602f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs new file mode 100644 index 000000000..43b230c5e --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Scrubs secrets out of text before it is logged or returned. Use on every error/log + /// path that might contain an auth header or a key value. + /// + public static class SecretRedactor + { + private const string Mask = "***REDACTED***"; + + // Authorization-scheme tokens: "Bearer xxx", "Key xxx", "Token xxx". + private static readonly Regex SchemeToken = new Regex( + @"\b(Bearer|Key|Token)\s+\S{6,}", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Redact auth-scheme tokens from arbitrary text (cheap; no store reads). + public static string Scrub(string text) + { + if (string.IsNullOrEmpty(text)) return text; + return SchemeToken.Replace(text, m => m.Groups[1].Value + " " + Mask); + } + + /// Redact a specific known secret value as well as auth-scheme tokens. + public static string Scrub(string text, string secret) + { + if (string.IsNullOrEmpty(text)) return text; + if (!string.IsNullOrEmpty(secret) && secret.Length >= 4) + { + text = text.Replace(secret, Mask); + } + return Scrub(text); + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta new file mode 100644 index 000000000..28e456db7 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08d3717562bc41cca65e51e8eb165084 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs new file mode 100644 index 000000000..c4c650c1c --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs @@ -0,0 +1,65 @@ +using UnityEngine; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Entry point for at-rest provider key storage. Selects the best OS secure store for + /// the current platform and layers the read-only environment-variable override on top. + /// Keys never transit the MCP bridge; only the C# editor side reads them, at call time. + /// + public static class SecureKeyStore + { + private static ISecureKeyStore _current; + + public static ISecureKeyStore Current => _current ??= Build(); + + /// Test seam: substitute an in-memory or temp-dir store. + internal static void OverrideForTests(ISecureKeyStore store) => _current = store; + + /// Test seam: clear the cached instance. + internal static void ResetForTests() => _current = null; + + private static ISecureKeyStore Build() => new EnvOverlayKeyStore(SelectPlatform()); + + private static ISecureKeyStore SelectPlatform() + { + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + return new MacKeychainKeyStore(); + case RuntimePlatform.WindowsEditor: + return new WindowsCredentialKeyStore(); + case RuntimePlatform.LinuxEditor: + return LinuxSecretToolKeyStore.IsAvailable() + ? (ISecureKeyStore)new LinuxSecretToolKeyStore() + : new EncryptedFileKeyStore(); + default: + return new EncryptedFileKeyStore(); + } + } + } + + /// + /// Wraps a platform store so an MCPFORUNITY_<PROVIDER>_API_KEY environment + /// variable takes precedence on reads. Writes always go to the underlying store. + /// + internal sealed class EnvOverlayKeyStore : ISecureKeyStore + { + private readonly ISecureKeyStore _inner; + + public EnvOverlayKeyStore(ISecureKeyStore inner) { _inner = inner; } + + public bool TryGet(string providerId, out string apiKey) + { + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + return _inner.TryGet(providerId, out apiKey); + } + + public bool Has(string providerId) + => EnvKeyOverride.TryGet(providerId, out _) || _inner.Has(providerId); + + public void Set(string providerId, string apiKey) => _inner.Set(providerId, apiKey); + + public void Delete(string providerId) => _inner.Delete(providerId); + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta new file mode 100644 index 000000000..202e7f55b --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e75b60b25da4b9b9c0dc0df42fa1017 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs new file mode 100644 index 000000000..7d3c2b23d --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs @@ -0,0 +1,15 @@ +namespace MCPForUnity.Editor.Security +{ + /// Shared constants for the secure key store. + internal static class SecureKeyStoreConstants + { + /// Service / target namespace used in the OS secure store. + internal const string ServiceName = "MCPForUnity.AssetGen"; + + /// Known asset-generation provider ids (lowercase). + internal static readonly string[] ProviderIds = + { + "tripo", "meshy", "sketchfab", "fal", "openrouter" + }; + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta new file mode 100644 index 000000000..6431c9126 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59ac5388ff204ea99427dfab8a0370f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs new file mode 100644 index 000000000..ac7f40764 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs @@ -0,0 +1,106 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Windows Credential Manager-backed key store (DPAPI-protected by the OS) via advapi32 + /// Cred* APIs. Target = "MCPForUnity.AssetGen:<provider>", blob = UTF-8 key bytes. + /// + internal sealed class WindowsCredentialKeyStore : ISecureKeyStore + { + private const int CRED_TYPE_GENERIC = 1; + private const int CRED_PERSIST_LOCAL_MACHINE = 2; + + private static string Target(string providerId) + => SecureKeyStoreConstants.ServiceName + ":" + providerId; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")] + private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")] + private static extern bool CredWrite([In] ref CREDENTIAL credential, int flags); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")] + private static extern bool CredDelete(string target, int type, int flags); + + [DllImport("advapi32.dll", EntryPoint = "CredFree")] + private static extern void CredFree(IntPtr buffer); + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + if (!CredRead(Target(providerId), CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return false; + try + { + var cred = Marshal.PtrToStructure(ptr); + if (cred.CredentialBlobSize <= 0 || cred.CredentialBlob == IntPtr.Zero) return false; + byte[] bytes = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize); + apiKey = Encoding.UTF8.GetString(bytes); + return !string.IsNullOrEmpty(apiKey); + } + finally + { + CredFree(ptr); + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + byte[] blob = Encoding.UTF8.GetBytes(apiKey); + IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length); + try + { + Marshal.Copy(blob, 0, blobPtr, blob.Length); + var cred = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = Target(providerId), + CredentialBlobSize = blob.Length, + CredentialBlob = blobPtr, + Persist = CRED_PERSIST_LOCAL_MACHINE, + UserName = providerId, + }; + if (!CredWrite(ref cred, 0)) + { + throw new InvalidOperationException( + "CredWrite failed (Win32 " + Marshal.GetLastWin32Error() + ")"); + } + } + finally + { + Marshal.FreeHGlobal(blobPtr); + } + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + CredDelete(Target(providerId), CRED_TYPE_GENERIC, 0); + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta new file mode 100644 index 000000000..799d42134 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c777dcfa11b446897576292db1982a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen.meta b/MCPForUnity/Editor/Services/AssetGen.meta new file mode 100644 index 000000000..375e0155d --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2796223ccc4747ffa205b1bac8b2ceef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs new file mode 100644 index 000000000..f784a9300 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -0,0 +1,532 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Import; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Canceled } + + /// + /// Snapshot of a generation/import job. Persisted to SessionState so a `status` query + /// still works after an unrelated domain reload. NEVER carries a key or secret. + /// + public sealed class AssetGenJob + { + public string JobId; + public string Kind; // model | image | marketplace + public string Provider; + public string Action; + public AssetGenJobState State; + public float Progress; + public string Format; + public float TargetSize = 1f; + public string AssetPath; + public string AssetGuid; + public string Error; + } + + /// + /// Drives asset-generation jobs on the Unity main thread via EditorApplication.update. Each + /// job runs a generic submit → poll → (download | inline) → import state machine; the model + /// and image paths supply their own submit/poll/import delegates. Because UnityWebRequest + /// completes on the main thread, polling Task.IsCompleted from the update loop is + /// main-thread-safe — we never block or use threadpool waits. The provider key is read once at + /// submit time, captured only inside the submit/poll closures — never stored on the job, + /// persisted, or logged. + /// + [InitializeOnLoad] + public static class AssetGenJobManager + { + private const string JobKeyPrefix = "MCPForUnity.AssetGen.Job."; + private const string JobIndexKey = "MCPForUnity.AssetGen.JobIndex"; + + // Test seams (overridable; defaults are the production implementations). + internal static IHttpTransport TransportOverrideForTests; + internal static Func ImportOverrideForTests; + internal static double PollIntervalSeconds = 3.0; + internal static double TimeoutSeconds = 600.0; + + private static readonly Dictionary Jobs = new(); + private static readonly Dictionary Runners = new(); + private static readonly List _tickIds = new(); + private static bool _ticking; + + static AssetGenJobManager() + { + try + { + string index = SessionState.GetString(JobIndexKey, string.Empty); + if (string.IsNullOrEmpty(index)) return; + foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + string json = SessionState.GetString(JobKeyPrefix + id, string.Empty); + if (string.IsNullOrEmpty(json)) continue; + var job = JsonConvert.DeserializeObject(json); + if (job == null) continue; + if (!IsTerminal(job.State)) + { + job.State = AssetGenJobState.Failed; + job.Error = "Interrupted by an editor reload; please retry."; + Persist(job); + } + Jobs[id] = job; + } + } + catch { /* recovery is best-effort */ } + } + + public static AssetGenJob StartModelGeneration(ModelGenRequest req) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + string provider = string.IsNullOrEmpty(req.Provider) ? "tripo" : req.Provider; + IModelProviderAdapter adapter = AssetGenProviders.Model(provider); // throws NotSupportedException if unimplemented + + var job = NewJob("model", provider, "generate"); + job.Format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format; + job.TargetSize = req.TargetSize <= 0 ? 1f : req.TargetSize; + + if (!TryResolveKey(provider, job, out string apiKey)) return job; + + IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), + ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, + Transport = transport, + OutputFolder = req.OutputFolder, + Ext = (job.Format ?? "glb").TrimStart('.'), + Name = NameFrom(req.Name, req.Prompt, job.JobId), + Subfolder = "Models", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob StartImageGeneration(ImageGenRequest req) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider; + IImageProviderAdapter adapter = AssetGenProviders.Image(provider); // throws NotSupportedException if unimplemented + + var job = NewJob("image", provider, "generate"); + job.Format = "png"; + + if (!TryResolveKey(provider, job, out string apiKey)) return job; + + IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + bool asSprite = req.AsSprite; + bool transparent = req.Transparent; + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), + ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)), + Transport = transport, + OutputFolder = req.OutputFolder, + Ext = "png", + Name = NameFrom(req.Name, req.Prompt, job.JobId), + Subfolder = "Images", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob StartMarketplaceImport(string uid, float targetSize, string name, string outputFolder) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentException("uid required"); + var adapter = AssetGenProviders.Marketplace("sketchfab"); // throws NotSupported if unimplemented + var job = NewJob("marketplace", "sketchfab", "import"); + job.TargetSize = targetSize <= 0 ? 1f : targetSize; + if (!TryResolveKey("sketchfab", job, out string apiKey)) return job; + var transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.ResolveDownloadUrlAsync(uid, apiKey, transport, ct), // returns the zip/gltf URL as providerJobId + PollFn = (pid, ct) => Task.FromResult(new ProviderPollResult { State = ProviderPollState.Succeeded, Progress = 1f, DownloadUrl = pid, ResultExt = "zip" }), + ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, + Transport = transport, + OutputFolder = outputFolder, + Ext = "zip", + Name = NameFrom(name, uid, job.JobId), + Subfolder = "Sketchfab", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob GetJob(string jobId) + => string.IsNullOrEmpty(jobId) ? null : (Jobs.TryGetValue(jobId, out var j) ? j : null); + + /// Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys. + public static IReadOnlyList RecentJobs(int max = 20) + { + var all = new List(Jobs.Values); + int start = Math.Max(0, all.Count - max); + var slice = all.GetRange(start, all.Count - start); + slice.Reverse(); + return slice; + } + + public static bool Cancel(string jobId) + { + if (string.IsNullOrEmpty(jobId)) return false; + if (Runners.TryGetValue(jobId, out var r)) + { + r.Canceled = true; + try { r.Cts.Cancel(); } catch { } + return true; + } + if (Jobs.TryGetValue(jobId, out var job) && job.State == AssetGenJobState.Queued) + { + job.State = AssetGenJobState.Canceled; + Persist(job); + return true; + } + return false; + } + + // ---------- runner ---------- + + private enum RunnerPhase { Submit, AwaitSubmit, Poll, AwaitPoll, Download, AwaitDownload, Import } + + private sealed class Runner + { + public AssetGenJob Job; + public Func> SubmitFn; + public Func> PollFn; + public Func ImportFn; + public IHttpTransport Transport; + public string OutputFolder; + public string Ext; + public string OverrideExt; + public string Name; + public string Subfolder; + + public CancellationTokenSource Cts = new(); + public RunnerPhase Phase = RunnerPhase.Submit; + public double StartedAt; + public double NextPollAt; + public string ProviderJobId; + public string DownloadUrl; + public string LocalPath; + public Task SubmitTask; + public Task PollTask; + public Task DownloadTask; + public bool Canceled; + } + + private static bool TryResolveKey(string provider, AssetGenJob job, out string apiKey) + { + if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey)) + { + job.State = AssetGenJobState.Failed; + job.Error = AssetGenProviders.MissingKeyMessage(provider); + Jobs[job.JobId] = job; + Persist(job); + return false; + } + return true; + } + + private static void Register(AssetGenJob job, Runner runner) + { + runner.StartedAt = Now(); + Jobs[job.JobId] = job; + Runners[job.JobId] = runner; + Persist(job); + EnsureTicking(); + } + + private static void EnsureTicking() + { + if (_ticking) return; + EditorApplication.update += Tick; + _ticking = true; + } + + private static void Tick() + { + if (Runners.Count == 0) + { + EditorApplication.update -= Tick; + _ticking = false; + return; + } + // Snapshot keys into a reused buffer so Advance can mutate Runners mid-iteration + // without churning the GC on every editor-update frame. + _tickIds.Clear(); + _tickIds.AddRange(Runners.Keys); + foreach (string id in _tickIds) + { + if (Runners.TryGetValue(id, out var r)) Advance(r); + } + } + + /// Advance one job one step. Returns true when the job is terminal. + internal static bool TryAdvanceForTests(string jobId) + { + if (Runners.TryGetValue(jobId, out var r)) + { + Advance(r); + return IsTerminal(r.Job.State); + } + return Jobs.TryGetValue(jobId, out var j) && IsTerminal(j.State); + } + + private static void Advance(Runner r) + { + if (IsTerminal(r.Job.State)) { Finalize(r); return; } + if (r.Canceled) { r.Job.State = AssetGenJobState.Canceled; Persist(r.Job); Finalize(r); return; } + if (Now() - r.StartedAt > TimeoutSeconds) { Fail(r, $"Timed out after {TimeoutSeconds:0}s."); return; } + + try + { + switch (r.Phase) + { + case RunnerPhase.Submit: + r.Job.State = AssetGenJobState.Running; + Persist(r.Job); + r.SubmitTask = r.SubmitFn(r.Cts.Token); + r.Phase = RunnerPhase.AwaitSubmit; + break; + + case RunnerPhase.AwaitSubmit: + if (!r.SubmitTask.IsCompleted) break; + if (Faulted(r.SubmitTask, out string subErr)) { Fail(r, subErr); break; } + r.ProviderJobId = r.SubmitTask.Result; + if (string.IsNullOrEmpty(r.ProviderJobId)) { Fail(r, "Provider returned no job id."); break; } + r.NextPollAt = Now(); + r.Phase = RunnerPhase.Poll; + break; + + case RunnerPhase.Poll: + if (Now() < r.NextPollAt) break; + r.PollTask = r.PollFn(r.ProviderJobId, r.Cts.Token); + r.Phase = RunnerPhase.AwaitPoll; + break; + + case RunnerPhase.AwaitPoll: + if (!r.PollTask.IsCompleted) break; + if (Faulted(r.PollTask, out string pollErr)) { Fail(r, pollErr); break; } + ProviderPollResult pr = r.PollTask.Result; + r.Job.Progress = Mathf.Clamp01(pr.Progress); + Persist(r.Job); + if (pr.State == ProviderPollState.Succeeded) + { + r.OverrideExt = pr.ResultExt; + if (pr.InlineData != null && pr.InlineData.Length > 0) + { + r.LocalPath = WriteFile(r, pr.InlineData); + r.Job.State = AssetGenJobState.Importing; + Persist(r.Job); + r.Phase = RunnerPhase.Import; + } + else if (!string.IsNullOrEmpty(pr.DownloadUrl)) + { + r.DownloadUrl = pr.DownloadUrl; + r.Phase = RunnerPhase.Download; + } + else + { + Fail(r, "Provider succeeded but returned no result data."); + } + } + else if (pr.State == ProviderPollState.Failed) + { + Fail(r, string.IsNullOrEmpty(pr.Error) ? "Provider reported failure." : pr.Error); + } + else + { + r.NextPollAt = Now() + PollIntervalSeconds; + r.Phase = RunnerPhase.Poll; + } + break; + + case RunnerPhase.Download: + // The download URL comes from an untrusted provider response. Only fetch + // http(s) — refuse file://, ftp://, etc. so a malicious response can't read + // a local file into the project or hit an internal host. + if (!IsAllowedDownloadUrl(r.DownloadUrl)) + { + Fail(r, "Refusing to fetch a non-http(s) download URL returned by the provider."); + break; + } + r.DownloadTask = r.Transport.SendAsync( + new HttpRequestSpec { Method = "GET", Url = r.DownloadUrl }, r.Cts.Token); + r.Phase = RunnerPhase.AwaitDownload; + break; + + case RunnerPhase.AwaitDownload: + if (!r.DownloadTask.IsCompleted) break; + if (Faulted(r.DownloadTask, out string dlErr)) { Fail(r, dlErr); break; } + HttpResult res = r.DownloadTask.Result; + if (res == null || !res.IsSuccess || res.Body == null || res.Body.Length == 0) + { + Fail(r, $"Download failed (HTTP {res?.Status})."); + break; + } + r.LocalPath = WriteFile(r, res.Body); + r.Job.State = AssetGenJobState.Importing; + Persist(r.Job); + r.Phase = RunnerPhase.Import; + break; + + case RunnerPhase.Import: + // The result file was just written via File.WriteAllBytes (outside the + // AssetDatabase). Refresh so Unity registers it before we import it, + // mirroring ImportModelFile. Skipped under the test import seam. + if (ImportOverrideForTests == null) AssetDatabase.Refresh(); + AssetGenJob imported = r.ImportFn(r.Job, r.LocalPath); + if (imported != null) r.Job = imported; + if (r.Job.State != AssetGenJobState.Failed) + { + r.Job.State = AssetGenJobState.Done; + r.Job.Progress = 1f; + } + Persist(r.Job); + Finalize(r); + break; + } + } + catch (Exception e) + { + Fail(r, SecretRedactor.Scrub(e.Message)); + } + } + + private static string WriteFile(Runner r, byte[] bytes) + { + string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext; + string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant(); + string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder + : (AssetGenPrefs.OutputRoot + "/" + r.Subfolder); + if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root)) + root = AssetGenPrefs.DefaultOutputRoot + "/" + r.Subfolder; + string absRoot = AssetGenPaths.ToAbsolute(root); + Directory.CreateDirectory(absRoot); + string baseName = SanitizeName(r.Name); + string fileName = baseName + "." + ext; + string abs = Path.Combine(absRoot, fileName); + int n = 1; + while (File.Exists(abs)) { fileName = baseName + "_" + n++ + "." + ext; abs = Path.Combine(absRoot, fileName); } + File.WriteAllBytes(abs, bytes); + return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/'); + } + + private static string NameFrom(string explicitName, string prompt, string jobId) + { + if (!string.IsNullOrWhiteSpace(explicitName)) return explicitName; + if (!string.IsNullOrWhiteSpace(prompt)) return prompt; + return "asset_" + jobId.Substring(0, 8); + } + + private static string SanitizeName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return "asset"; + var sb = new System.Text.StringBuilder(); + foreach (char c in raw.Trim()) + { + if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c); + else if (c == ' ') sb.Append('_'); + if (sb.Length >= 48) break; + } + string s = sb.ToString().Trim('_', '-'); + return string.IsNullOrEmpty(s) ? "asset" : s; + } + + private static void Fail(Runner r, string message) + { + r.Job.State = AssetGenJobState.Failed; + r.Job.Error = SecretRedactor.Scrub(string.IsNullOrEmpty(message) ? "Generation failed." : message); + Persist(r.Job); + Finalize(r); + } + + private static void Finalize(Runner r) + { + Runners.Remove(r.Job.JobId); + try { r.Cts?.Dispose(); } catch { } + } + + private static AssetGenJob NewJob(string kind, string provider, string action) + { + return new AssetGenJob + { + JobId = Guid.NewGuid().ToString("N"), + Kind = kind, + Provider = provider, + Action = action, + State = AssetGenJobState.Queued, + Progress = 0f, + }; + } + + private static void Persist(AssetGenJob job) + { + try + { + SessionState.SetString(JobKeyPrefix + job.JobId, JsonConvert.SerializeObject(job)); + string index = SessionState.GetString(JobIndexKey, string.Empty); + var ids = new HashSet(index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + if (ids.Add(job.JobId)) + SessionState.SetString(JobIndexKey, string.Join(",", ids)); + } + catch { /* persistence is best-effort */ } + } + + private static bool Faulted(Task t, out string error) + { + if (t.IsFaulted) + { + Exception ex = t.Exception?.GetBaseException(); + error = SecretRedactor.Scrub(ex?.Message ?? "request failed"); + return true; + } + if (t.IsCanceled) { error = "Canceled."; return true; } + error = null; + return false; + } + + private static bool IsTerminal(AssetGenJobState s) + => s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled; + + /// Only http(s) download URLs are allowed; provider responses are untrusted. + private static bool IsAllowedDownloadUrl(string url) + => Uri.TryCreate(url, UriKind.Absolute, out Uri u) + && (u.Scheme == Uri.UriSchemeHttps || u.Scheme == Uri.UriSchemeHttp); + + private static double Now() => EditorApplication.timeSinceStartup; + + internal static void ResetForTests() + { + foreach (var r in new List(Runners.Values)) + { + try { r.Cts?.Cancel(); r.Cts?.Dispose(); } catch { } + } + Runners.Clear(); + string index = SessionState.GetString(JobIndexKey, string.Empty); + foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + SessionState.EraseString(JobKeyPrefix + id); + SessionState.EraseString(JobIndexKey); + Jobs.Clear(); + TransportOverrideForTests = null; + ImportOverrideForTests = null; + PollIntervalSeconds = 3.0; + TimeoutSeconds = 600.0; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta new file mode 100644 index 000000000..55d49ac4e --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1955ee1b47384a8a880f00d384fff433 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http.meta b/MCPForUnity/Editor/Services/AssetGen/Http.meta new file mode 100644 index 000000000..e4d7290db --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 29756f9999cd4779b156e75b9370ca8a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs new file mode 100644 index 000000000..cbcf59c63 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Test double for . Records every request it is handed and + /// returns a canned response, so provider adapters can be exercised without a network. + /// Match responses either with the delegate (full control) or with + /// (first entry whose key is contained in the request URL). + /// + public sealed class FakeHttpTransport : IHttpTransport + { + public List RecordedRequests { get; } = new List(); + + /// Highest-priority responder; return null to fall through to . + public Func Handler { get; set; } + + /// Canned responses keyed by a substring expected to appear in the request URL. + public Dictionary ByUrlSubstring { get; } = new Dictionary(); + + public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + { + RecordedRequests.Add(spec); + + HttpResult result = Handler?.Invoke(spec); + + if (result == null && spec?.Url != null) + { + foreach (var kv in ByUrlSubstring) + { + if (spec.Url.IndexOf(kv.Key, StringComparison.Ordinal) >= 0) + { + result = kv.Value; + break; + } + } + } + + if (result == null) + { + result = new HttpResult + { + Status = 500, + IsSuccess = false, + Text = "FakeHttpTransport: no canned response matched " + (spec?.Url ?? "") + }; + } + + return Task.FromResult(result); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta new file mode 100644 index 000000000..0dc9f9dc0 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46a96ea76bac45cab5025a37f56ba671 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs new file mode 100644 index 000000000..f00923680 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Transport-agnostic description of a single HTTP request. Provider adapters build one + /// of these and hand it to an ; this keeps adapters free of + /// any direct dependency on UnityWebRequest so they can be unit-tested without a network. + /// + public sealed class HttpRequestSpec + { + public string Method; + public string Url; + public Dictionary Headers = new Dictionary(); + public byte[] Body; + public string ContentType; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta new file mode 100644 index 000000000..72bf78f1d --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82093efd40c44dc3bd83e481484ef827 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs new file mode 100644 index 000000000..2b654c2b7 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs @@ -0,0 +1,19 @@ +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Transport-agnostic result of an . is + /// the numeric HTTP status code; reflects the transport's own view + /// of success (e.g. UnityWebRequest.Result.Success), which adapters combine with their own + /// body-level checks. + /// + public sealed class HttpResult + { + public int Status; + public byte[] Body; + public string Text; + public bool IsSuccess; + + /// True when the transport reports success or the status code is 2xx. + public bool Ok => IsSuccess || (Status >= 200 && Status < 300); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta new file mode 100644 index 000000000..6d401c21e --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 507ef9b8ac9d4f42be51a3bc9203eeff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs new file mode 100644 index 000000000..09d606150 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// The HTTP seam that provider adapters depend on. Production uses + /// ; tests inject so + /// adapter request/response shaping can be verified without touching the network. + /// + public interface IHttpTransport + { + Task SendAsync(HttpRequestSpec spec, CancellationToken ct); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta new file mode 100644 index 000000000..815afd8c6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 746824fdb60a436b880f22fee8cdb177 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs new file mode 100644 index 000000000..395fc0beb --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine.Networking; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Production backed by UnityWebRequest. Must be invoked on the + /// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is + /// awaited via a wired to the async op's completed + /// callback, so the call never blocks the editor loop. + /// + public sealed class UnityWebRequestTransport : IHttpTransport + { + public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + { + if (spec == null) throw new ArgumentNullException(nameof(spec)); + + var tcs = new TaskCompletionSource(); + + var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET) + { + downloadHandler = new DownloadHandlerBuffer() + }; + if (spec.Body != null) + { + request.uploadHandler = new UploadHandlerRaw(spec.Body); + } + if (!string.IsNullOrEmpty(spec.ContentType)) + { + request.SetRequestHeader("Content-Type", spec.ContentType); + } + if (spec.Headers != null) + { + foreach (var kv in spec.Headers) + { + request.SetRequestHeader(kv.Key, kv.Value); + } + } + + CancellationTokenRegistration ctReg = default; + if (ct.CanBeCanceled) + { + ctReg = ct.Register(() => + { + try { request.Abort(); } catch { /* ignore */ } + tcs.TrySetCanceled(); + }); + } + + var op = request.SendWebRequest(); + op.completed += _ => + { + try + { + var result = new HttpResult + { + Status = (int)request.responseCode, + Body = request.downloadHandler?.data, + Text = request.downloadHandler?.text, + IsSuccess = request.result == UnityWebRequest.Result.Success + }; + tcs.TrySetResult(result); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + finally + { + ctReg.Dispose(); + request.Dispose(); + } + }; + + return tcs.Task; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta new file mode 100644 index 000000000..fd636e604 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 333575db1d8f487a9f2d32fa78dbb009 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import.meta b/MCPForUnity/Editor/Services/AssetGen/Import.meta new file mode 100644 index 000000000..4718cdc01 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8915ae71731f43f1a1c03cc6f901a39d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs new file mode 100644 index 000000000..c1cfb0862 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using UnityEditor; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Imports a generated 2D image (PNG, already under Assets/) and applies TextureImporter + /// settings: Sprite vs Default, alpha-is-transparency, and sRGB (color) vs linear (data maps). + /// + public static class ImageImportPipeline + { + public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool asSprite, bool transparent, bool isColor) + { + if (job == null) return null; + try + { + if (string.IsNullOrEmpty(localFilePath)) + return Fail(job, "No file to import."); + + if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) + return Fail(job, "Generated file is not under the Assets folder."); + + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); + + if (AssetImporter.GetAtPath(rel) is TextureImporter importer) + { + importer.textureType = asSprite ? TextureImporterType.Sprite : TextureImporterType.Default; + importer.alphaIsTransparency = transparent; + importer.sRGBTexture = isColor; // color maps sRGB; normal/roughness/metallic would be linear + if (asSprite) + { + importer.spriteImportMode = SpriteImportMode.Single; + importer.mipmapEnabled = false; + } + importer.SaveAndReimport(); + } + + job.AssetPath = rel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(rel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the image but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + catch (Exception e) + { + return Fail(job, SecretRedactor.Scrub(e.Message)); + } + } + + private static AssetGenJob Fail(AssetGenJob job, string message) + { + job.State = AssetGenJobState.Failed; + job.Error = message; + return job; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta new file mode 100644 index 000000000..ae11c95b4 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5088530e3bc348e1b6539464588bc8bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs new file mode 100644 index 000000000..fa3fe8c69 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Imports a downloaded model file (already under Assets/) into the project and applies + /// import settings. GLB/glTF require the optional glTFast package (installed from the + /// Dependencies tab); FBX/OBJ use Unity's built-in ModelImporter. Optionally normalizes + /// the model's scale to a target size. + /// + public static class ModelImportPipeline + { + // Inert asset types permitted out of an UNTRUSTED provider archive (Sketchfab et al.). + // Anything else — scripts, assemblies, asmdefs — is skipped on extraction so it can never + // compile or load inside the Editor. See SafeZipExtractor for the enforcement. + private static readonly HashSet ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl", + ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr", + ".ktx", ".ktx2", ".basis", ".dds", + }; + + public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath) + { + if (job == null) return null; + try + { + if (string.IsNullOrEmpty(localFilePath)) + return Fail(job, "No file to import."); + + if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) + return Fail(job, "Generated file is not under the Assets folder."); + + string ext = Path.GetExtension(rel).ToLowerInvariant(); + if (ext == ".zip") + return ImportArchive(job, rel); + + return ImportModelFile(job, rel, ext); + } + catch (Exception e) + { + return Fail(job, SecretRedactor.Scrub(e.Message)); + } + } + + private static AssetGenJob ImportModelFile(AssetGenJob job, string rel, string ext) + { + bool isGltf = ext == ".glb" || ext == ".gltf"; + + if (isGltf && !IsGltfastAvailable()) + { + return Fail(job, + "GLB import requires glTFast. Install it from the MCP for Unity → Dependencies tab, or choose FBX output."); + } + + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); + + if (!isGltf) + ApplyModelImporterSettings(rel, job); + + job.AssetPath = rel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(rel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the file but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + + /// + /// Unpack a downloaded archive (Sketchfab ships .zip) into a sibling folder named + /// after the archive, import it, then locate the first model file inside and import that. + /// FBX/OBJ are preferred over glTF; a glTF-only archive still requires glTFast. + /// + private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel) + { + string zipAbs = AssetGenPaths.ToAbsolute(zipRel); + if (!File.Exists(zipAbs)) + return Fail(job, "Downloaded archive was not found on disk."); + + string folderRel = zipRel.Substring(0, zipRel.Length - ".zip".Length); + string folderAbs = AssetGenPaths.ToAbsolute(folderRel); + + Directory.CreateDirectory(folderAbs); + // Provider archives are untrusted: only inert model/texture files are written under + // Assets/ — scripts/assemblies are skipped so they can't be compiled on import. + SafeZipExtractor.ExtractTo(zipAbs, folderAbs, ArchiveAllowedExtensions); + + AssetDatabase.Refresh(); + AssetDatabase.ImportAsset(folderRel, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate); + + string modelRel = FindFirstModel(folderAbs); + if (string.IsNullOrEmpty(modelRel)) + return Fail(job, "Archive extracted but no model file (.fbx/.obj/.glb/.gltf) was found inside."); + + string ext = Path.GetExtension(modelRel).ToLowerInvariant(); + bool isGltf = ext == ".glb" || ext == ".gltf"; + if (isGltf && !IsGltfastAvailable()) + { + return Fail(job, + "This model is glTF (.glb/.gltf), which requires glTFast. Install it from the MCP for Unity → Dependencies tab."); + } + + AssetDatabase.ImportAsset(modelRel, ImportAssetOptions.ForceUpdate); + + if (!isGltf) + ApplyModelImporterSettings(modelRel, job); + + job.AssetPath = modelRel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(modelRel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the extracted model but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + + /// + /// Walk the extracted directory for a model file, preferring FBX/OBJ (built-in importer) + /// over glTF (needs glTFast). Returns a project-relative path or null when none found. + /// + private static string FindFirstModel(string folderAbs) + { + string[] all; + try { all = Directory.GetFiles(folderAbs, "*", SearchOption.AllDirectories); } + catch { return null; } + + string firstGltf = null; + foreach (string abs in all) + { + string e = Path.GetExtension(abs).ToLowerInvariant(); + if (e == ".fbx" || e == ".obj") + return AssetGenPaths.ToProjectRelative(abs); + if (firstGltf == null && (e == ".glb" || e == ".gltf")) + firstGltf = abs; + } + return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf); + } + + private static void ApplyModelImporterSettings(string rel, AssetGenJob job) + { + if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return; + importer.useFileScale = true; + importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard; + importer.animationType = ModelImporterAnimationType.None; + + if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f) + { + float maxDim = ComputeMaxDimension(rel); + if (maxDim > 0.0001f) + { + float scale = job.TargetSize / maxDim; + if (scale > 0f && Math.Abs(scale - 1f) > 0.01f) + { + importer.useFileScale = false; + importer.globalScale = Mathf.Clamp(importer.globalScale * scale, 0.0001f, 1_000_000f); + } + } + } + importer.SaveAndReimport(); + } + + private static float ComputeMaxDimension(string rel) + { + try + { + var go = AssetDatabase.LoadAssetAtPath(rel); + if (go == null) return 0f; + + bool any = false; + Bounds acc = new Bounds(Vector3.zero, Vector3.zero); + foreach (var mf in go.GetComponentsInChildren(true)) + { + if (mf.sharedMesh == null) continue; + if (!any) { acc = mf.sharedMesh.bounds; any = true; } + else acc.Encapsulate(mf.sharedMesh.bounds); + } + foreach (var smr in go.GetComponentsInChildren(true)) + { + if (smr.sharedMesh == null) continue; + if (!any) { acc = smr.sharedMesh.bounds; any = true; } + else acc.Encapsulate(smr.sharedMesh.bounds); + } + if (!any) return 0f; + Vector3 s = acc.size; + return Mathf.Max(s.x, Mathf.Max(s.y, s.z)); + } + catch { return 0f; } + } + + private static bool? _gltfastAvailable; + + /// + /// True when the glTFast package is present. Cached after the first probe — the result only + /// changes on a package install/uninstall, which triggers a domain reload that resets this + /// static. Shared with the Asset Gen settings tab so the reflection scan runs at most once. + /// + internal static bool IsGltfastAvailable() + { + if (_gltfastAvailable.HasValue) return _gltfastAvailable.Value; + bool found = Type.GetType("GLTFast.GltfImport, glTFast") != null; + if (!found) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try { if (asm.GetType("GLTFast.GltfImport") != null) { found = true; break; } } + catch { /* dynamic/!resolvable assembly */ } + } + } + _gltfastAvailable = found; + return found; + } + + private static AssetGenJob Fail(AssetGenJob job, string message) + { + job.State = AssetGenJobState.Failed; + job.Error = message; + return job; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta new file mode 100644 index 000000000..53b398026 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d63a324540d4a4a904e8b345ccf81bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs new file mode 100644 index 000000000..b5ef2b3c0 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Extracts a .zip into a destination directory while rejecting Zip-Slip path traversal: + /// every entry's resolved target must stay inside destDir. Directory entries are + /// created; file entries are written by copying the entry stream (no reliance on the + /// ZipFileExtensions helper). Used to unpack marketplace model archives (e.g. Sketchfab). + /// + /// When is supplied, file entries whose extension is not + /// on the allowlist are SKIPPED (not written). Callers that extract UNTRUSTED archives into the + /// Assets tree MUST pass an allowlist of inert asset types so executable content (.cs/.dll/ + /// .asmdef) can never land under Assets/ and be compiled/loaded by the Editor. + /// + public static class SafeZipExtractor + { + public static void ExtractTo(string zipPath, string destDir, ISet allowedExtensions = null) + { + if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath)); + if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir)); + + Directory.CreateDirectory(destDir); + string destFull = Path.GetFullPath(destDir); + string prefix = destFull.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? destFull + : destFull + Path.DirectorySeparatorChar; + + using (FileStream fs = File.OpenRead(zipPath)) + using (var archive = new ZipArchive(fs, ZipArchiveMode.Read)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + string name = entry.FullName; + if (string.IsNullOrEmpty(name)) continue; + + // Reject traversal / absolute paths up front. + if (name.Contains("..") || Path.IsPathRooted(name)) + throw new IOException($"Unsafe zip entry rejected: {name}"); + + string target = Path.GetFullPath(Path.Combine(destDir, name)); + if (!target.StartsWith(prefix, StringComparison.Ordinal)) + throw new IOException($"Unsafe zip entry escapes destination: {name}"); + + // A directory entry has an empty Name (FullName ends with a separator). + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(target); + continue; + } + + // Allowlist gate: skip anything that isn't an inert asset type the caller permits. + if (allowedExtensions != null && allowedExtensions.Count > 0 + && !allowedExtensions.Contains(Path.GetExtension(entry.Name).ToLowerInvariant())) + { + continue; + } + + string parent = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent); + + using (Stream src = entry.Open()) + using (FileStream dst = File.Create(target)) + { + src.CopyTo(dst); + } + } + } + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta new file mode 100644 index 000000000..572c6cafe --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dcd4c25a7b44e96b22ea605ffd73409 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers.meta b/MCPForUnity/Editor/Services/AssetGen/Providers.meta new file mode 100644 index 000000000..dca2b3a73 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3b451683dd204e6e9d28fd7dbe4676a6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs new file mode 100644 index 000000000..132a5581a --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Security; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter + /// (model: tripo/meshy; image: fal/openrouter; marketplace: sketchfab); unknown ids throw + /// . advertises providers and reports + /// Configured existence only — never a key value. + /// + public static class AssetGenProviders + { + public static IModelProviderAdapter Model(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "tripo": + return new TripoAdapter(); + case "meshy": + return new MeshyAdapter(); + default: + throw new NotSupportedException($"Unknown model provider '{id}'."); + } + } + + public static IImageProviderAdapter Image(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "fal": + return new FalAdapter(); + case "openrouter": + return new OpenRouterAdapter(); + default: + throw new NotSupportedException($"Unknown image provider '{id}'."); + } + } + + public static IMarketplaceProviderAdapter Marketplace(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "sketchfab": + return new SketchfabAdapter(); + default: + throw new NotSupportedException($"Unknown marketplace provider '{id}'."); + } + } + + public static IReadOnlyList List() + { + return new List + { + new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } }, + new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } }, + }; + } + + private static bool IsConfigured(string id) + { + try { return SecureKeyStore.Current.Has(id); } + catch { return false; } + } + + /// + /// Standard "no key" message: points the user at the Asset Generation tab and the env override. + /// Shared by the asset-gen tools and the job manager so the wording stays in one place. + /// + public static string MissingKeyMessage(string provider) + => $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " + + $"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY)."; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta new file mode 100644 index 000000000..6ac1295e3 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ab11255ce314c7193df3bca43286f83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs new file mode 100644 index 000000000..d888a0f63 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -0,0 +1,162 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header + /// "Authorization: Key <key>"), polls the request's status_url, then fetches the result and + /// returns the first image URL for the job manager to download. + /// + public sealed class FalAdapter : IImageProviderAdapter + { + private const string QueueBase = "https://queue.fal.run/"; + // FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives: + // fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality). + private const string DefaultModel = "fal-ai/flux-2"; + + public string Id => "fal"; + + public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + + var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 }; + string url; + if (image) + { + // image→image / editing lives on the model's /edit endpoint and takes an image_urls + // array; each entry accepts a hosted URL or an inline base64 data URI (local image_path). + url = QueueBase + model + "/edit"; + string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body["image_urls"] = new JArray(imageRef); + } + else + { + url = QueueBase + model; + } + // Forward explicit output dimensions for text→image only; fal's image_size accepts a + // {width,height} object. (/edit derives size from the source image and may reject it. + // FLUX has no transparency param — transparent backgrounds aren't a generation-time option.) + if (!image && req.Width > 0 && req.Height > 0) + body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = url, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Key " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "submit"); + + // Prefer response_url; fall back to building it from request_id. + string responseUrl = json["response_url"]?.ToString(); + if (string.IsNullOrEmpty(responseUrl)) + { + string requestId = json["request_id"]?.ToString(); + if (string.IsNullOrEmpty(requestId)) + throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); + // Queue request URLs are namespaced by owner/app without the action sub-path, + // so build from the base model id (not `url`, which may end in /edit). + responseUrl = QueueBase + model + "/requests/" + requestId; + } + return responseUrl; + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + string responseUrl = providerJobId; + + var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" }; + statusSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult statusRes = await http.SendAsync(statusSpec, ct); + JObject statusJson = ParseOk(statusRes, apiKey, "status"); + + string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant(); + var result = new ProviderPollResult(); + switch (status) + { + case "COMPLETED": + case "OK": + result.State = ProviderPollState.Succeeded; + break; + case "IN_PROGRESS": + result.State = ProviderPollState.Running; + return result; + case "IN_QUEUE": + result.State = ProviderPollState.Queued; + return result; + case "ERROR": + case "FAILED": + result.State = ProviderPollState.Failed; + result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey); + return result; + default: + result.State = ProviderPollState.Running; + return result; + } + + // Completed: fetch the result payload and extract the first image URL. + var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl }; + resultSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult resultRes = await http.SendAsync(resultSpec, ct); + JObject resultJson = ParseOk(resultRes, apiKey, "result"); + + result.Progress = 1f; + result.DownloadUrl = ExtractImageUrl(resultJson); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "fal completed but no image URL was present in the result."; + } + return result; + } + + private static string ExtractImageUrl(JObject result) + { + JToken images = result["images"]; + if (images is JArray arr && arr.Count > 0) + { + string u = arr[0]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + string single = result["image"]?["url"]?.ToString(); + return string.IsNullOrEmpty(single) ? null : single; + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta new file mode 100644 index 000000000..1432534bb --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a36ad2c4285c4799ae9eb4495813a44c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs new file mode 100644 index 000000000..561923c7f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.AssetGen.Http; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side + /// job id; poll reports progress and, on success, the download URL. The api key is passed in + /// at call time and never cached on the adapter. + /// + public interface IModelProviderAdapter + { + string Id { get; } + Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct); + Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); + } + + /// A generative 2D image provider (fal, OpenRouter, ...). Phase 7. + public interface IImageProviderAdapter + { + string Id { get; } + Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct); + Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); + } + + /// A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6. + public interface IMarketplaceProviderAdapter + { + string Id { get; } + Task SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct); + Task PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct); + Task ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta new file mode 100644 index 000000000..90dbcd30b --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f76130e504cd4423abbe82bbb718d25b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs new file mode 100644 index 000000000..ce81358f5 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode + /// it as a base64 data: URI — the inline form fal, Meshy, and OpenRouter accept for image + /// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately. + /// + internal static class LocalImage + { + // Extensions that can be inlined as a data URI for provider image input. + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".png", ".jpg", ".jpeg", ".webp", ".gif" }; + + /// + /// Resolve an image path under the project's Assets folder to an existing absolute file of + /// a supported image type. Returns false with a user-facing for + /// an unsafe path, a missing file, or an unsupported extension, so the handler can fail + /// fast before any provider request is made. + /// + public static bool ResolveExisting(string path, out string absPath, out string error) + { + absPath = null; + error = null; + if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; } + if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel)) + { + error = "image_path must point to a file under the project's Assets folder."; + return false; + } + string abs = AssetGenPaths.ToAbsolute(rel); + if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; } + if (!SupportedExtensions.Contains(Path.GetExtension(abs))) + { + error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif."; + return false; + } + absPath = abs; + return true; + } + + /// + /// Read a local image and return a "data:image/<mime>;base64,..." URI. Throws + /// for an unsupported extension. + /// + public static string ToDataUri(string absPath) + { + if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel)) + throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder."); + absPath = AssetGenPaths.ToAbsolute(rel); + string mime = MimeFromExtension(Path.GetExtension(absPath)); + byte[] bytes = File.ReadAllBytes(absPath); + return "data:" + mime + ";base64," + Convert.ToBase64String(bytes); + } + + private static string MimeFromExtension(string ext) + { + switch ((ext ?? string.Empty).ToLowerInvariant()) + { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".webp": return "image/webp"; + case ".gif": return "image/gif"; + default: + throw new NotSupportedException( + $"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif."); + } + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta new file mode 100644 index 000000000..e17c9ef28 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f8290c406df4661b9ad2ff6cd324871 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs new file mode 100644 index 000000000..58a8a182c --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs @@ -0,0 +1,208 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry + /// only); when textures are requested it then issues a "refine" task and surfaces the textured + /// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each + /// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and + /// never logged; every error is run through . + /// + public sealed class MeshyAdapter : IModelProviderAdapter + { + private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d"; + private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d"; + + public string Id => "meshy"; + + // Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow. + private string _format = "glb"; + private bool _isImage; + private bool _wantTexture = true; + private string _refineTaskId; + private bool _refineSubmitted; + + public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + _format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant(); + _wantTexture = req.Texture; + _isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + + JObject body; + string url; + if (_isImage) + { + // image→3D textures in a single call (no separate refine task). image_url accepts a + // hosted URL or an inline base64 data URI (for a local image_path). + url = ImageEndpoint; + string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body = new JObject + { + ["image_url"] = imageRef, + ["ai_model"] = "meshy-6", + ["should_texture"] = _wantTexture + }; + } + else + { + // text→3D preview is geometry only; texturing happens via a follow-up refine task. + url = TextEndpoint; + body = new JObject + { + ["mode"] = "preview", + ["prompt"] = req.Prompt ?? string.Empty, + ["ai_model"] = "meshy-6" + }; + } + + return await PostTask(url, body, apiKey, http, ct, "submit"); + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + bool refinePhase = _refineSubmitted; + string pollId = refinePhase ? _refineTaskId : providerJobId; + // image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d. + string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint; + + var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "poll"); + + ProviderPollState state = MapState(json["status"]?.ToString()); + var result = new ProviderPollResult { State = state }; + + // Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1). + bool twoPhase = !_isImage && _wantTexture; + float raw = 0f; + JToken prog = json["progress"]; + if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value() / 100f); + result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f); + + if (state == ProviderPollState.Succeeded) + { + // Preview just finished and textures were requested: start the refine task and keep + // polling it; never surface the untextured preview result. + if (twoPhase && !refinePhase) + { + var refineBody = new JObject + { + ["mode"] = "refine", + ["preview_task_id"] = providerJobId, + ["ai_model"] = "meshy-6" + }; + _refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine"); + _refineSubmitted = true; + result.State = ProviderPollState.Running; + result.Progress = 0.5f; + return result; + } + + result.Progress = 1f; + result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "Meshy reported success but no model URL was present in the response."; + } + } + else if (state == ProviderPollState.Failed) + { + string err = json["task_error"]?["message"]?.ToString() + ?? json["message"]?.ToString() + ?? "Meshy task failed."; + result.Error = SecretRedactor.Scrub(err, apiKey); + } + + return result; + } + + /// POST a task body and return its result task id (or null). + private static async Task PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase) + { + var spec = new HttpRequestSpec + { + Method = "POST", + Url = url, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, phase); + string id = json["result"]?.ToString(); + if (string.IsNullOrEmpty(id)) + throw new Exception(SecretRedactor.Scrub( + $"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey)); + return id; + } + + private string ExtractModelUrl(JObject urls) + { + if (urls == null) return null; + string byFormat = urls[_format]?.ToString(); + if (!string.IsNullOrEmpty(byFormat)) return byFormat; + string glb = urls["glb"]?.ToString(); + if (!string.IsNullOrEmpty(glb)) return glb; + string fbx = urls["fbx"]?.ToString(); + return string.IsNullOrEmpty(fbx) ? null : fbx; + } + + private static ProviderPollState MapState(string status) + { + switch ((status ?? string.Empty).ToUpperInvariant()) + { + case "SUCCEEDED": + return ProviderPollState.Succeeded; + case "FAILED": + case "EXPIRED": + case "CANCELED": + case "CANCELLED": + return ProviderPollState.Failed; + case "IN_PROGRESS": + return ProviderPollState.Running; + case "PENDING": + default: + return ProviderPollState.Queued; + } + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta new file mode 100644 index 000000000..0f5479929 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb6ba98874ed46cca5f019c89b2edb0a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs new file mode 100644 index 000000000..52a8b0d00 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs @@ -0,0 +1,155 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// OpenRouter image provider via the (synchronous) chat-completions endpoint with an + /// image-capable multimodal model. The image is returned inline (base64 data URL), so the + /// work happens in and returns it immediately. + /// One adapter instance handles a single job (the job manager captures it for submit+poll). + /// + public sealed class OpenRouterAdapter : IImageProviderAdapter + { + private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; + private const string DefaultModel = "google/gemini-2.5-flash-image"; + + public string Id => "openrouter"; + + private byte[] _inlineData; + private string _downloadUrl; + private string _error; + + public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + + // image->image: attach the reference image as an image_url content part alongside the + // text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a + // base64 data URI. Plain text->image uses a string content. + bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + // image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path). + string imageRef = image + ? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath)) + : null; + JToken content = image + ? new JArray( + new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty }, + new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } }) + : (JToken)(req.Prompt ?? string.Empty); + + var body = new JObject + { + ["model"] = model, + ["modalities"] = new JArray("image", "text"), + ["messages"] = new JArray(new JObject + { + ["role"] = "user", + ["content"] = content + }) + }; + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = Endpoint, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey); + + string url = ExtractImageUrl(json); + if (string.IsNullOrEmpty(url)) + { + _error = "OpenRouter returned no image. The selected model may not support image output."; + return "ready"; + } + + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase); + if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; } + try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); } + catch { _error = "OpenRouter image was not valid base64."; } + } + else + { + _downloadUrl = url; + } + return "ready"; + } + + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + var result = new ProviderPollResult { Progress = 1f }; + if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl))) + { + result.State = ProviderPollState.Failed; + result.Error = _error ?? "OpenRouter produced no image."; + } + else + { + result.State = ProviderPollState.Succeeded; + result.InlineData = _inlineData; + result.DownloadUrl = _downloadUrl; + } + return Task.FromResult(result); + } + + private static string ExtractImageUrl(JObject json) + { + JToken message = json["choices"]?[0]?["message"]; + if (message == null) return null; + + // Preferred: message.images[].image_url.url + if (message["images"] is JArray imgs && imgs.Count > 0) + { + string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + // Fallback: a content array with image_url parts + if (message["content"] is JArray parts) + { + foreach (JToken part in parts) + { + string u = part?["image_url"]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + } + return null; + } + + private static JObject ParseOk(HttpResult res, string apiKey) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString() + ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta new file mode 100644 index 000000000..befadb503 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55b1b120b06948f6aeada0381b51ce86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs new file mode 100644 index 000000000..1539e491e --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs @@ -0,0 +1,28 @@ +using System.Text; +using MCPForUnity.Editor.Services.AssetGen.Http; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Shared HTTP-response helpers for provider adapters: read the response text (falling back to + /// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages. + /// + internal static class ProviderHttp + { + /// Response text, falling back to a UTF-8 decode of the raw body when Text is empty. + public static string BodyText(HttpResult res) + { + string text = res?.Text; + if (string.IsNullOrEmpty(text) && res?.Body != null) + text = Encoding.UTF8.GetString(res.Body); + return text; + } + + /// Cap a (possibly null) string at 500 chars for inclusion in an error message. + public static string Truncate(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + return s.Length <= 500 ? s : s.Substring(0, 500) + "…"; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta new file mode 100644 index 000000000..04c96d687 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1584e5cffb5b45e6816fec509f1bfc8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs new file mode 100644 index 000000000..4ee117562 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -0,0 +1,75 @@ +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// Normalized lifecycle state reported by a provider poll, across all providers. + public enum ProviderPollState + { + Queued, + Running, + Succeeded, + Failed + } + + /// + /// Outcome of a single provider poll. is normalized to 0..1. + /// On , points at the + /// result the C# side will download into the project. On + /// , carries a redacted message. + /// + public sealed class ProviderPollResult + { + public ProviderPollState State; + public float Progress; + public string DownloadUrl; + /// Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter), + /// so the job manager skips the download step. Takes precedence over . + public byte[] InlineData; + /// Overrides the downloaded file extension, e.g. "zip" for archive results. + public string ResultExt; + public string Error; + } + + /// Request to generate a 3D model. Shared by every model provider adapter. + public sealed class ModelGenRequest + { + public string Provider; + public string Mode; // text | image + public string Prompt; + public string ImagePath; + public string ImageUrl; + public string Format = "glb"; + public float TargetSize = 1f; + public bool Texture = true; + public string Tier; + public string Name; + public string OutputFolder; + } + + /// Request to generate a 2D image. Shared by every image provider adapter. + public sealed class ImageGenRequest + { + public string Provider; + public string Mode; // text | image + public string Prompt; + public string ImagePath; + public string ImageUrl; + public string Model; + public bool Transparent; + public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture + public int Width; + public int Height; + public string Name; + public string OutputFolder; + } + + /// + /// Public, key-free description of a provider for list_providers. Never carries a key + /// value — reports existence only. + /// + public sealed class ProviderInfo + { + public string Id; + public string Kind; // model | image | marketplace + public bool Configured; + public string[] Capabilities; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta new file mode 100644 index 000000000..93158d385 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65be6fd6b6384effb45822a57e541f04 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs new file mode 100644 index 000000000..392dff12a --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API + /// JSON to the caller; hits the model download endpoint + /// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the + /// "Token <key>" scheme; the key is supplied per call and never logged. + /// + public sealed class SketchfabAdapter : IMarketplaceProviderAdapter + { + private const string SearchEndpoint = "https://api.sketchfab.com/v3/search"; + private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models"; + + public string Id => "sketchfab"; + + public async Task SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false") + + "&q=" + Uri.EscapeDataString(query ?? string.Empty); + if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories); + if (count.HasValue) url += "&count=" + count.Value; + if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor); + var spec = new HttpRequestSpec { Method = "GET", Url = url }; + spec.Headers["Authorization"] = "Token " + apiKey; + HttpResult res = await http.SendAsync(spec, ct); + // The raw response carries pagination (`cursors.next` / `next`) for the caller to page. + return RawOk(res, apiKey, "search"); + } + + public async Task PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid)); + if (http == null) throw new ArgumentNullException(nameof(http)); + var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid }; + spec.Headers["Authorization"] = "Token " + apiKey; + HttpResult res = await http.SendAsync(spec, ct); + return RawOk(res, apiKey, "preview"); + } + + public async Task ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid)); + if (http == null) throw new ArgumentNullException(nameof(http)); + var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" }; + spec.Headers["Authorization"] = "Token " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "download"); + + string url = json["gltf"]?["url"]?.ToString(); + if (string.IsNullOrEmpty(url)) + { + throw new Exception(SecretRedactor.Scrub( + $"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey)); + } + return url; + } + + private static string RawOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + bool ok = res?.Ok == true; + if (!ok) + throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey)); + return text ?? string.Empty; + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta new file mode 100644 index 000000000..031870f98 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 860350c25d8d490aaaa925d76ce29407 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs new file mode 100644 index 000000000..da9765f04 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs @@ -0,0 +1,222 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and + /// polls for completion. The bearer key is supplied per call and never logged; every error + /// message is run through before it is surfaced. + /// + public sealed class TripoAdapter : IModelProviderAdapter + { + private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task"; + // Current recommended Tripo model (v3.1). Premium alternative: P1-20260311. + private const string ModelVersion = "v3.1-20260211"; + + public string Id => "tripo"; + + public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + // Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files, + // which isn't wired yet — fail clearly rather than silently falling back to text mode. + bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase); + if (imageMode && string.IsNullOrEmpty(req.ImageUrl)) + throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image)."); + + JObject body; + bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl); + if (image) + { + body = new JObject + { + ["type"] = "image_to_model", + ["file"] = new JObject + { + ["type"] = "url", + ["url"] = req.ImageUrl + } + }; + } + else + { + body = new JObject + { + ["type"] = "text_to_model", + ["prompt"] = req.Prompt ?? string.Empty, + ["model_version"] = ModelVersion + }; + } + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = TaskEndpoint, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseAndValidate(res, apiKey, "submit"); + + string taskId = json["data"]?["task_id"]?.ToString(); + if (string.IsNullOrEmpty(taskId)) + { + throw new Exception(SecretRedactor.Scrub( + "Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); + } + return taskId; + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + var spec = new HttpRequestSpec + { + Method = "GET", + Url = TaskEndpoint + "/" + providerJobId + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseAndValidate(res, apiKey, "poll"); + JObject data = json["data"] as JObject ?? new JObject(); + + var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) }; + + JToken progressTok = data["progress"]; + if (progressTok != null && progressTok.Type != JTokenType.Null) + { + result.Progress = Mathf.Clamp01(progressTok.Value() / 100f); + } + + if (result.State == ProviderPollState.Succeeded) + { + result.Progress = 1f; + result.DownloadUrl = ExtractDownloadUrl(data); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "Tripo reported success but no model URL was present in the response."; + } + } + else if (result.State == ProviderPollState.Failed) + { + string err = data["error"]?.ToString() + ?? data["message"]?.ToString() + ?? json["message"]?.ToString() + ?? "Tripo task failed."; + result.Error = SecretRedactor.Scrub(err, apiKey); + } + + return result; + } + + private static ProviderPollState MapState(string status) + { + switch ((status ?? string.Empty).ToLowerInvariant()) + { + case "success": + case "succeeded": + return ProviderPollState.Succeeded; + case "failed": + case "error": + case "cancelled": + case "canceled": + case "banned": + case "expired": + return ProviderPollState.Failed; + case "running": + case "processing": + return ProviderPollState.Running; + default: + return ProviderPollState.Queued; + } + } + + /// + /// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a + /// textured / PBR model; fall back to the base model. Accepts both the newer flat form + /// (output.pbr_model = url string) and the older nested form + /// (result.pbr_model.url = object with a url field). + /// + private static string ExtractDownloadUrl(JObject data) + { + JObject output = data["output"] as JObject; + JObject resultObj = data["result"] as JObject; + + return UrlOf(output?["pbr_model"]) + ?? UrlOf(resultObj?["pbr_model"]) + ?? UrlOf(output?["model"]) + ?? UrlOf(resultObj?["model"]) + ?? UrlOf(output?["base_model"]) + ?? UrlOf(resultObj?["base_model"]); + } + + /// A field may be a plain URL string or an object carrying a "url" property. + private static string UrlOf(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return null; + if (token.Type == JTokenType.String) + { + string s = token.ToString(); + return string.IsNullOrEmpty(s) ? null : s; + } + if (token is JObject obj) + { + string u = obj["url"]?.ToString(); + return string.IsNullOrEmpty(u) ? null : u; + } + return null; + } + + /// + /// Validate transport success + Tripo's body-level code (0 == ok), parse the JSON, + /// and throw a redacted exception otherwise. + /// + private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } + catch { /* non-JSON body; handled below */ } + } + + bool httpOk = res?.Ok == true; + + int code = 0; + JToken codeTok = json?["code"]; + if (codeTok != null && codeTok.Type != JTokenType.Null) + { + try { code = codeTok.Value(); } catch { code = -1; } + } + + if (!httpOk || code != 0) + { + string detail = json?["message"]?.ToString() + ?? json?["error"]?.ToString() + ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub( + $"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey)); + } + + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta new file mode 100644 index 000000000..45c5aa318 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a0761a0b205e4041950b0ab29a3d6466 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen.meta b/MCPForUnity/Editor/Tools/AssetGen.meta new file mode 100644 index 000000000..e1a8a6fb2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9ba1214fff954a9498b166afcda0dc4b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs new file mode 100644 index 000000000..a3e3e087e --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// 2D image generation via an aggregator (fal.ai / OpenRouter). Triggered here (never from the + /// GUI); the C# side reads the provider key from the secure store and runs the job. Returns a + /// job_id immediately; the client polls the `status` action. + /// + [McpForUnityTool("generate_image", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)] + public static class GenerateImage + { + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? string.Empty).ToLowerInvariant(); + try + { + switch (action) + { + case "generate": return Generate(p); + case "remove_background": + return new ErrorResponse("remove_background is not implemented in this version."); + case "status": return Status(p); + case "cancel": return Cancel(p); + case "list_providers": return ListProviders(); + case "": return new ErrorResponse("'action' parameter is required."); + default: + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers."); + } + } + catch (NotSupportedException nse) + { + return new ErrorResponse(nse.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub(e.Message)); + } + } + + private static object Generate(ToolParams p) + { + string provider = (p.Get("provider", "fal") ?? "fal").ToLowerInvariant(); + AssetGenProviders.Image(provider); // throws NotSupportedException for unknown providers + + if (!SecureKeyStore.Current.Has(provider)) + return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider)); + + var req = new ImageGenRequest + { + Provider = provider, + Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(), + Prompt = p.Get("prompt"), + ImagePath = p.Get("imagePath"), + ImageUrl = p.Get("imageUrl"), + Model = p.Get("model"), + Transparent = p.GetBool("transparent", false), + AsSprite = p.GetBool("asSprite", true), + Width = p.GetInt("width", 0) ?? 0, + Height = p.GetInt("height", 0) ?? 0, + Name = p.Get("name"), + OutputFolder = p.Get("outputFolder"), + }; + if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) + return new ErrorResponse(outputErr); + + if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt)) + return new ErrorResponse("'prompt' is required for text mode."); + if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl)) + { + if (string.IsNullOrWhiteSpace(req.ImagePath)) + return new ErrorResponse("image mode requires 'image_url' or 'image_path'."); + if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr)) + return new ErrorResponse(imgErr); + req.ImagePath = absImg; + } + + AssetGenJob job = AssetGenJobManager.StartImageGeneration(req); + if (job.State == AssetGenJobState.Failed) + return new ErrorResponse(job.Error ?? "Failed to start generation."); + + return new PendingResponse( + $"Image generation started with '{provider}'. Poll the status action with this job_id.", + pollIntervalSeconds: 2.0, + data: new { job_id = job.JobId, provider, status = "pending" }); + } + + private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error) + { + normalized = outputFolder; + error = null; + if (string.IsNullOrWhiteSpace(outputFolder)) return true; + if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true; + error = "'output_folder' must resolve under the project's Assets folder."; + return false; + } + + private static object Status(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); + AssetGenJob job = AssetGenJobManager.GetJob(jobId); + if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); + + switch (job.State) + { + case AssetGenJobState.Done: + return new SuccessResponse( + $"Image generated: {job.AssetPath}", + new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); + case AssetGenJobState.Failed: + return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" }); + case AssetGenJobState.Canceled: + return new SuccessResponse("Generation canceled.", new { state = "canceled" }); + default: + return new PendingResponse( + $"Image {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", + pollIntervalSeconds: 2.0, + data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); + } + } + + private static object Cancel(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); + return AssetGenJobManager.Cancel(jobId) + ? new SuccessResponse($"Cancel requested for job '{jobId}'.") + : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); + } + + private static object ListProviders() + { + var list = new List(); + foreach (ProviderInfo info in AssetGenProviders.List()) + { + if (info.Kind != "image") continue; + list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); + } + return new SuccessResponse($"{list.Count} image provider(s).", new { providers = list }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs.meta new file mode 100644 index 000000000..9a168b27a --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5e03ff5e4e040959fedaac555752094 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs new file mode 100644 index 000000000..0a3180699 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// 3D model generation (Tripo/Meshy). Generation is triggered here (never from + /// the GUI); the C# side reads the provider key from the secure store and runs the job. + /// Long-running: returns a job_id immediately and the client polls the `status` action. + /// + [McpForUnityTool("generate_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)] + public static class GenerateModel + { + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? string.Empty).ToLowerInvariant(); + try + { + switch (action) + { + case "generate": return Generate(p); + case "status": return Status(p); + case "cancel": return Cancel(p); + case "list_providers": return ListProviders(); + case "": return new ErrorResponse("'action' parameter is required."); + default: + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + } + } + catch (NotSupportedException nse) + { + return new ErrorResponse(nse.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub(e.Message)); + } + } + + private static object Generate(ToolParams p) + { + string provider = (p.Get("provider", "tripo") ?? "tripo").ToLowerInvariant(); + AssetGenProviders.Model(provider); // throws NotSupportedException for unimplemented providers + + if (!SecureKeyStore.Current.Has(provider)) + return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider)); + + var req = new ModelGenRequest + { + Provider = provider, + Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(), + Prompt = p.Get("prompt"), + ImagePath = p.Get("imagePath"), + ImageUrl = p.Get("imageUrl"), + Format = (p.Get("format", "glb") ?? "glb").ToLowerInvariant(), + TargetSize = p.GetFloat("targetSize", 1f) ?? 1f, + Texture = p.GetBool("texture", true), + Tier = p.Get("tier"), + Name = p.Get("name"), + OutputFolder = p.Get("outputFolder"), + }; + if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) + return new ErrorResponse(outputErr); + + if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt)) + return new ErrorResponse("'prompt' is required for text mode."); + if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl)) + { + if (string.IsNullOrWhiteSpace(req.ImagePath)) + return new ErrorResponse("image mode requires 'image_url' or 'image_path'."); + if (provider == "tripo") + return new ErrorResponse("Tripo image input requires a hosted 'image_url'; local 'image_path' is not supported for Tripo (use Meshy for local-image→3D)."); + if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr)) + return new ErrorResponse(imgErr); + req.ImagePath = absImg; + } + + AssetGenJob job = AssetGenJobManager.StartModelGeneration(req); + if (job.State == AssetGenJobState.Failed) + return new ErrorResponse(job.Error ?? "Failed to start generation."); + + return new PendingResponse( + $"3D generation started with '{provider}'. Poll the status action with this job_id.", + pollIntervalSeconds: 3.0, + data: new { job_id = job.JobId, provider, status = "pending" }); + } + + private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error) + { + normalized = outputFolder; + error = null; + if (string.IsNullOrWhiteSpace(outputFolder)) return true; + if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true; + error = "'output_folder' must resolve under the project's Assets folder."; + return false; + } + + private static object Status(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); + AssetGenJob job = AssetGenJobManager.GetJob(jobId); + if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); + + switch (job.State) + { + case AssetGenJobState.Done: + return new SuccessResponse( + $"Generation complete: {job.AssetPath}", + new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); + case AssetGenJobState.Failed: + return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" }); + case AssetGenJobState.Canceled: + return new SuccessResponse("Generation canceled.", new { state = "canceled" }); + default: + return new PendingResponse( + $"Generation {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", + pollIntervalSeconds: 3.0, + data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); + } + } + + private static object Cancel(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); + return AssetGenJobManager.Cancel(jobId) + ? new SuccessResponse($"Cancel requested for job '{jobId}'.") + : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); + } + + private static object ListProviders() + { + var list = new List(); + foreach (ProviderInfo info in AssetGenProviders.List()) + list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); + return new SuccessResponse($"{list.Count} provider(s).", new { providers = list }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs.meta new file mode 100644 index 000000000..2c70c1914 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fa7787add8344fd99adfceff3b5dd57e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs b/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs new file mode 100644 index 000000000..0f5103692 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// 3D marketplace import (Sketchfab). Search/preview are read-only calls awaited directly: + /// the handler is async so the underlying UnityWebRequest completes on the editor loop + /// rather than blocking the main thread (a synchronous .GetResult() here deadlocks the + /// editor — the request can only finish on a tick the blocked main thread can't run). + /// Import downloads the model archive and unpacks it into the project as a long-running job + /// (returns a job_id; the client polls the `status` action). The provider key is read from + /// the secure store on the C# side and never transits the bridge. + /// + [McpForUnityTool("import_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)] + public static class ImportModel + { + private const string Provider = "sketchfab"; + + // Test seam for the search/preview calls (import routes through the job manager's + // own transport seam). Defaults to the production UnityWebRequest transport. + internal static IHttpTransport TransportOverrideForTests; + + public static async Task HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? string.Empty).ToLowerInvariant(); + try + { + switch (action) + { + case "search": return await Search(p); + case "preview": return await Preview(p); + case "import": return Import(p); + case "status": return Status(p); + case "cancel": return Cancel(p); + case "list_providers": return ListProviders(); + case "": return new ErrorResponse("'action' parameter is required."); + default: + return new ErrorResponse($"Unknown action: '{action}'. Supported: search, preview, import, status, cancel, list_providers."); + } + } + catch (NotSupportedException nse) + { + return new ErrorResponse(nse.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub(e.Message)); + } + } + + private static IHttpTransport Transport() => TransportOverrideForTests ?? new UnityWebRequestTransport(); + + private static async Task Search(ToolParams p) + { + string query = p.Get("query"); + if (string.IsNullOrWhiteSpace(query)) return new ErrorResponse("'query' is required for search."); + if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key)) + return KeyError(); + + IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider); + string results = await adapter.SearchAsync( + query, p.Get("categories"), p.GetBool("downloadable", true), p.GetInt("count"), p.Get("cursor"), + key, Transport(), CancellationToken.None); + return new SuccessResponse($"Search results for '{query}'.", + new { provider = Provider, results = ParseOrRaw(results) }); + } + + private static async Task Preview(ToolParams p) + { + string uid = p.Get("uid"); + if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for preview."); + if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key)) + return KeyError(); + + IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider); + string preview = await adapter.PreviewAsync(uid, key, Transport(), CancellationToken.None); + return new SuccessResponse($"Preview for '{uid}'.", + new { provider = Provider, uid, preview = ParseOrRaw(preview) }); + } + + private static object Import(ToolParams p) + { + string uid = p.Get("uid"); + if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for import."); + AssetGenProviders.Marketplace(Provider); // throws NotSupportedException for unimplemented providers + + float targetSize = p.GetFloat("targetSize", 1f) ?? 1f; + string name = p.Get("name"); + string outputFolder = p.Get("outputFolder"); + if (!string.IsNullOrWhiteSpace(outputFolder) + && !AssetGenPaths.TryGetAssetsFolder(outputFolder, out outputFolder)) + { + return new ErrorResponse("'output_folder' must resolve under the project's Assets folder."); + } + + AssetGenJob job = AssetGenJobManager.StartMarketplaceImport(uid, targetSize, name, outputFolder); + if (job.State == AssetGenJobState.Failed) + return new ErrorResponse(job.Error ?? "Failed to start import."); + + return new PendingResponse( + $"Sketchfab import started for '{uid}'. Poll the status action with this job_id.", + pollIntervalSeconds: 3.0, + data: new { job_id = job.JobId, provider = Provider, status = "pending" }); + } + + private static object Status(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); + AssetGenJob job = AssetGenJobManager.GetJob(jobId); + if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); + + switch (job.State) + { + case AssetGenJobState.Done: + return new SuccessResponse( + $"Import complete: {job.AssetPath}", + new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); + case AssetGenJobState.Failed: + return new ErrorResponse(job.Error ?? "Import failed.", new { state = "failed" }); + case AssetGenJobState.Canceled: + return new SuccessResponse("Import canceled.", new { state = "canceled" }); + default: + return new PendingResponse( + $"Import {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", + pollIntervalSeconds: 3.0, + data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); + } + } + + private static object Cancel(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); + return AssetGenJobManager.Cancel(jobId) + ? new SuccessResponse($"Cancel requested for job '{jobId}'.") + : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); + } + + private static object ListProviders() + { + var list = new List(); + foreach (ProviderInfo info in AssetGenProviders.List()) + { + if (info.Kind != "marketplace") continue; + list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); + } + return new SuccessResponse($"{list.Count} provider(s).", new { providers = list }); + } + + private static object KeyError() + => new ErrorResponse(AssetGenProviders.MissingKeyMessage(Provider)); + + private static object ParseOrRaw(string json) + { + if (string.IsNullOrEmpty(json)) return null; + try { return JToken.Parse(json); } catch { return json; } + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs.meta new file mode 100644 index 000000000..89ba3cfde --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 294fabfc32d2417ba2d6274ebcf3eb4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs b/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs new file mode 100644 index 000000000..134c58957 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Import; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// Import a local 3D model file (already on disk — e.g. exported from Blender/Maya) into the + /// Unity project. DCC-agnostic and key-free: the file is copied under Assets/ and run through + /// the shared ModelImportPipeline (glTFast/FBX/OBJ/zip handling, scale-normalize, material + /// settings). Placement into the scene is the caller's job (kept single-purpose). + /// + [McpForUnityTool("import_model_file", AutoRegister = false, Group = "asset_gen")] + public static class ImportModelFile + { + private static readonly string[] SupportedExt = { ".fbx", ".obj", ".glb", ".gltf", ".zip" }; + + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + try + { + string source = p.Get("sourcePath"); + if (string.IsNullOrWhiteSpace(source)) + return new ErrorResponse("'source_path' is required."); + + string srcAbs = ResolveSource(source); + if (!File.Exists(srcAbs)) + return new ErrorResponse($"Source file not found: {source}"); + + string ext = Path.GetExtension(srcAbs).ToLowerInvariant(); + if (Array.IndexOf(SupportedExt, ext) < 0) + return new ErrorResponse( + $"Unsupported model extension '{ext}'. Supported: .fbx, .obj, .glb, .gltf, .zip."); + + string baseName = p.Get("name"); + if (string.IsNullOrWhiteSpace(baseName)) + baseName = Path.GetFileNameWithoutExtension(srcAbs); + + string destRel = StageUnderAssets(srcAbs, baseName, ext, p.Get("outputFolder")); + AssetDatabase.Refresh(); + + var job = new AssetGenJob { TargetSize = p.GetFloat("targetSize", 1f) ?? 1f }; + AssetGenJob result = ModelImportPipeline.ImportInto(job, destRel); + + if (result == null || result.State == AssetGenJobState.Failed) + return new ErrorResponse(result?.Error ?? "Import failed."); + + return new SuccessResponse( + $"Imported model: {result.AssetPath}", + new { asset_path = result.AssetPath, asset_guid = result.AssetGuid }); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub(e.Message)); + } + } + + private static string ResolveSource(string source) + { + string s = source.Replace('\\', '/'); + if (s == "Assets" || s.StartsWith("Assets/")) return AssetGenPaths.ToAbsolute(s); + return s; // absolute path on disk + } + + private static string StageUnderAssets(string srcAbs, string baseName, string ext, string outputFolder) + { + string root = !string.IsNullOrWhiteSpace(outputFolder) + ? outputFolder + : AssetGenPrefs.OutputRoot + "/Imported"; + if (!AssetGenPaths.TryGetAssetsFolder(root, out root)) + { + if (!string.IsNullOrWhiteSpace(outputFolder)) + throw new ArgumentException("'output_folder' must resolve under the project's Assets folder."); + root = AssetGenPrefs.DefaultOutputRoot + "/Imported"; + } + + string absRoot = AssetGenPaths.ToAbsolute(root); + Directory.CreateDirectory(absRoot); + + string safe = SanitizeName(baseName); + string fileName = safe + ext; + string abs = Path.Combine(absRoot, fileName); + int n = 1; + while (File.Exists(abs)) { fileName = safe + "_" + n++ + ext; abs = Path.Combine(absRoot, fileName); } + + File.Copy(srcAbs, abs); + return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/'); + } + + private static string SanitizeName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return "model"; + foreach (char c in Path.GetInvalidFileNameChars()) raw = raw.Replace(c, '_'); + return raw.Trim(); + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs.meta new file mode 100644 index 000000000..48127f5a8 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e71d7281f8e84c45802ed38d1cceed81 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen.meta b/MCPForUnity/Editor/Windows/Components/AssetGen.meta new file mode 100644 index 000000000..8ae30475a --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/AssetGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4f880cc3dc8a412982f5ee198048d002 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs new file mode 100644 index 000000000..4a44e3845 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Import; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows.Components.AssetGen +{ + /// + /// Controller for the AI Asset Generation settings tab. This tab is CONFIG ONLY: + /// it lets users enter/clear per-provider API keys, toggle providers on/off, + /// presence-check a key, and set non-secret generation preferences. + /// Generation itself is never triggered here — only via MCP tools / CLI. + /// + /// Keys are written to the OS secure store (), never to + /// EditorPrefs or the project. The stored key is never read back into the field; only + /// its presence is surfaced through the status label. + /// + public class McpAssetGenSection + { + // Fixed provider lists. Each Id is both the SecureKeyStore key and the + // AssetGenPrefs enable-flag id. All model/marketplace providers below emit GLB. + private static readonly (string Id, string Label)[] ModelProviders = + { + ("tripo", "Tripo"), + ("meshy", "Meshy"), + ("sketchfab", "Sketchfab"), + }; + + private static readonly (string Id, string Label)[] ImageProviders = + { + ("fal", "fal"), + ("openrouter", "OpenRouter"), + }; + + // UI Elements + private VisualElement providersContainer; + private VisualElement gltfastNotice; + private DropdownField formatDropdown; + private TextField outputRootField; + private Toggle autoNormalizeToggle; + + // Per-provider enable toggles for the GLB-capable (model) providers, used to + // recompute the glTFast notice when a toggle changes. + private readonly List<(string Id, Toggle Toggle)> modelEnableToggles = new(); + + public VisualElement Root { get; private set; } + + public McpAssetGenSection(VisualElement root) + { + Root = root; + CacheUIElements(); + InitializeUI(); + RegisterCallbacks(); + } + + private void CacheUIElements() + { + providersContainer = Root.Q("assetgen-providers-container"); + gltfastNotice = Root.Q("gltfast-notice"); + formatDropdown = Root.Q("assetgen-format-dropdown"); + outputRootField = Root.Q("assetgen-output-root"); + autoNormalizeToggle = Root.Q("assetgen-auto-normalize"); + } + + private void InitializeUI() + { + // One-time choices + tooltips; the field values are populated by SyncFromPrefs. + if (formatDropdown != null) + { + formatDropdown.choices = new List { "glb", "fbx", "obj" }; + formatDropdown.tooltip = "Default container format for generated 3D models."; + } + + if (outputRootField != null) + { + outputRootField.tooltip = + $"Project-relative folder where generated assets are written. Empty = {AssetGenPrefs.DefaultOutputRoot}."; + } + + if (autoNormalizeToggle != null) + { + autoNormalizeToggle.tooltip = "Uniformly scale imported models to the target size on import."; + } + + SyncFromPrefs(); + } + + private void RegisterCallbacks() + { + if (formatDropdown != null) + { + formatDropdown.RegisterValueChangedCallback(evt => + { + AssetGenPrefs.DefaultFormat = evt.newValue; + }); + } + + if (outputRootField != null) + { + outputRootField.RegisterCallback(_ => + { + AssetGenPrefs.OutputRoot = outputRootField.text?.Trim(); + // Reflect the normalized/default value (empty -> default) without re-triggering. + outputRootField.SetValueWithoutNotify(AssetGenPrefs.OutputRoot); + }); + } + + if (autoNormalizeToggle != null) + { + autoNormalizeToggle.RegisterValueChangedCallback(evt => + { + AssetGenPrefs.AutoNormalize = evt.newValue; + }); + } + } + + /// + /// Re-reads secure-store presence and prefs and rebuilds the rows. Called when the + /// tab becomes visible so keys set elsewhere (e.g. via CLI) are reflected. + /// + public void Refresh() => SyncFromPrefs(); + + /// Rebuild the provider rows and reflect current prefs into the fields. + private void SyncFromPrefs() + { + BuildProviderRows(); + formatDropdown?.SetValueWithoutNotify(NormalizeFormat(AssetGenPrefs.DefaultFormat)); + outputRootField?.SetValueWithoutNotify(AssetGenPrefs.OutputRoot); + autoNormalizeToggle?.SetValueWithoutNotify(AssetGenPrefs.AutoNormalize); + UpdateGltfastNotice(); + } + + private void BuildProviderRows() + { + if (providersContainer == null) + { + return; + } + + providersContainer.Clear(); + modelEnableToggles.Clear(); + + AddGroupLabel("3D Model Providers"); + foreach (var provider in ModelProviders) + { + var toggle = AddProviderRow(provider.Id, provider.Label); + modelEnableToggles.Add((provider.Id, toggle)); + } + + AddGroupLabel("Image Providers"); + foreach (var provider in ImageProviders) + { + AddProviderRow(provider.Id, provider.Label); + } + + AddBlenderHandoffRow(); + } + + private void AddGroupLabel(string text) + { + var label = new Label(text); + label.AddToClassList("config-label"); + providersContainer.Add(label); + } + + /// + /// Informational handoff row (not a keyed provider): best-effort "is Blender installed" + /// status + a pointer to the blender-to-unity workflow. BlenderMCP itself runs in the AI + /// client and isn't detectable from Unity, so this only reports the local Blender app. + /// + private void AddBlenderHandoffRow() + { + AddGroupLabel("Blender → Unity Handoff"); + + var row = new VisualElement(); + row.style.marginBottom = 8; + + bool blender = BlenderDetection.IsInstalled(); + var status = new Label(blender ? "Blender app detected ✓" : "Blender app not found on this machine"); + status.AddToClassList("help-text"); + status.style.color = blender ? new Color(0.4f, 0.8f, 0.4f) : new Color(0.7f, 0.7f, 0.7f); + row.Add(status); + + var help = new Label( + "Pair Blender with the BlenderMCP server in your AI client, then run the blender-to-unity " + + "skill to export the current model — it imports via the import_model_file tool. (BlenderMCP " + + "is configured in your AI client and can't be detected here.)"); + help.AddToClassList("help-text"); + help.style.whiteSpace = WhiteSpace.Normal; + row.Add(help); + + providersContainer.Add(row); + } + + private Toggle AddProviderRow(string id, string displayName) + { + var row = new VisualElement(); + row.style.marginBottom = 8; + row.style.paddingBottom = 8; + row.style.borderBottomWidth = 1; + row.style.borderBottomColor = new Color(0.3f, 0.3f, 0.3f, 0.3f); + + // Header: bold provider name + enable toggle. + var header = new VisualElement(); + header.style.flexDirection = FlexDirection.Row; + header.style.alignItems = Align.Center; + header.style.marginBottom = 2; + + var nameLabel = new Label(displayName); + nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + nameLabel.style.flexGrow = 1; + header.Add(nameLabel); + + var enableToggle = new Toggle("Enabled"); + enableToggle.SetValueWithoutNotify(AssetGenPrefs.IsProviderEnabled(id)); + enableToggle.tooltip = $"Enable the {displayName} provider for asset generation."; + header.Add(enableToggle); + + row.Add(header); + + var statusLabel = new Label(); + statusLabel.AddToClassList("help-text"); + + // Masked key field + Save / Clear / Test buttons. + var fieldRow = new VisualElement(); + fieldRow.style.flexDirection = FlexDirection.Row; + fieldRow.style.alignItems = Align.Center; + + var keyField = new TextField(); + keyField.isPasswordField = true; + keyField.maskChar = '*'; + keyField.style.flexGrow = 1; + keyField.style.flexShrink = 1; + keyField.style.marginRight = 4; + keyField.tooltip = + $"Paste your {displayName} API key, then press Save (or click away). " + + "The key is stored in your OS secure store and is never read back into this field."; + fieldRow.Add(keyField); + + var saveButton = new Button { text = "Save" }; + saveButton.AddToClassList("icon-button"); + fieldRow.Add(saveButton); + + var clearButton = new Button { text = "Clear" }; + clearButton.AddToClassList("icon-button"); + fieldRow.Add(clearButton); + + var testButton = new Button { text = "Test" }; + testButton.AddToClassList("icon-button"); + fieldRow.Add(testButton); + + row.Add(fieldRow); + row.Add(statusLabel); + + // Persist the typed key, then clear the field so the secret is never displayed. + void SaveKeyFromField() + { + string text = keyField.text?.Trim(); + if (string.IsNullOrEmpty(text)) + { + return; + } + + try + { + SecureKeyStore.Current.Set(id, text); + keyField.SetValueWithoutNotify(string.Empty); + SetStatus(statusLabel, "saved ✓", true); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to store {id} key: {ex.Message}"); + SetStatus(statusLabel, "save failed", false); + } + } + + keyField.RegisterCallback(_ => SaveKeyFromField()); + saveButton.clicked += SaveKeyFromField; + + clearButton.clicked += () => + { + try + { + SecureKeyStore.Current.Delete(id); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to delete {id} key: {ex.Message}"); + } + + keyField.SetValueWithoutNotify(string.Empty); + SetStatus(statusLabel, "not set", false); + }; + + // v1 surfaces presence only. Live endpoint validation (an actual auth ping to the + // provider) is a future enhancement and intentionally not performed here. + testButton.clicked += () => + { + bool present = HasKey(id); + SetStatus(statusLabel, present ? "key present ✓" : "no key set", present); + }; + + enableToggle.RegisterValueChangedCallback(evt => + { + AssetGenPrefs.SetProviderEnabled(id, evt.newValue); + UpdateGltfastNotice(); + }); + + // Initial status reflects secure-store presence (existence only; never the value). + bool has = HasKey(id); + SetStatus(statusLabel, has ? "saved ✓" : "not set", has); + + providersContainer.Add(row); + return enableToggle; + } + + private static void SetStatus(Label label, string text, bool ok) + { + if (label == null) + { + return; + } + + label.text = text; + label.style.color = ok + ? new Color(0.4f, 0.8f, 0.4f) + : new Color(0.7f, 0.7f, 0.7f); + } + + private static bool HasKey(string id) + { + try { return SecureKeyStore.Current.Has(id); } + catch { return false; } + } + + private static string NormalizeFormat(string format) + { + switch ((format ?? string.Empty).Trim().ToLowerInvariant()) + { + case "glb": + case "fbx": + case "obj": + return format.Trim().ToLowerInvariant(); + default: + return AssetGenPrefs.DefaultFormatValue; + } + } + + private void UpdateGltfastNotice() + { + if (gltfastNotice == null) + { + return; + } + + bool anyGlbProviderEnabled = false; + foreach (var entry in modelEnableToggles) + { + bool enabled = entry.Toggle != null + ? entry.Toggle.value + : AssetGenPrefs.IsProviderEnabled(entry.Id); + if (enabled) + { + anyGlbProviderEnabled = true; + break; + } + } + + bool show = anyGlbProviderEnabled && !ModelImportPipeline.IsGltfastAvailable(); + gltfastNotice.EnableInClassList("visible", show); + } + } +} diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs.meta b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs.meta new file mode 100644 index 000000000..25099733f --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdb478d5413049048560d485405b8b02 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.uxml b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.uxml new file mode 100644 index 000000000..233b8dd4d --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.uxml @@ -0,0 +1,28 @@ + +