fix(asset-gen): security hardening + provider correctness + local image_path

Security review + code review of the asset-gen feature surfaced concrete issues;
this fixes them and adds regression tests (request-shaping layer, FakeHttpTransport).

Security
- SafeZipExtractor enforces an extension allowlist; ModelImportPipeline passes an
  inert model/texture allowlist so a provider archive can't drop a .cs/.dll under
  Assets/ and have the Editor compile/load it (code execution on import).
- AssetGenJobManager refuses non-http(s) download URLs before fetching
  (file:// SSRF / local-file read into the project).

Provider correctness
- Meshy image->3D polls /openapi/v1/image-to-3d/{id} (was the v2 text URL).
- Meshy text->3D honors texture=true via the preview->refine two-phase flow.
- OpenRouter image->image attaches the reference image (content image_url part).
- fal image->image uses the /edit endpoint + image_urls array; width/height
  forwarded as image_size.
- Sketchfab search forwards categories/count/cursor/downloadable; preview doc
  corrected (returns metadata, not a base64 thumbnail).
- Job import calls AssetDatabase.Refresh() before importing a freshly written file.

Local image input (image_path)
- New LocalImage helper; image_path is read and sent inline as a base64 data URI
  for Meshy / fal / OpenRouter. Tripo rejects local images with a clear error
  (needs a hosted image_url; its upload flow is not wired).

Cleanup (no behavior change)
- Shared AssetGenPaths + ProviderHttp helpers, HttpResult.Ok, MissingKeyMessage,
  cached glTFast probe, dead-field / per-frame-alloc removal, CLI _emit.

Docs: README + manual-verification updated (image_path support; transparency is
import-flag-only; width/height fal-only).

Verified: package compiles clean; Python 1306 passed / 3 skipped. Meshy refine,
fal /edit, and image_path data-URI paths are unit-tested at the request layer
only -- live smoke per provider (real keys) still pending.

Claude-Session: https://claude.ai/code/session_015DAUrMR5UaSEzEn2wNPrEP
This commit is contained in:
Shutong Wu
2026-06-28 21:30:00 -07:00
parent e243e30c4b
commit 2efb786042
37 changed files with 796 additions and 269 deletions
@@ -0,0 +1,31 @@
using System.IO;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// 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.
/// </summary>
public static class AssetGenPaths
{
/// <summary>Resolve a project-relative ("Assets/...") path to an absolute, forward-slashed path.</summary>
public static string ToAbsolute(string projectRelative)
{
string dataPath = Application.dataPath.Replace('\\', '/');
string projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
return Path.Combine(projectRoot, projectRelative).Replace('\\', '/');
}
/// <summary>Convert an absolute (or already-relative) path to a project-relative ("Assets/...") path.</summary>
public static string ToProjectRelative(string path)
{
string p = path.Replace('\\', '/');
if (p.StartsWith("Assets")) return p;
string dataPath = Application.dataPath.Replace('\\', '/');
if (p.StartsWith(dataPath)) return "Assets" + p.Substring(dataPath.Length);
return p;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: afc641959f464b55911f6703a96b5f37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -58,6 +58,7 @@ namespace MCPForUnity.Editor.Services.AssetGen
private static readonly Dictionary<string, AssetGenJob> Jobs = new();
private static readonly Dictionary<string, Runner> Runners = new();
private static readonly List<string> _tickIds = new();
private static bool _ticking;
static AssetGenJobManager()
@@ -221,7 +222,6 @@ namespace MCPForUnity.Editor.Services.AssetGen
public double NextPollAt;
public string ProviderJobId;
public string DownloadUrl;
public byte[] InlineData;
public string LocalPath;
public Task<string> SubmitTask;
public Task<ProviderPollResult> PollTask;
@@ -234,7 +234,7 @@ namespace MCPForUnity.Editor.Services.AssetGen
if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey))
{
job.State = AssetGenJobState.Failed;
job.Error = $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab.";
job.Error = AssetGenProviders.MissingKeyMessage(provider);
Jobs[job.JobId] = job;
Persist(job);
return false;
@@ -266,7 +266,11 @@ namespace MCPForUnity.Editor.Services.AssetGen
_ticking = false;
return;
}
foreach (string id in new List<string>(Runners.Keys))
// 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);
}
@@ -353,6 +357,14 @@ namespace MCPForUnity.Editor.Services.AssetGen
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;
@@ -374,6 +386,10 @@ namespace MCPForUnity.Editor.Services.AssetGen
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)
@@ -399,7 +415,7 @@ namespace MCPForUnity.Editor.Services.AssetGen
string root = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder
: (AssetGenPrefs.OutputRoot + "/" + r.Subfolder);
if (!root.Replace('\\', '/').StartsWith("Assets")) root = AssetGenPrefs.OutputRoot + "/" + r.Subfolder;
string absRoot = ToAbsolute(root);
string absRoot = AssetGenPaths.ToAbsolute(root);
Directory.CreateDirectory(absRoot);
string baseName = SanitizeName(r.Name);
string fileName = baseName + "." + ext;
@@ -417,13 +433,6 @@ namespace MCPForUnity.Editor.Services.AssetGen
return "asset_" + jobId.Substring(0, 8);
}
private static string ToAbsolute(string projectRelative)
{
string dataPath = Application.dataPath;
string projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
return Path.Combine(projectRoot, projectRelative);
}
private static string SanitizeName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "asset";
@@ -494,6 +503,11 @@ namespace MCPForUnity.Editor.Services.AssetGen
private static bool IsTerminal(AssetGenJobState s)
=> s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled;
/// <summary>Only http(s) download URLs are allowed; provider responses are untrusted.</summary>
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()
@@ -12,5 +12,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Http
public byte[] Body;
public string Text;
public bool IsSuccess;
/// <summary>True when the transport reports success or the status code is 2xx.</summary>
public bool Ok => IsSuccess || (Status >= 200 && Status < 300);
}
}
@@ -1,8 +1,8 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
@@ -20,7 +20,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
if (string.IsNullOrEmpty(localFilePath))
return Fail(job, "No file to import.");
string rel = ToProjectRelative(localFilePath);
string rel = AssetGenPaths.ToProjectRelative(localFilePath);
if (string.IsNullOrEmpty(rel) || !rel.Replace('\\', '/').StartsWith("Assets"))
return Fail(job, "Generated file is not under the Assets folder.");
@@ -54,15 +54,6 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
}
}
private static string ToProjectRelative(string path)
{
string p = path.Replace('\\', '/');
if (p.StartsWith("Assets")) return p;
string dataPath = Application.dataPath.Replace('\\', '/');
if (p.StartsWith(dataPath)) return "Assets" + p.Substring(dataPath.Length);
return p;
}
private static AssetGenJob Fail(AssetGenJob job, string message)
{
job.State = AssetGenJobState.Failed;
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
@@ -15,6 +16,15 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
/// </summary>
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<string> ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl",
".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr", ".ktx2", ".basis",
};
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath)
{
if (job == null) return null;
@@ -23,7 +33,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
if (string.IsNullOrEmpty(localFilePath))
return Fail(job, "No file to import.");
string rel = ToProjectRelative(localFilePath);
string rel = AssetGenPaths.ToProjectRelative(localFilePath);
if (string.IsNullOrEmpty(rel) || !rel.Replace('\\', '/').StartsWith("Assets"))
return Fail(job, "Generated file is not under the Assets folder.");
@@ -71,15 +81,17 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
/// </summary>
private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel)
{
string zipAbs = ToAbsolute(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 = ToAbsolute(folderRel);
string folderAbs = AssetGenPaths.ToAbsolute(folderRel);
Directory.CreateDirectory(folderAbs);
SafeZipExtractor.ExtractTo(zipAbs, 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);
@@ -126,18 +138,11 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
{
string e = Path.GetExtension(abs).ToLowerInvariant();
if (e == ".fbx" || e == ".obj")
return ToProjectRelative(abs);
return AssetGenPaths.ToProjectRelative(abs);
if (firstGltf == null && (e == ".glb" || e == ".gltf"))
firstGltf = abs;
}
return firstGltf == null ? null : ToProjectRelative(firstGltf);
}
private static string ToAbsolute(string projectRelative)
{
string dataPath = Application.dataPath.Replace('\\', '/');
string projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
return Path.Combine(projectRoot, projectRelative).Replace('\\', '/');
return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf);
}
private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
@@ -191,24 +196,27 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
catch { return 0f; }
}
private static bool IsGltfastAvailable()
{
if (Type.GetType("GLTFast.GltfImport, glTFast") != null) return true;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try { if (asm.GetType("GLTFast.GltfImport") != null) return true; }
catch { /* dynamic/!resolvable assembly */ }
}
return false;
}
private static bool? _gltfastAvailable;
private static string ToProjectRelative(string path)
/// <summary>
/// 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.
/// </summary>
internal static bool IsGltfastAvailable()
{
string p = path.Replace('\\', '/');
if (p.StartsWith("Assets")) return p;
string dataPath = Application.dataPath.Replace('\\', '/');
if (p.StartsWith(dataPath)) return "Assets" + p.Substring(dataPath.Length);
return p;
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)
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
@@ -9,10 +10,15 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
/// every entry's resolved target must stay inside <c>destDir</c>. 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 <paramref name="allowedExtensions"/> 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.
/// </summary>
public static class SafeZipExtractor
{
public static void ExtractTo(string zipPath, string destDir)
public static void ExtractTo(string zipPath, string destDir, ISet<string> allowedExtensions = null)
{
if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath));
if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir));
@@ -46,6 +52,13 @@ namespace MCPForUnity.Editor.Services.AssetGen.Import
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);
@@ -66,5 +66,13 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
try { return SecureKeyStore.Current.Has(id); }
catch { return false; }
}
/// <summary>
/// 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.
/// </summary>
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).";
}
}
@@ -29,14 +29,32 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
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 };
if (string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(req.ImageUrl))
body["image_url"] = req.ImageUrl;
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; fal's image_size accepts a {width,height} object.
// (FLUX has no transparency param — transparent backgrounds aren't a generation-time option.)
if (req.Width > 0 && req.Height > 0)
body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height };
var spec = new HttpRequestSpec
{
Method = "POST",
Url = QueueBase + model,
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
@@ -51,8 +69,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
string requestId = json["request_id"]?.ToString();
if (string.IsNullOrEmpty(requestId))
throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + Truncate(res?.Text), apiKey));
responseUrl = QueueBase + model + "/requests/" + requestId;
throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
responseUrl = url + "/requests/" + requestId;
}
return responseUrl;
}
@@ -121,8 +139,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null) text = Encoding.UTF8.GetString(res.Body);
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
@@ -130,19 +147,13 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? Truncate(text);
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();
}
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -28,7 +28,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
public interface IMarketplaceProviderAdapter
{
string Id { get; }
Task<string> SearchAsync(string query, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
}
@@ -0,0 +1,53 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode
/// it as a base64 <c>data:</c> 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.
/// </summary>
internal static class LocalImage
{
/// <summary>Resolve an "Assets/..."-relative or absolute path to an existing absolute file.</summary>
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; }
string p = path.Replace('\\', '/');
string abs = (p == "Assets" || p.StartsWith("Assets/")) ? AssetGenPaths.ToAbsolute(p) : p;
if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; }
absPath = abs;
return true;
}
/// <summary>
/// Read a local image and return a "data:image/&lt;mime&gt;;base64,..." URI. Throws
/// <see cref="NotSupportedException"/> for an unsupported extension.
/// </summary>
public static string ToDataUri(string absPath)
{
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.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f8290c406df4661b9ad2ff6cd324871
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -11,11 +11,11 @@ using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint;
/// image→3D posts to the v1 image-to-3d endpoint. Both mint a task id (returned by the API
/// in the <c>result</c> field). Polling reads the task, mapping Meshy's status enum and
/// surfacing the model URL for the requested format on success. The bearer key is supplied
/// per call and never logged; every error is run through <see cref="SecretRedactor"/>.
/// 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 <see cref="SecretRedactor"/>.
/// </summary>
public sealed class MeshyAdapter : IModelProviderAdapter
{
@@ -24,8 +24,12 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
public string Id => "meshy";
// Stashed at submit so poll can pick the matching model_urls entry.
// 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<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
@@ -33,19 +37,28 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
if (http == null) throw new ArgumentNullException(nameof(http));
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(req.ImageUrl);
_wantTexture = req.Texture;
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
JObject body;
string url;
if (image)
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;
body = new JObject { ["image_url"] = req.ImageUrl, ["ai_model"] = "meshy-6" };
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
{
@@ -55,6 +68,85 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
};
}
string taskId = await PostTask(url, body, apiKey, http, ct, "submit");
if (string.IsNullOrEmpty(taskId))
throw new Exception(SecretRedactor.Scrub("Meshy submit returned no task id.", apiKey));
return taskId;
}
public async Task<ProviderPollResult> 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<float>() / 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");
if (string.IsNullOrEmpty(_refineTaskId))
{
result.State = ProviderPollState.Failed;
result.Error = "Meshy refine task could not be started.";
return result;
}
_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;
}
/// <summary>POST a task body and return its <c>result</c> task id (or null).</summary>
private static async Task<string> PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase)
{
var spec = new HttpRequestSpec
{
Method = "POST",
@@ -65,50 +157,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "submit");
string taskId = json["result"]?.ToString();
if (string.IsNullOrEmpty(taskId))
throw new Exception(SecretRedactor.Scrub("Meshy submit returned no task id: " + Truncate(res?.Text), apiKey));
return taskId;
}
public async Task<ProviderPollResult> 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 = TextEndpoint + "/" + providerJobId };
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "poll");
var result = new ProviderPollResult { State = MapState(json["status"]?.ToString()) };
JToken prog = json["progress"];
if (prog != null && prog.Type != JTokenType.Null)
result.Progress = Mathf.Clamp01(prog.Value<float>() / 100f);
if (result.State == ProviderPollState.Succeeded)
{
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 (result.State == ProviderPollState.Failed)
{
string err = json["task_error"]?["message"]?.ToString()
?? json["message"]?.ToString()
?? "Meshy task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
JObject json = ParseOk(res, apiKey, phase);
return json["result"]?.ToString();
}
private string ExtractModelUrl(JObject urls)
@@ -130,6 +180,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
return ProviderPollState.Succeeded;
case "FAILED":
case "EXPIRED":
case "CANCELED":
case "CANCELLED":
return ProviderPollState.Failed;
case "IN_PROGRESS":
return ProviderPollState.Running;
@@ -141,8 +193,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null) text = Encoding.UTF8.GetString(res.Body);
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
@@ -150,19 +201,13 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? Truncate(text);
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();
}
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -32,6 +32,22 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
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,
@@ -39,7 +55,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
["messages"] = new JArray(new JObject
{
["role"] = "user",
["content"] = req.Prompt ?? string.Empty
["content"] = content
})
};
@@ -118,8 +134,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
private static JObject ParseOk(HttpResult res, string apiKey)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null) text = Encoding.UTF8.GetString(res.Body);
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
@@ -127,11 +142,11 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString()
?? (string.IsNullOrEmpty(text) ? string.Empty : (text.Length <= 500 ? text : text.Substring(0, 500)));
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
@@ -0,0 +1,28 @@
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// 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.
/// </summary>
internal static class ProviderHttp
{
/// <summary>Response text, falling back to a UTF-8 decode of the raw body when Text is empty.</summary>
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;
}
/// <summary>Cap a (possibly null) string at 500 chars for inclusion in an error message.</summary>
public static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1584e5cffb5b45e6816fec509f1bfc8b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,4 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
@@ -21,13 +20,18 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
public string Id => "sketchfab";
public async Task<string> SearchAsync(string query, string apiKey, IHttpTransport http, CancellationToken ct)
public async Task<string> 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=true&q=" + Uri.EscapeDataString(query ?? string.Empty);
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");
}
@@ -55,26 +59,24 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
if (string.IsNullOrEmpty(url))
{
throw new Exception(SecretRedactor.Scrub(
$"Sketchfab download returned no gltf url for '{uid}': {Truncate(res?.Text)}", apiKey));
$"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 = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null) text = Encoding.UTF8.GetString(res.Body);
string text = ProviderHttp.BodyText(res);
bool ok = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool ok = res?.Ok == true;
if (!ok)
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {Truncate(text)}", apiKey));
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 = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null) text = Encoding.UTF8.GetString(res.Body);
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
@@ -82,19 +84,13 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? Truncate(text);
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();
}
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -28,9 +28,14 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
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 = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(req.ImageUrl);
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
if (image)
{
body = new JObject
@@ -69,7 +74,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
if (string.IsNullOrEmpty(taskId))
{
throw new Exception(SecretRedactor.Scrub(
"Tripo submit returned no task_id: " + Truncate(res?.Text), apiKey));
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
}
return taskId;
}
@@ -184,11 +189,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
/// </summary>
private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null)
{
text = Encoding.UTF8.GetString(res.Body);
}
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
@@ -197,7 +198,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
catch { /* non-JSON body; handled below */ }
}
bool httpOk = res != null && (res.IsSuccess || (res.Status >= 200 && res.Status < 300));
bool httpOk = res?.Ok == true;
int code = 0;
JToken codeTok = json?["code"];
@@ -210,18 +211,12 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
string detail = json?["message"]?.ToString()
?? json?["error"]?.ToString()
?? Truncate(text);
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
}
return json ?? new JObject();
}
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -52,11 +52,7 @@ namespace MCPForUnity.Editor.Tools.AssetGen
AssetGenProviders.Image(provider); // throws NotSupportedException for unknown providers
if (!SecureKeyStore.Current.Has(provider))
{
return new ErrorResponse(
$"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{provider.ToUpperInvariant()}_API_KEY).");
}
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
var req = new ImageGenRequest
{
@@ -76,8 +72,14 @@ namespace MCPForUnity.Editor.Tools.AssetGen
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) && string.IsNullOrWhiteSpace(req.ImagePath))
return new ErrorResponse("'imageUrl' or 'imagePath' is required for image 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)
@@ -50,11 +50,7 @@ namespace MCPForUnity.Editor.Tools.AssetGen
AssetGenProviders.Model(provider); // throws NotSupportedException for unimplemented providers
if (!SecureKeyStore.Current.Has(provider))
{
return new ErrorResponse(
$"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{provider.ToUpperInvariant()}_API_KEY).");
}
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
var req = new ModelGenRequest
{
@@ -73,8 +69,14 @@ namespace MCPForUnity.Editor.Tools.AssetGen
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) && string.IsNullOrWhiteSpace(req.ImagePath))
return new ErrorResponse("'imageUrl' or 'imagePath' is required for image 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.StartModelGeneration(req);
if (job.State == AssetGenJobState.Failed)
@@ -69,7 +69,9 @@ namespace MCPForUnity.Editor.Tools.AssetGen
return KeyError();
IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider);
string results = await adapter.SearchAsync(query, key, Transport(), CancellationToken.None);
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) });
}
@@ -153,9 +155,7 @@ namespace MCPForUnity.Editor.Tools.AssetGen
}
private static object KeyError()
=> new ErrorResponse(
$"No API key configured for '{Provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{Provider.ToUpperInvariant()}_API_KEY).");
=> new ErrorResponse(AssetGenProviders.MissingKeyMessage(Provider));
private static object ParseOrRaw(string json)
{
@@ -6,7 +6,6 @@ using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Import;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.AssetGen
{
@@ -66,7 +65,7 @@ namespace MCPForUnity.Editor.Tools.AssetGen
private static string ResolveSource(string source)
{
string s = source.Replace('\\', '/');
if (s == "Assets" || s.StartsWith("Assets/")) return ToAbsolute(s);
if (s == "Assets" || s.StartsWith("Assets/")) return AssetGenPaths.ToAbsolute(s);
return s; // absolute path on disk
}
@@ -78,7 +77,7 @@ namespace MCPForUnity.Editor.Tools.AssetGen
if (!root.Replace('\\', '/').StartsWith("Assets"))
root = AssetGenPrefs.OutputRoot + "/Imported";
string absRoot = ToAbsolute(root);
string absRoot = AssetGenPaths.ToAbsolute(root);
Directory.CreateDirectory(absRoot);
string safe = SanitizeName(baseName);
@@ -91,13 +90,6 @@ namespace MCPForUnity.Editor.Tools.AssetGen
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
}
private static string ToAbsolute(string projectRelative)
{
string dataPath = Application.dataPath.Replace('\\', '/');
string projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
return Path.Combine(projectRoot, projectRelative).Replace('\\', '/');
}
private static string SanitizeName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "model";
@@ -2,6 +2,7 @@ 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;
@@ -66,29 +67,25 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen
private void InitializeUI()
{
BuildProviderRows();
// One-time choices + tooltips; the field values are populated by SyncFromPrefs.
if (formatDropdown != null)
{
formatDropdown.choices = new List<string> { "glb", "fbx", "obj" };
formatDropdown.SetValueWithoutNotify(NormalizeFormat(AssetGenPrefs.DefaultFormat));
formatDropdown.tooltip = "Default container format for generated 3D models.";
}
if (outputRootField != null)
{
outputRootField.SetValueWithoutNotify(AssetGenPrefs.OutputRoot);
outputRootField.tooltip =
$"Project-relative folder where generated assets are written. Empty = {AssetGenPrefs.DefaultOutputRoot}.";
}
if (autoNormalizeToggle != null)
{
autoNormalizeToggle.SetValueWithoutNotify(AssetGenPrefs.AutoNormalize);
autoNormalizeToggle.tooltip = "Uniformly scale imported models to the target size on import.";
}
UpdateGltfastNotice();
SyncFromPrefs();
}
private void RegisterCallbacks()
@@ -124,23 +121,15 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen
/// 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.
/// </summary>
public void Refresh()
public void Refresh() => SyncFromPrefs();
/// <summary>Rebuild the provider rows and reflect current prefs into the fields.</summary>
private void SyncFromPrefs()
{
BuildProviderRows();
if (formatDropdown != null)
{
formatDropdown.SetValueWithoutNotify(NormalizeFormat(AssetGenPrefs.DefaultFormat));
}
if (outputRootField != null)
{
outputRootField.SetValueWithoutNotify(AssetGenPrefs.OutputRoot);
}
if (autoNormalizeToggle != null)
{
autoNormalizeToggle.SetValueWithoutNotify(AssetGenPrefs.AutoNormalize);
}
formatDropdown?.SetValueWithoutNotify(NormalizeFormat(AssetGenPrefs.DefaultFormat));
outputRootField?.SetValueWithoutNotify(AssetGenPrefs.OutputRoot);
autoNormalizeToggle?.SetValueWithoutNotify(AssetGenPrefs.AutoNormalize);
UpdateGltfastNotice();
}
@@ -349,33 +338,8 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen
}
}
bool show = anyGlbProviderEnabled && !IsGltfastPresent();
bool show = anyGlbProviderEnabled && !ModelImportPipeline.IsGltfastAvailable();
gltfastNotice.EnableInClassList("visible", show);
}
private static bool IsGltfastPresent()
{
if (Type.GetType("GLTFast.GltfImport, glTFast") != null)
{
return true;
}
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
if (assembly.GetType("GLTFast.GltfImport") != null)
{
return true;
}
}
catch
{
// Some dynamic/reflection-only assemblies throw on GetType; ignore them.
}
}
return false;
}
}
}
+9 -3
View File
@@ -79,14 +79,20 @@ Bring-your-own-key generation of **3D models** (text→3D and image→3D), **2D
**Usage**
Generation runs through the MCP tools (or the `asset-gen` CLI), never from the GUI. Long-running jobs are async: the tool returns a `job_id`, then you call `action="status"` with that `job_id` until it completes.
Generation runs through the MCP tools (or the `asset-gen` CLI), never from the GUI. Long-running jobs are async: the tool returns a `job_id`, then you call `action="status"` with that `job_id` until it completes. Both 3D and 2D accept **text** or **image** input — pass `image_url` (a hosted URL) or `image_path` (a local file, e.g. under `Assets/`).
```text
generate_model action=generate provider=tripo mode=text prompt="a low-poly oak tree" format=fbx → then action=status with job_id
generate_image action=generate provider=fal prompt="a pixel-art coin" transparent=true
generate_model action=generate provider=tripo mode=text prompt="a low-poly oak tree" format=fbx → then action=status with job_id
generate_model action=generate provider=meshy mode=image image_path=Assets/refs/chair.png → image→3D from a local image
generate_image action=generate provider=fal mode=text prompt="a pixel-art coin"
import_model action=search query="wooden chair" → action=import uid=<from search>
```
**Notes**
- **Local images** (`image_path`) are supported by Meshy (image→3D) and fal / OpenRouter (image→image); they're sent inline as base64. Tripo image→3D currently needs a hosted `image_url`.
- **Transparency / size:** `transparent` only sets the Unity texture import flag — fal/FLUX has no transparent-background *generation*. `width`/`height` are forwarded to fal only (OpenRouter's chat API has no size control).
Your API keys never leave the Editor and never cross the MCP bridge.
**Blender handoff:** With BlenderMCP connected, use the `blender-to-unity` skill to export
+12 -15
View File
@@ -19,6 +19,15 @@ def asset_gen():
pass
def _emit(result, config, verb):
"""Echo the command result, then (on success with a job_id) print the status-poll hint."""
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"{verb} started. Poll with: unity-mcp asset-gen status --job-id {job_id}")
@asset_gen.command("generate-model")
@click.option("--provider", default=None, help="Provider id (tripo, meshy).")
@click.option("--mode", default=None, help="Generation mode: text or image.")
@@ -71,11 +80,7 @@ def generate_model(
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("generate_model", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Generation started. Poll with: unity-mcp asset-gen status --job-id {job_id}")
_emit(result, config, "Generation")
@asset_gen.command("import-model")
@@ -108,11 +113,7 @@ def import_model(
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("import_model", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Import started. Poll with: unity-mcp asset-gen status --job-id {job_id}")
_emit(result, config, "Import")
@asset_gen.command("import-model-file")
@@ -188,11 +189,7 @@ def generate_image(
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("generate_image", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Generation started. Poll with: unity-mcp asset-gen status --job-id {job_id}")
_emit(result, config, "Generation")
@asset_gen.command("status")
+3 -1
View File
@@ -48,7 +48,9 @@ async def generate_image(
image_path: Annotated[str, "Path to a source image for image->image / remove_background."] | None = None,
image_url: Annotated[str, "URL of a source image for image->image."] | None = None,
model: Annotated[str, "Provider model id/slug (e.g. FLUX, gemini-2.5-flash-image)."] | None = None,
transparent: Annotated[bool, "Request a transparent background."] | None = None,
transparent: Annotated[bool, "Mark the imported texture as alpha-is-transparency. NOTE: fal/FLUX "
"and OpenRouter have no generation-time transparency, so this only sets the "
"Unity import flag — it does not make the model render a transparent background."] | None = None,
width: Annotated[int, "Output width in pixels."] | None = None,
height: Annotated[int, "Output height in pixels."] | None = None,
name: Annotated[str, "Base name for the imported asset."] | None = None,
+2 -1
View File
@@ -26,7 +26,8 @@ from transport.legacy.unity_connection import async_send_command_with_retry
"ACTIONS:\n"
"- search: Search Sketchfab. Params: query, categories, downloadable, count, "
"cursor -> results with model uids.\n"
"- preview: Fetch a base64 thumbnail for a uid (preview before import).\n"
"- preview: Fetch model metadata (name, thumbnail URLs, license, vertex/face counts) "
"for a uid before import.\n"
"- import: Download + import a model by uid. Returns { job_id } immediately; poll "
"with the status action. Params: uid, target_size, name, output_folder.\n"
"- status: Poll an async import job by job_id -> { state, progress, assetPath?, error? }.\n"
@@ -116,6 +116,24 @@ namespace MCPForUnityTests.Editor.AssetGen
StringAssert.DoesNotContain(Secret, serialized);
}
[Test]
public void FileSchemeDownloadUrl_Rejected_FailsJob()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
// Poll succeeds but hands back a malicious local-file URL as the model.
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"file:///etc/passwd\"}}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("http", job.Error.ToLowerInvariant());
}
[Test]
public void Cancel_BeforeRun_MarksCanceled()
{
@@ -32,6 +32,40 @@ namespace MCPForUnityTests.Editor.AssetGen
StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
}
[Test]
public void Submit_WithDimensions_IncludesImageSize()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat", Width = 512, Height = 768 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_size", sent);
StringAssert.Contains("512", sent);
StringAssert.Contains("768", sent);
}
[Test]
public void Submit_ImageMode_UsesEditEndpoint_WithImageUrlsArray()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "make it night", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/edit", rec.Url);
string body = System.Text.Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("image_urls", body);
StringAssert.Contains("https://ex.com/in.png", body);
}
[Test]
public void Poll_Completed_FetchesResult_ReturnsImageUrl()
{
@@ -66,6 +66,29 @@ namespace MCPForUnityTests.Editor.AssetGen
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
_store.Set("fal", "falkey");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_LocalPath_Accepted_ReturnsPending()
{
_store.Set("fal", "falkey");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = tmp, ["prompt"] = "edit it" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void ListProviders_ImageOnly()
{
@@ -68,6 +68,29 @@ namespace MCPForUnityTests.Editor.AssetGen
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
_store.Set("tripo", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_LocalPath_Accepted_ReturnsPending()
{
_store.Set("meshy", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_TextMode_RequiresPrompt()
{
@@ -1,3 +1,5 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
@@ -54,7 +56,8 @@ namespace MCPForUnityTests.Editor.AssetGen
"\"model_urls\":{\"glb\":\"https://assets.meshy.ai/model.glb\",\"fbx\":\"https://assets.meshy.ai/model.fbx\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb" };
// Texture=false -> single-phase (no refine), so a SUCCEEDED preview surfaces directly.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb", Texture = false };
adapter.SubmitAsync(req, "k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
@@ -70,6 +73,11 @@ namespace MCPForUnityTests.Editor.AssetGen
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\",\"progress\":37}") };
var adapter = new MeshyAdapter();
// Submit single-phase (Texture=false) so progress is reported raw (not split across refine).
adapter.SubmitAsync(
new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false },
"k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
@@ -77,6 +85,87 @@ namespace MCPForUnityTests.Editor.AssetGen
Assert.AreEqual(0.37f, res.Progress, 0.001f);
}
[Test]
public void Submit_ImageMode_PollsV1ImageEndpoint()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"img1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/i.glb\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImageUrl = "https://ex.com/ref.png", Format = "glb" };
string id = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("img1", id);
StringAssert.Contains("/openapi/v1/image-to-3d", submitFake.RecordedRequests[0].Url);
ProviderPollResult res = adapter.PollAsync(id, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
// Image tasks must be polled at the v1 image endpoint, not the v2 text endpoint.
StringAssert.Contains("/openapi/v1/image-to-3d/img1", pollFake.RecordedRequests[0].Url);
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://m/i.glb", res.DownloadUrl);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string tmp = Path.Combine(Path.GetTempPath(), "mcp_meshyimg_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImagePath = tmp, Format = "glb" };
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/openapi/v1/image-to-3d", rec.Url);
StringAssert.Contains("data:image/png;base64,", Encoding.UTF8.GetString(rec.Body));
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void TextWithTexture_PreviewSucceeded_SubmitsRefine_ThenReturnsTexturedModel()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"prev1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = spec => spec.Method == "POST"
? Json("{\"result\":\"refine1\"}") // refine submit
: Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/refined.glb\"}}")
};
var adapter = new MeshyAdapter();
// Texture defaults to true -> two-phase preview+refine.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a chair", Format = "glb" };
string previewId = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("prev1", previewId);
// Poll #1: preview SUCCEEDED -> adapter submits a refine task and reports Running.
ProviderPollResult p1 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, p1.State);
bool refinePosted = false;
foreach (HttpRequestSpec r in pollFake.RecordedRequests)
{
if (r.Method == "POST" && r.Body != null)
{
string b = Encoding.UTF8.GetString(r.Body);
if (b.Contains("refine") && b.Contains("prev1")) refinePosted = true;
}
}
Assert.IsTrue(refinePosted, "expected a refine POST carrying the preview_task_id");
// Poll #2: the refine task SUCCEEDED -> textured model url surfaced.
ProviderPollResult p2 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, p2.State);
Assert.AreEqual("https://m/refined.glb", p2.DownloadUrl);
}
[Test]
public void Poll_Failed_MapsFailed_WithError()
{
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
@@ -36,6 +37,46 @@ namespace MCPForUnityTests.Editor.AssetGen
CollectionAssert.AreEqual(expected, pr.InlineData);
}
[Test]
public void Submit_ImageMode_IncludesReferenceImageInBody()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "make it watercolor", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("https://ex.com/in.png", sent);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string tmp = Path.Combine(Path.GetTempPath(), "mcp_orimg_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "watercolor", ImagePath = tmp };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("data:image/png;base64,", sent);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Submit_NoImage_PollFails()
{
@@ -47,6 +47,45 @@ namespace MCPForUnityTests.Editor.AssetGen
return zipPath;
}
private string MakeMultiZip(params (string name, string content)[] entries)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
[Test]
public void Allowlist_SkipsDisallowedEntries()
{
// A hostile marketplace archive: a valid model plus an editor script + a managed dll.
string zip = MakeMultiZip(
("teapot.obj", "o teapot"),
("Editor/Hack.cs", "// [InitializeOnLoad] arbitrary code"),
("plugins/Evil.dll", "MZ..."));
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest, new System.Collections.Generic.HashSet<string> { ".obj" });
Assert.IsTrue(File.Exists(Path.Combine(dest, "teapot.obj")), "allowed model must be written");
Assert.IsFalse(File.Exists(Path.Combine(dest, "Editor", "Hack.cs")), "disallowed .cs must be skipped");
Assert.IsFalse(File.Exists(Path.Combine(dest, "plugins", "Evil.dll")), "disallowed .dll must be skipped");
}
[Test]
public void NormalEntry_Extracts()
{
@@ -28,7 +28,7 @@ namespace MCPForUnityTests.Editor.AssetGen
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[{\"uid\":\"abc\"}]}") };
var adapter = new SketchfabAdapter();
string raw = adapter.SearchAsync("castle", "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult();
string raw = adapter.SearchAsync("castle", null, true, null, null, "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("\"uid\":\"abc\"", raw);
HttpRequestSpec rec = http.RecordedRequests[0];
@@ -40,6 +40,22 @@ namespace MCPForUnityTests.Editor.AssetGen
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void Search_ForwardsCategoriesCountCursorAndDownloadableFlag()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[],\"cursors\":{\"next\":\"2\"}}") };
var adapter = new SketchfabAdapter();
adapter.SearchAsync("castle", "architecture", false, 12, "2", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
string url = http.RecordedRequests[0].Url;
StringAssert.Contains("categories=architecture", url);
StringAssert.Contains("count=12", url);
StringAssert.Contains("cursor=2", url);
StringAssert.Contains("downloadable=false", url);
}
[Test]
public void ResolveDownloadUrl_ParsesGltfUrl()
{
@@ -120,5 +120,17 @@ namespace MCPForUnityTests.Editor.AssetGen
StringAssert.Contains("image_to_model", body);
StringAssert.Contains("https://example.com/in.png", body);
}
[Test]
public void Submit_ImageMode_LocalPathOnly_Throws()
{
// Tripo can't take a local image inline (no data-URI support, upload not wired) — it must
// fail clearly rather than silently falling back to text mode.
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "image", ImagePath = "/tmp/whatever.png" };
Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", new FakeHttpTransport(), CancellationToken.None).GetAwaiter().GetResult());
}
}
}
+22
View File
@@ -52,6 +52,28 @@ genuine provider keys and an interactive Editor before shipping.
- [ ] `generate_model(provider=meshy, mode=text, prompt="...")`, poll status.
- [ ] Confirm the model imports.
## Image input & provider params (verify the post-review fixes)
These paths are covered by unit tests at the request-shaping layer only — confirm them against
**real provider APIs** (the unit tests can't validate that the provider accepts the shape).
- [ ] **Meshy text→3D textures:** `generate_model(provider=meshy, mode=text, prompt="...", texture=true)`
→ confirm the result is **textured** (Meshy runs a preview then a refine task internally).
- [ ] **Local image→3D (Meshy):** `generate_model(provider=meshy, mode=image, image_path=Assets/refs/x.png)`
→ confirm it imports a model derived from the local image.
- [ ] **Local image→image (fal):** `generate_image(provider=fal, mode=image, image_path=Assets/refs/x.png, prompt="make it night")`
→ confirm fal's `/edit` endpoint accepts the request and returns an edited image.
- [ ] **Local image→image (OpenRouter):** `generate_image(provider=openrouter, mode=image, image_path=..., prompt="...")`
→ confirm the reference image influences the result.
- [ ] **fal output size:** `generate_image(provider=fal, width=512, height=512, prompt="...")`
→ confirm the returned image is 512×512.
- [ ] **Tripo local image is rejected clearly:** `generate_model(provider=tripo, mode=image, image_path=...)`
→ confirm the job fails with a clear "Tripo requires a hosted image_url" message (no silent text fallback).
- [ ] **Sketchfab search filters/paging:** `import_model(action=search, query="chair", categories=furniture-home, count=12, cursor=<from prior cursors.next>)`
→ confirm filtering works and `cursors.next` advances the page.
- [ ] **Transparency is import-only:** `generate_image(transparent=true)` sets the Unity alpha-is-transparency
flag but does NOT produce a transparent background (fal/FLUX limitation) — confirm the expectation.
## Multi-agent / security spot-check
- [ ] Confirm no key value ever appears in MCP tool output.