From 66b96d2626302f338472531c6c82eded7d4043b6 Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Sun, 12 Jul 2026 10:59:53 -0700
Subject: [PATCH 01/10] =?UTF-8?q?feat(asset-gen):=20audio=20backend=20?=
=?UTF-8?q?=E2=80=94=20fal=20adapter,=20import=20pipeline,=20job=20manager?=
=?UTF-8?q?=20entry?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 of fal.ai audio generation. Adds AudioGenRequest + IAudioProviderAdapter,
a FalAudioAdapter fronting the v1 fal audio models (stable-audio-25, cassetteai/*,
lyria2), an AudioImportPipeline that branches AudioImporter load type on clip
length, AssetGenProviders.Audio + an audio List row, and
AssetGenJobManager.StartAudioGeneration. Also threads ModelGenRequest.Model
(consumed in Phase 3). Compiles on the 2021.3 floor; adapter/import/e2e tests green.
Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y
---
.../Services/AssetGen/AssetGenJobManager.cs | 30 ++-
.../AssetGen/Import/AudioImportPipeline.cs | 71 +++++
.../Import/AudioImportPipeline.cs.meta | 11 +
.../AssetGen/Providers/AssetGenProviders.cs | 17 +-
.../AssetGen/Providers/FalAudioAdapter.cs | 166 ++++++++++++
.../Providers/FalAudioAdapter.cs.meta | 11 +
.../AssetGen/Providers/IProviderAdapters.cs | 12 +
.../AssetGen/Providers/ProviderModels.cs | 18 +-
.../AssetGen/AssetGenJobManagerTests.cs | 75 ++++++
.../AssetGen/AssetGenProvidersTests.cs | 45 ++++
.../AssetGen/AudioImportPipelineTests.cs | 38 +++
.../AssetGen/AudioImportPipelineTests.cs.meta | 11 +
.../EditMode/AssetGen/FalAudioAdapterTests.cs | 246 ++++++++++++++++++
.../AssetGen/FalAudioAdapterTests.cs.meta | 11 +
14 files changed, 758 insertions(+), 4 deletions(-)
create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs
create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta
create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta
diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
index 1f23fa578..58f95e77c 100644
--- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
@@ -23,7 +23,7 @@ public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Cancele
public sealed class AssetGenJob
{
public string JobId;
- public string Kind; // model | image | marketplace
+ public string Kind; // model | image | audio | marketplace
public string Provider;
public string Action;
public AssetGenJobState State;
@@ -145,6 +145,34 @@ public static AssetGenJob StartImageGeneration(ImageGenRequest req)
return job;
}
+ public static AssetGenJob StartAudioGeneration(AudioGenRequest req)
+ {
+ if (req == null) throw new ArgumentNullException(nameof(req));
+ string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider;
+ IAudioProviderAdapter adapter = AssetGenProviders.Audio(provider); // throws NotSupportedException if unimplemented
+
+ var job = NewJob("audio", provider, "generate");
+ job.Format = "wav";
+
+ 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 ?? AudioImportPipeline.ImportInto,
+ Transport = transport,
+ OutputFolder = req.OutputFolder,
+ Ext = "wav", // default; the poll's ResultExt (wav/mp3) overrides at write time
+ Name = NameFrom(req.Name, req.Prompt, job.JobId),
+ Subfolder = "Audio",
+ };
+ 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");
diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs
new file mode 100644
index 000000000..f7aa204f7
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs
@@ -0,0 +1,71 @@
+using System;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Security;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Services.AssetGen.Import
+{
+ ///
+ /// Imports a downloaded audio clip (already under Assets/) and applies AudioImporter settings.
+ /// Load type is chosen by clip length: short one-shots (SFX) decompress into memory for
+ /// zero-latency playback; medium clips stay compressed in memory; long BGM streams — so a
+ /// generated soundtrack doesn't sit fully decompressed in RAM.
+ ///
+ public static class AudioImportPipeline
+ {
+ 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.");
+
+ AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
+ ApplyAudioImporterSettings(rel);
+
+ job.AssetPath = rel;
+ job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
+ if (string.IsNullOrEmpty(job.AssetGuid))
+ return Fail(job, "Imported the audio 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 void ApplyAudioImporterSettings(string rel)
+ {
+ if (!(AssetImporter.GetAtPath(rel) is AudioImporter importer)) return;
+
+ AudioClip clip = AssetDatabase.LoadAssetAtPath(rel);
+ float len = clip != null ? clip.length : 0f;
+
+ AudioImporterSampleSettings s = importer.defaultSampleSettings;
+ if (len > 30f) s.loadType = AudioClipLoadType.Streaming; // long BGM
+ else if (len > 10f) s.loadType = AudioClipLoadType.CompressedInMemory; // medium track
+ else s.loadType = AudioClipLoadType.DecompressOnLoad; // short SFX one-shot
+ importer.defaultSampleSettings = s;
+
+ importer.forceToMono = false;
+ importer.loadInBackground = false;
+ importer.SaveAndReimport();
+ }
+
+ 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/AudioImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta
new file mode 100644
index 000000000..00d1f7b45
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cb94201110f1247c7bdf60548a93a30e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs
index 132a5581a..026de16b3 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs
@@ -6,8 +6,8 @@ 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
+ /// (model: tripo/meshy; image: fal/openrouter; audio: fal; marketplace: sketchfab); unknown ids
+ /// throw . advertises providers and reports
/// Configured existence only — never a key value.
///
public static class AssetGenProviders
@@ -38,6 +38,17 @@ public static IImageProviderAdapter Image(string id)
}
}
+ public static IAudioProviderAdapter Audio(string id)
+ {
+ switch ((id ?? string.Empty).ToLowerInvariant())
+ {
+ case "fal":
+ return new FalAudioAdapter();
+ default:
+ throw new NotSupportedException($"Unknown audio provider '{id}'.");
+ }
+ }
+
public static IMarketplaceProviderAdapter Marketplace(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
@@ -58,6 +69,8 @@ public static IReadOnlyList List()
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" } },
+ // fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key.
+ new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } },
};
}
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
new file mode 100644
index 000000000..23dcb0ae5
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
@@ -0,0 +1,166 @@
+using System;
+using System.IO;
+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 audio provider via the queue API. One adapter fronts every v1 audio model
+ /// (stable-audio-25, cassetteai/*, lyria2); the model id in
+ /// selects the endpoint. Submits to queue.fal.run/{model} (auth header
+ /// "Authorization: Key <key>"), polls status, then returns the result audio URL for the job
+ /// manager to download. Reuses the single existing "fal" secure key.
+ ///
+ public sealed class FalAudioAdapter : IAudioProviderAdapter
+ {
+ private const string QueueBase = "https://queue.fal.run/";
+ // Stable Audio 2.5: music + SFX in one model, up to ~190s. The catalog default.
+ private const string DefaultModel = "fal-ai/stable-audio-25/text-to-audio";
+
+ public string Id => "fal";
+
+ public async Task SubmitAsync(AudioGenRequest 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;
+ string url = QueueBase + model;
+
+ var spec = new HttpRequestSpec
+ {
+ Method = "POST",
+ Url = url,
+ ContentType = "application/json",
+ Body = Encoding.UTF8.GetBytes(BuildBody(model, req).ToString(Formatting.None))
+ };
+ spec.Headers["Authorization"] = "Key " + apiKey;
+
+ HttpResult res = await http.SendAsync(spec, ct);
+ JObject json = ParseOk(res, apiKey, "submit");
+
+ 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));
+ responseUrl = QueueBase + model + "/requests/" + requestId;
+ }
+ return responseUrl;
+ }
+
+ // Only Stable Audio and CassetteAI SFX have a confirmed duration knob. CassetteAI Music and
+ // Lyria 2 ship prompt-only until their body schemas are verified against a live response.
+ private static JObject BuildBody(string model, AudioGenRequest req)
+ {
+ var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty };
+ if (req.Duration > 0f)
+ {
+ if (model.IndexOf("stable-audio-25", StringComparison.OrdinalIgnoreCase) >= 0)
+ body["seconds_total"] = (int)Math.Min(req.Duration, 190f);
+ else if (model.IndexOf("sound-effects", StringComparison.OrdinalIgnoreCase) >= 0)
+ body["duration"] = (int)Math.Min(req.Duration, 30f);
+ }
+ return body;
+ }
+
+ 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;
+ }
+
+ 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");
+
+ string audioUrl = ExtractAudioUrl(resultJson);
+ if (string.IsNullOrEmpty(audioUrl))
+ {
+ result.State = ProviderPollState.Failed;
+ result.Error = "fal completed but no audio URL was present in the result.";
+ return result;
+ }
+ result.Progress = 1f;
+ result.DownloadUrl = audioUrl;
+ // CassetteAI/Lyria return mp3, Stable Audio wav — derive the ext from the result URL.
+ result.ResultExt = ExtractExt(audioUrl);
+ return result;
+ }
+
+ private static string ExtractAudioUrl(JObject result)
+ {
+ string u = result["audio"]?["url"]?.ToString();
+ if (!string.IsNullOrEmpty(u)) return u;
+ u = result["audio_file"]?["url"]?.ToString();
+ if (!string.IsNullOrEmpty(u)) return u;
+ u = result["audio_url"]?.ToString();
+ return string.IsNullOrEmpty(u) ? null : u;
+ }
+
+ private static string ExtractExt(string url)
+ {
+ try
+ {
+ string ext = Path.GetExtension(new Uri(url).AbsolutePath).TrimStart('.').ToLowerInvariant();
+ return string.IsNullOrEmpty(ext) ? "wav" : ext;
+ }
+ catch { return "wav"; }
+ }
+
+ 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/FalAudioAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta
new file mode 100644
index 000000000..0c6b10862
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 13bdc532957f463e94feb4c25df0329f
+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
index 561923c7f..3ba8b19cb 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs
@@ -24,6 +24,18 @@ public interface IImageProviderAdapter
Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
+ ///
+ /// A generative audio provider (fal.ai for v1). All v1 audio models route through the single
+ /// fal adapter; the model is chosen inside . The api key is
+ /// passed in at call time and never cached on the adapter.
+ ///
+ public interface IAudioProviderAdapter
+ {
+ string Id { get; }
+ Task SubmitAsync(AudioGenRequest 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
{
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs
index 4ee117562..1e8d0f9e1 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs
@@ -40,6 +40,7 @@ public sealed class ModelGenRequest
public float TargetSize = 1f;
public bool Texture = true;
public string Tier;
+ public string Model; // provider model id/version; null => adapter DefaultModel
public string Name;
public string OutputFolder;
}
@@ -61,6 +62,21 @@ public sealed class ImageGenRequest
public string OutputFolder;
}
+ ///
+ /// Request to generate an audio clip (fal.ai for v1). selects the fal
+ /// endpoint (stable-audio-25 / cassetteai/* / lyria2). is a per-gen
+ /// input; 0 => provider default. Never carries a key; never persisted (transient request only).
+ ///
+ public sealed class AudioGenRequest
+ {
+ public string Provider; // "fal" for v1
+ public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio
+ public string Prompt;
+ public float Duration; // seconds; 0 => per-model default. Soft-clamped per model in the adapter.
+ public string Name;
+ public string OutputFolder;
+ }
+
///
/// Public, key-free description of a provider for list_providers. Never carries a key
/// value — reports existence only.
@@ -68,7 +84,7 @@ public sealed class ImageGenRequest
public sealed class ProviderInfo
{
public string Id;
- public string Kind; // model | image | marketplace
+ public string Kind; // model | image | audio | marketplace
public bool Configured;
public string[] Capabilities;
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
index bb3d76354..69a3ba39d 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
@@ -19,6 +19,9 @@ public class AssetGenJobManagerTests
{
private const string EnvVar = "MCPFORUNITY_TRIPO_API_KEY";
private const string Secret = "tsk_e2e_secret_value";
+ private const string FalEnvVar = "MCPFORUNITY_FAL_API_KEY";
+ private const string FalSecret = "fal_e2e_secret_value";
+ private const string AudioResp = "https://queue.fal.run/fal-ai/stable-audio-25/text-to-audio/requests/r1";
private const string TestFolder = "Assets/Generated/__assetgen_jobtest";
private FakeHttpTransport _fake;
@@ -28,6 +31,7 @@ public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, Secret);
+ Environment.SetEnvironmentVariable(FalEnvVar, FalSecret);
_fake = new FakeHttpTransport();
AssetGenJobManager.TransportOverrideForTests = _fake;
AssetGenJobManager.PollIntervalSeconds = 0;
@@ -40,6 +44,7 @@ public void TearDown()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, null);
+ Environment.SetEnvironmentVariable(FalEnvVar, null);
try
{
string abs = Path.Combine(ProjectRoot(), TestFolder);
@@ -68,6 +73,14 @@ private static string ProjectRoot()
OutputFolder = TestFolder,
};
+ private static AudioGenRequest AudioReq() => new AudioGenRequest
+ {
+ Provider = "fal",
+ Prompt = "gentle rain",
+ Name = "audiotest",
+ OutputFolder = TestFolder,
+ };
+
private static void Pump(string jobId)
{
int guard = 0;
@@ -96,6 +109,68 @@ public void EndToEnd_ReachesDone_WithAssetPath()
Assert.AreEqual(1f, job.Progress);
}
+ [Test]
+ public void Audio_EndToEnd_ReachesDone_WithWavAssetPath_AndLeaksNoKey()
+ {
+ _fake.Handler = spec =>
+ {
+ if (spec.Method == "POST") return Json("{\"response_url\":\"" + AudioResp + "\"}");
+ if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
+ if (spec.Url.Contains("cdn.example.com"))
+ return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } };
+ return Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}"); // result payload
+ };
+
+ AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq());
+ Pump(job.JobId);
+
+ Assert.AreEqual(AssetGenJobState.Done, job.State);
+ Assert.IsNotNull(job.AssetPath);
+ StringAssert.EndsWith("audiotest.wav", job.AssetPath);
+ Assert.AreEqual(1f, job.Progress);
+
+ string serialized = JsonConvert.SerializeObject(job);
+ StringAssert.DoesNotContain(FalSecret, serialized);
+ }
+
+ [Test]
+ public void Audio_Mp3Result_DownloadsWithMp3Ext()
+ {
+ _fake.Handler = spec =>
+ {
+ if (spec.Method == "POST") return Json("{\"response_url\":\"" + AudioResp + "\"}");
+ if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
+ if (spec.Url.Contains("cdn.example.com"))
+ return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } };
+ return Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/track.mp3\"}}");
+ };
+
+ AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq());
+ Pump(job.JobId);
+
+ Assert.AreEqual(AssetGenJobState.Done, job.State);
+ StringAssert.EndsWith("audiotest.mp3", job.AssetPath);
+ }
+
+ [Test]
+ public void Audio_NoKey_FailsImmediately()
+ {
+ Environment.SetEnvironmentVariable(FalEnvVar, null); // remove the env key
+ string dir = Path.Combine(Path.GetTempPath(), "mcp_jobmgr_audio_nokey_" + Guid.NewGuid().ToString("N"));
+ MCPForUnity.Editor.Security.SecureKeyStore.OverrideForTests(new MCPForUnity.Editor.Security.EncryptedFileKeyStore(dir));
+ try
+ {
+ AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq());
+ Assert.AreEqual(AssetGenJobState.Failed, job.State);
+ StringAssert.Contains("No API key", job.Error);
+ }
+ finally
+ {
+ MCPForUnity.Editor.Security.SecureKeyStore.ResetForTests();
+ try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { }
+ }
+ }
+
[Test]
public void FailedPoll_FailsJob_AndLeaksNoKey()
{
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs
index a6349e33b..2b8e0b84f 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs
@@ -27,6 +27,42 @@ public void Model_Unimplemented_Throws()
Assert.Throws(() => AssetGenProviders.Model("hunyuan"));
}
+ [Test]
+ public void Audio_Fal_ReturnsAdapter()
+ {
+ IAudioProviderAdapter adapter = AssetGenProviders.Audio("fal");
+ Assert.IsNotNull(adapter);
+ Assert.AreEqual("fal", adapter.Id);
+ }
+
+ [Test]
+ public void Audio_Unimplemented_Throws()
+ {
+ Assert.Throws(() => AssetGenProviders.Audio("elevenlabs"));
+ }
+
+ [Test]
+ public void List_IncludesFalAudioRow()
+ {
+ ProviderInfo row = FindByIdKind("fal", "audio");
+ Assert.IsNotNull(row, "List() should advertise a fal audio row");
+ Assert.AreEqual("audio", row.Kind);
+ }
+
+ [Test]
+ public void List_HasExactlyOneFalImage_AndOneFalAudio()
+ {
+ int image = 0, audio = 0;
+ foreach (ProviderInfo p in AssetGenProviders.List())
+ {
+ if (p.Id != "fal") continue;
+ if (p.Kind == "image") image++;
+ else if (p.Kind == "audio") audio++;
+ }
+ Assert.AreEqual(1, image, "exactly one fal image row");
+ Assert.AreEqual(1, audio, "exactly one fal audio row");
+ }
+
[Test]
public void List_IncludesTripo_ConfiguredIsBool()
{
@@ -65,5 +101,14 @@ private static ProviderInfo FindTripo()
}
return null;
}
+
+ private static ProviderInfo FindByIdKind(string id, string kind)
+ {
+ foreach (ProviderInfo p in AssetGenProviders.List())
+ {
+ if (p.Id == id && p.Kind == kind) return p;
+ }
+ return null;
+ }
}
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
new file mode 100644
index 000000000..5367852ca
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
@@ -0,0 +1,38 @@
+using MCPForUnity.Editor.Services.AssetGen;
+using MCPForUnity.Editor.Services.AssetGen.Import;
+using NUnit.Framework;
+
+namespace MCPForUnityTests.Editor.AssetGen
+{
+ ///
+ /// Validates the audio import pipeline's guard paths without fabricating audio bytes. Real
+ /// AudioClip import + AudioImporter settings are left to the licensed editor (manual checklist /
+ /// live smoke test), matching ModelImportPipelineTests' guard-only philosophy.
+ ///
+ public class AudioImportPipelineTests
+ {
+ private static AssetGenJob Job() => new AssetGenJob
+ {
+ JobId = "test",
+ Kind = "audio",
+ Provider = "fal",
+ State = AssetGenJobState.Importing,
+ Format = "wav",
+ };
+
+ [Test]
+ public void ImportInto_NullPath_Fails()
+ {
+ AssetGenJob result = AudioImportPipeline.ImportInto(Job(), null);
+ Assert.AreEqual(AssetGenJobState.Failed, result.State);
+ }
+
+ [Test]
+ public void ImportInto_PathOutsideAssets_Fails()
+ {
+ AssetGenJob result = AudioImportPipeline.ImportInto(Job(), "/tmp/somewhere/sound.wav");
+ Assert.AreEqual(AssetGenJobState.Failed, result.State);
+ StringAssert.Contains("Assets", result.Error);
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta
new file mode 100644
index 000000000..2867b40c5
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a74b37c946289499eb717c4e37dbd3a0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
new file mode 100644
index 000000000..254156b97
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
@@ -0,0 +1,246 @@
+using System.Text;
+using System.Threading;
+using MCPForUnity.Editor.Services.AssetGen.Http;
+using MCPForUnity.Editor.Services.AssetGen.Providers;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+
+namespace MCPForUnityTests.Editor.AssetGen
+{
+ public class FalAudioAdapterTests
+ {
+ private const string StableAudio = "fal-ai/stable-audio-25/text-to-audio";
+ private const string Resp = "https://queue.fal.run/fal-ai/stable-audio-25/text-to-audio/requests/r1";
+
+ private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
+
+ private static AudioGenRequest Req(string model = null, float duration = 0f) =>
+ new AudioGenRequest { Provider = "fal", Model = model, Prompt = "gentle rain", Duration = duration };
+
+ private static string SubmittedBody(FakeHttpTransport fake) =>
+ Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
+
+ [Test]
+ public void Submit_PostsModelEndpoint_WithKeyHeader_ReturnsResponseUrl()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = _ => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(Resp, pid);
+ HttpRequestSpec sent = fake.RecordedRequests[0];
+ Assert.AreEqual("POST", sent.Method);
+ StringAssert.Contains(StableAudio, sent.Url);
+ Assert.IsTrue(sent.Headers.ContainsKey("Authorization"));
+ StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
+ }
+
+ [Test]
+ public void Submit_ModelOverride_UsesModelEndpoint()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("cassetteai/sound-effects-generator"), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ StringAssert.Contains("cassetteai/sound-effects-generator", fake.RecordedRequests[0].Url);
+ }
+
+ [Test]
+ public void Submit_StableAudio_WithDuration_IncludesSecondsTotal()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req(StableAudio, 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ StringAssert.Contains("seconds_total", SubmittedBody(fake));
+ }
+
+ [Test]
+ public void Submit_StableAudio_OverMax_ClampsTo190()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req(StableAudio, 250f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.AreEqual(190, (int)body["seconds_total"]);
+ }
+
+ [Test]
+ public void Submit_CassetteSfx_WithDuration_IncludesDuration_ClampsTo30()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("cassetteai/sound-effects-generator", 45f), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.AreEqual(30, (int)body["duration"]);
+ }
+
+ [Test]
+ public void Submit_Lyria_And_CassetteMusic_PromptOnly()
+ {
+ foreach (string model in new[] { "fal-ai/lyria2", "cassetteai/music-generator" })
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req(model, 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ string body = SubmittedBody(fake);
+ StringAssert.DoesNotContain("seconds_total", body);
+ StringAssert.DoesNotContain("duration", body);
+ StringAssert.Contains("prompt", body);
+ }
+ }
+
+ [Test]
+ public void Submit_FallbackResponseUrl_UsesModelRequestsPath()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"request_id\":\"r1\"}") }; // no response_url
+ var adapter = new FalAudioAdapter();
+
+ string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ StringAssert.Contains(StableAudio + "/requests/r1", pid);
+ }
+
+ [Test]
+ public void Poll_Status_KeyHeaderPresent()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = spec => spec.Url.EndsWith("/status")
+ ? Json("{\"status\":\"COMPLETED\"}")
+ : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ foreach (HttpRequestSpec sent in fake.RecordedRequests)
+ {
+ Assert.IsTrue(sent.Headers.ContainsKey("Authorization"));
+ StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
+ }
+ }
+
+ [Test]
+ public void Poll_Completed_WavResult_ReturnsWavExt()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = spec => spec.Url.EndsWith("/status")
+ ? Json("{\"status\":\"COMPLETED\"}")
+ : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
+ Assert.AreEqual("https://cdn.example.com/a.wav", pr.DownloadUrl);
+ Assert.AreEqual("wav", pr.ResultExt);
+ }
+
+ [Test]
+ public void Poll_Completed_Mp3Result_ReturnsMp3Ext()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = spec => spec.Url.EndsWith("/status")
+ ? Json("{\"status\":\"COMPLETED\"}")
+ : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/track.mp3\"}}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual("mp3", pr.ResultExt);
+ }
+
+ [Test]
+ public void Poll_AudioShape_And_BareUrl_Extracted()
+ {
+ foreach (string result in new[]
+ {
+ "{\"audio\":{\"url\":\"https://cdn.example.com/a.wav\"}}",
+ "{\"audio_url\":\"https://cdn.example.com/a.wav\"}"
+ })
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = spec => spec.Url.EndsWith("/status") ? Json("{\"status\":\"COMPLETED\"}") : Json(result)
+ };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
+ Assert.AreEqual("https://cdn.example.com/a.wav", pr.DownloadUrl);
+ }
+ }
+
+ [Test]
+ public void Poll_Completed_NoUrl_Fails()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = spec => spec.Url.EndsWith("/status") ? Json("{\"status\":\"COMPLETED\"}") : Json("{}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Failed, pr.State);
+ StringAssert.Contains("audio", pr.Error);
+ }
+
+ [Test]
+ public void Poll_InProgress_Running()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\"}") };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Running, pr.State);
+ }
+
+ [Test]
+ public void Poll_InQueue_Queued()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_QUEUE\"}") };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Queued, pr.State);
+ }
+
+ [Test]
+ public void Poll_Failed_RedactsError()
+ {
+ var fake = new FakeHttpTransport
+ {
+ Handler = _ => Json("{\"status\":\"ERROR\",\"error\":\"boom falkey123 leaked\"}")
+ };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Failed, pr.State);
+ StringAssert.DoesNotContain("falkey123", pr.Error);
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta
new file mode 100644
index 000000000..743d94540
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: dc7e0ad00c6945a88a078ae0e52c54a9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From 8c0a5dfe0d140d5f88eadf27fe9068c20fafa2c1 Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Sun, 12 Jul 2026 11:20:16 -0700
Subject: [PATCH 02/10] feat(asset-gen): model catalog + generate_audio tool +
default-model wiring
Phases 2-3. Adds a curated AssetGenModelCatalog (image/3D/audio models with
price/duration/use-case metadata; defaults reference the adapter constants so the
panel default equals what an omitted model resolves to). Adds the generate_audio
MCP tool + CLI, threads a per-(kind,provider) selected-model pref so a GUI choice
becomes the default generate_* uses when no model is passed, and makes
Tripo/Meshy consume req.Model. Per-provider prefs (not per-type) so disjoint model
lists never clobber. Compiles on 2021.3 floor; Python + adapter/catalog tests green.
Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y
---
.../Editor/Constants/EditorPrefKeys.cs | 5 +
MCPForUnity/Editor/Helpers/AssetGenPrefs.cs | 28 ++++
.../Services/AssetGen/AssetGenModelCatalog.cs | 89 +++++++++++
.../AssetGen/AssetGenModelCatalog.cs.meta | 11 ++
.../Services/AssetGen/Providers/FalAdapter.cs | 3 +-
.../AssetGen/Providers/FalAudioAdapter.cs | 3 +-
.../AssetGen/Providers/MeshyAdapter.cs | 10 +-
.../AssetGen/Providers/OpenRouterAdapter.cs | 3 +-
.../AssetGen/Providers/TripoAdapter.cs | 5 +-
.../Editor/Tools/AssetGen/GenerateAudio.cs | 144 +++++++++++++++++
.../Tools/AssetGen/GenerateAudio.cs.meta | 11 ++
.../Editor/Tools/AssetGen/GenerateImage.cs | 8 +-
.../Editor/Tools/AssetGen/GenerateModel.cs | 12 +-
Server/src/cli/commands/asset_gen.py | 43 ++++++
Server/src/services/tools/generate_audio.py | 79 ++++++++++
Server/src/services/tools/generate_model.py | 5 +-
Server/tests/test_asset_gen_audio.py | 145 ++++++++++++++++++
Server/tests/test_asset_gen_model.py | 11 +-
.../AssetGen/AssetGenModelCatalogTests.cs | 71 +++++++++
.../AssetGenModelCatalogTests.cs.meta | 11 ++
.../EditMode/AssetGen/AssetGenPrefsTests.cs | 31 ++++
.../EditMode/AssetGen/GenerateAudioTests.cs | 85 ++++++++++
.../AssetGen/GenerateAudioTests.cs.meta | 11 ++
.../EditMode/AssetGen/GenerateImageTests.cs | 49 ++++++
.../EditMode/AssetGen/MeshyAdapterTests.cs | 26 ++++
.../EditMode/AssetGen/TripoAdapterTests.cs | 26 ++++
26 files changed, 913 insertions(+), 12 deletions(-)
create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs
create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta
create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs.meta
create mode 100644 Server/src/services/tools/generate_audio.py
create mode 100644 Server/tests/test_asset_gen_audio.py
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs.meta
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs
create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs.meta
diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
index 5fdfeb0c9..6f7ca9f04 100644
--- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
+++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
@@ -77,6 +77,11 @@ internal static class EditorPrefKeys
// 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 AssetGenSelectedAudioProvider = "MCPForUnity.AssetGen.AudioProvider";
+ // Selected model id per (kind, provider): key = prefix + ".". Empty => use
+ // the catalog default. Per-provider (not per-type) so e.g. the Tripo and Meshy 3D dropdowns
+ // — which have disjoint model lists — never clobber each other's selection.
+ internal const string AssetGenSelectedModelPrefix = "MCPForUnity.AssetGen.Model.";
internal const string AssetGenDefaultFormat = "MCPForUnity.AssetGen.Format";
internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot";
internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize";
diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs
index b34ea47f0..03a8b6b36 100644
--- a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs
+++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs
@@ -16,6 +16,7 @@ public static class AssetGenPrefs
public const string DefaultOutputRoot = "Assets/Generated";
public const string DefaultModelProvider = "tripo";
public const string DefaultImageProvider = "fal";
+ public const string DefaultAudioProvider = "fal";
public const string DefaultFormatValue = "glb";
public static string ModelProvider
@@ -30,6 +31,33 @@ public static string ImageProvider
set => SetOrDelete(EditorPrefKeys.AssetGenSelectedImageProvider, value);
}
+ public static string AudioProvider
+ {
+ get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedAudioProvider, DefaultAudioProvider);
+ set => SetOrDelete(EditorPrefKeys.AssetGenSelectedAudioProvider, value);
+ }
+
+ ///
+ /// Selected model id for a (kind, provider) pair — the GUI dropdown writes it and the
+ /// generate_* tools read it when the caller omits `model`. Empty by design: the tool's
+ /// empty -> catalog-default chain is the single source of the default, so a blank value
+ /// means "use the catalog default for this provider". Per-provider (not per-type) so
+ /// disjoint provider model lists (Tripo vs Meshy, fal vs OpenRouter) never clobber each other.
+ ///
+ public static string GetSelectedModel(string kind, string providerId)
+ => string.IsNullOrEmpty(providerId)
+ ? string.Empty
+ : EditorPrefs.GetString(ModelKey(kind, providerId), string.Empty);
+
+ public static void SetSelectedModel(string kind, string providerId, string value)
+ {
+ if (string.IsNullOrEmpty(providerId)) return;
+ SetOrDelete(ModelKey(kind, providerId), value);
+ }
+
+ private static string ModelKey(string kind, string providerId)
+ => EditorPrefKeys.AssetGenSelectedModelPrefix + kind + "." + providerId;
+
public static string DefaultFormat
{
get => EditorPrefs.GetString(EditorPrefKeys.AssetGenDefaultFormat, DefaultFormatValue);
diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs
new file mode 100644
index 000000000..c80e7b2b5
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.Services.AssetGen.Providers;
+
+namespace MCPForUnity.Editor.Services.AssetGen
+{
+ ///
+ /// One selectable model in the Asset Generation panel, plus the metadata the GUI shows
+ /// (use-case / price / max duration) and the license caveat to surface. is the
+ /// exact string passed as the tool's model param — it reaches the adapter request as the
+ /// fal endpoint, the Tripo model_version, the Meshy ai_model, or the OpenRouter
+ /// model slug.
+ ///
+ public sealed class ModelEntry
+ {
+ public string Id;
+ public string Label;
+ public string Provider;
+ public string Kind; // image | model | audio
+ public string UseCase;
+ public string PriceLabel;
+ public float MaxDurationSeconds; // 0 => not time-bounded (image / 3D)
+ public bool Loopable;
+ public string CommercialNote; // non-null => show a license caveat under the dropdown
+ public bool FromRefresh; // true => merged from a fal-catalog refresh (Phase 5)
+ }
+
+ ///
+ /// Curated, always-present registry of selectable models per provider+kind, with metadata for
+ /// the Asset Generation panel. The first curated entry per (provider, kind) is the default, and
+ /// each default's references the owning adapter's constant directly
+ /// — so the panel's shown default always equals what an omitted model param resolves to
+ /// (a drift-guard test pins the two). A fal-catalog refresh overlay is layered on in Phase 5.
+ ///
+ public static class AssetGenModelCatalog
+ {
+ private static readonly ModelEntry[] Curated =
+ {
+ // Image — fal (FalAdapter.DefaultModel is first => the default)
+ new ModelEntry { Id = FalAdapter.DefaultModel, Label = "FLUX.2", Provider = "fal", Kind = "image", UseCase = "General image" },
+ new ModelEntry { Id = "fal-ai/flux-2/flash", Label = "FLUX.2 Flash", Provider = "fal", Kind = "image", UseCase = "Fast / cheap image" },
+ new ModelEntry { Id = "fal-ai/flux-2-pro", Label = "FLUX.2 Pro", Provider = "fal", Kind = "image", UseCase = "Top-quality image" },
+
+ // Image — openrouter
+ new ModelEntry { Id = OpenRouterAdapter.DefaultModel, Label = "Gemini 2.5 Flash Image", Provider = "openrouter", Kind = "image", UseCase = "General image" },
+
+ // 3D — tripo / meshy (defaults reference the adapter constants)
+ new ModelEntry { Id = TripoAdapter.ModelVersion, Label = "Tripo v3.1", Provider = "tripo", Kind = "model", UseCase = "Text / image -> 3D" },
+ new ModelEntry { Id = "P1-20260311", Label = "Tripo P1 (premium)", Provider = "tripo", Kind = "model", UseCase = "Premium 3D" },
+ new ModelEntry { Id = MeshyAdapter.DefaultModel, Label = "Meshy 6", Provider = "meshy", Kind = "model", UseCase = "Text / image -> 3D" },
+
+ // Audio — fal (order: stable-audio, cassette SFX, cassette music, lyria)
+ new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f,
+ CommercialNote = "Free under $1M annual revenue (Stability Community License); an Enterprise license is required at or above $1M." },
+ new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", PriceLabel = "$0.01/gen", MaxDurationSeconds = 30f },
+ new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f },
+ new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f },
+ };
+
+ /// Curated entries for a provider+kind, in curated order (default first). Never null.
+ public static IReadOnlyList ForProvider(string provider, string kind)
+ {
+ var result = new List();
+ foreach (ModelEntry e in Curated)
+ if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) result.Add(e);
+ return result;
+ }
+
+ /// The curated entry with this exact id, or null.
+ public static ModelEntry Find(string id)
+ {
+ if (string.IsNullOrEmpty(id)) return null;
+ foreach (ModelEntry e in Curated)
+ if (Eq(e.Id, id)) return e;
+ return null;
+ }
+
+ /// The default model id for a provider+kind (the first curated entry), or null.
+ public static string DefaultModelId(string provider, string kind)
+ => ForProvider(provider, kind).FirstOrDefault()?.Id;
+
+ /// Clears any test/refresh state. The refresh overlay is added in Phase 5; no-op today.
+ internal static void ResetForTests() { }
+
+ private static bool Eq(string a, string b)
+ => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta
new file mode 100644
index 000000000..1451f9aaa
--- /dev/null
+++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b6497ad09f4143c4a4a27bb6c00a6e3c
+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
index d888a0f63..48ec19760 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs
@@ -19,7 +19,8 @@ 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";
+ // internal so the model catalog references it directly (single source of truth, drift-guarded).
+ internal const string DefaultModel = "fal-ai/flux-2";
public string Id => "fal";
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
index 23dcb0ae5..f2345b2c8 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
@@ -21,7 +21,8 @@ public sealed class FalAudioAdapter : IAudioProviderAdapter
{
private const string QueueBase = "https://queue.fal.run/";
// Stable Audio 2.5: music + SFX in one model, up to ~190s. The catalog default.
- private const string DefaultModel = "fal-ai/stable-audio-25/text-to-audio";
+ // internal so the model catalog references it directly (single source of truth, drift-guarded).
+ internal const string DefaultModel = "fal-ai/stable-audio-25/text-to-audio";
public string Id => "fal";
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs
index 58a8a182c..d4a720545 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs
@@ -21,6 +21,8 @@ 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";
+ // Default model; the catalog mirrors this and the drift-guard test pins the two together.
+ internal const string DefaultModel = "meshy-6";
public string Id => "meshy";
@@ -28,6 +30,7 @@ public sealed class MeshyAdapter : IModelProviderAdapter
private string _format = "glb";
private bool _isImage;
private bool _wantTexture = true;
+ private string _aiModel = DefaultModel; // stashed so the refine task reuses the submit model
private string _refineTaskId;
private bool _refineSubmitted;
@@ -38,6 +41,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
_wantTexture = req.Texture;
+ _aiModel = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
@@ -52,7 +56,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT
body = new JObject
{
["image_url"] = imageRef,
- ["ai_model"] = "meshy-6",
+ ["ai_model"] = _aiModel,
["should_texture"] = _wantTexture
};
}
@@ -64,7 +68,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT
{
["mode"] = "preview",
["prompt"] = req.Prompt ?? string.Empty,
- ["ai_model"] = "meshy-6"
+ ["ai_model"] = _aiModel
};
}
@@ -107,7 +111,7 @@ public async Task PollAsync(string providerJobId, string api
{
["mode"] = "refine",
["preview_task_id"] = providerJobId,
- ["ai_model"] = "meshy-6"
+ ["ai_model"] = _aiModel
};
_refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine");
_refineSubmitted = true;
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs
index 52a8b0d00..3fb6fc2d0 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs
@@ -18,7 +18,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers
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";
+ // internal so the model catalog references it directly (single source of truth).
+ internal const string DefaultModel = "google/gemini-2.5-flash-image";
public string Id => "openrouter";
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
index da9765f04..20ebe8a15 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
@@ -19,7 +19,8 @@ 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";
+ // internal so the model catalog references it directly (single source of truth, drift-guarded).
+ internal const string ModelVersion = "v3.1-20260211";
public string Id => "tripo";
@@ -54,7 +55,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT
{
["type"] = "text_to_model",
["prompt"] = req.Prompt ?? string.Empty,
- ["model_version"] = ModelVersion
+ ["model_version"] = string.IsNullOrEmpty(req.Model) ? ModelVersion : req.Model
};
}
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
new file mode 100644
index 000000000..d4b931474
--- /dev/null
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
@@ -0,0 +1,144 @@
+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
+{
+ ///
+ /// Audio generation (SFX / music) via fal.ai. Triggered here (never from the GUI); the C# side
+ /// reads the fal key from the secure store and runs the job. Returns a job_id immediately; the
+ /// client polls the `status` action. When `model` is omitted it falls back to the model selected
+ /// in the Asset Generation tab, then the catalog default.
+ ///
+ [McpForUnityTool("generate_audio", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 600)]
+ public static class GenerateAudio
+ {
+ 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", "fal") ?? "fal").ToLowerInvariant();
+ AssetGenProviders.Audio(provider); // throws NotSupportedException for unknown providers
+
+ if (!SecureKeyStore.Current.Has(provider))
+ return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
+
+ string prompt = p.Get("prompt");
+ if (string.IsNullOrWhiteSpace(prompt))
+ return new ErrorResponse("'prompt' is required for audio generation.");
+
+ // Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own
+ // default; a resolved id is passed through verbatim (the catalog default equals the
+ // adapter constant, so an omitted model is a no-op either way).
+ string model = p.Get("model");
+ if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("audio", provider);
+ if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "audio");
+
+ var req = new AudioGenRequest
+ {
+ Provider = provider,
+ Model = string.IsNullOrWhiteSpace(model) ? null : model,
+ Prompt = prompt,
+ Duration = p.GetFloat("duration", 0f) ?? 0f,
+ Name = p.Get("name"),
+ OutputFolder = p.Get("outputFolder"),
+ };
+ if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
+ return new ErrorResponse(outputErr);
+
+ AssetGenJob job = AssetGenJobManager.StartAudioGeneration(req);
+ if (job.State == AssetGenJobState.Failed)
+ return new ErrorResponse(job.Error ?? "Failed to start generation.");
+
+ return new PendingResponse(
+ $"Audio 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(
+ $"Audio 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(
+ $"Audio {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
internal static class ProviderHttp
{
+ ///
+ /// Throw unless is an absolute https URL whose host is exactly
+ /// . Adapters route every auth-bearing request URL through
+ /// this so a malicious/MITM'd provider response (e.g. a rogue response_url) can't redirect
+ /// the API key to an attacker host. The error is scrubbed of the key.
+ ///
+ public static void RequireHost(string url, string allowedHost, string apiKey, string context)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out Uri u)
+ || u.Scheme != Uri.UriSchemeHttps
+ || !string.Equals(u.Host, allowedHost, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new Exception(SecretRedactor.Scrub(
+ $"{context}: refusing to send credentials to an unexpected host in URL '{url}' (expected https://{allowedHost}).",
+ apiKey));
+ }
+ }
+
/// Response text, falling back to a UTF-8 decode of the raw body when Text is empty.
public static string BodyText(HttpResult res)
{
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
index 20ebe8a15..f5a356afd 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs
@@ -42,6 +42,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT
body = new JObject
{
["type"] = "image_to_model",
+ ["model_version"] = string.IsNullOrEmpty(req.Model) ? ModelVersion : req.Model,
["file"] = new JObject
{
["type"] = "url",
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
index d4b931474..8462a9909 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
@@ -60,14 +60,12 @@ private static object Generate(ToolParams p)
// Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own
// default; a resolved id is passed through verbatim (the catalog default equals the
// adapter constant, so an omitted model is a no-op either way).
- string model = p.Get("model");
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("audio", provider);
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "audio");
+ string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model"));
var req = new AudioGenRequest
{
Provider = provider,
- Model = string.IsNullOrWhiteSpace(model) ? null : model,
+ Model = model,
Prompt = prompt,
Duration = p.GetFloat("duration", 0f) ?? 0f,
Name = p.Get("name"),
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
index 31ee7356d..c85c6ba03 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
@@ -56,9 +56,7 @@ private static object Generate(ToolParams p)
// Empty -> GUI-selected model -> catalog default. Null still reaches the adapter's own
// default (no regression when nothing is selected anywhere).
- string model = p.Get("model");
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("image", provider);
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "image");
+ string model = AssetGenModelCatalog.ResolveModel("image", provider, p.Get("model"));
var req = new ImageGenRequest
{
@@ -67,7 +65,7 @@ private static object Generate(ToolParams p)
Prompt = p.Get("prompt"),
ImagePath = p.Get("imagePath"),
ImageUrl = p.Get("imageUrl"),
- Model = string.IsNullOrWhiteSpace(model) ? null : model,
+ Model = model,
Transparent = p.GetBool("transparent", false),
AsSprite = p.GetBool("asSprite", true),
Width = p.GetInt("width", 0) ?? 0,
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
index c7bd8966c..3da42e7d1 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
@@ -54,9 +54,7 @@ private static object Generate(ToolParams p)
// Empty -> GUI-selected model -> catalog default. Null still reaches the adapter's own
// default (Tripo ModelVersion / Meshy meshy-6).
- string model = p.Get("model");
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("model", provider);
- if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "model");
+ string model = AssetGenModelCatalog.ResolveModel("model", provider, p.Get("model"));
var req = new ModelGenRequest
{
@@ -69,7 +67,7 @@ private static object Generate(ToolParams p)
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
Texture = p.GetBool("texture", true),
Tier = p.Get("tier"),
- Model = string.IsNullOrWhiteSpace(model) ? null : model,
+ Model = model,
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs
index c0a88eb65..6b0e6d113 100644
--- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs
@@ -327,6 +327,7 @@ void SaveKeyFromField()
SecureKeyStore.Current.Set(id, text);
keyField.SetValueWithoutNotify(string.Empty);
SetStatus(statusLabel, "saved ✓", true);
+ RebuildIfSharedKey(id);
}
catch (Exception ex)
{
@@ -351,6 +352,7 @@ void SaveKeyFromField()
keyField.SetValueWithoutNotify(string.Empty);
SetStatus(statusLabel, "not set", false);
+ RebuildIfSharedKey(id);
};
// v1 surfaces presence only. Live endpoint validation (an actual auth ping to the
@@ -394,7 +396,16 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider
string selectedId = AssetGenPrefs.GetSelectedModel(kind, providerId);
if (string.IsNullOrEmpty(selectedId)) selectedId = AssetGenModelCatalog.DefaultModelId(providerId, kind);
- ModelEntry selected = AssetGenModelCatalog.Find(selectedId) ?? models[0];
+ ModelEntry selected = AssetGenModelCatalog.Find(selectedId);
+ if (selected == null)
+ {
+ // The stored pref points at a model that's no longer in the catalog (stale/invalid).
+ // The dropdown falls back to the first model — clear the pref so generate_* resolves to
+ // the same shown model instead of sending the stale id.
+ selected = models[0];
+ if (!string.IsNullOrEmpty(AssetGenPrefs.GetSelectedModel(kind, providerId)))
+ AssetGenPrefs.SetSelectedModel(kind, providerId, string.Empty);
+ }
// Lay the dropdown out like the Format row: a horizontal .setting-row (align-items:center,
// min-height:24px) with a .setting-label + a label-less DropdownField. Adding the dropdown
@@ -476,6 +487,18 @@ private void AddAudioRow(VisualElement parent)
parent.Add(row);
}
+ ///
+ /// The fal key is shared with the audio row, whose "key present" status is snapshotted at
+ /// build time. When the 2D fal key is saved/cleared, schedule a full rebuild so the audio row
+ /// reflects it without a manual Refresh. Deferred so we don't destroy the element whose
+ /// callback is still running.
+ ///
+ private void RebuildIfSharedKey(string id)
+ {
+ if (!string.Equals(id, "fal", StringComparison.OrdinalIgnoreCase)) return;
+ Root?.schedule.Execute(SyncFromPrefs);
+ }
+
private static ModelEntry FindByLabel(IReadOnlyList models, string label)
{
foreach (ModelEntry m in models)
@@ -489,7 +512,9 @@ private static void UpdateModelMeta(Label label, ModelEntry m)
var parts = new List();
if (!string.IsNullOrEmpty(m.UseCase)) parts.Add(m.UseCase);
if (!string.IsNullOrEmpty(m.PriceLabel)) parts.Add(m.PriceLabel);
- if (m.MaxDurationSeconds > 0f) parts.Add($"≤{m.MaxDurationSeconds:0}s");
+ // Only surface the "≤Ns" hint for models with an actual duration control (DurationField);
+ // Lyria advertises a max but takes no duration input, so showing a hint would mislead.
+ if (m.MaxDurationSeconds > 0f && !string.IsNullOrEmpty(m.DurationField)) parts.Add($"≤{m.MaxDurationSeconds:0}s");
if (m.Loopable) parts.Add("loopable");
label.text = string.Join(" · ", parts);
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
index 69a3ba39d..8ed73151a 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
@@ -191,6 +191,57 @@ public void FailedPoll_FailsJob_AndLeaksNoKey()
StringAssert.DoesNotContain(Secret, serialized);
}
+ [Test]
+ public void IsAllowedResultExtension_AllowsSafeTypes_RejectsCodeTypes()
+ {
+ // H2/P8: a provider-controlled result extension must be gated per kind so a rogue provider
+ // can't land a .cs/.asmdef/.meta/.asset under Assets/ and get it compiled/imported.
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("audio", "wav"));
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("audio", ".mp3"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("audio", "cs"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("audio", "asmdef"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("audio", "meta"));
+
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("image", "png"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("image", "cs"));
+
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("model", "glb"));
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("model", "zip"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("model", "cs"));
+ // marketplace shares the model allowlist.
+ Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("marketplace", "zip"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("marketplace", "dll"));
+ }
+
+ [Test]
+ public void Audio_DisallowedResultExt_FailsJob_WritesNoAsset()
+ {
+ // H2/P8: a poll returning a .cs result extension must fail the job before any file is
+ // written under Assets/ — never a compilable/importable payload.
+ _fake.Handler = spec =>
+ {
+ if (spec.Method == "POST") return Json("{\"response_url\":\"" + AudioResp + "\"}");
+ if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
+ if (spec.Url.Contains("cdn.example.com"))
+ return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } };
+ // Provider hands back a payload whose URL implies a .cs extension.
+ return Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/payload.cs\"}}");
+ };
+
+ AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq());
+ Pump(job.JobId);
+
+ Assert.AreEqual(AssetGenJobState.Failed, job.State);
+ StringAssert.Contains("disallowed", job.Error.ToLowerInvariant());
+ Assert.IsTrue(string.IsNullOrEmpty(job.AssetPath), "no asset path should be recorded for a rejected type");
+
+ // Nothing named payload.cs may have been written under the test output folder.
+ string abs = Path.Combine(ProjectRoot(), TestFolder);
+ if (Directory.Exists(abs))
+ foreach (string f in Directory.GetFiles(abs))
+ StringAssert.DoesNotEndWith(".cs", f);
+ }
+
[Test]
public void FileSchemeDownloadUrl_Rejected_FailsJob()
{
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs
index 233b7547d..9ef048e6c 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs
@@ -37,6 +37,29 @@ public void Curated_HasAllFourAudioModels()
Assert.IsNull(audio[3].CommercialNote);
}
+ [Test]
+ public void Audio_DurationFields_MatchEndpointSchemas()
+ {
+ IReadOnlyList audio = AssetGenModelCatalog.ForProvider("fal", "audio");
+
+ // stable-audio -> seconds_total; both cassette models -> duration; lyria -> no duration knob.
+ CollectionAssert.AreEqual(
+ new[] { "seconds_total", "duration", "duration", null },
+ audio.Select(e => e.DurationField).ToList());
+
+ // The two required-duration models carry a non-zero default so a Duration=0 call still
+ // sends a valid body (C2), and a floor >= 1 so fractional durations never send 0 (C3).
+ ModelEntry music = AssetGenModelCatalog.Find("cassetteai/music-generator");
+ Assert.Greater(music.DefaultDurationSeconds, 0f);
+ Assert.GreaterOrEqual(music.MinDurationSeconds, 1f);
+
+ ModelEntry sfx = AssetGenModelCatalog.Find("cassetteai/sound-effects-generator");
+ Assert.Greater(sfx.DefaultDurationSeconds, 0f);
+ Assert.GreaterOrEqual(sfx.MinDurationSeconds, 1f);
+
+ Assert.IsNull(AssetGenModelCatalog.Find("fal-ai/lyria2").DurationField, "Lyria has no duration control");
+ }
+
[Test]
public void DefaultModelId_PerKindPerProvider()
{
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
index 5367852ca..020032673 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
@@ -34,5 +34,15 @@ public void ImportInto_PathOutsideAssets_Fails()
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("Assets", result.Error);
}
+
+ [Test]
+ public void ImportInto_NonAudioExtension_UnderAssets_Rejected()
+ {
+ // Defense-in-depth: even a path under Assets is refused if it isn't an audio type, so a
+ // .cs payload can never be handed to AssetDatabase.ImportAsset.
+ AssetGenJob result = AudioImportPipeline.ImportInto(Job(), "Assets/Generated/payload.cs");
+ Assert.AreEqual(AssetGenJobState.Failed, result.State);
+ StringAssert.Contains("non-audio", result.Error.ToLowerInvariant());
+ }
}
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs
index 01c01a2be..48081430f 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs
@@ -121,5 +121,42 @@ public void Poll_InProgress_ReturnsRunning()
Assert.AreEqual(ProviderPollState.Running, pr.State);
}
+
+ [Test]
+ public void Poll_UnknownStatus_FailsFast_NotRunning()
+ {
+ // C4: an unmapped terminal status must fail immediately, not poll until the job timeout.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"WEIRD_UNKNOWN\"}") };
+ var adapter = new FalAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Failed, pr.State);
+ StringAssert.Contains("WEIRD_UNKNOWN", pr.Error);
+ }
+
+ [Test]
+ public void Poll_ForeignHost_Throws_AndSendsNothing()
+ {
+ // H3: the key must never be attached to a provider-controlled host.
+ var fake = new FakeHttpTransport();
+ var adapter = new FalAdapter();
+
+ Assert.Throws(() =>
+ adapter.PollAsync("https://attacker.example/harvest", "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult());
+ Assert.IsEmpty(fake.RecordedRequests, "no request (and no key) may be sent to a foreign host");
+ }
+
+ [Test]
+ public void Submit_ForeignResponseUrl_Throws()
+ {
+ // H3: a poisoned response_url from the provider must be rejected before it's used to poll.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"https://evil.example/x\"}") };
+ var adapter = new FalAdapter();
+
+ Assert.Throws(() =>
+ adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult());
+ }
}
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
index 254156b97..8098c6ca0 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
@@ -88,20 +88,60 @@ public void Submit_CassetteSfx_WithDuration_IncludesDuration_ClampsTo30()
}
[Test]
- public void Submit_Lyria_And_CassetteMusic_PromptOnly()
+ public void Submit_CassetteMusic_WithDuration_IncludesDuration_ClampsTo180()
{
- foreach (string model in new[] { "fal-ai/lyria2", "cassetteai/music-generator" })
- {
- var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
- var adapter = new FalAudioAdapter();
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
- adapter.SubmitAsync(Req(model, 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+ adapter.SubmitAsync(Req("cassetteai/music-generator", 300f), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
- string body = SubmittedBody(fake);
- StringAssert.DoesNotContain("seconds_total", body);
- StringAssert.DoesNotContain("duration", body);
- StringAssert.Contains("prompt", body);
- }
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.AreEqual(180, (int)body["duration"]);
+ }
+
+ [Test]
+ public void Submit_CassetteMusic_DefaultDuration_IncludesDuration_NotPromptOnly()
+ {
+ // C2: CassetteAI Music REQUIRES duration; a default (Duration=0) call must still send a
+ // valid duration >= 1, not a prompt-only body (which fal rejects with 422).
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("cassetteai/music-generator"), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.IsNotNull(body["duration"], "duration must be present for a required-duration model");
+ Assert.GreaterOrEqual((int)body["duration"], 1);
+ }
+
+ [Test]
+ public void Submit_CassetteMusic_FractionalDuration_FloorsToAtLeastOne()
+ {
+ // C3: Duration=0.5 must not (int)-truncate to 0 (which fal rejects); it clamps to >= 1.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("cassetteai/music-generator", 0.5f), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.GreaterOrEqual((int)body["duration"], 1);
+ }
+
+ [Test]
+ public void Submit_Lyria_PromptOnly()
+ {
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("fal-ai/lyria2", 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ string body = SubmittedBody(fake);
+ StringAssert.DoesNotContain("seconds_total", body);
+ StringAssert.DoesNotContain("duration", body);
+ StringAssert.Contains("prompt", body);
}
[Test]
@@ -242,5 +282,42 @@ public void Poll_Failed_RedactsError()
Assert.AreEqual(ProviderPollState.Failed, pr.State);
StringAssert.DoesNotContain("falkey123", pr.Error);
}
+
+ [Test]
+ public void Poll_UnknownStatus_FailsFast_NotRunning()
+ {
+ // C4: an unmapped terminal status must fail immediately, not poll until the job timeout.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"WEIRD_UNKNOWN\"}") };
+ var adapter = new FalAudioAdapter();
+
+ ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.AreEqual(ProviderPollState.Failed, pr.State);
+ StringAssert.Contains("WEIRD_UNKNOWN", pr.Error);
+ }
+
+ [Test]
+ public void Poll_ForeignHost_Throws_AndSendsNothing()
+ {
+ // H3: the key must never be attached to a provider-controlled host.
+ var fake = new FakeHttpTransport();
+ var adapter = new FalAudioAdapter();
+
+ Assert.Throws(() =>
+ adapter.PollAsync("https://attacker.example/harvest", "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult());
+ Assert.IsEmpty(fake.RecordedRequests, "no request (and no key) may be sent to a foreign host");
+ }
+
+ [Test]
+ public void Submit_ForeignResponseUrl_Throws()
+ {
+ // H3: a poisoned response_url from the provider must be rejected before it's used to poll.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"https://evil.example/x\"}") };
+ var adapter = new FalAudioAdapter();
+
+ Assert.Throws(() =>
+ adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult());
+ }
}
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs
index a0580edeb..65c13a88e 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs
@@ -147,6 +147,44 @@ public void Submit_ImageMode_UsesImageToModel()
StringAssert.Contains("https://example.com/in.png", body);
}
+ [Test]
+ public void Submit_ImageMode_HonorsModelVersion_WhenProvided()
+ {
+ var http = new FakeHttpTransport { Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"img1\"}}") };
+ var adapter = new TripoAdapter();
+ var req = new ModelGenRequest
+ {
+ Provider = "tripo",
+ Mode = "image",
+ ImageUrl = "https://example.com/in.png",
+ Model = "P1-20260311",
+ };
+
+ adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult();
+
+ string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body);
+ StringAssert.Contains("image_to_model", body);
+ StringAssert.Contains("\"model_version\":\"P1-20260311\"", body);
+ }
+
+ [Test]
+ public void Submit_ImageMode_FallsBackToDefaultModelVersion_WhenModelEmpty()
+ {
+ var http = new FakeHttpTransport { Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"img1\"}}") };
+ var adapter = new TripoAdapter();
+ var req = new ModelGenRequest
+ {
+ Provider = "tripo",
+ Mode = "image",
+ ImageUrl = "https://example.com/in.png", // Model null
+ };
+
+ adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult();
+
+ string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body);
+ StringAssert.Contains("\"model_version\":\"" + TripoAdapter.ModelVersion + "\"", body);
+ }
+
[Test]
public void Submit_ImageMode_LocalPathOnly_Throws()
{
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs
new file mode 100644
index 000000000..8c3b22b8e
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs
@@ -0,0 +1,39 @@
+using MCPForUnity.Editor.Services.AssetGen.Http;
+using NUnit.Framework;
+
+namespace MCPForUnityTests.Editor.AssetGen
+{
+ ///
+ /// H1: an auth-bearing request must not follow redirects (UnityWebRequest re-sends the
+ /// Authorization header to a 3xx target by default). We can't drive a real redirect headlessly,
+ /// so we test the decision helper that gates redirectLimit = 0.
+ ///
+ public class UnityWebRequestTransportTests
+ {
+ [Test]
+ public void CarriesAuth_TrueWhenAuthorizationHeaderPresent()
+ {
+ var spec = new HttpRequestSpec { Method = "GET", Url = "https://queue.fal.run/x" };
+ spec.Headers["Authorization"] = "Key secret123";
+
+ Assert.IsTrue(UnityWebRequestTransport.CarriesAuth(spec));
+ }
+
+ [Test]
+ public void CarriesAuth_CaseInsensitiveHeaderKey()
+ {
+ var spec = new HttpRequestSpec { Method = "GET", Url = "https://queue.fal.run/x" };
+ spec.Headers["authorization"] = "Bearer secret123";
+
+ Assert.IsTrue(UnityWebRequestTransport.CarriesAuth(spec));
+ }
+
+ [Test]
+ public void CarriesAuth_FalseWhenNoAuthorizationHeader()
+ {
+ var spec = new HttpRequestSpec { Method = "GET", Url = "https://cdn.example.com/a.wav" };
+
+ Assert.IsFalse(UnityWebRequestTransport.CarriesAuth(spec));
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs.meta
new file mode 100644
index 000000000..f482acab5
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/UnityWebRequestTransportTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1f9a1eafd557475683b6426972bd8e71
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs
index 52abe6e2c..06628db72 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs
@@ -714,10 +714,10 @@ public void McpAssetGenSection_HasModelDropdownAndAudioRowBuilders()
Assert.IsNotNull(type.GetMethod("AddAudioRow", BindingFlags.NonPublic | BindingFlags.Instance),
"expected a fal audio row builder");
- // AddProviderRow now takes (id, displayName, kind) so each row knows its catalog kind.
+ // AddProviderRow takes (parent, id, displayName, kind) so each row knows its catalog kind.
var addRow = type.GetMethod("AddProviderRow", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.IsNotNull(addRow);
- Assert.AreEqual(3, addRow.GetParameters().Length, "AddProviderRow should take (id, displayName, kind)");
+ Assert.AreEqual(4, addRow.GetParameters().Length, "AddProviderRow should take (parent, id, displayName, kind)");
foreach (string phase in new[] { "CacheUIElements", "InitializeUI", "RegisterCallbacks" })
Assert.IsNotNull(type.GetMethod(phase, BindingFlags.NonPublic | BindingFlags.Instance),
From ea3ce5a7e0b9d86926e68492a160a6fedcce5cc7 Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Mon, 13 Jul 2026 00:29:58 -0700
Subject: [PATCH 08/10] chore(asset-gen): "Asset Gen" tool-group label +
regenerate reference docs
- McpToolsSection: add the asset_gen -> "Asset Gen" group display name so
the tool-group tab no longer falls back to the raw "Asset_gen".
- Update the asset_gen group blurb (registry + CLI) to include audio gen.
- Regenerate the tool reference docs from the Python registry: add the
missing generate_audio.md and refresh the asset_gen landscape/index.
Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
---
.../Components/Tools/McpToolsSection.cs | 1 +
Server/src/cli/commands/asset_gen.py | 2 +-
Server/src/services/registry/tool_registry.py | 2 +-
.../tools/asset_gen/generate_audio.md | 47 +++++++++++++++++++
.../tools/asset_gen/generate_model.md | 3 +-
.../docs/reference/tools/asset_gen/index.md | 3 +-
website/docs/reference/tools/index.md | 5 +-
7 files changed, 57 insertions(+), 6 deletions(-)
create mode 100644 website/docs/reference/tools/asset_gen/generate_audio.md
diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs
index 723ec5eed..6e3b69400 100644
--- a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs
@@ -46,6 +46,7 @@ public class McpToolsSection
{ "testing", "Testing" },
{ "probuilder", "ProBuilder — Experimental" },
{ "profiling", "Profiling & Frame Debugger" },
+ { "asset_gen", "Asset Gen" },
};
public VisualElement Root { get; }
diff --git a/Server/src/cli/commands/asset_gen.py b/Server/src/cli/commands/asset_gen.py
index c6bd940f9..99b41d77b 100644
--- a/Server/src/cli/commands/asset_gen.py
+++ b/Server/src/cli/commands/asset_gen.py
@@ -1,4 +1,4 @@
-"""AI asset generation CLI commands (3D model gen/import, 2D image gen).
+"""AI asset generation CLI commands (3D model gen/import, 2D image gen, audio gen).
Thin pass-through to Unity over HTTP: these commands carry NO API keys and NO
file bytes. The C# side reads provider keys from the OS secure store, performs
diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py
index dd09c40e8..3b79a9478 100644
--- a/Server/src/services/registry/tool_registry.py
+++ b/Server/src/services/registry/tool_registry.py
@@ -25,7 +25,7 @@
"testing": "Test runner & async test jobs",
"probuilder": "ProBuilder 3D modeling – requires com.unity.probuilder package",
"profiling": "Unity Profiler session control, counters, memory snapshots & Frame Debugger",
- "asset_gen": "AI asset generation – 3D model gen/import & 2D image gen (bring-your-own-key)",
+ "asset_gen": "AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-your-own-key)",
}
DEFAULT_ENABLED_GROUPS: set[str] = {"core"}
diff --git a/website/docs/reference/tools/asset_gen/generate_audio.md b/website/docs/reference/tools/asset_gen/generate_audio.md
new file mode 100644
index 000000000..ad1df7f0b
--- /dev/null
+++ b/website/docs/reference/tools/asset_gen/generate_audio.md
@@ -0,0 +1,47 @@
+---
+title: generate_audio
+sidebar_label: generate_audio
+description: "Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project."
+---
+
+# `generate_audio`
+
+> **Auto-generated** from the Python tool registry. Do not hand-edit outside `` blocks — the generator (`tools/generate_docs_reference.py`) will overwrite them.
+
+**Group:** `asset_gen` · **Module:** `services.tools.generate_audio`
+
+## Description
+
+Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in the editor's secure store (shared with image generation) and never crosses the bridge.
+
+MODELS (all via fal.ai): fal-ai/stable-audio-25/text-to-audio (music + SFX, <=190s), cassetteai/sound-effects-generator (SFX, <=30s), cassetteai/music-generator (music), fal-ai/lyria2 (music). Omit model to use the model selected in the MCP for Unity -> Asset Generation tab.
+
+ACTIONS:
+- generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the status action. Params: provider (fal), prompt, model, duration (seconds), name, output_folder.
+- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.
+- cancel: Cancel an in-flight job by job_id.
+- list_providers: List configured audio providers and capabilities (no key values).
+
+## Parameters
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `action` | `Literal['generate', 'status', 'cancel', 'list_providers']` | yes | Action to perform. |
+| `provider` | `str \| None` | — | Provider id (fal). |
+| `prompt` | `str \| None` | — | Text prompt describing the sound or music. |
+| `model` | `str \| None` | — | fal model id (e.g. fal-ai/stable-audio-25/text-to-audio). Omit to use the GUI-selected default. |
+| `duration` | `float \| None` | — | Requested length in seconds (soft-clamped per model). |
+| `name` | `str \| None` | — | Base name for the imported asset. |
+| `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. |
+| `job_id` | `str \| None` | — | Job id for status/cancel. |
+
+## Returns
+
+A `dict` containing the Unity response. The exact shape depends on the action.
+
+## Examples
+
+
+*No examples yet. Add usage examples here — they will be preserved across regenerations.*
+
+
diff --git a/website/docs/reference/tools/asset_gen/generate_model.md b/website/docs/reference/tools/asset_gen/generate_model.md
index f9413ffda..a5a5da4fa 100644
--- a/website/docs/reference/tools/asset_gen/generate_model.md
+++ b/website/docs/reference/tools/asset_gen/generate_model.md
@@ -15,7 +15,7 @@ description: "Generate 3D models with AI providers (Tripo, Meshy) and import the
Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project. Bring-your-own-key: provider keys live in the editor's secure store and never cross the bridge.
ACTIONS:
-- generate: Submit a generation job (text->3D or image->3D). Returns { job_id } immediately; poll with the status action. Params: provider, mode (text|image), prompt, image_path|image_url, format (glb|fbx|obj|usdz), target_size, texture, tier, name, output_folder.
+- generate: Submit a generation job (text->3D or image->3D). Returns { job_id } immediately; poll with the status action. Params: provider, mode (text|image), prompt, image_path|image_url, format (glb|fbx|obj|usdz), target_size, texture, tier, model, name, output_folder.
- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.
- cancel: Cancel an in-flight job by job_id.
- list_providers: List configured 3D providers and capabilities (no key values).
@@ -34,6 +34,7 @@ ACTIONS:
| `target_size` | `float \| None` | — | Normalize the largest dimension to this size (meters). |
| `texture` | `bool \| None` | — | Whether to generate textures for the model. |
| `tier` | `str \| None` | — | Provider quality/cost tier. |
+| `model` | `str \| None` | — | Provider model id/version (e.g. Tripo v3.1, Meshy meshy-6). Omit for the GUI-selected default. |
| `name` | `str \| None` | — | Base name for the imported asset. |
| `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. |
| `job_id` | `str \| None` | — | Job id for status/cancel. |
diff --git a/website/docs/reference/tools/asset_gen/index.md b/website/docs/reference/tools/asset_gen/index.md
index 5ce505ed1..a13023ee8 100644
--- a/website/docs/reference/tools/asset_gen/index.md
+++ b/website/docs/reference/tools/asset_gen/index.md
@@ -6,8 +6,9 @@ description: "MCP for Unity tools in the asset_gen group."
# `asset_gen` tools
-AI asset generation – 3D model gen/import & 2D image gen (bring-your-own-key)
+AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-your-own-key)
+- **[`generate_audio`](./generate_audio.md)** — Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project.
- **[`generate_image`](./generate_image.md)** — Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as textures/sprites into the Unity project.
- **[`generate_model`](./generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project.
- **[`import_model`](./import_model.md)** — Import 3D models from the Sketchfab marketplace into the Unity project.
diff --git a/website/docs/reference/tools/index.md b/website/docs/reference/tools/index.md
index 87c01a735..a7466a134 100644
--- a/website/docs/reference/tools/index.md
+++ b/website/docs/reference/tools/index.md
@@ -16,8 +16,9 @@ Every tool MCP for Unity exposes, generated directly from the Python `@mcp_for_u
Animator control & AnimationClip creation
- **[`manage_animation`](./animation/manage_animation.md)** — Manage Unity animation: Animator control and AnimationClip creation.
-## `asset_gen` (4 tools)
-AI asset generation – 3D model gen/import & 2D image gen (bring-your-own-key)
+## `asset_gen` (5 tools)
+AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-your-own-key)
+- **[`generate_audio`](./asset_gen/generate_audio.md)** — Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project.
- **[`generate_image`](./asset_gen/generate_image.md)** — Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as textures/sprites into the Unity project.
- **[`generate_model`](./asset_gen/generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project.
- **[`import_model`](./asset_gen/import_model.md)** — Import 3D models from the Sketchfab marketplace into the Unity project.
From 26f8a1002e94bb5494f6070f108d5402bc696fe5 Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Mon, 13 Jul 2026 00:44:28 -0700
Subject: [PATCH 09/10] =?UTF-8?q?fix(asset-gen):=20address=20Copilot=20rev?=
=?UTF-8?q?iew=20=E2=80=94=20floor=20duration,=20fail-closed=20allowlist?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- FalAudioAdapter.BuildBody: floor (not Math.Round) the clamped duration so
it never exceeds the requested value, then enforce >= 1 (banker's rounding
could round 2.5 -> 2 and could exceed the request).
- AssetGenJobManager.AllowedExtensionsFor: fail closed — an unexpected/unknown
job kind now allows nothing instead of falling through to the model
allowlist, so the RCE boundary never opens by default. Explicit cases for
model + marketplace.
- Tests: pin floor semantics (10.9 -> 10) and the fail-closed behavior
(unknown/null/empty kind -> not allowed).
Verified: EditMode 1167 tests / 0 failures + in-editor smoke (BuildBody(10.9)
-> duration=10; IsAllowed("bogus","glb")=false).
Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
---
.../Services/AssetGen/AssetGenJobManager.cs | 6 +++++-
.../AssetGen/Providers/FalAudioAdapter.cs | 4 ++--
.../EditMode/AssetGen/AssetGenJobManagerTests.cs | 5 +++++
.../EditMode/AssetGen/FalAudioAdapterTests.cs | 15 +++++++++++++++
4 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
index 562422bcc..e9fa9e674 100644
--- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
@@ -453,6 +453,8 @@ private static void Advance(Runner r)
{
"glb", "gltf", "fbx", "obj", "usd", "usdz", "dae", "ply", "stl", "zip",
};
+ // Fail closed: an unexpected kind allows nothing, so the RCE boundary never opens by default.
+ private static readonly HashSet NoAllowedExtensions = new(StringComparer.OrdinalIgnoreCase);
///
/// Whether (no leading dot) is an allowed result extension for the
@@ -468,7 +470,9 @@ private static HashSet AllowedExtensionsFor(string kind)
{
case "audio": return AudioAllowedExtensions;
case "image": return ImageAllowedExtensions;
- default: return ModelAllowedExtensions; // model + marketplace
+ case "model":
+ case "marketplace": return ModelAllowedExtensions;
+ default: return NoAllowedExtensions; // fail closed for unexpected kinds
}
}
diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
index f74689b81..dbf682ca0 100644
--- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
+++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs
@@ -77,8 +77,8 @@ private static JObject BuildBody(string model, AudioGenRequest req)
float dur = req.Duration > 0f ? req.Duration : entry.DefaultDurationSeconds;
float floor = Math.Max(1f, entry.MinDurationSeconds);
dur = Math.Min(Math.Max(dur, floor), entry.MaxDurationSeconds);
- int seconds = (int)Math.Round(dur);
- if (seconds < 1) seconds = 1;
+ // Floor (not round) so we never exceed the requested duration, then enforce >= 1.
+ int seconds = Math.Max(1, (int)Math.Floor(dur));
body[entry.DurationField] = seconds;
}
return body;
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
index 8ed73151a..ae073c57f 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs
@@ -211,6 +211,11 @@ public void IsAllowedResultExtension_AllowsSafeTypes_RejectsCodeTypes()
// marketplace shares the model allowlist.
Assert.IsTrue(AssetGenJobManager.IsAllowedResultExtension("marketplace", "zip"));
Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("marketplace", "dll"));
+
+ // Fail closed: an unexpected/unknown kind allows nothing at the RCE boundary.
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("bogus_kind", "glb"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension(null, "png"));
+ Assert.IsFalse(AssetGenJobManager.IsAllowedResultExtension("", "wav"));
}
[Test]
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
index 8098c6ca0..d0315c785 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs
@@ -130,6 +130,21 @@ public void Submit_CassetteMusic_FractionalDuration_FloorsToAtLeastOne()
Assert.GreaterOrEqual((int)body["duration"], 1);
}
+ [Test]
+ public void Submit_CassetteMusic_FractionalDuration_Floors_NotRounds()
+ {
+ // Duration must be floored (not rounded) so it never exceeds the requested value:
+ // 10.9 -> 10, not 11.
+ var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
+ var adapter = new FalAudioAdapter();
+
+ adapter.SubmitAsync(Req("cassetteai/music-generator", 10.9f), "falkey123", fake, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ JObject body = JObject.Parse(SubmittedBody(fake));
+ Assert.AreEqual(10, (int)body["duration"]);
+ }
+
[Test]
public void Submit_Lyria_PromptOnly()
{
From 36cbe62fe1d3fe7780544b494881650dd547624a Mon Sep 17 00:00:00 2001
From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:26:31 -0700
Subject: [PATCH 10/10] refactor(asset-gen): dedupe generate_* tool shell
(CodeRabbit review)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
status / cancel / list_providers were near-identical across GenerateAudio,
GenerateImage and GenerateModel (differing only by a kind label + poll
interval), and NormalizeOutputFolder was a verbatim triple copy.
- New AssetGenToolHelpers.{Status,Cancel,ListProviders} — the three tools now
delegate, passing their kind label / poll interval.
- NormalizeOutputFolder moved to AssetGenPaths (it already lived on
TryGetAssetsFolder).
- Behaviour preserved (image `remove_background` arm kept; provider-list
filtering unchanged). Net -120 lines of duplication.
Verified: EditMode 1167 tests / 0 failures + in-editor smoke (audio/image/model
list_providers, status/cancel error paths, image remove_background all intact).
Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
---
MCPForUnity/Editor/Helpers/AssetGenPaths.cs | 15 +++++
.../Tools/AssetGen/AssetGenToolHelpers.cs | 66 ++++++++++++++++++
.../AssetGen/AssetGenToolHelpers.cs.meta | 11 +++
.../Editor/Tools/AssetGen/GenerateAudio.cs | 67 ++-----------------
.../Editor/Tools/AssetGen/GenerateImage.cs | 67 ++-----------------
.../Editor/Tools/AssetGen/GenerateModel.cs | 66 ++----------------
6 files changed, 110 insertions(+), 182 deletions(-)
create mode 100644 MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs
create mode 100644 MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta
diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs
index 290cb8de0..9b59fdc1c 100644
--- a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs
+++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs
@@ -81,6 +81,21 @@ public static bool TryGetAssetsFolder(string path, out string projectRelative)
return true;
}
+ ///
+ /// Validate an optional caller-supplied output folder. Empty is allowed (the tool picks a
+ /// default); a non-empty value must resolve under the project's Assets folder. Shared by the
+ /// generate_* tools.
+ ///
+ public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
+ {
+ normalized = outputFolder;
+ error = null;
+ if (string.IsNullOrWhiteSpace(outputFolder)) return true;
+ if (TryGetAssetsFolder(outputFolder, out normalized)) return true;
+ error = "'output_folder' must resolve under the project's Assets folder.";
+ return false;
+ }
+
private static string ProjectRoot()
{
string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs
new file mode 100644
index 000000000..e2955f82a
--- /dev/null
+++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs
@@ -0,0 +1,66 @@
+using System.Collections.Generic;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Services.AssetGen;
+using MCPForUnity.Editor.Services.AssetGen.Providers;
+
+namespace MCPForUnity.Editor.Tools.AssetGen
+{
+ ///
+ /// Shared action handlers for the generate_* asset tools (audio / image / model). The three tools
+ /// differ only in their generate step and a kind label; status, cancel, and
+ /// list_providers are identical modulo that label, so they live here once.
+ ///
+ internal static class AssetGenToolHelpers
+ {
+ ///
+ /// Poll a job by id and map its state to a client response.
+ /// prefixes the human-readable message (e.g. "Audio", "Image", "3D model").
+ ///
+ public static object Status(ToolParams p, string kindLabel, double pollIntervalSeconds)
+ {
+ 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(
+ $"{kindLabel} 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(
+ $"{kindLabel} {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
+ pollIntervalSeconds: pollIntervalSeconds,
+ data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
+ }
+ }
+
+ /// Request cancellation of a job by id.
+ public 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}'.");
+ }
+
+ /// List the configured providers for a given kind (audio / image / model).
+ public static object ListProviders(string kind)
+ {
+ var list = new List();
+ foreach (ProviderInfo info in AssetGenProviders.List())
+ {
+ if (info.Kind != kind) continue;
+ list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
+ }
+ return new SuccessResponse($"{list.Count} {kind} provider(s).", new { providers = list });
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta
new file mode 100644
index 000000000..210f04527
--- /dev/null
+++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8320589d7a679498c8ad56b59f662878
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
index 8462a9909..598d7c443 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
@@ -12,7 +11,8 @@ namespace MCPForUnity.Editor.Tools.AssetGen
/// Audio generation (SFX / music) via fal.ai. Triggered here (never from the GUI); the C# side
/// reads the fal key from the secure store and runs the job. Returns a job_id immediately; the
/// client polls the `status` action. When `model` is omitted it falls back to the model selected
- /// in the Asset Generation tab, then the catalog default.
+ /// in the Asset Generation tab, then the catalog default. Status / cancel / list_providers are
+ /// shared across the generate_* tools via .
///
[McpForUnityTool("generate_audio", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 600)]
public static class GenerateAudio
@@ -27,9 +27,9 @@ public static object HandleCommand(JObject @params)
switch (action)
{
case "generate": return Generate(p);
- case "status": return Status(p);
- case "cancel": return Cancel(p);
- case "list_providers": return ListProviders();
+ case "status": return AssetGenToolHelpers.Status(p, "Audio", 3.0);
+ case "cancel": return AssetGenToolHelpers.Cancel(p);
+ case "list_providers": return AssetGenToolHelpers.ListProviders("audio");
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers.");
@@ -71,7 +71,7 @@ private static object Generate(ToolParams p)
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
- if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
+ if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
return new ErrorResponse(outputErr);
AssetGenJob job = AssetGenJobManager.StartAudioGeneration(req);
@@ -83,60 +83,5 @@ private static object Generate(ToolParams p)
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(
- $"Audio 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(
- $"Audio {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 != "audio") continue;
- list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
- }
- return new SuccessResponse($"{list.Count} audio provider(s).", new { providers = list });
- }
}
}
diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
index c85c6ba03..1da99d563 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
@@ -11,7 +10,8 @@ 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.
+ /// job_id immediately; the client polls the `status` action. Status / cancel / list_providers are
+ /// shared across the generate_* tools via .
///
[McpForUnityTool("generate_image", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
public static class GenerateImage
@@ -28,9 +28,9 @@ public static object HandleCommand(JObject @params)
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 "status": return AssetGenToolHelpers.Status(p, "Image", 2.0);
+ case "cancel": return AssetGenToolHelpers.Cancel(p);
+ case "list_providers": return AssetGenToolHelpers.ListProviders("image");
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers.");
@@ -73,7 +73,7 @@ private static object Generate(ToolParams p)
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
- if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
+ if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
return new ErrorResponse(outputErr);
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
@@ -96,60 +96,5 @@ private static object Generate(ToolParams p)
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/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
index 3da42e7d1..718c92a94 100644
--- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
+++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
@@ -12,6 +11,8 @@ 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.
+ /// Status / cancel / list_providers are shared across the generate_* tools via
+ /// .
///
[McpForUnityTool("generate_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
public static class GenerateModel
@@ -26,9 +27,9 @@ public static object HandleCommand(JObject @params)
switch (action)
{
case "generate": return Generate(p);
- case "status": return Status(p);
- case "cancel": return Cancel(p);
- case "list_providers": return ListProviders();
+ case "status": return AssetGenToolHelpers.Status(p, "3D model", 3.0);
+ case "cancel": return AssetGenToolHelpers.Cancel(p);
+ case "list_providers": return AssetGenToolHelpers.ListProviders("model");
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers.");
@@ -71,7 +72,7 @@ private static object Generate(ToolParams p)
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
- if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
+ if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
return new ErrorResponse(outputErr);
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
@@ -96,60 +97,5 @@ private static object Generate(ToolParams p)
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())
- {
- if (info.Kind != "model") continue; // keep image/audio rows out of the 3D provider list
- list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
- }
- return new SuccessResponse($"{list.Count} model provider(s).", new { providers = list });
- }
}
}