Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "<kind>.<provider>". 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";
Expand Down
15 changes: 15 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetGenPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ public static bool TryGetAssetsFolder(string path, out string projectRelative)
return true;
}

/// <summary>
/// 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.
/// </summary>
public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
{
normalized = outputFolder;
error = null;
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
Comment on lines +89 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize whitespace-only outputFolder to null.

When outputFolder is whitespace-only (e.g. " "), normalized is set to the raw whitespace string and the method returns true. Downstream consumers receive a whitespace-only path that may bypass IsNullOrEmpty checks. Normalizing to null (consistent with AssetGenModelCatalog.ResolveModel which returns null for whitespace) makes the contract clearer: whitespace means "no folder specified."

🛡️ Proposed fix
         public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
         {
-            normalized = outputFolder;
+            normalized = null;
             error = null;
-            if (string.IsNullOrWhiteSpace(outputFolder)) return true;
+            if (string.IsNullOrWhiteSpace(outputFolder)) return true;
             if (TryGetAssetsFolder(outputFolder, out normalized)) return true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
{
normalized = outputFolder;
error = null;
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
{
normalized = null;
error = null;
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MCPForUnity/Editor/Helpers/AssetGenPaths.cs` around lines 89 - 93, Update
NormalizeOutputFolder so whitespace-only outputFolder values set normalized to
null before returning true, while preserving the existing behavior for
non-whitespace paths and invalid inputs.

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('\\', '/');
Expand Down
28 changes: 28 additions & 0 deletions MCPForUnity/Editor/Helpers/AssetGenPrefs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

/// <summary>
/// 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.
/// </summary>
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);
Expand Down
71 changes: 70 additions & 1 deletion MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -409,10 +437,51 @@ private static void Advance(Runner r)
}
}

// Per-kind allowlist of result file extensions. The write extension can come from a
// provider-controlled result URL (OverrideExt), so a rogue provider could otherwise land a
// .cs/.asmdef/.meta/.asset under Assets/ and get it compiled/imported on Refresh — Editor
// RCE. Anything outside these sets is rejected. Mirrors ModelImportPipeline's allowlist style.
private static readonly HashSet<string> AudioAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"wav", "mp3", "ogg", "aiff", "aif", "flac",
};
private static readonly HashSet<string> ImageAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"png", "jpg", "jpeg", "exr", "tga", "psd", "tiff", "webp", "gif", "bmp",
};
private static readonly HashSet<string> ModelAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"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<string> NoAllowedExtensions = new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Whether <paramref name="ext"/> (no leading dot) is an allowed result extension for the
/// job <paramref name="kind"/> (audio | image | model | marketplace). Internal so the
/// allowlist can be unit-tested directly.
/// </summary>
internal static bool IsAllowedResultExtension(string kind, string ext)
=> AllowedExtensionsFor(kind).Contains((ext ?? string.Empty).TrimStart('.'));

private static HashSet<string> AllowedExtensionsFor(string kind)
{
switch ((kind ?? string.Empty).ToLowerInvariant())
{
case "audio": return AudioAllowedExtensions;
case "image": return ImageAllowedExtensions;
case "model":
case "marketplace": return ModelAllowedExtensions;
default: return NoAllowedExtensions; // fail closed for unexpected kinds
}
}

private static string WriteFile(Runner r, byte[] bytes)
{
string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext;
string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant();
if (!IsAllowedResultExtension(r.Job.Kind, ext))
throw new Exception($"provider returned a disallowed file type '.{ext}'");
string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder
: (AssetGenPrefs.OutputRoot + "/" + r.Subfolder);
if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root))
Expand Down
119 changes: 119 additions & 0 deletions MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.AssetGen.Providers;

namespace MCPForUnity.Editor.Services.AssetGen
{
/// <summary>
/// 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. <see cref="Id"/> is the
/// exact string passed as the tool's <c>model</c> param — it reaches the adapter request as the
/// fal endpoint, the Tripo <c>model_version</c>, the Meshy <c>ai_model</c>, or the OpenRouter
/// model slug.
/// </summary>
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)
// Audio duration knob. DurationField is the request key ("seconds_total" / "duration");
// null => the model has no duration control (prompt-only). DefaultDurationSeconds is used
// when the caller passes 0 to a model whose endpoint requires a duration. MinDurationSeconds
// is the clamp floor.
public string DurationField;
public float DefaultDurationSeconds;
public float MinDurationSeconds;
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)
}

/// <summary>
/// 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 <see cref="ModelEntry.Id"/> references the owning adapter's constant directly
/// — so the panel's shown default always equals what an omitted <c>model</c> param resolves to
/// (a drift-guard test pins the two). A fal-catalog refresh overlay is layered on in Phase 5.
/// </summary>
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). DurationField
// is the request key each endpoint expects; null (Lyria) => prompt-only, no duration knob.
new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f,
DurationField = "seconds_total", DefaultDurationSeconds = 30f,
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,
DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f },
new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f,
DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f },
new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f },
};

/// <summary>Curated entries for a provider+kind, in curated order (default first). Never null.</summary>
public static IReadOnlyList<ModelEntry> ForProvider(string provider, string kind)
{
var result = new List<ModelEntry>();
foreach (ModelEntry e in Curated)
if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) result.Add(e);
return result;
}

/// <summary>The curated entry with this exact id, or null.</summary>
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;
}

/// <summary>The default model id for a provider+kind (the first curated entry), or null.</summary>
public static string DefaultModelId(string provider, string kind)
{
foreach (ModelEntry e in Curated)
if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) return e.Id;
return null;
}

/// <summary>
/// The model id a generate_* tool should use: an explicit <paramref name="requested"/> wins,
/// else the GUI-selected model for this (kind, provider), else the curated default. Null when
/// nothing resolves (the adapter then falls back to its own constant). Single home for the
/// empty -> GUI-selected -> catalog-default precedence shared by all three generate tools.
/// </summary>
public static string ResolveModel(string kind, string provider, string requested)
{
string model = requested;
if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel(kind, provider);
if (string.IsNullOrWhiteSpace(model)) model = DefaultModelId(provider, kind);
return string.IsNullOrWhiteSpace(model) ? null : model;
}

/// <summary>Clears any test/refresh state. The refresh overlay is added in Phase 5; no-op today.</summary>
internal static void ResetForTests() { }

private static bool Eq(string a, string b)
=> string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
}
11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
request.SetRequestHeader(kv.Key, kv.Value);
}
}
// UnityWebRequest re-sends the Authorization header to a 3xx target by default. Never
// follow a redirect on an auth-bearing request — the key must not leak to the redirect
// host. No-auth downloads may still follow.
if (CarriesAuth(spec)) request.redirectLimit = 0;

CancellationTokenRegistration ctReg = default;
if (ct.CanBeCanceled)
Expand Down Expand Up @@ -76,5 +80,15 @@ public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)

return tcs.Task;
}

/// <summary>True iff the request carries an Authorization header (case-insensitive key).</summary>
internal static bool CarriesAuth(HttpRequestSpec spec)
{
if (spec?.Headers == null) return false;
foreach (var kv in spec.Headers)
if (string.Equals(kv.Key, "Authorization", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
}
Loading
Loading