chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2796223ccc4747ffa205b1bac8b2ceef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,533 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Import;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen
{
public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Canceled }
/// <summary>
/// Snapshot of a generation/import job. Persisted to SessionState so a `status` query
/// still works after an unrelated domain reload. NEVER carries a key or secret.
/// </summary>
public sealed class AssetGenJob
{
public string JobId;
public string Kind; // model | image | marketplace
public string Provider;
public string Action;
public AssetGenJobState State;
public float Progress;
public string Format;
public float TargetSize = 1f;
public string AnimationType; // FBX/OBJ rig mode: null/none | generic | humanoid | legacy
public string AssetPath;
public string AssetGuid;
public string Error;
}
/// <summary>
/// Drives asset-generation jobs on the Unity main thread via EditorApplication.update. Each
/// job runs a generic submit → poll → (download | inline) → import state machine; the model
/// and image paths supply their own submit/poll/import delegates. Because UnityWebRequest
/// completes on the main thread, polling Task.IsCompleted from the update loop is
/// main-thread-safe — we never block or use threadpool waits. The provider key is read once at
/// submit time, captured only inside the submit/poll closures — never stored on the job,
/// persisted, or logged.
/// </summary>
[InitializeOnLoad]
public static class AssetGenJobManager
{
private const string JobKeyPrefix = "MCPForUnity.AssetGen.Job.";
private const string JobIndexKey = "MCPForUnity.AssetGen.JobIndex";
// Test seams (overridable; defaults are the production implementations).
internal static IHttpTransport TransportOverrideForTests;
internal static Func<AssetGenJob, string, AssetGenJob> ImportOverrideForTests;
internal static double PollIntervalSeconds = 3.0;
internal static double TimeoutSeconds = 600.0;
private static readonly Dictionary<string, AssetGenJob> Jobs = new();
private static readonly Dictionary<string, Runner> Runners = new();
private static readonly List<string> _tickIds = new();
private static bool _ticking;
static AssetGenJobManager()
{
try
{
string index = SessionState.GetString(JobIndexKey, string.Empty);
if (string.IsNullOrEmpty(index)) return;
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
{
string json = SessionState.GetString(JobKeyPrefix + id, string.Empty);
if (string.IsNullOrEmpty(json)) continue;
var job = JsonConvert.DeserializeObject<AssetGenJob>(json);
if (job == null) continue;
if (!IsTerminal(job.State))
{
job.State = AssetGenJobState.Failed;
job.Error = "Interrupted by an editor reload; please retry.";
Persist(job);
}
Jobs[id] = job;
}
}
catch { /* recovery is best-effort */ }
}
public static AssetGenJob StartModelGeneration(ModelGenRequest req)
{
if (req == null) throw new ArgumentNullException(nameof(req));
string provider = string.IsNullOrEmpty(req.Provider) ? "tripo" : req.Provider;
IModelProviderAdapter adapter = AssetGenProviders.Model(provider); // throws NotSupportedException if unimplemented
var job = NewJob("model", provider, "generate");
job.Format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format;
job.TargetSize = req.TargetSize <= 0 ? 1f : req.TargetSize;
if (!TryResolveKey(provider, job, out string apiKey)) return job;
IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
var runner = new Runner
{
Job = job,
SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct),
PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct),
ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto,
Transport = transport,
OutputFolder = req.OutputFolder,
Ext = (job.Format ?? "glb").TrimStart('.'),
Name = NameFrom(req.Name, req.Prompt, job.JobId),
Subfolder = "Models",
};
Register(job, runner);
return job;
}
public static AssetGenJob StartImageGeneration(ImageGenRequest req)
{
if (req == null) throw new ArgumentNullException(nameof(req));
string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider;
IImageProviderAdapter adapter = AssetGenProviders.Image(provider); // throws NotSupportedException if unimplemented
var job = NewJob("image", provider, "generate");
job.Format = "png";
if (!TryResolveKey(provider, job, out string apiKey)) return job;
IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
bool asSprite = req.AsSprite;
bool transparent = req.Transparent;
var runner = new Runner
{
Job = job,
SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct),
PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct),
ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)),
Transport = transport,
OutputFolder = req.OutputFolder,
Ext = "png",
Name = NameFrom(req.Name, req.Prompt, job.JobId),
Subfolder = "Images",
};
Register(job, runner);
return job;
}
public static AssetGenJob StartMarketplaceImport(string uid, float targetSize, string name, string outputFolder)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentException("uid required");
var adapter = AssetGenProviders.Marketplace("sketchfab"); // throws NotSupported if unimplemented
var job = NewJob("marketplace", "sketchfab", "import");
job.TargetSize = targetSize <= 0 ? 1f : targetSize;
if (!TryResolveKey("sketchfab", job, out string apiKey)) return job;
var transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
var runner = new Runner
{
Job = job,
SubmitFn = ct => adapter.ResolveDownloadUrlAsync(uid, apiKey, transport, ct), // returns the zip/gltf URL as providerJobId
PollFn = (pid, ct) => Task.FromResult(new ProviderPollResult { State = ProviderPollState.Succeeded, Progress = 1f, DownloadUrl = pid, ResultExt = "zip" }),
ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto,
Transport = transport,
OutputFolder = outputFolder,
Ext = "zip",
Name = NameFrom(name, uid, job.JobId),
Subfolder = "Sketchfab",
};
Register(job, runner);
return job;
}
public static AssetGenJob GetJob(string jobId)
=> string.IsNullOrEmpty(jobId) ? null : (Jobs.TryGetValue(jobId, out var j) ? j : null);
/// <summary>Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys.</summary>
public static IReadOnlyList<AssetGenJob> RecentJobs(int max = 20)
{
var all = new List<AssetGenJob>(Jobs.Values);
int start = Math.Max(0, all.Count - max);
var slice = all.GetRange(start, all.Count - start);
slice.Reverse();
return slice;
}
public static bool Cancel(string jobId)
{
if (string.IsNullOrEmpty(jobId)) return false;
if (Runners.TryGetValue(jobId, out var r))
{
r.Canceled = true;
try { r.Cts.Cancel(); } catch { }
return true;
}
if (Jobs.TryGetValue(jobId, out var job) && job.State == AssetGenJobState.Queued)
{
job.State = AssetGenJobState.Canceled;
Persist(job);
return true;
}
return false;
}
// ---------- runner ----------
private enum RunnerPhase { Submit, AwaitSubmit, Poll, AwaitPoll, Download, AwaitDownload, Import }
private sealed class Runner
{
public AssetGenJob Job;
public Func<CancellationToken, Task<string>> SubmitFn;
public Func<string, CancellationToken, Task<ProviderPollResult>> PollFn;
public Func<AssetGenJob, string, AssetGenJob> ImportFn;
public IHttpTransport Transport;
public string OutputFolder;
public string Ext;
public string OverrideExt;
public string Name;
public string Subfolder;
public CancellationTokenSource Cts = new();
public RunnerPhase Phase = RunnerPhase.Submit;
public double StartedAt;
public double NextPollAt;
public string ProviderJobId;
public string DownloadUrl;
public string LocalPath;
public Task<string> SubmitTask;
public Task<ProviderPollResult> PollTask;
public Task<HttpResult> DownloadTask;
public bool Canceled;
}
private static bool TryResolveKey(string provider, AssetGenJob job, out string apiKey)
{
if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey))
{
job.State = AssetGenJobState.Failed;
job.Error = AssetGenProviders.MissingKeyMessage(provider);
Jobs[job.JobId] = job;
Persist(job);
return false;
}
return true;
}
private static void Register(AssetGenJob job, Runner runner)
{
runner.StartedAt = Now();
Jobs[job.JobId] = job;
Runners[job.JobId] = runner;
Persist(job);
EnsureTicking();
}
private static void EnsureTicking()
{
if (_ticking) return;
EditorApplication.update += Tick;
_ticking = true;
}
private static void Tick()
{
if (Runners.Count == 0)
{
EditorApplication.update -= Tick;
_ticking = false;
return;
}
// Snapshot keys into a reused buffer so Advance can mutate Runners mid-iteration
// without churning the GC on every editor-update frame.
_tickIds.Clear();
_tickIds.AddRange(Runners.Keys);
foreach (string id in _tickIds)
{
if (Runners.TryGetValue(id, out var r)) Advance(r);
}
}
/// <summary>Advance one job one step. Returns true when the job is terminal.</summary>
internal static bool TryAdvanceForTests(string jobId)
{
if (Runners.TryGetValue(jobId, out var r))
{
Advance(r);
return IsTerminal(r.Job.State);
}
return Jobs.TryGetValue(jobId, out var j) && IsTerminal(j.State);
}
private static void Advance(Runner r)
{
if (IsTerminal(r.Job.State)) { Finalize(r); return; }
if (r.Canceled) { r.Job.State = AssetGenJobState.Canceled; Persist(r.Job); Finalize(r); return; }
if (Now() - r.StartedAt > TimeoutSeconds) { Fail(r, $"Timed out after {TimeoutSeconds:0}s."); return; }
try
{
switch (r.Phase)
{
case RunnerPhase.Submit:
r.Job.State = AssetGenJobState.Running;
Persist(r.Job);
r.SubmitTask = r.SubmitFn(r.Cts.Token);
r.Phase = RunnerPhase.AwaitSubmit;
break;
case RunnerPhase.AwaitSubmit:
if (!r.SubmitTask.IsCompleted) break;
if (Faulted(r.SubmitTask, out string subErr)) { Fail(r, subErr); break; }
r.ProviderJobId = r.SubmitTask.Result;
if (string.IsNullOrEmpty(r.ProviderJobId)) { Fail(r, "Provider returned no job id."); break; }
r.NextPollAt = Now();
r.Phase = RunnerPhase.Poll;
break;
case RunnerPhase.Poll:
if (Now() < r.NextPollAt) break;
r.PollTask = r.PollFn(r.ProviderJobId, r.Cts.Token);
r.Phase = RunnerPhase.AwaitPoll;
break;
case RunnerPhase.AwaitPoll:
if (!r.PollTask.IsCompleted) break;
if (Faulted(r.PollTask, out string pollErr)) { Fail(r, pollErr); break; }
ProviderPollResult pr = r.PollTask.Result;
r.Job.Progress = Mathf.Clamp01(pr.Progress);
Persist(r.Job);
if (pr.State == ProviderPollState.Succeeded)
{
r.OverrideExt = pr.ResultExt;
if (pr.InlineData != null && pr.InlineData.Length > 0)
{
r.LocalPath = WriteFile(r, pr.InlineData);
r.Job.State = AssetGenJobState.Importing;
Persist(r.Job);
r.Phase = RunnerPhase.Import;
}
else if (!string.IsNullOrEmpty(pr.DownloadUrl))
{
r.DownloadUrl = pr.DownloadUrl;
r.Phase = RunnerPhase.Download;
}
else
{
Fail(r, "Provider succeeded but returned no result data.");
}
}
else if (pr.State == ProviderPollState.Failed)
{
Fail(r, string.IsNullOrEmpty(pr.Error) ? "Provider reported failure." : pr.Error);
}
else
{
r.NextPollAt = Now() + PollIntervalSeconds;
r.Phase = RunnerPhase.Poll;
}
break;
case RunnerPhase.Download:
// The download URL comes from an untrusted provider response. Only fetch
// http(s) — refuse file://, ftp://, etc. so a malicious response can't read
// a local file into the project or hit an internal host.
if (!IsAllowedDownloadUrl(r.DownloadUrl))
{
Fail(r, "Refusing to fetch a non-http(s) download URL returned by the provider.");
break;
}
r.DownloadTask = r.Transport.SendAsync(
new HttpRequestSpec { Method = "GET", Url = r.DownloadUrl }, r.Cts.Token);
r.Phase = RunnerPhase.AwaitDownload;
break;
case RunnerPhase.AwaitDownload:
if (!r.DownloadTask.IsCompleted) break;
if (Faulted(r.DownloadTask, out string dlErr)) { Fail(r, dlErr); break; }
HttpResult res = r.DownloadTask.Result;
if (res == null || !res.IsSuccess || res.Body == null || res.Body.Length == 0)
{
Fail(r, $"Download failed (HTTP {res?.Status}).");
break;
}
r.LocalPath = WriteFile(r, res.Body);
r.Job.State = AssetGenJobState.Importing;
Persist(r.Job);
r.Phase = RunnerPhase.Import;
break;
case RunnerPhase.Import:
// The result file was just written via File.WriteAllBytes (outside the
// AssetDatabase). Refresh so Unity registers it before we import it,
// mirroring ImportModelFile. Skipped under the test import seam.
if (ImportOverrideForTests == null) AssetDatabase.Refresh();
AssetGenJob imported = r.ImportFn(r.Job, r.LocalPath);
if (imported != null) r.Job = imported;
if (r.Job.State != AssetGenJobState.Failed)
{
r.Job.State = AssetGenJobState.Done;
r.Job.Progress = 1f;
}
Persist(r.Job);
Finalize(r);
break;
}
}
catch (Exception e)
{
Fail(r, SecretRedactor.Scrub(e.Message));
}
}
private static string WriteFile(Runner r, byte[] bytes)
{
string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext;
string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant();
string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder
: (AssetGenPrefs.OutputRoot + "/" + r.Subfolder);
if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root))
root = AssetGenPrefs.DefaultOutputRoot + "/" + r.Subfolder;
string absRoot = AssetGenPaths.ToAbsolute(root);
Directory.CreateDirectory(absRoot);
string baseName = SanitizeName(r.Name);
string fileName = baseName + "." + ext;
string abs = Path.Combine(absRoot, fileName);
int n = 1;
while (File.Exists(abs)) { fileName = baseName + "_" + n++ + "." + ext; abs = Path.Combine(absRoot, fileName); }
File.WriteAllBytes(abs, bytes);
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
}
private static string NameFrom(string explicitName, string prompt, string jobId)
{
if (!string.IsNullOrWhiteSpace(explicitName)) return explicitName;
if (!string.IsNullOrWhiteSpace(prompt)) return prompt;
return "asset_" + jobId.Substring(0, 8);
}
private static string SanitizeName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "asset";
var sb = new System.Text.StringBuilder();
foreach (char c in raw.Trim())
{
if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c);
else if (c == ' ') sb.Append('_');
if (sb.Length >= 48) break;
}
string s = sb.ToString().Trim('_', '-');
return string.IsNullOrEmpty(s) ? "asset" : s;
}
private static void Fail(Runner r, string message)
{
r.Job.State = AssetGenJobState.Failed;
r.Job.Error = SecretRedactor.Scrub(string.IsNullOrEmpty(message) ? "Generation failed." : message);
Persist(r.Job);
Finalize(r);
}
private static void Finalize(Runner r)
{
Runners.Remove(r.Job.JobId);
try { r.Cts?.Dispose(); } catch { }
}
private static AssetGenJob NewJob(string kind, string provider, string action)
{
return new AssetGenJob
{
JobId = Guid.NewGuid().ToString("N"),
Kind = kind,
Provider = provider,
Action = action,
State = AssetGenJobState.Queued,
Progress = 0f,
};
}
private static void Persist(AssetGenJob job)
{
try
{
SessionState.SetString(JobKeyPrefix + job.JobId, JsonConvert.SerializeObject(job));
string index = SessionState.GetString(JobIndexKey, string.Empty);
var ids = new HashSet<string>(index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
if (ids.Add(job.JobId))
SessionState.SetString(JobIndexKey, string.Join(",", ids));
}
catch { /* persistence is best-effort */ }
}
private static bool Faulted(Task t, out string error)
{
if (t.IsFaulted)
{
Exception ex = t.Exception?.GetBaseException();
error = SecretRedactor.Scrub(ex?.Message ?? "request failed");
return true;
}
if (t.IsCanceled) { error = "Canceled."; return true; }
error = null;
return false;
}
private static bool IsTerminal(AssetGenJobState s)
=> s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled;
/// <summary>Only http(s) download URLs are allowed; provider responses are untrusted.</summary>
private static bool IsAllowedDownloadUrl(string url)
=> Uri.TryCreate(url, UriKind.Absolute, out Uri u)
&& (u.Scheme == Uri.UriSchemeHttps || u.Scheme == Uri.UriSchemeHttp);
private static double Now() => EditorApplication.timeSinceStartup;
internal static void ResetForTests()
{
foreach (var r in new List<Runner>(Runners.Values))
{
try { r.Cts?.Cancel(); r.Cts?.Dispose(); } catch { }
}
Runners.Clear();
string index = SessionState.GetString(JobIndexKey, string.Empty);
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
SessionState.EraseString(JobKeyPrefix + id);
SessionState.EraseString(JobIndexKey);
Jobs.Clear();
TransportOverrideForTests = null;
ImportOverrideForTests = null;
PollIntervalSeconds = 3.0;
TimeoutSeconds = 600.0;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1955ee1b47384a8a880f00d384fff433
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 29756f9999cd4779b156e75b9370ca8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Test double for <see cref="IHttpTransport"/>. Records every request it is handed and
/// returns a canned response, so provider adapters can be exercised without a network.
/// Match responses either with the <see cref="Handler"/> delegate (full control) or with
/// <see cref="ByUrlSubstring"/> (first entry whose key is contained in the request URL).
/// </summary>
public sealed class FakeHttpTransport : IHttpTransport
{
public List<HttpRequestSpec> RecordedRequests { get; } = new List<HttpRequestSpec>();
/// <summary>Highest-priority responder; return null to fall through to <see cref="ByUrlSubstring"/>.</summary>
public Func<HttpRequestSpec, HttpResult> Handler { get; set; }
/// <summary>Canned responses keyed by a substring expected to appear in the request URL.</summary>
public Dictionary<string, HttpResult> ByUrlSubstring { get; } = new Dictionary<string, HttpResult>();
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
{
RecordedRequests.Add(spec);
HttpResult result = Handler?.Invoke(spec);
if (result == null && spec?.Url != null)
{
foreach (var kv in ByUrlSubstring)
{
if (spec.Url.IndexOf(kv.Key, StringComparison.Ordinal) >= 0)
{
result = kv.Value;
break;
}
}
}
if (result == null)
{
result = new HttpResult
{
Status = 500,
IsSuccess = false,
Text = "FakeHttpTransport: no canned response matched " + (spec?.Url ?? "<null>")
};
}
return Task.FromResult(result);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46a96ea76bac45cab5025a37f56ba671
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Transport-agnostic description of a single HTTP request. Provider adapters build one
/// of these and hand it to an <see cref="IHttpTransport"/>; this keeps adapters free of
/// any direct dependency on UnityWebRequest so they can be unit-tested without a network.
/// </summary>
public sealed class HttpRequestSpec
{
public string Method;
public string Url;
public Dictionary<string, string> Headers = new Dictionary<string, string>();
public byte[] Body;
public string ContentType;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82093efd40c44dc3bd83e481484ef827
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Transport-agnostic result of an <see cref="HttpRequestSpec"/>. <see cref="Status"/> is
/// the numeric HTTP status code; <see cref="IsSuccess"/> reflects the transport's own view
/// of success (e.g. UnityWebRequest.Result.Success), which adapters combine with their own
/// body-level checks.
/// </summary>
public sealed class HttpResult
{
public int Status;
public byte[] Body;
public string Text;
public bool IsSuccess;
/// <summary>True when the transport reports success or the status code is 2xx.</summary>
public bool Ok => IsSuccess || (Status >= 200 && Status < 300);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 507ef9b8ac9d4f42be51a3bc9203eeff
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// The HTTP seam that provider adapters depend on. Production uses
/// <see cref="UnityWebRequestTransport"/>; tests inject <see cref="FakeHttpTransport"/> so
/// adapter request/response shaping can be verified without touching the network.
/// </summary>
public interface IHttpTransport
{
Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 746824fdb60a436b880f22fee8cdb177
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine.Networking;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Production <see cref="IHttpTransport"/> backed by UnityWebRequest. Must be invoked on the
/// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is
/// awaited via a <see cref="TaskCompletionSource{T}"/> wired to the async op's completed
/// callback, so the call never blocks the editor loop.
/// </summary>
public sealed class UnityWebRequestTransport : IHttpTransport
{
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
{
if (spec == null) throw new ArgumentNullException(nameof(spec));
var tcs = new TaskCompletionSource<HttpResult>();
var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET)
{
downloadHandler = new DownloadHandlerBuffer()
};
if (spec.Body != null)
{
request.uploadHandler = new UploadHandlerRaw(spec.Body);
}
if (!string.IsNullOrEmpty(spec.ContentType))
{
request.SetRequestHeader("Content-Type", spec.ContentType);
}
if (spec.Headers != null)
{
foreach (var kv in spec.Headers)
{
request.SetRequestHeader(kv.Key, kv.Value);
}
}
CancellationTokenRegistration ctReg = default;
if (ct.CanBeCanceled)
{
ctReg = ct.Register(() =>
{
try { request.Abort(); } catch { /* ignore */ }
tcs.TrySetCanceled();
});
}
var op = request.SendWebRequest();
op.completed += _ =>
{
try
{
var result = new HttpResult
{
Status = (int)request.responseCode,
Body = request.downloadHandler?.data,
Text = request.downloadHandler?.text,
IsSuccess = request.result == UnityWebRequest.Result.Success
};
tcs.TrySetResult(result);
}
catch (Exception e)
{
tcs.TrySetException(e);
}
finally
{
ctReg.Dispose();
request.Dispose();
}
};
return tcs.Task;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 333575db1d8f487a9f2d32fa78dbb009
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8915ae71731f43f1a1c03cc6f901a39d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using UnityEditor;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Imports a generated 2D image (PNG, already under Assets/) and applies TextureImporter
/// settings: Sprite vs Default, alpha-is-transparency, and sRGB (color) vs linear (data maps).
/// </summary>
public static class ImageImportPipeline
{
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool asSprite, bool transparent, bool isColor)
{
if (job == null) return null;
try
{
if (string.IsNullOrEmpty(localFilePath))
return Fail(job, "No file to import.");
if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel))
return Fail(job, "Generated file is not under the Assets folder.");
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
if (AssetImporter.GetAtPath(rel) is TextureImporter importer)
{
importer.textureType = asSprite ? TextureImporterType.Sprite : TextureImporterType.Default;
importer.alphaIsTransparency = transparent;
importer.sRGBTexture = isColor; // color maps sRGB; normal/roughness/metallic would be linear
if (asSprite)
{
importer.spriteImportMode = SpriteImportMode.Single;
importer.mipmapEnabled = false;
}
importer.SaveAndReimport();
}
job.AssetPath = rel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the image but Unity did not register it as an asset.");
if (job.State != AssetGenJobState.Failed)
job.State = AssetGenJobState.Done;
return job;
}
catch (Exception e)
{
return Fail(job, SecretRedactor.Scrub(e.Message));
}
}
private static AssetGenJob Fail(AssetGenJob job, string message)
{
job.State = AssetGenJobState.Failed;
job.Error = message;
return job;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5088530e3bc348e1b6539464588bc8bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Imports a downloaded model file (already under Assets/) into the project and applies
/// import settings. GLB/glTF require the optional glTFast package (installed from the
/// Dependencies tab); FBX/OBJ use Unity's built-in ModelImporter. Optionally normalizes
/// the model's scale to a target size.
/// </summary>
public static class ModelImportPipeline
{
// Inert asset types permitted out of an UNTRUSTED provider archive (Sketchfab et al.).
// Anything else — scripts, assemblies, asmdefs — is skipped on extraction so it can never
// compile or load inside the Editor. See SafeZipExtractor for the enforcement.
private static readonly HashSet<string> ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl",
".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr",
".ktx", ".ktx2", ".basis", ".dds",
};
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath)
{
if (job == null) return null;
try
{
if (string.IsNullOrEmpty(localFilePath))
return Fail(job, "No file to import.");
if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel))
return Fail(job, "Generated file is not under the Assets folder.");
string ext = Path.GetExtension(rel).ToLowerInvariant();
if (ext == ".zip")
return ImportArchive(job, rel);
return ImportModelFile(job, rel, ext);
}
catch (Exception e)
{
return Fail(job, SecretRedactor.Scrub(e.Message));
}
}
private static AssetGenJob ImportModelFile(AssetGenJob job, string rel, string ext)
{
bool isGltf = ext == ".glb" || ext == ".gltf";
if (isGltf && !IsGltfastAvailable())
{
return Fail(job,
"GLB import requires glTFast. Install it from the MCP for Unity → Dependencies tab, or choose FBX output.");
}
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
if (!isGltf)
ApplyModelImporterSettings(rel, job);
job.AssetPath = rel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the file but Unity did not register it as an asset.");
if (job.State != AssetGenJobState.Failed)
job.State = AssetGenJobState.Done;
return job;
}
/// <summary>
/// Unpack a downloaded archive (Sketchfab ships .zip) into a sibling folder named
/// after the archive, import it, then locate the first model file inside and import that.
/// FBX/OBJ are preferred over glTF; a glTF-only archive still requires glTFast.
/// </summary>
private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel)
{
string zipAbs = AssetGenPaths.ToAbsolute(zipRel);
if (!File.Exists(zipAbs))
return Fail(job, "Downloaded archive was not found on disk.");
string folderRel = zipRel.Substring(0, zipRel.Length - ".zip".Length);
string folderAbs = AssetGenPaths.ToAbsolute(folderRel);
Directory.CreateDirectory(folderAbs);
// Provider archives are untrusted: only inert model/texture files are written under
// Assets/ — scripts/assemblies are skipped so they can't be compiled on import.
SafeZipExtractor.ExtractTo(zipAbs, folderAbs, ArchiveAllowedExtensions);
AssetDatabase.Refresh();
AssetDatabase.ImportAsset(folderRel, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate);
string modelRel = FindFirstModel(folderAbs);
if (string.IsNullOrEmpty(modelRel))
return Fail(job, "Archive extracted but no model file (.fbx/.obj/.glb/.gltf) was found inside.");
string ext = Path.GetExtension(modelRel).ToLowerInvariant();
bool isGltf = ext == ".glb" || ext == ".gltf";
if (isGltf && !IsGltfastAvailable())
{
return Fail(job,
"This model is glTF (.glb/.gltf), which requires glTFast. Install it from the MCP for Unity → Dependencies tab.");
}
AssetDatabase.ImportAsset(modelRel, ImportAssetOptions.ForceUpdate);
if (!isGltf)
ApplyModelImporterSettings(modelRel, job);
job.AssetPath = modelRel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(modelRel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the extracted model but Unity did not register it as an asset.");
if (job.State != AssetGenJobState.Failed)
job.State = AssetGenJobState.Done;
return job;
}
/// <summary>
/// Walk the extracted directory for a model file, preferring FBX/OBJ (built-in importer)
/// over glTF (needs glTFast). Returns a project-relative path or null when none found.
/// </summary>
private static string FindFirstModel(string folderAbs)
{
string[] all;
try { all = Directory.GetFiles(folderAbs, "*", SearchOption.AllDirectories); }
catch { return null; }
string firstGltf = null;
foreach (string abs in all)
{
string e = Path.GetExtension(abs).ToLowerInvariant();
if (e == ".fbx" || e == ".obj")
return AssetGenPaths.ToProjectRelative(abs);
if (firstGltf == null && (e == ".glb" || e == ".gltf"))
firstGltf = abs;
}
return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf);
}
private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
{
if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return;
importer.useFileScale = true;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard;
importer.animationType = ParseAnimationType(job?.AnimationType);
if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f)
{
float maxDim = ComputeMaxDimension(rel);
if (maxDim > 0.0001f)
{
float scale = job.TargetSize / maxDim;
if (scale > 0f && Math.Abs(scale - 1f) > 0.01f)
{
importer.useFileScale = false;
importer.globalScale = Mathf.Clamp(importer.globalScale * scale, 0.0001f, 1_000_000f);
}
}
}
importer.SaveAndReimport();
}
/// <summary>
/// Map the caller's rig mode to a <see cref="ModelImporterAnimationType"/>. Defaults to
/// <c>None</c> (no rig) when unset — pass "generic"/"humanoid" to surface a rigged FBX/OBJ's
/// AnimationClips, which the None default otherwise hides. "legacy" selects Unity's
/// legacy Animation system — rarely needed.
/// </summary>
internal static ModelImporterAnimationType ParseAnimationType(string value)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "generic": return ModelImporterAnimationType.Generic;
case "humanoid":
case "human": return ModelImporterAnimationType.Human;
case "legacy": return ModelImporterAnimationType.Legacy;
default: return ModelImporterAnimationType.None;
}
}
private static float ComputeMaxDimension(string rel)
{
try
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(rel);
if (go == null) return 0f;
bool any = false;
Bounds acc = new Bounds(Vector3.zero, Vector3.zero);
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
{
if (mf.sharedMesh == null) continue;
if (!any) { acc = mf.sharedMesh.bounds; any = true; }
else acc.Encapsulate(mf.sharedMesh.bounds);
}
foreach (var smr in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.sharedMesh == null) continue;
if (!any) { acc = smr.sharedMesh.bounds; any = true; }
else acc.Encapsulate(smr.sharedMesh.bounds);
}
if (!any) return 0f;
Vector3 s = acc.size;
return Mathf.Max(s.x, Mathf.Max(s.y, s.z));
}
catch { return 0f; }
}
private static bool? _gltfastAvailable;
/// <summary>
/// True when the glTFast package is present. Cached after the first probe — the result only
/// changes on a package install/uninstall, which triggers a domain reload that resets this
/// static. Shared with the Asset Gen settings tab so the reflection scan runs at most once.
/// </summary>
internal static bool IsGltfastAvailable()
{
if (_gltfastAvailable.HasValue) return _gltfastAvailable.Value;
bool found = Type.GetType("GLTFast.GltfImport, glTFast") != null;
if (!found)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try { if (asm.GetType("GLTFast.GltfImport") != null) { found = true; break; } }
catch { /* dynamic/!resolvable assembly */ }
}
}
_gltfastAvailable = found;
return found;
}
private static AssetGenJob Fail(AssetGenJob job, string message)
{
job.State = AssetGenJobState.Failed;
job.Error = message;
return job;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d63a324540d4a4a904e8b345ccf81bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Extracts a .zip into a destination directory while rejecting Zip-Slip path traversal:
/// every entry's resolved target must stay inside <c>destDir</c>. Directory entries are
/// created; file entries are written by copying the entry stream (no reliance on the
/// ZipFileExtensions helper). Used to unpack marketplace model archives (e.g. Sketchfab).
///
/// When <paramref name="allowedExtensions"/> is supplied, file entries whose extension is not
/// on the allowlist are SKIPPED (not written). Callers that extract UNTRUSTED archives into the
/// Assets tree MUST pass an allowlist of inert asset types so executable content (.cs/.dll/
/// .asmdef) can never land under Assets/ and be compiled/loaded by the Editor.
/// </summary>
public static class SafeZipExtractor
{
public static void ExtractTo(string zipPath, string destDir, ISet<string> allowedExtensions = null)
{
if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath));
if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir));
Directory.CreateDirectory(destDir);
string destFull = Path.GetFullPath(destDir);
string prefix = destFull.EndsWith(Path.DirectorySeparatorChar.ToString())
? destFull
: destFull + Path.DirectorySeparatorChar;
using (FileStream fs = File.OpenRead(zipPath))
using (var archive = new ZipArchive(fs, ZipArchiveMode.Read))
{
foreach (ZipArchiveEntry entry in archive.Entries)
{
string name = entry.FullName;
if (string.IsNullOrEmpty(name)) continue;
// Reject traversal / absolute paths up front.
if (name.Contains("..") || Path.IsPathRooted(name))
throw new IOException($"Unsafe zip entry rejected: {name}");
string target = Path.GetFullPath(Path.Combine(destDir, name));
if (!target.StartsWith(prefix, StringComparison.Ordinal))
throw new IOException($"Unsafe zip entry escapes destination: {name}");
// A directory entry has an empty Name (FullName ends with a separator).
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(target);
continue;
}
// Allowlist gate: skip anything that isn't an inert asset type the caller permits.
if (allowedExtensions != null && allowedExtensions.Count > 0
&& !allowedExtensions.Contains(Path.GetExtension(entry.Name).ToLowerInvariant()))
{
continue;
}
string parent = Path.GetDirectoryName(target);
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
using (Stream src = entry.Open())
using (FileStream dst = File.Create(target))
{
src.CopyTo(dst);
}
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2dcd4c25a7b44e96b22ea605ffd73409
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3b451683dd204e6e9d28fd7dbe4676a6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Security;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// 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
/// <see cref="NotSupportedException"/>. <see cref="List"/> advertises providers and reports
/// <c>Configured</c> existence only — never a key value.
/// </summary>
public static class AssetGenProviders
{
public static IModelProviderAdapter Model(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "tripo":
return new TripoAdapter();
case "meshy":
return new MeshyAdapter();
default:
throw new NotSupportedException($"Unknown model provider '{id}'.");
}
}
public static IImageProviderAdapter Image(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "fal":
return new FalAdapter();
case "openrouter":
return new OpenRouterAdapter();
default:
throw new NotSupportedException($"Unknown image provider '{id}'.");
}
}
public static IMarketplaceProviderAdapter Marketplace(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "sketchfab":
return new SketchfabAdapter();
default:
throw new NotSupportedException($"Unknown marketplace provider '{id}'.");
}
}
public static IReadOnlyList<ProviderInfo> List()
{
return new List<ProviderInfo>
{
new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } },
new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } },
new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } },
new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } },
new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } },
};
}
private static bool IsConfigured(string id)
{
try { return SecureKeyStore.Current.Has(id); }
catch { return false; }
}
/// <summary>
/// Standard "no key" message: points the user at the Asset Generation tab and the env override.
/// Shared by the asset-gen tools and the job manager so the wording stays in one place.
/// </summary>
public static string MissingKeyMessage(string provider)
=> $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY).";
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ab11255ce314c7193df3bca43286f83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,162 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header
/// "Authorization: Key &lt;key&gt;"), polls the request's status_url, then fetches the result and
/// returns the first image URL for the job manager to download.
/// </summary>
public sealed class FalAdapter : IImageProviderAdapter
{
private const string QueueBase = "https://queue.fal.run/";
// FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives:
// fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality).
private const string DefaultModel = "fal-ai/flux-2";
public string Id => "fal";
public async Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 };
string url;
if (image)
{
// image→image / editing lives on the model's /edit endpoint and takes an image_urls
// array; each entry accepts a hosted URL or an inline base64 data URI (local image_path).
url = QueueBase + model + "/edit";
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body["image_urls"] = new JArray(imageRef);
}
else
{
url = QueueBase + model;
}
// Forward explicit output dimensions for text→image only; fal's image_size accepts a
// {width,height} object. (/edit derives size from the source image and may reject it.
// FLUX has no transparency param — transparent backgrounds aren't a generation-time option.)
if (!image && req.Width > 0 && req.Height > 0)
body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height };
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Key " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "submit");
// Prefer response_url; fall back to building it from request_id.
string responseUrl = json["response_url"]?.ToString();
if (string.IsNullOrEmpty(responseUrl))
{
string requestId = json["request_id"]?.ToString();
if (string.IsNullOrEmpty(requestId))
throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
// Queue request URLs are namespaced by owner/app without the action sub-path,
// so build from the base model id (not `url`, which may end in /edit).
responseUrl = QueueBase + model + "/requests/" + requestId;
}
return responseUrl;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
string responseUrl = providerJobId;
var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" };
statusSpec.Headers["Authorization"] = "Key " + apiKey;
HttpResult statusRes = await http.SendAsync(statusSpec, ct);
JObject statusJson = ParseOk(statusRes, apiKey, "status");
string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant();
var result = new ProviderPollResult();
switch (status)
{
case "COMPLETED":
case "OK":
result.State = ProviderPollState.Succeeded;
break;
case "IN_PROGRESS":
result.State = ProviderPollState.Running;
return result;
case "IN_QUEUE":
result.State = ProviderPollState.Queued;
return result;
case "ERROR":
case "FAILED":
result.State = ProviderPollState.Failed;
result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey);
return result;
default:
result.State = ProviderPollState.Running;
return result;
}
// Completed: fetch the result payload and extract the first image URL.
var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl };
resultSpec.Headers["Authorization"] = "Key " + apiKey;
HttpResult resultRes = await http.SendAsync(resultSpec, ct);
JObject resultJson = ParseOk(resultRes, apiKey, "result");
result.Progress = 1f;
result.DownloadUrl = ExtractImageUrl(resultJson);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "fal completed but no image URL was present in the result.";
}
return result;
}
private static string ExtractImageUrl(JObject result)
{
JToken images = result["images"];
if (images is JArray arr && arr.Count > 0)
{
string u = arr[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
string single = result["image"]?["url"]?.ToString();
return string.IsNullOrEmpty(single) ? null : single;
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a36ad2c4285c4799ae9eb4495813a44c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side
/// job id; poll reports progress and, on success, the download URL. The api key is passed in
/// at call time and never cached on the adapter.
/// </summary>
public interface IModelProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A generative 2D image provider (fal, OpenRouter, ...). Phase 7.</summary>
public interface IImageProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6.</summary>
public interface IMarketplaceProviderAdapter
{
string Id { get; }
Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f76130e504cd4423abbe82bbb718d25b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode
/// it as a base64 <c>data:</c> URI — the inline form fal, Meshy, and OpenRouter accept for image
/// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately.
/// </summary>
internal static class LocalImage
{
// Extensions that can be inlined as a data URI for provider image input.
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".png", ".jpg", ".jpeg", ".webp", ".gif" };
/// <summary>
/// Resolve an image path under the project's Assets folder to an existing absolute file of
/// a supported image type. Returns false with a user-facing <paramref name="error"/> for
/// an unsafe path, a missing file, or an unsupported extension, so the handler can fail
/// fast before any provider request is made.
/// </summary>
public static bool ResolveExisting(string path, out string absPath, out string error)
{
absPath = null;
error = null;
if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; }
if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel))
{
error = "image_path must point to a file under the project's Assets folder.";
return false;
}
string abs = AssetGenPaths.ToAbsolute(rel);
if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; }
if (!SupportedExtensions.Contains(Path.GetExtension(abs)))
{
error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif.";
return false;
}
absPath = abs;
return true;
}
/// <summary>
/// Read a local image and return a "data:image/&lt;mime&gt;;base64,..." URI. Throws
/// <see cref="NotSupportedException"/> for an unsupported extension.
/// </summary>
public static string ToDataUri(string absPath)
{
if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel))
throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder.");
absPath = AssetGenPaths.ToAbsolute(rel);
string mime = MimeFromExtension(Path.GetExtension(absPath));
byte[] bytes = File.ReadAllBytes(absPath);
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
}
private static string MimeFromExtension(string ext)
{
switch ((ext ?? string.Empty).ToLowerInvariant())
{
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".gif": return "image/gif";
default:
throw new NotSupportedException(
$"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f8290c406df4661b9ad2ff6cd324871
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,208 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry
/// only); when textures are requested it then issues a "refine" task and surfaces the textured
/// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each
/// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and
/// never logged; every error is run through <see cref="SecretRedactor"/>.
/// </summary>
public sealed class MeshyAdapter : IModelProviderAdapter
{
private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d";
private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d";
public string Id => "meshy";
// Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow.
private string _format = "glb";
private bool _isImage;
private bool _wantTexture = true;
private string _refineTaskId;
private bool _refineSubmitted;
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
_wantTexture = req.Texture;
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
JObject body;
string url;
if (_isImage)
{
// image→3D textures in a single call (no separate refine task). image_url accepts a
// hosted URL or an inline base64 data URI (for a local image_path).
url = ImageEndpoint;
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body = new JObject
{
["image_url"] = imageRef,
["ai_model"] = "meshy-6",
["should_texture"] = _wantTexture
};
}
else
{
// text→3D preview is geometry only; texturing happens via a follow-up refine task.
url = TextEndpoint;
body = new JObject
{
["mode"] = "preview",
["prompt"] = req.Prompt ?? string.Empty,
["ai_model"] = "meshy-6"
};
}
return await PostTask(url, body, apiKey, http, ct, "submit");
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
bool refinePhase = _refineSubmitted;
string pollId = refinePhase ? _refineTaskId : providerJobId;
// image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d.
string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint;
var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId };
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "poll");
ProviderPollState state = MapState(json["status"]?.ToString());
var result = new ProviderPollResult { State = state };
// Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1).
bool twoPhase = !_isImage && _wantTexture;
float raw = 0f;
JToken prog = json["progress"];
if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value<float>() / 100f);
result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f);
if (state == ProviderPollState.Succeeded)
{
// Preview just finished and textures were requested: start the refine task and keep
// polling it; never surface the untextured preview result.
if (twoPhase && !refinePhase)
{
var refineBody = new JObject
{
["mode"] = "refine",
["preview_task_id"] = providerJobId,
["ai_model"] = "meshy-6"
};
_refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine");
_refineSubmitted = true;
result.State = ProviderPollState.Running;
result.Progress = 0.5f;
return result;
}
result.Progress = 1f;
result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Meshy reported success but no model URL was present in the response.";
}
}
else if (state == ProviderPollState.Failed)
{
string err = json["task_error"]?["message"]?.ToString()
?? json["message"]?.ToString()
?? "Meshy task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
/// <summary>POST a task body and return its <c>result</c> task id (or null).</summary>
private static async Task<string> PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase)
{
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, phase);
string id = json["result"]?.ToString();
if (string.IsNullOrEmpty(id))
throw new Exception(SecretRedactor.Scrub(
$"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey));
return id;
}
private string ExtractModelUrl(JObject urls)
{
if (urls == null) return null;
string byFormat = urls[_format]?.ToString();
if (!string.IsNullOrEmpty(byFormat)) return byFormat;
string glb = urls["glb"]?.ToString();
if (!string.IsNullOrEmpty(glb)) return glb;
string fbx = urls["fbx"]?.ToString();
return string.IsNullOrEmpty(fbx) ? null : fbx;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToUpperInvariant())
{
case "SUCCEEDED":
return ProviderPollState.Succeeded;
case "FAILED":
case "EXPIRED":
case "CANCELED":
case "CANCELLED":
return ProviderPollState.Failed;
case "IN_PROGRESS":
return ProviderPollState.Running;
case "PENDING":
default:
return ProviderPollState.Queued;
}
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb6ba98874ed46cca5f019c89b2edb0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// OpenRouter image provider via the (synchronous) chat-completions endpoint with an
/// image-capable multimodal model. The image is returned inline (base64 data URL), so the
/// work happens in <see cref="SubmitAsync"/> and <see cref="PollAsync"/> returns it immediately.
/// One adapter instance handles a single job (the job manager captures it for submit+poll).
/// </summary>
public sealed class OpenRouterAdapter : IImageProviderAdapter
{
private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
private const string DefaultModel = "google/gemini-2.5-flash-image";
public string Id => "openrouter";
private byte[] _inlineData;
private string _downloadUrl;
private string _error;
public async Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
// image->image: attach the reference image as an image_url content part alongside the
// text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a
// base64 data URI. Plain text->image uses a string content.
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
// image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path).
string imageRef = image
? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath))
: null;
JToken content = image
? new JArray(
new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty },
new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } })
: (JToken)(req.Prompt ?? string.Empty);
var body = new JObject
{
["model"] = model,
["modalities"] = new JArray("image", "text"),
["messages"] = new JArray(new JObject
{
["role"] = "user",
["content"] = content
})
};
var spec = new HttpRequestSpec
{
Method = "POST",
Url = Endpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey);
string url = ExtractImageUrl(json);
if (string.IsNullOrEmpty(url))
{
_error = "OpenRouter returned no image. The selected model may not support image output.";
return "ready";
}
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; }
try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); }
catch { _error = "OpenRouter image was not valid base64."; }
}
else
{
_downloadUrl = url;
}
return "ready";
}
public Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
var result = new ProviderPollResult { Progress = 1f };
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
{
result.State = ProviderPollState.Failed;
result.Error = _error ?? "OpenRouter produced no image.";
}
else
{
result.State = ProviderPollState.Succeeded;
result.InlineData = _inlineData;
result.DownloadUrl = _downloadUrl;
}
return Task.FromResult(result);
}
private static string ExtractImageUrl(JObject json)
{
JToken message = json["choices"]?[0]?["message"];
if (message == null) return null;
// Preferred: message.images[].image_url.url
if (message["images"] is JArray imgs && imgs.Count > 0)
{
string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
// Fallback: a content array with image_url parts
if (message["content"] is JArray parts)
{
foreach (JToken part in parts)
{
string u = part?["image_url"]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
}
return null;
}
private static JObject ParseOk(HttpResult res, string apiKey)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 55b1b120b06948f6aeada0381b51ce86
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Shared HTTP-response helpers for provider adapters: read the response text (falling back to
/// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages.
/// </summary>
internal static class ProviderHttp
{
/// <summary>Response text, falling back to a UTF-8 decode of the raw body when Text is empty.</summary>
public static string BodyText(HttpResult res)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null)
text = Encoding.UTF8.GetString(res.Body);
return text;
}
/// <summary>Cap a (possibly null) string at 500 chars for inclusion in an error message.</summary>
public static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1584e5cffb5b45e6816fec509f1bfc8b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>Normalized lifecycle state reported by a provider poll, across all providers.</summary>
public enum ProviderPollState
{
Queued,
Running,
Succeeded,
Failed
}
/// <summary>
/// Outcome of a single provider poll. <see cref="Progress"/> is normalized to 0..1.
/// On <see cref="ProviderPollState.Succeeded"/>, <see cref="DownloadUrl"/> points at the
/// result the C# side will download into the project. On
/// <see cref="ProviderPollState.Failed"/>, <see cref="Error"/> carries a redacted message.
/// </summary>
public sealed class ProviderPollResult
{
public ProviderPollState State;
public float Progress;
public string DownloadUrl;
/// <summary>Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter),
/// so the job manager skips the download step. Takes precedence over <see cref="DownloadUrl"/>.</summary>
public byte[] InlineData;
/// <summary>Overrides the downloaded file extension, e.g. "zip" for archive results.</summary>
public string ResultExt;
public string Error;
}
/// <summary>Request to generate a 3D model. Shared by every model provider adapter.</summary>
public sealed class ModelGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Format = "glb";
public float TargetSize = 1f;
public bool Texture = true;
public string Tier;
public string Name;
public string OutputFolder;
}
/// <summary>Request to generate a 2D image. Shared by every image provider adapter.</summary>
public sealed class ImageGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Model;
public bool Transparent;
public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture
public int Width;
public int Height;
public string Name;
public string OutputFolder;
}
/// <summary>
/// Public, key-free description of a provider for <c>list_providers</c>. Never carries a key
/// value — <see cref="Configured"/> reports existence only.
/// </summary>
public sealed class ProviderInfo
{
public string Id;
public string Kind; // model | image | marketplace
public bool Configured;
public string[] Capabilities;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65be6fd6b6384effb45822a57e541f04
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,96 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API
/// JSON to the caller; <see cref="ResolveDownloadUrlAsync"/> hits the model download endpoint
/// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the
/// "Token &lt;key&gt;" scheme; the key is supplied per call and never logged.
/// </summary>
public sealed class SketchfabAdapter : IMarketplaceProviderAdapter
{
private const string SearchEndpoint = "https://api.sketchfab.com/v3/search";
private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models";
public string Id => "sketchfab";
public async Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (http == null) throw new ArgumentNullException(nameof(http));
string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false")
+ "&q=" + Uri.EscapeDataString(query ?? string.Empty);
if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories);
if (count.HasValue) url += "&count=" + count.Value;
if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor);
var spec = new HttpRequestSpec { Method = "GET", Url = url };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
// The raw response carries pagination (`cursors.next` / `next`) for the caller to page.
return RawOk(res, apiKey, "search");
}
public async Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
return RawOk(res, apiKey, "preview");
}
public async Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "download");
string url = json["gltf"]?["url"]?.ToString();
if (string.IsNullOrEmpty(url))
{
throw new Exception(SecretRedactor.Scrub(
$"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey));
}
return url;
}
private static string RawOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
bool ok = res?.Ok == true;
if (!ok)
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey));
return text ?? string.Empty;
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 860350c25d8d490aaaa925d76ce29407
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,222 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and
/// polls for completion. The bearer key is supplied per call and never logged; every error
/// message is run through <see cref="SecretRedactor"/> before it is surfaced.
/// </summary>
public sealed class TripoAdapter : IModelProviderAdapter
{
private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task";
// Current recommended Tripo model (v3.1). Premium alternative: P1-20260311.
private const string ModelVersion = "v3.1-20260211";
public string Id => "tripo";
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
// Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files,
// which isn't wired yet — fail clearly rather than silently falling back to text mode.
bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase);
if (imageMode && string.IsNullOrEmpty(req.ImageUrl))
throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image).");
JObject body;
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
if (image)
{
body = new JObject
{
["type"] = "image_to_model",
["file"] = new JObject
{
["type"] = "url",
["url"] = req.ImageUrl
}
};
}
else
{
body = new JObject
{
["type"] = "text_to_model",
["prompt"] = req.Prompt ?? string.Empty,
["model_version"] = ModelVersion
};
}
var spec = new HttpRequestSpec
{
Method = "POST",
Url = TaskEndpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "submit");
string taskId = json["data"]?["task_id"]?.ToString();
if (string.IsNullOrEmpty(taskId))
{
throw new Exception(SecretRedactor.Scrub(
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
}
return taskId;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec
{
Method = "GET",
Url = TaskEndpoint + "/" + providerJobId
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "poll");
JObject data = json["data"] as JObject ?? new JObject();
var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) };
JToken progressTok = data["progress"];
if (progressTok != null && progressTok.Type != JTokenType.Null)
{
result.Progress = Mathf.Clamp01(progressTok.Value<float>() / 100f);
}
if (result.State == ProviderPollState.Succeeded)
{
result.Progress = 1f;
result.DownloadUrl = ExtractDownloadUrl(data);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Tripo reported success but no model URL was present in the response.";
}
}
else if (result.State == ProviderPollState.Failed)
{
string err = data["error"]?.ToString()
?? data["message"]?.ToString()
?? json["message"]?.ToString()
?? "Tripo task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToLowerInvariant())
{
case "success":
case "succeeded":
return ProviderPollState.Succeeded;
case "failed":
case "error":
case "cancelled":
case "canceled":
case "banned":
case "expired":
return ProviderPollState.Failed;
case "running":
case "processing":
return ProviderPollState.Running;
default:
return ProviderPollState.Queued;
}
}
/// <summary>
/// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a
/// textured / PBR model; fall back to the base model. Accepts both the newer flat form
/// (<c>output.pbr_model</c> = url string) and the older nested form
/// (<c>result.pbr_model.url</c> = object with a url field).
/// </summary>
private static string ExtractDownloadUrl(JObject data)
{
JObject output = data["output"] as JObject;
JObject resultObj = data["result"] as JObject;
return UrlOf(output?["pbr_model"])
?? UrlOf(resultObj?["pbr_model"])
?? UrlOf(output?["model"])
?? UrlOf(resultObj?["model"])
?? UrlOf(output?["base_model"])
?? UrlOf(resultObj?["base_model"]);
}
/// <summary>A field may be a plain URL string or an object carrying a "url" property.</summary>
private static string UrlOf(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token.Type == JTokenType.String)
{
string s = token.ToString();
return string.IsNullOrEmpty(s) ? null : s;
}
if (token is JObject obj)
{
string u = obj["url"]?.ToString();
return string.IsNullOrEmpty(u) ? null : u;
}
return null;
}
/// <summary>
/// Validate transport success + Tripo's body-level <c>code</c> (0 == ok), parse the JSON,
/// and throw a redacted exception otherwise.
/// </summary>
private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); }
catch { /* non-JSON body; handled below */ }
}
bool httpOk = res?.Ok == true;
int code = 0;
JToken codeTok = json?["code"];
if (codeTok != null && codeTok.Type != JTokenType.Null)
{
try { code = codeTok.Value<int>(); } catch { code = -1; }
}
if (!httpOk || code != 0)
{
string detail = json?["message"]?.ToString()
?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a0761a0b205e4041950b0ab29a3d6466
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,157 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
/// </summary>
public class BridgeControlService : IBridgeControlService
{
private readonly TransportManager _transportManager;
private TransportMode _preferredMode = TransportMode.Http;
public BridgeControlService()
{
_transportManager = MCPServiceLocator.TransportManager;
}
private TransportMode ResolvePreferredMode()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
_preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio;
return _preferredMode;
}
private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null)
{
bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true);
string transportLabel = string.IsNullOrWhiteSpace(state.TransportName)
? mode.ToString().ToLowerInvariant()
: state.TransportName;
string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]";
string message = messageOverride
?? state.Error
?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}");
return new BridgeVerificationResult
{
Success = pingSucceeded && handshakeValid,
HandshakeValid = handshakeValid,
PingSucceeded = pingSucceeded,
Message = message
};
}
public bool IsRunning
{
get
{
var mode = ResolvePreferredMode();
return _transportManager.IsRunning(mode);
}
}
public int CurrentPort
{
get
{
var mode = ResolvePreferredMode();
var state = _transportManager.GetState(mode);
if (state.Port.HasValue)
{
return state.Port.Value;
}
// Legacy fallback while the stdio bridge is still in play
return StdioBridgeHost.GetCurrentPort();
}
}
public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode();
public TransportMode? ActiveMode => ResolvePreferredMode();
public async Task<bool> StartAsync()
{
var mode = ResolvePreferredMode();
try
{
// Treat transports as mutually exclusive for user-driven session starts:
// stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP).
var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http;
try
{
await _transportManager.StopAsync(otherMode);
}
catch (Exception ex)
{
McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}");
}
// Legacy safety: stdio may have been started outside TransportManager state.
if (otherMode == TransportMode.Stdio)
{
try { StdioBridgeHost.Stop(); } catch { }
}
bool started = await _transportManager.StartAsync(mode);
if (!started)
{
McpLog.Warn($"Failed to start MCP transport: {mode}");
}
return started;
}
catch (Exception ex)
{
McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}");
return false;
}
}
public async Task StopAsync()
{
try
{
var mode = ResolvePreferredMode();
await _transportManager.StopAsync(mode);
}
catch (Exception ex)
{
McpLog.Warn($"Error stopping MCP transport: {ex.Message}");
}
}
public async Task<BridgeVerificationResult> VerifyAsync()
{
var mode = ResolvePreferredMode();
bool pingSucceeded = await _transportManager.VerifyAsync(mode);
var state = _transportManager.GetState(mode);
return BuildVerificationResult(state, mode, pingSucceeded);
}
public BridgeVerificationResult Verify(int port)
{
var mode = ResolvePreferredMode();
bool pingSucceeded = _transportManager.VerifyAsync(mode).GetAwaiter().GetResult();
var state = _transportManager.GetState(mode);
if (mode == TransportMode.Stdio)
{
bool handshakeValid = state.IsConnected && port == CurrentPort;
string message = handshakeValid
? $"STDIO transport listening on port {CurrentPort}"
: $"STDIO transport port mismatch (expected {CurrentPort}, got {port})";
return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid);
}
return BuildVerificationResult(state, mode, pingSucceeded);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ed4f9f69d84a945248dafc0f0b5a62dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Implementation of client configuration service
/// </summary>
public class ClientConfigurationService : IClientConfigurationService
{
private readonly List<IMcpClientConfigurator> configurators;
public ClientConfigurationService()
{
configurators = McpClientRegistry.All.ToList();
}
public IReadOnlyList<IMcpClientConfigurator> GetAllClients() => configurators;
public void ConfigureClient(IMcpClientConfigurator configurator)
{
// When using a local server path, clean stale build artifacts first.
// This prevents old deleted .py files from being picked up by Python's auto-discovery.
if (AssetPathUtility.IsLocalServerPath())
{
AssetPathUtility.CleanLocalServerBuildArtifacts();
}
ConfigureWithTransportCoercion(configurator);
}
public ClientConfigurationSummary ConfigureAllDetectedClients()
{
// When using a local server path, clean stale build artifacts once before configuring all clients.
if (AssetPathUtility.IsLocalServerPath())
{
AssetPathUtility.CleanLocalServerBuildArtifacts();
}
var summary = new ClientConfigurationSummary();
foreach (var configurator in configurators)
{
if (!configurator.IsInstalled)
{
summary.SkippedCount++;
continue;
}
try
{
// Always re-run configuration so core fields stay current
configurator.CheckStatus(attemptAutoRewrite: false);
ConfigureWithTransportCoercion(configurator);
summary.SuccessCount++;
summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully");
}
catch (Exception ex)
{
summary.FailureCount++;
summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}");
}
}
return summary;
}
private static void ConfigureWithTransportCoercion(IMcpClientConfigurator configurator)
{
bool originalHttp = EditorConfigurationCache.Instance.UseHttpTransport;
try
{
CoerceTransportFor(configurator);
configurator.Configure();
}
finally
{
EditorConfigurationCache.Instance.SetUseHttpTransport(originalHttp);
}
}
public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true)
{
var previous = configurator.Status;
var current = configurator.CheckStatus(attemptAutoRewrite);
return current != previous;
}
private static void CoerceTransportFor(IMcpClientConfigurator configurator)
{
var supported = configurator.SupportedTransports;
if (supported == null || supported.Count == 0) return;
bool currentlyHttp = EditorConfigurationCache.Instance.UseHttpTransport;
var requested = currentlyHttp ? ConfiguredTransport.Http : ConfiguredTransport.Stdio;
// Accept any HTTP variant (Http, HttpRemote) when the user wants HTTP — a client that
// only supports HttpRemote should not get coerced to stdio just because Http isn't
// explicitly listed.
if (SupportsRequested(supported, requested)) return;
// Fall back in the direction of the user's intent: if they wanted HTTP, prefer any
// HTTP variant the client does support; otherwise prefer stdio. Honors the
// configurator's declared order when more than one option remains.
ConfiguredTransport chosen = PickFallback(supported, requested);
bool needHttp = IsHttpVariant(chosen);
if (EditorConfigurationCache.Instance.UseHttpTransport != needHttp)
{
EditorConfigurationCache.Instance.SetUseHttpTransport(needHttp);
McpLog.Info(
$"[{configurator.DisplayName}] auto-selected {chosen} transport (client does not support {requested}).");
}
}
private static bool IsHttpVariant(ConfiguredTransport t)
=> t == ConfiguredTransport.Http || t == ConfiguredTransport.HttpRemote;
private static bool SupportsRequested(IReadOnlyList<ConfiguredTransport> supported, ConfiguredTransport requested)
{
if (requested == ConfiguredTransport.Http)
{
foreach (var t in supported)
if (IsHttpVariant(t)) return true;
return false;
}
return supported.Contains(requested);
}
private static ConfiguredTransport PickFallback(IReadOnlyList<ConfiguredTransport> supported, ConfiguredTransport requested)
{
if (requested == ConfiguredTransport.Http)
{
foreach (var t in supported)
if (IsHttpVariant(t)) return t;
}
else
{
foreach (var t in supported)
if (t == ConfiguredTransport.Stdio) return t;
}
return supported[0];
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76cad34d10fd24aaa95c4583c1f88fdf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,321 @@
using System;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Centralized cache for frequently-read EditorPrefs values.
/// Reduces scattered EditorPrefs.Get* calls and provides change notification.
///
/// Usage:
/// var config = EditorConfigurationCache.Instance;
/// if (config.UseHttpTransport) { ... }
/// config.OnConfigurationChanged += (key) => { /* refresh UI */ };
/// </summary>
public class EditorConfigurationCache
{
private static EditorConfigurationCache _instance;
private static readonly object _lock = new object();
/// <summary>
/// Singleton instance. Thread-safe lazy initialization.
/// </summary>
public static EditorConfigurationCache Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new EditorConfigurationCache();
}
}
}
return _instance;
}
}
/// <summary>
/// Event fired when any cached configuration value changes.
/// The string parameter is the EditorPrefKeys constant name that changed.
/// </summary>
public event Action<string> OnConfigurationChanged;
// Cached values - most frequently read
private bool _useHttpTransport;
private bool _debugLogs;
private bool _devModeForceServerRefresh;
private string _uvxPathOverride;
private string _gitUrlOverride;
private string _httpBaseUrl;
private string _httpRemoteBaseUrl;
private string _claudeCliPathOverride;
private string _httpTransportScope;
private int _unitySocketPort;
/// <summary>
/// Whether to use HTTP transport (true) or Stdio transport (false).
/// Default: true
/// </summary>
public bool UseHttpTransport => _useHttpTransport;
/// <summary>
/// Whether debug logging is enabled.
/// Default: false
/// </summary>
public bool DebugLogs => _debugLogs;
/// <summary>
/// Whether to force server refresh in dev mode (--no-cache --refresh).
/// Default: false
/// </summary>
public bool DevModeForceServerRefresh => _devModeForceServerRefresh;
/// <summary>
/// Custom path override for uvx executable.
/// Default: empty string (auto-detect)
/// </summary>
public string UvxPathOverride => _uvxPathOverride;
/// <summary>
/// Custom Git URL override for server installation.
/// Default: empty string (use default)
/// </summary>
public string GitUrlOverride => _gitUrlOverride;
/// <summary>
/// HTTP base URL for the local MCP server.
/// Default: empty string
/// </summary>
public string HttpBaseUrl => _httpBaseUrl;
/// <summary>
/// HTTP base URL for the remote-hosted MCP server.
/// Default: empty string
/// </summary>
public string HttpRemoteBaseUrl => _httpRemoteBaseUrl;
/// <summary>
/// Custom path override for Claude CLI executable.
/// Default: empty string (auto-detect)
/// </summary>
public string ClaudeCliPathOverride => _claudeCliPathOverride;
/// <summary>
/// HTTP transport scope: "local" or "remote".
/// Default: empty string
/// </summary>
public string HttpTransportScope => _httpTransportScope;
/// <summary>
/// Unity socket port for Stdio transport.
/// Default: 0 (auto-assign)
/// </summary>
public int UnitySocketPort => _unitySocketPort;
private EditorConfigurationCache()
{
Refresh();
}
/// <summary>
/// Refresh all cached values from EditorPrefs.
/// Call this after bulk EditorPrefs changes or domain reload.
/// </summary>
public void Refresh()
{
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
}
/// <summary>
/// Set UseHttpTransport and update cache + EditorPrefs atomically.
/// </summary>
public void SetUseHttpTransport(bool value)
{
if (_useHttpTransport != value)
{
_useHttpTransport = value;
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value);
OnConfigurationChanged?.Invoke(nameof(UseHttpTransport));
}
}
/// <summary>
/// Set DebugLogs and update cache + EditorPrefs atomically.
/// </summary>
public void SetDebugLogs(bool value)
{
if (_debugLogs != value)
{
_debugLogs = value;
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value);
OnConfigurationChanged?.Invoke(nameof(DebugLogs));
}
}
/// <summary>
/// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically.
/// </summary>
public void SetDevModeForceServerRefresh(bool value)
{
if (_devModeForceServerRefresh != value)
{
_devModeForceServerRefresh = value;
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value);
OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh));
}
}
/// <summary>
/// Set UvxPathOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetUvxPathOverride(string value)
{
value = value ?? string.Empty;
if (_uvxPathOverride != value)
{
_uvxPathOverride = value;
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value);
OnConfigurationChanged?.Invoke(nameof(UvxPathOverride));
}
}
/// <summary>
/// Set GitUrlOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetGitUrlOverride(string value)
{
value = value ?? string.Empty;
if (_gitUrlOverride != value)
{
_gitUrlOverride = value;
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value);
OnConfigurationChanged?.Invoke(nameof(GitUrlOverride));
}
}
/// <summary>
/// Set HttpBaseUrl and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpBaseUrl(string value)
{
value = value ?? string.Empty;
if (_httpBaseUrl != value)
{
_httpBaseUrl = value;
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value);
OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl));
}
}
/// <summary>
/// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpRemoteBaseUrl(string value)
{
value = value ?? string.Empty;
if (_httpRemoteBaseUrl != value)
{
_httpRemoteBaseUrl = value;
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value);
OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl));
}
}
/// <summary>
/// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetClaudeCliPathOverride(string value)
{
value = value ?? string.Empty;
if (_claudeCliPathOverride != value)
{
_claudeCliPathOverride = value;
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value);
OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride));
}
}
/// <summary>
/// Set HttpTransportScope and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpTransportScope(string value)
{
value = value ?? string.Empty;
if (_httpTransportScope != value)
{
_httpTransportScope = value;
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value);
OnConfigurationChanged?.Invoke(nameof(HttpTransportScope));
}
}
/// <summary>
/// Set UnitySocketPort and update cache + EditorPrefs atomically.
/// </summary>
public void SetUnitySocketPort(int value)
{
if (_unitySocketPort != value)
{
_unitySocketPort = value;
EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value);
OnConfigurationChanged?.Invoke(nameof(UnitySocketPort));
}
}
/// <summary>
/// Force refresh of a single cached value from EditorPrefs.
/// Useful when external code modifies EditorPrefs directly.
/// </summary>
public void InvalidateKey(string keyName)
{
switch (keyName)
{
case nameof(UseHttpTransport):
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
break;
case nameof(DebugLogs):
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
break;
case nameof(DevModeForceServerRefresh):
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
break;
case nameof(UvxPathOverride):
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
break;
case nameof(GitUrlOverride):
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
break;
case nameof(HttpBaseUrl):
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
break;
case nameof(HttpRemoteBaseUrl):
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
break;
case nameof(ClaudeCliPathOverride):
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
break;
case nameof(HttpTransportScope):
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
break;
case nameof(UnitySocketPort):
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
break;
}
OnConfigurationChanged?.Invoke(keyName);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b4a183ac9b63c408886bce40ae58f462
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using System;
using MCPForUnity.Editor.Windows;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for managing the EditorPrefs window
/// Follows the Class-level Singleton pattern
/// </summary>
public class EditorPrefsWindowService
{
private static EditorPrefsWindowService _instance;
/// <summary>
/// Get the singleton instance
/// </summary>
public static EditorPrefsWindowService Instance
{
get
{
if (_instance == null)
{
throw new Exception("EditorPrefsWindowService not initialized");
}
return _instance;
}
}
/// <summary>
/// Initialize the service
/// </summary>
public static void Initialize()
{
if (_instance == null)
{
_instance = new EditorPrefsWindowService();
}
}
private EditorPrefsWindowService()
{
// Private constructor for singleton
}
/// <summary>
/// Show the EditorPrefs window
/// </summary>
public void ShowWindow()
{
EditorPrefsWindow.ShowWindow();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,573 @@
using System;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
/// Updated on the main thread via Editor callbacks and periodic update ticks.
/// </summary>
[InitializeOnLoad]
internal static class EditorStateCache
{
private static readonly object LockObj = new();
private static long _sequence;
private static long _observedUnixMs;
private static bool _lastIsCompiling;
private static long? _lastCompileStartedUnixMs;
private static long? _lastCompileFinishedUnixMs;
private static bool _domainReloadPending;
private static long? _domainReloadBeforeUnixMs;
private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
// State tracking to detect when snapshot actually changes (checked BEFORE building)
private static string _lastTrackedScenePath;
private static string _lastTrackedSceneName;
private static bool _lastTrackedIsFocused;
private static bool _lastTrackedIsPlaying;
private static bool _lastTrackedIsPaused;
private static bool _lastTrackedIsUpdating;
private static bool _lastTrackedTestsRunning;
private static string _lastTrackedActivityPhase;
private static JObject _cached;
private sealed class EditorStateSnapshot
{
[JsonProperty("schema_version")]
public string SchemaVersion { get; set; }
[JsonProperty("observed_at_unix_ms")]
public long ObservedAtUnixMs { get; set; }
[JsonProperty("sequence")]
public long Sequence { get; set; }
[JsonProperty("unity")]
public EditorStateUnity Unity { get; set; }
[JsonProperty("editor")]
public EditorStateEditor Editor { get; set; }
[JsonProperty("activity")]
public EditorStateActivity Activity { get; set; }
[JsonProperty("compilation")]
public EditorStateCompilation Compilation { get; set; }
[JsonProperty("assets")]
public EditorStateAssets Assets { get; set; }
[JsonProperty("tests")]
public EditorStateTests Tests { get; set; }
[JsonProperty("transport")]
public EditorStateTransport Transport { get; set; }
[JsonProperty("settings")]
public EditorStateSettings Settings { get; set; }
}
private sealed class EditorStateUnity
{
[JsonProperty("instance_id")]
public string InstanceId { get; set; }
[JsonProperty("unity_version")]
public string UnityVersion { get; set; }
[JsonProperty("project_id")]
public string ProjectId { get; set; }
[JsonProperty("platform")]
public string Platform { get; set; }
[JsonProperty("is_batch_mode")]
public bool? IsBatchMode { get; set; }
}
private sealed class EditorStateEditor
{
[JsonProperty("is_focused")]
public bool? IsFocused { get; set; }
[JsonProperty("play_mode")]
public EditorStatePlayMode PlayMode { get; set; }
[JsonProperty("active_scene")]
public EditorStateActiveScene ActiveScene { get; set; }
}
private sealed class EditorStatePlayMode
{
[JsonProperty("is_playing")]
public bool? IsPlaying { get; set; }
[JsonProperty("is_paused")]
public bool? IsPaused { get; set; }
[JsonProperty("is_changing")]
public bool? IsChanging { get; set; }
}
private sealed class EditorStateActiveScene
{
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("guid")]
public string Guid { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
}
private sealed class EditorStateActivity
{
[JsonProperty("phase")]
public string Phase { get; set; }
[JsonProperty("since_unix_ms")]
public long SinceUnixMs { get; set; }
[JsonProperty("reasons")]
public string[] Reasons { get; set; }
}
private sealed class EditorStateCompilation
{
[JsonProperty("is_compiling")]
public bool? IsCompiling { get; set; }
[JsonProperty("is_domain_reload_pending")]
public bool? IsDomainReloadPending { get; set; }
[JsonProperty("last_compile_started_unix_ms")]
public long? LastCompileStartedUnixMs { get; set; }
[JsonProperty("last_compile_finished_unix_ms")]
public long? LastCompileFinishedUnixMs { get; set; }
[JsonProperty("last_domain_reload_before_unix_ms")]
public long? LastDomainReloadBeforeUnixMs { get; set; }
[JsonProperty("last_domain_reload_after_unix_ms")]
public long? LastDomainReloadAfterUnixMs { get; set; }
}
private sealed class EditorStateAssets
{
[JsonProperty("is_updating")]
public bool? IsUpdating { get; set; }
[JsonProperty("external_changes_dirty")]
public bool? ExternalChangesDirty { get; set; }
[JsonProperty("external_changes_last_seen_unix_ms")]
public long? ExternalChangesLastSeenUnixMs { get; set; }
[JsonProperty("external_changes_dirty_since_unix_ms")]
public long? ExternalChangesDirtySinceUnixMs { get; set; }
[JsonProperty("external_changes_last_cleared_unix_ms")]
public long? ExternalChangesLastClearedUnixMs { get; set; }
[JsonProperty("refresh")]
public EditorStateRefresh Refresh { get; set; }
}
private sealed class EditorStateRefresh
{
[JsonProperty("is_refresh_in_progress")]
public bool? IsRefreshInProgress { get; set; }
[JsonProperty("last_refresh_requested_unix_ms")]
public long? LastRefreshRequestedUnixMs { get; set; }
[JsonProperty("last_refresh_finished_unix_ms")]
public long? LastRefreshFinishedUnixMs { get; set; }
}
private sealed class EditorStateTests
{
[JsonProperty("is_running")]
public bool? IsRunning { get; set; }
[JsonProperty("mode")]
public string Mode { get; set; }
[JsonProperty("current_job_id")]
public string CurrentJobId { get; set; }
[JsonProperty("started_unix_ms")]
public long? StartedUnixMs { get; set; }
[JsonProperty("started_by")]
public string StartedBy { get; set; }
[JsonProperty("last_run")]
public EditorStateLastRun LastRun { get; set; }
}
private sealed class EditorStateLastRun
{
[JsonProperty("finished_unix_ms")]
public long? FinishedUnixMs { get; set; }
[JsonProperty("result")]
public string Result { get; set; }
[JsonProperty("counts")]
public object Counts { get; set; }
}
private sealed class EditorStateTransport
{
[JsonProperty("unity_bridge_connected")]
public bool? UnityBridgeConnected { get; set; }
[JsonProperty("last_message_unix_ms")]
public long? LastMessageUnixMs { get; set; }
}
private sealed class EditorStateSettings
{
[JsonProperty("batch_execute_max_commands")]
public int BatchExecuteMaxCommands { get; set; }
}
static EditorStateCache()
{
try
{
_sequence = 0;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_cached = BuildSnapshot("init");
EditorApplication.update += OnUpdate;
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
// Tracks whether an assembly compilation is actually running, for
// GetActualIsCompiling's Play-mode check. Statics reset on domain reload
// and this [InitializeOnLoad] ctor re-subscribes, so the flag is per-domain.
UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => _pipelineCompilationRunning = true;
UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => _pipelineCompilationRunning = false;
AssemblyReloadEvents.beforeAssemblyReload += () =>
{
_domainReloadPending = true;
_domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("before_domain_reload");
};
AssemblyReloadEvents.afterAssemblyReload += () =>
{
_domainReloadPending = false;
_domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("after_domain_reload");
};
}
catch (Exception ex)
{
McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}");
}
}
private static void OnUpdate()
{
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup;
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();
// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
return;
}
// Fast state-change detection BEFORE building snapshot.
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
// These checks are much cheaper than building a full JSON snapshot.
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneName = scene.name ?? string.Empty;
bool isFocused = InternalEditorUtility.isApplicationActive;
bool isPlaying = EditorApplication.isPlaying;
bool isPaused = EditorApplication.isPaused;
bool isUpdating = EditorApplication.isUpdating;
bool testsRunning = TestRunStatus.IsRunning;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
bool hasChanges = compilationEdge
|| _lastTrackedScenePath != scenePath
|| _lastTrackedSceneName != sceneName
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsPaused != isPaused
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedTestsRunning != testsRunning
|| _lastTrackedActivityPhase != activityPhase;
if (!hasChanges)
{
// No state change - skip the expensive BuildSnapshot entirely.
// This is the key optimization that prevents the 28ms GC spikes.
return;
}
// Update tracked state
_lastTrackedScenePath = scenePath;
_lastTrackedSceneName = sceneName;
_lastTrackedIsFocused = isFocused;
_lastTrackedIsPlaying = isPlaying;
_lastTrackedIsPaused = isPaused;
_lastTrackedIsUpdating = isUpdating;
_lastTrackedTestsRunning = testsRunning;
_lastTrackedActivityPhase = activityPhase;
_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
}
private static void ForceUpdate(string reason)
{
lock (LockObj)
{
_cached = BuildSnapshot(reason);
}
}
private static JObject BuildSnapshot(string reason)
{
_sequence++;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool isCompiling = GetActualIsCompiling();
if (isCompiling && !_lastIsCompiling)
{
_lastCompileStartedUnixMs = _observedUnixMs;
}
else if (!isCompiling && _lastIsCompiling)
{
_lastCompileFinishedUnixMs = _observedUnixMs;
}
_lastIsCompiling = isCompiling;
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
bool testsRunning = TestRunStatus.IsRunning;
var testsMode = TestRunStatus.Mode?.ToString();
string currentJobId = TestJobManager.CurrentJobId;
bool isFocused = InternalEditorUtility.isApplicationActive;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (EditorApplication.isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
var snapshot = new EditorStateSnapshot
{
SchemaVersion = "unity-mcp/editor_state@2",
ObservedAtUnixMs = _observedUnixMs,
Sequence = _sequence,
Unity = new EditorStateUnity
{
InstanceId = null,
UnityVersion = Application.unityVersion,
ProjectId = null,
Platform = Application.platform.ToString(),
IsBatchMode = Application.isBatchMode
},
Editor = new EditorStateEditor
{
IsFocused = isFocused,
PlayMode = new EditorStatePlayMode
{
IsPlaying = EditorApplication.isPlaying,
IsPaused = EditorApplication.isPaused,
IsChanging = EditorApplication.isPlayingOrWillChangePlaymode
},
ActiveScene = new EditorStateActiveScene
{
Path = scenePath,
Guid = sceneGuid,
Name = scene.name ?? string.Empty
}
},
Activity = new EditorStateActivity
{
Phase = activityPhase,
SinceUnixMs = _observedUnixMs,
Reasons = new[] { reason }
},
Compilation = new EditorStateCompilation
{
IsCompiling = isCompiling,
IsDomainReloadPending = _domainReloadPending,
LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
},
Assets = new EditorStateAssets
{
IsUpdating = EditorApplication.isUpdating,
ExternalChangesDirty = false,
ExternalChangesLastSeenUnixMs = null,
ExternalChangesDirtySinceUnixMs = null,
ExternalChangesLastClearedUnixMs = null,
Refresh = new EditorStateRefresh
{
IsRefreshInProgress = false,
LastRefreshRequestedUnixMs = null,
LastRefreshFinishedUnixMs = null
}
},
Tests = new EditorStateTests
{
IsRunning = testsRunning,
Mode = testsMode,
CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
StartedUnixMs = TestRunStatus.StartedUnixMs,
StartedBy = "unknown",
LastRun = TestRunStatus.FinishedUnixMs.HasValue
? new EditorStateLastRun
{
FinishedUnixMs = TestRunStatus.FinishedUnixMs,
Result = "unknown",
Counts = null
}
: null
},
Transport = new EditorStateTransport
{
UnityBridgeConnected = null,
LastMessageUnixMs = null
},
Settings = new EditorStateSettings
{
BatchExecuteMaxCommands = Tools.BatchExecute.GetMaxCommandsPerBatch()
}
};
return JObject.FromObject(snapshot);
}
public static JObject GetSnapshot()
{
lock (LockObj)
{
// Defensive: if something went wrong early, rebuild once.
if (_cached == null)
{
_cached = BuildSnapshot("rebuild");
}
// Always return a fresh clone to prevent mutation bugs.
// The main GC optimization comes from state-change detection (OnUpdate)
// which prevents unnecessary _cached rebuilds, not from caching the clone.
var clone = (JObject)_cached.DeepClone();
// When Unity is backgrounded, OnUpdate is throttled and the
// cached timestamp grows stale even though the data is current.
// Re-stamp only in that case so the server-side staleness check
// still fires for genuinely unresponsive editors when focused.
if (!InternalEditorUtility.isApplicationActive)
{
clone["observed_at_unix_ms"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
return clone;
}
}
// Set/cleared by the CompilationPipeline.compilationStarted/Finished events
// subscribed in the static ctor. NOTE: CompilationPipeline.isCompiling does not
// exist on the supported Unity range (verified by reflection probe on 2021.3 and
// 6000.4 — neither public nor non-public), so the reflection this replaced never
// resolved and always fell back to the raw signal.
private static bool _pipelineCompilationRunning;
/// <summary>
/// Returns the actual compilation state, working around a known Unity quirk where
/// EditorApplication.isCompiling can return false positives in Play mode (e.g. a
/// recompile deferred by Recompile-After-Finished-Playing keeps it true for the
/// whole play session). See: https://github.com/CoplayDev/unity-mcp/issues/549
/// </summary>
internal static bool GetActualIsCompiling()
{
// If EditorApplication.isCompiling is false, Unity is definitely not compiling
if (!EditorApplication.isCompiling)
{
return false;
}
// In Play mode, trust the event-tracked pipeline state instead: a deferred
// recompile keeps EditorApplication.isCompiling true without any compilation
// actually running.
if (EditorApplication.isPlaying)
{
return _pipelineCompilationRunning;
}
// Outside Play mode the raw signal is reliable.
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa7909967ce3c48c493181c978782a54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,357 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Windows;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Automatically starts the HTTP MCP bridge on editor load when the user has opted in
/// via the "Auto-Start on Editor Load" toggle in Advanced Settings.
/// This complements HttpBridgeReloadHandler (which only resumes after domain reloads).
/// </summary>
[InitializeOnLoad]
internal static class HttpAutoStartHandler
{
internal const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized";
// Set while AutoStartAsync is in flight. A domain reload kills the in-flight task but
// leaves this set, so the next domain load can finish the connect phase without
// re-spawning the server (StartLocalHttpServer would first stop a still-booting process).
internal const string ConnectPendingKey = "HttpAutoStartHandler.ConnectPending";
// Bounds the per-frame retry when editor services keep throwing on a fresh launch.
// Plain static, so every domain reload grants a fresh budget.
private const int MaxServiceNotReadyRetries = 300;
private static int _serviceNotReadyRetries;
internal enum TickDecision
{
DeferBusy,
DeferToResume,
Skip,
ShouldStart,
ShouldReconnect,
}
static HttpAutoStartHandler()
{
if (Application.isBatchMode &&
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{
return;
}
bool latched = SessionState.GetBool(SessionInitKey, false);
bool connectPending = SessionState.GetBool(ConnectPendingKey, false);
// Cheap pre-check so the common case (auto-start off, nothing pending) costs one
// EditorPrefs read per domain load instead of an update subscription. The pref is
// re-read every domain load, so enabling it takes effect at the next reload.
if (!latched && !connectPending &&
!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false))
{
return;
}
// Latched with nothing pending: this session already auto-started.
if (latched && !connectPending)
{
return;
}
// Pending EditorApplication.delayCall/update delegates are wiped by domain reloads,
// so the deferred work must NOT latch up front — latching eagerly killed auto-start
// for the whole session whenever startup included a compile (#1229). An update tick
// is reload-safe because this ctor re-arms it on every domain load.
EditorApplication.update += WaitForEditorReady;
}
/// <summary>
/// Drops a reload-interrupted auto-start connect. Called when the user takes manual
/// control of the bridge lifecycle, so no later domain load revives the connect.
/// </summary>
internal static void CancelPendingReconnect() => SessionState.EraseBool(ConnectPendingKey);
private static void WaitForEditorReady()
{
switch (TickCore(HttpBridgeReloadHandler.IsEditorBusy()))
{
case TickDecision.DeferBusy:
case TickDecision.DeferToResume:
return; // stay registered, try again next tick
case TickDecision.Skip:
EditorApplication.update -= WaitForEditorReady;
return;
case TickDecision.ShouldStart:
if (!TryBeginAutoStart())
{
DeferOrGiveUp();
return;
}
SessionState.SetBool(SessionInitKey, true);
EditorApplication.update -= WaitForEditorReady;
return;
case TickDecision.ShouldReconnect:
if (!TryBeginReconnect())
{
DeferOrGiveUp();
return;
}
EditorApplication.update -= WaitForEditorReady;
return;
}
}
// Services may not be initialized on the first frames of a fresh launch; retry next
// tick, but not forever — a persistently broken environment shouldn't churn exceptions
// every frame for the whole session. A later domain reload retries with a fresh budget.
private static void DeferOrGiveUp()
{
if (++_serviceNotReadyRetries < MaxServiceNotReadyRetries) return;
EditorApplication.update -= WaitForEditorReady;
McpLog.Warn("[HTTP Auto-Start] Editor services unavailable; giving up until the next domain reload");
}
/// <summary>
/// Decision core for the editor-ready tick, separated so EditMode tests can drive it
/// without spawning servers. Never writes the session latch — the caller latches only
/// after the start work actually dispatches.
/// </summary>
internal static TickDecision TickCore(bool editorBusy)
{
if (editorBusy) return TickDecision.DeferBusy;
bool connectPending = SessionState.GetBool(ConnectPendingKey, false);
if (!connectPending)
{
if (SessionState.GetBool(SessionInitKey, false)) return TickDecision.Skip;
// Only check lightweight EditorPrefs here — heavier services are touched in
// TryBeginAutoStart once the editor is idle. No latch when disabled: the pref
// is re-read on the next domain load.
if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return TickDecision.Skip;
}
// A pending reload-resume owns bridge revival — checked only when we would
// otherwise act, so a plain Skip never waits out the resume window.
if (HttpBridgeReloadHandler.IsResumePending) return TickDecision.DeferToResume;
return connectPending ? TickDecision.ShouldReconnect : TickDecision.ShouldStart;
}
/// <summary>
/// Returns true when the auto-start decision was actually made (including deliberate
/// early-outs). Returns false when services were not ready yet, so the caller leaves
/// the session latch unset and retries instead of consuming it.
/// </summary>
private static bool TryBeginAutoStart()
{
try
{
if (!EditorConfigurationCache.Instance.UseHttpTransport) return true;
// Don't auto-start if bridge is already running.
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return true;
_ = AutoStartAsync();
return true;
}
catch (Exception ex)
{
McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}");
return false;
}
}
/// <summary>
/// Returns false when services were not ready yet (caller retries). On true the
/// pending reconnect was either dispatched or deliberately dropped (auto-start
/// disabled, transport switched, bridge already running).
/// </summary>
internal static bool TryBeginReconnect()
{
bool proceed;
try
{
proceed = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)
&& EditorConfigurationCache.Instance.UseHttpTransport
&& !MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http);
}
catch (Exception ex)
{
McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}");
return false;
}
if (!proceed)
{
SessionState.EraseBool(ConnectPendingKey);
return true;
}
_ = ReconnectAsync();
return true;
}
private static async Task AutoStartAsync()
{
SessionState.SetBool(ConnectPendingKey, true);
try
{
bool isLocal = !HttpEndpointUtility.IsRemoteScope();
if (isLocal)
{
// For HTTP Local: launch the server process first, then connect the bridge.
// This mirrors what the UI "Start Server" button does.
if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(
HttpEndpointUtility.GetLocalBaseUrl(), out string policyError))
{
McpLog.Debug($"[HTTP Auto-Start] Local URL blocked by security policy: {policyError}");
return;
}
// Check if server is already reachable (e.g. user started it externally).
if (!MCPServiceLocator.Server.IsLocalHttpServerReachable())
{
bool serverStarted = MCPServiceLocator.Server.StartLocalHttpServer(quiet: true);
if (!serverStarted)
{
McpLog.Warn("[HTTP Auto-Start] Failed to start local HTTP server");
return;
}
}
// Wait for the server to become reachable, then connect.
await WaitForServerAndConnectAsync();
}
else
{
// For HTTP Remote: server is external, just connect the bridge.
await ConnectBridgeAsync();
}
}
catch (Exception ex)
{
McpLog.Warn($"[HTTP Auto-Start] Failed: {ex.Message}");
}
finally
{
// Reached on every terminal outcome. A domain reload that kills the task
// mid-flight skips this, leaving the key set for the reconnect path.
SessionState.EraseBool(ConnectPendingKey);
}
}
/// <summary>
/// Finishes an auto-start whose connect phase was killed by a domain reload.
/// Connect-only: never spawns a server — the previous domain already did.
/// </summary>
private static async Task ReconnectAsync()
{
try
{
if (HttpEndpointUtility.IsRemoteScope())
{
await ConnectBridgeAsync();
return;
}
await WaitForServerAndConnectAsync();
}
catch (Exception ex)
{
McpLog.Warn($"[HTTP Auto-Start] Post-reload reconnect failed: {ex.Message}");
}
finally
{
SessionState.EraseBool(ConnectPendingKey);
}
}
/// <summary>
/// Waits for the local HTTP server to accept connections, then connects the bridge.
/// Mirrors TryAutoStartSessionAsync in McpConnectionSection: while a managed launch
/// process is alive, keep polling reachability and declare failure only when it exits
/// without the port coming up. Without a launch handle (post-reload reconnect, or a
/// server started externally) there is nothing to watch die, so poll to the hard cap.
/// </summary>
private static async Task WaitForServerAndConnectAsync()
{
var server = MCPServiceLocator.Server;
string url = HttpEndpointUtility.GetLocalBaseUrl();
var pollDelay = TimeSpan.FromMilliseconds(500);
var hardCap = TimeSpan.FromMinutes(5);
double startTime = EditorApplication.timeSinceStartup;
while (true)
{
// Abort if user changed settings while we were waiting.
if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return;
if (!EditorConfigurationCache.Instance.UseHttpTransport) return;
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return;
if (server.IsLocalHttpServerReachable())
{
McpLog.Info($"Server ready on {url}");
bool started = await MCPServiceLocator.Bridge.StartAsync();
if (started)
{
McpLog.Info("Session connected");
MCPForUnityEditorWindow.RequestHealthVerification();
return;
}
}
double elapsed = EditorApplication.timeSinceStartup - startTime;
bool launchProcessDied = server.HasManagedServerLaunchHandle
&& !server.IsManagedServerLaunchProcessAlive()
&& elapsed > 1.0;
if (launchProcessDied || elapsed > hardCap.TotalSeconds)
{
// Last-resort connect attempt in case reachability detection missed a live server.
if (await MCPServiceLocator.Bridge.StartAsync())
{
McpLog.Info("Session connected");
MCPForUnityEditorWindow.RequestHealthVerification();
return;
}
server.LogLocalHttpServerLaunchFailure();
return;
}
try { await Task.Delay(pollDelay); }
catch { return; }
}
}
/// <summary>
/// Connects the bridge directly (for remote HTTP where the server is already running).
/// </summary>
private static async Task ConnectBridgeAsync()
{
string url = HttpEndpointUtility.GetRemoteBaseUrl();
McpLog.Info($"Connecting to {url}…");
bool started = await MCPServiceLocator.Bridge.StartAsync();
if (started)
{
McpLog.Info("Connected");
MCPForUnityEditorWindow.RequestHealthVerification();
}
else
{
McpLog.Warn("Connection failed: could not connect to remote HTTP server");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3d8f1790992fe0742938d8a879056ee6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,223 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Windows;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge.
/// </summary>
[InitializeOnLoad]
internal static class HttpBridgeReloadHandler
{
// SessionState, not EditorPrefs: it survives domain reloads but dies with the editor
// process and is per-editor-instance. EditorPrefs is per-user machine-global, so a
// second open editor could consume or delete this editor's pending resume, and a
// crash mid-compile would leave a stale flag that resurrects the bridge on the next
// launch (#1229).
internal const string ResumeSessionKey = "MCPForUnity.ResumeHttpAfterReload";
private static readonly TimeSpan[] ResumeRetrySchedule =
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
};
static HttpBridgeReloadHandler()
{
// Migration: the flag lived in EditorPrefs before it moved to SessionState — the
// key STRING is shared, so renaming ResumeSessionKey would silently break this
// cleanup. Once per session, not per reload: the EditorPrefs key is machine-global,
// and an older-version editor open concurrently still uses it for its own resume.
// Safe to delete this block a few releases after v10.
const string migratedKey = "MCPForUnity.ResumeHttpAfterReload.Migrated";
if (!SessionState.GetBool(migratedKey, false))
{
EditorPrefs.DeleteKey(ResumeSessionKey);
SessionState.SetBool(migratedKey, true);
}
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
}
internal static bool IsResumePending => SessionState.GetBool(ResumeSessionKey, false);
/// <summary>
/// Drops a pending reload-resume. Called when the user takes manual control of the
/// bridge lifecycle (Connect, End Session, transport switch, orphan cleanup); the
/// retry loop re-checks the flag per attempt, so this also aborts an in-flight loop.
/// </summary>
internal static void CancelPendingResume() => SessionState.EraseBool(ResumeSessionKey);
private static void OnBeforeAssemblyReload()
{
try
{
OnBeforeAssemblyReloadCore(MCPServiceLocator.TransportManager);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}");
}
}
internal static void OnBeforeAssemblyReloadCore(TransportManager transport)
{
if (transport.IsRunning(TransportMode.Http))
{
SessionState.SetBool(ResumeSessionKey, true);
// beforeAssemblyReload is synchronous; force a synchronous teardown so we do not
// leave an orphaned socket due to an unfinished async close handshake.
transport.ForceStop(TransportMode.Http);
}
// When the bridge is not running, leave any pending flag alone: during a multi-pass
// compile the next reload lands before the deferred resume ran, and deleting the
// flag here is what used to lose the resume permanently (#1229). Explicit cancel
// paths (End Session, transport switch, orphan cleanup) erase the flag instead.
}
private static void OnAfterAssemblyReload()
{
if (OnAfterAssemblyReloadCore())
{
EditorApplication.update += ResumeTick;
}
}
/// <summary>
/// Decision core, separated so EditMode tests can drive it. Returns true when a resume
/// should be scheduled. Does not consume the flag — it survives until the resume
/// succeeds, is cancelled, or exhausts its retries, so a further reload in the middle
/// of any deferral re-enters here instead of losing the resume.
/// </summary>
internal static bool OnAfterAssemblyReloadCore()
{
try
{
if (!SessionState.GetBool(ResumeSessionKey, false)) return false;
// Only resume HTTP if it is still the selected transport.
if (!EditorConfigurationCache.Instance.UseHttpTransport)
{
SessionState.EraseBool(ResumeSessionKey);
return false;
}
return true;
}
catch (Exception ex)
{
// Transport-config read failed (services racing the reload boundary): schedule
// the resume anyway rather than dropping it — the retry loop re-checks the
// transport per attempt and erases the flag itself on cancel/switch/exhaustion,
// so a stale flag cannot wedge the session.
McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}");
return true;
}
}
private static void ResumeTick()
{
if (IsEditorBusy()) return;
EditorApplication.update -= ResumeTick;
_ = ResumeHttpWithRetriesAsync();
}
/// <summary>
/// Busy gate for the deferral ticks. Uses the #549-aware compiling check because raw
/// EditorApplication.isCompiling stays true for a whole play session under the
/// "Recompile After Finished Playing" preference, which would block resume until
/// play mode exits.
/// </summary>
internal static bool IsEditorBusy()
=> EditorStateCache.GetActualIsCompiling() || EditorApplication.isUpdating;
// scheduleOverride lets EditMode tests pass an all-zero schedule so the loop
// completes synchronously (the test framework floor cannot run async tests).
internal static async Task ResumeHttpWithRetriesAsync(TimeSpan[] scheduleOverride = null)
{
TimeSpan[] schedule = scheduleOverride ?? ResumeRetrySchedule;
Exception lastException = null;
for (int i = 0; i < schedule.Length; i++)
{
int attempt = i + 1;
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt}/{schedule.Length}");
TimeSpan delay = schedule[i];
if (delay > TimeSpan.Zero)
{
McpLog.Debug($"[HTTP Reload] Waiting {delay.TotalSeconds:0.#}s before resume attempt {attempt}");
try { await Task.Delay(delay); }
catch { return; }
}
// The flag doubles as the cancel signal (see CancelPendingResume).
if (!IsResumePending) return;
try
{
// Inside the attempt try: a service read racing the reload boundary must
// burn a retry, not kill this fire-and-forget task with the flag still set
// (which would leave nothing scheduled to consume it until the next reload).
// Abort retries if the user switched transports while we were waiting.
if (!EditorConfigurationCache.Instance.UseHttpTransport)
{
SessionState.EraseBool(ResumeSessionKey);
return;
}
// Never bounce a session someone else established while we were waiting
// (WebSocketTransportClient.StartAsync tears down a live connection first).
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http))
{
SessionState.EraseBool(ResumeSessionKey);
return;
}
bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
if (started)
{
SessionState.EraseBool(ResumeSessionKey);
McpLog.Debug($"[HTTP Reload] Resume succeeded on attempt {attempt}");
MCPForUnityEditorWindow.RequestHealthVerification();
return;
}
var state = MCPServiceLocator.TransportManager.GetState(TransportMode.Http);
string reason = string.IsNullOrWhiteSpace(state?.Error) ? "no error detail" : state.Error;
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} failed: {reason}");
}
catch (Exception ex)
{
lastException = ex;
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} threw: {ex.Message}");
}
}
// Exhausted: erase the flag so later reload boundaries don't replay this failure
// loop for the rest of the session. This cannot swallow a multi-pass resume — a
// reload mid-loop kills the task before this line, leaving the flag set.
SessionState.EraseBool(ResumeSessionKey);
if (lastException != null)
{
McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {lastException.Message}");
}
else
{
McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4c0cf970a7b494a659be151dc0124296
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,82 @@
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.Transport;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for controlling the MCP for Unity Bridge connection
/// </summary>
public interface IBridgeControlService
{
/// <summary>
/// Gets whether the bridge is currently running
/// </summary>
bool IsRunning { get; }
/// <summary>
/// Gets the current port the bridge is listening on
/// </summary>
int CurrentPort { get; }
/// <summary>
/// Gets whether the bridge is in auto-connect mode
/// </summary>
bool IsAutoConnectMode { get; }
/// <summary>
/// Gets the currently active transport mode, if any
/// </summary>
TransportMode? ActiveMode { get; }
/// <summary>
/// Starts the MCP for Unity Bridge asynchronously
/// </summary>
/// <returns>True if the bridge started successfully</returns>
Task<bool> StartAsync();
/// <summary>
/// Stops the MCP for Unity Bridge asynchronously
/// </summary>
Task StopAsync();
/// <summary>
/// Verifies the bridge connection by sending a ping and waiting for a pong response
/// </summary>
/// <param name="port">The port to verify</param>
/// <returns>Verification result with detailed status</returns>
BridgeVerificationResult Verify(int port);
/// <summary>
/// Verifies the connection asynchronously (works for both HTTP and stdio transports)
/// </summary>
/// <returns>Verification result with detailed status</returns>
Task<BridgeVerificationResult> VerifyAsync();
}
/// <summary>
/// Result of a bridge verification attempt
/// </summary>
public class BridgeVerificationResult
{
/// <summary>
/// Whether the verification was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Human-readable message about the verification result
/// </summary>
public string Message { get; set; }
/// <summary>
/// Whether the handshake was valid (FRAMING=1 protocol)
/// </summary>
public bool HandshakeValid { get; set; }
/// <summary>
/// Whether the ping/pong exchange succeeded
/// </summary>
public bool PingSucceeded { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b5d9f677f6f54fc59e6fe921b260c61
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for configuring MCP clients
/// </summary>
public interface IClientConfigurationService
{
/// <summary>
/// Configures a specific MCP client
/// </summary>
/// <param name="client">The client to configure</param>
void ConfigureClient(IMcpClientConfigurator configurator);
/// <summary>
/// Configures all detected/installed MCP clients (skips clients where CLI/tools not found)
/// </summary>
/// <returns>Summary of configuration results</returns>
ClientConfigurationSummary ConfigureAllDetectedClients();
/// <summary>
/// Checks the configuration status of a client
/// </summary>
/// <param name="client">The client to check</param>
/// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param>
/// <returns>True if status changed, false otherwise</returns>
bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true);
/// <summary>Gets the registry of discovered configurators.</summary>
IReadOnlyList<IMcpClientConfigurator> GetAllClients();
}
/// <summary>
/// Summary of configuration results for multiple clients
/// </summary>
public class ClientConfigurationSummary
{
/// <summary>
/// Number of clients successfully configured
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// Number of clients that failed to configure
/// </summary>
public int FailureCount { get; set; }
/// <summary>
/// Number of clients skipped (already configured or tool not found)
/// </summary>
public int SkippedCount { get; set; }
/// <summary>
/// Detailed messages for each client
/// </summary>
public System.Collections.Generic.List<string> Messages { get; set; } = new();
/// <summary>
/// Gets a human-readable summary message
/// </summary>
public string GetSummaryMessage()
{
return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aae139cfae7ac4044ac52e2658005ea1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using System;
namespace MCPForUnity.Editor.Services
{
public interface IPackageDeploymentService
{
string GetStoredSourcePath();
void SetStoredSourcePath(string path);
void ClearStoredSourcePath();
string GetTargetPath();
string GetTargetDisplayPath();
string GetLastBackupPath();
bool HasBackup();
PackageDeploymentResult DeployFromStoredSource();
PackageDeploymentResult RestoreLastBackup();
}
public class PackageDeploymentResult
{
public bool Success { get; set; }
public string Message { get; set; }
public string SourcePath { get; set; }
public string TargetPath { get; set; }
public string BackupPath { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,89 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates and version information
/// </summary>
public interface IPackageUpdateService
{
/// <summary>
/// Checks if a newer version of the package is available
/// </summary>
/// <param name="currentVersion">The current package version</param>
/// <returns>Update check result containing availability and latest version info</returns>
UpdateCheckResult CheckForUpdate(string currentVersion);
/// <summary>
/// Returns a cached update result if one exists for today, or null if a network fetch is needed.
/// Main-thread only (reads EditorPrefs).
/// </summary>
UpdateCheckResult TryGetCachedResult(string currentVersion);
/// <summary>
/// Performs only the network fetch and version comparison (no EditorPrefs access).
/// Safe to call from a background thread.
/// </summary>
UpdateCheckResult FetchAndCompare(string currentVersion);
/// <summary>
/// Performs only the network fetch and version comparison using pre-computed installation info.
/// Use this overload when calling from a background thread to avoid main-thread-only API calls.
/// </summary>
UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch);
/// <summary>
/// Caches a successful fetch result in EditorPrefs. Must be called from the main thread.
/// </summary>
void CacheFetchResult(string currentVersion, string fetchedVersion);
/// <summary>
/// Compares two version strings to determine if the first is newer than the second
/// </summary>
/// <param name="version1">First version string</param>
/// <param name="version2">Second version string</param>
/// <returns>True if version1 is newer than version2</returns>
bool IsNewerVersion(string version1, string version2);
/// <summary>
/// Determines if the package was installed via Git or Asset Store
/// </summary>
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
bool IsGitInstallation();
/// <summary>
/// Determines the Git branch to check for updates (e.g. "main" or "beta").
/// Must be called from the main thread (uses Unity PackageManager APIs).
/// </summary>
string GetGitUpdateBranch(string currentVersion);
/// <summary>
/// Clears the cached update check data, forcing a fresh check on next request
/// </summary>
void ClearCache();
}
/// <summary>
/// Result of an update check operation
/// </summary>
public class UpdateCheckResult
{
/// <summary>
/// Whether an update is available
/// </summary>
public bool UpdateAvailable { get; set; }
/// <summary>
/// The latest version available (null if check failed or no update)
/// </summary>
public string LatestVersion { get; set; }
/// <summary>
/// Whether the check was successful (false if network error, etc.)
/// </summary>
public bool CheckSucceeded { get; set; }
/// <summary>
/// Optional message about the check result
/// </summary>
public string Message { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e94ae28f193184e4fb5068f62f4f00c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,77 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for resolving paths to required tools and supporting user overrides
/// </summary>
public interface IPathResolverService
{
/// <summary>
/// Gets the uvx package manager path (respects override if set)
/// </summary>
/// <returns>Path to the uvx executable, or null if not found</returns>
string GetUvxPath();
/// <summary>
/// Gets the Claude CLI path (respects override if set)
/// </summary>
/// <returns>Path to the claude executable, or null if not found</returns>
string GetClaudeCliPath();
/// <summary>
/// Checks if Python is detected on the system
/// </summary>
/// <returns>True if Python is found</returns>
bool IsPythonDetected();
/// <summary>
/// Checks if Claude CLI is detected on the system
/// </summary>
/// <returns>True if Claude CLI is found</returns>
bool IsClaudeCliDetected();
/// <summary>
/// Sets an override for the uvx path
/// </summary>
/// <param name="path">Path to override with</param>
void SetUvxPathOverride(string path);
/// <summary>
/// Sets an override for the Claude CLI path
/// </summary>
/// <param name="path">Path to override with</param>
void SetClaudeCliPathOverride(string path);
/// <summary>
/// Clears the uvx path override
/// </summary>
void ClearUvxPathOverride();
/// <summary>
/// Clears the Claude CLI path override
/// </summary>
void ClearClaudeCliPathOverride();
/// <summary>
/// Gets whether a uvx path override is active
/// </summary>
bool HasUvxPathOverride { get; }
/// <summary>
/// Gets whether a Claude CLI path override is active
/// </summary>
bool HasClaudeCliPathOverride { get; }
/// <summary>
/// Gets whether the uvx path used a fallback from override to system path
/// </summary>
bool HasUvxPathFallback { get; }
/// <summary>
/// Validates the provided uv executable by running "--version" and parsing the output.
/// </summary>
/// <param name="uvPath">Absolute or relative path to the uv/uvx executable.</param>
/// <param name="version">Parsed version string if successful.</param>
/// <returns>True when the executable runs and returns a uv version string.</returns>
bool TryValidateUvxExecutable(string uvPath, out string version);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e8d388be507345aeb0eaf27fbd3c022
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for platform detection and platform-specific environment access
/// </summary>
public interface IPlatformService
{
/// <summary>
/// Checks if the current platform is Windows
/// </summary>
/// <returns>True if running on Windows</returns>
bool IsWindows();
/// <summary>
/// Gets the SystemRoot environment variable (Windows-specific)
/// </summary>
/// <returns>SystemRoot path, or null if not available</returns>
string GetSystemRoot();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Metadata for a discovered resource
/// </summary>
public class ResourceMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string ClassName { get; set; }
public string Namespace { get; set; }
public string AssemblyName { get; set; }
public bool IsBuiltIn { get; set; }
}
/// <summary>
/// Service for discovering MCP resources via reflection
/// </summary>
public interface IResourceDiscoveryService
{
/// <summary>
/// Discovers all resources marked with [McpForUnityResource]
/// </summary>
List<ResourceMetadata> DiscoverAllResources();
/// <summary>
/// Gets metadata for a specific resource
/// </summary>
ResourceMetadata GetResourceMetadata(string resourceName);
/// <summary>
/// Returns only the resources currently enabled
/// </summary>
List<ResourceMetadata> GetEnabledResources();
/// <summary>
/// Checks whether a resource is currently enabled
/// </summary>
bool IsResourceEnabled(string resourceName);
/// <summary>
/// Updates the enabled state for a resource
/// </summary>
void SetResourceEnabled(string resourceName, bool enabled);
/// <summary>
/// Invalidates the resource discovery cache
/// </summary>
void InvalidateCache();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7afb4739669224c74b4b4d706e6bbb49
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,89 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Interface for server management operations
/// </summary>
public interface IServerManagementService
{
/// <summary>
/// Clear the local uvx cache for the MCP server package
/// </summary>
/// <returns>True if successful, false otherwise</returns>
bool ClearUvxCache();
/// <summary>
/// Start the local HTTP server headless (no terminal window), redirecting its output to a
/// per-port launch log. Stops any existing server on the port and clears stale artifacts first.
/// </summary>
/// <param name="quiet">When true, skip confirmation dialogs (used by auto-start).</param>
/// <returns>True if server was started successfully, false otherwise</returns>
bool StartLocalHttpServer(bool quiet = false);
/// <summary>
/// Gets the launch-log path for the configured local HTTP server port, or null if unavailable.
/// </summary>
string GetLocalHttpServerLaunchLogPath();
/// <summary>
/// Returns true while the most-recently launched headless server process is still alive.
/// Used by callers to keep waiting for reachability instead of declaring failure prematurely.
/// </summary>
bool IsManagedServerLaunchProcessAlive();
/// <summary>
/// True when this domain launched the local HTTP server and still holds its Process
/// handle. Handles do not survive domain reloads, so false also means "unknown" —
/// callers should wait rather than fail fast.
/// </summary>
bool HasManagedServerLaunchHandle { get; }
/// <summary>
/// Writes a launch-failure report to the Console (Error): the tail of the launch log,
/// the log path, and a copy-command hint pointing at the Manual Server Launch foldout.
/// </summary>
void LogLocalHttpServerLaunchFailure();
/// <summary>
/// Stop the local HTTP server by finding the process listening on the configured port
/// </summary>
bool StopLocalHttpServer();
/// <summary>
/// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
/// even if the current transport selection has changed.
/// </summary>
bool StopManagedLocalHttpServer();
/// <summary>
/// Best-effort detection: returns true if a local MCP HTTP server appears to be running
/// on the configured local URL/port (used to drive UI state even if the session is not active).
/// </summary>
bool IsLocalHttpServerRunning();
/// <summary>
/// Fast reachability check: returns true if a local TCP listener is accepting connections
/// for the configured local URL/port (used for UI state without process inspection).
/// </summary>
bool IsLocalHttpServerReachable();
/// <summary>
/// Attempts to get the command that will be executed when starting the local HTTP server
/// </summary>
/// <param name="command">The command that will be executed when available</param>
/// <param name="error">Reason why a command could not be produced</param>
/// <returns>True if a command is available, false otherwise</returns>
bool TryGetLocalHttpServerCommand(out string command, out string error);
/// <summary>
/// Check if the configured HTTP URL is a local address
/// </summary>
/// <returns>True if URL is local (localhost, 127.0.0.1, etc.)</returns>
bool IsLocalUrl();
/// <summary>
/// Check if the local HTTP server can be started
/// </summary>
/// <returns>True if HTTP transport is enabled and URL satisfies local launch security policy</returns>
bool CanStartLocalServer();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d41bfc9780b774affa6afbffd081eb79
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Options for filtering which tests to run.
/// All properties are optional - null or empty arrays are ignored.
/// </summary>
public class TestFilterOptions
{
/// <summary>
/// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod").
/// </summary>
public string[] TestNames { get; set; }
/// <summary>
/// Same as TestNames, except it allows for Regex.
/// </summary>
public string[] GroupNames { get; set; }
/// <summary>
/// NUnit category names to filter by (tests marked with [Category] attribute).
/// </summary>
public string[] CategoryNames { get; set; }
/// <summary>
/// Assembly names to filter tests by.
/// </summary>
public string[] AssemblyNames { get; set; }
}
/// <summary>
/// Provides access to Unity Test Runner data and execution.
/// </summary>
public interface ITestRunnerService
{
/// <summary>
/// Retrieve the list of tests for the requested mode(s).
/// When <paramref name="mode"/> is null, tests for both EditMode and PlayMode are returned.
/// </summary>
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);
/// <summary>
/// Execute tests for the supplied mode with optional filtering.
/// </summary>
/// <param name="mode">The test mode (EditMode or PlayMode).</param>
/// <param name="filterOptions">Optional filter options to run specific tests. Pass null to run all tests.</param>
Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d23bf32361ff444beaf3510818c94bae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,72 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Metadata for a discovered tool
/// </summary>
public class ToolMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public bool StructuredOutput { get; set; }
public List<ParameterMetadata> Parameters { get; set; }
public string ClassName { get; set; }
public string Namespace { get; set; }
public string AssemblyName { get; set; }
public bool AutoRegister { get; set; } = true;
public bool RequiresPolling { get; set; } = false;
public string PollAction { get; set; } = "status";
public int MaxPollSeconds { get; set; } = 0;
public bool IsBuiltIn { get; set; }
public string Group { get; set; } = "core";
}
/// <summary>
/// Metadata for a tool parameter
/// </summary>
public class ParameterMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string Type { get; set; } // "string", "int", "bool", "float", etc.
public bool Required { get; set; }
public string DefaultValue { get; set; }
}
/// <summary>
/// Service for discovering MCP tools via reflection
/// </summary>
public interface IToolDiscoveryService
{
/// <summary>
/// Discovers all tools marked with [McpForUnityTool]
/// </summary>
List<ToolMetadata> DiscoverAllTools();
/// <summary>
/// Gets metadata for a specific tool
/// </summary>
ToolMetadata GetToolMetadata(string toolName);
/// <summary>
/// Returns only the tools currently enabled for registration
/// </summary>
List<ToolMetadata> GetEnabledTools();
/// <summary>
/// Checks whether a tool is currently enabled for registration
/// </summary>
bool IsToolEnabled(string toolName);
/// <summary>
/// Updates the enabled state for a tool
/// </summary>
void SetToolEnabled(string toolName, bool enabled);
/// <summary>
/// Invalidates the tool discovery cache
/// </summary>
void InvalidateCache();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 497592a93fd994b2cb9803e7c8636ff7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,98 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service locator for accessing MCP services without dependency injection
/// </summary>
public static class MCPServiceLocator
{
private static IBridgeControlService _bridgeService;
private static IClientConfigurationService _clientService;
private static IPathResolverService _pathService;
private static ITestRunnerService _testRunnerService;
private static IPackageUpdateService _packageUpdateService;
private static IPlatformService _platformService;
private static IToolDiscoveryService _toolDiscoveryService;
private static IResourceDiscoveryService _resourceDiscoveryService;
private static IServerManagementService _serverManagementService;
private static TransportManager _transportManager;
private static IPackageDeploymentService _packageDeploymentService;
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
public static IPlatformService Platform => _platformService ??= new PlatformService();
public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService();
public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService();
public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService();
public static TransportManager TransportManager => _transportManager ??= new TransportManager();
public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService();
/// <summary>
/// Registers a custom implementation for a service (useful for testing)
/// </summary>
/// <typeparam name="T">The service interface type</typeparam>
/// <param name="implementation">The implementation to register</param>
public static void Register<T>(T implementation) where T : class
{
if (implementation is IBridgeControlService b)
_bridgeService = b;
else if (implementation is IClientConfigurationService c)
_clientService = c;
else if (implementation is IPathResolverService p)
_pathService = p;
else if (implementation is ITestRunnerService t)
_testRunnerService = t;
else if (implementation is IPackageUpdateService pu)
_packageUpdateService = pu;
else if (implementation is IPlatformService ps)
_platformService = ps;
else if (implementation is IToolDiscoveryService td)
_toolDiscoveryService = td;
else if (implementation is IResourceDiscoveryService rd)
_resourceDiscoveryService = rd;
else if (implementation is IServerManagementService sm)
_serverManagementService = sm;
else if (implementation is IPackageDeploymentService pd)
_packageDeploymentService = pd;
else if (implementation is TransportManager tm)
_transportManager = tm;
}
/// <summary>
/// Resets all services to their default implementations (useful for testing)
/// </summary>
public static void Reset()
{
(_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
(_packageUpdateService as IDisposable)?.Dispose();
(_platformService as IDisposable)?.Dispose();
(_toolDiscoveryService as IDisposable)?.Dispose();
(_resourceDiscoveryService as IDisposable)?.Dispose();
(_serverManagementService as IDisposable)?.Dispose();
(_transportManager as IDisposable)?.Dispose();
(_packageDeploymentService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
_pathService = null;
_testRunnerService = null;
_packageUpdateService = null;
_platformService = null;
_toolDiscoveryService = null;
_resourceDiscoveryService = null;
_serverManagementService = null;
_transportManager = null;
_packageDeploymentService = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 276d6a9f9a1714ead91573945de78992
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Best-effort cleanup when the Unity Editor is quitting.
/// - Stops active transports so clients don't see a "hung" session longer than necessary.
/// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a
/// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload.
/// </summary>
[InitializeOnLoad]
internal static class McpEditorShutdownCleanup
{
static McpEditorShutdownCleanup()
{
// Guard against duplicate subscriptions across domain reloads.
try { EditorApplication.quitting -= OnEditorQuitting; } catch { }
EditorApplication.quitting += OnEditorQuitting;
}
// A -batchmode/CI instance resolves the interactive editor's server via the global
// pidfile+port handshake, so cleanup there would stop another user's server. Mirror the
// sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in.
internal static bool ShouldRunCleanup() =>
ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) =>
!isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv);
private static void OnEditorQuitting()
{
if (!ShouldRunCleanup()) return;
// 1) Stop transports (best-effort, bounded wait).
try
{
var transport = MCPServiceLocator.TransportManager;
Task stopHttp = transport.StopAsync(TransportMode.Http);
Task stopStdio = transport.StopAsync(TransportMode.Stdio);
try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { }
}
catch (Exception ex)
{
// Avoid hard failures on quit.
McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}");
}
// 2) Stop the local HTTP server this Unity instance launched (best-effort).
// Headless servers have no terminal window, so an unstopped one is an invisible orphan.
// StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake,
// so it never touches servers launched by other Unity instances. This runs on quit only;
// domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting).
try
{
MCPServiceLocator.Server.StopManagedLocalHttpServer();
}
catch (Exception ex)
{
McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4150c04e0907c45d7b332260911a0567
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,304 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Handles copying a local MCPForUnity folder into the current project's package location with backup/restore support.
/// </summary>
public class PackageDeploymentService : IPackageDeploymentService
{
private const string BackupRootFolderName = "MCPForUnityDeployBackups";
public string GetStoredSourcePath()
{
return EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty);
}
public void SetStoredSourcePath(string path)
{
ValidateSource(path);
EditorPrefs.SetString(EditorPrefKeys.PackageDeploySourcePath, Path.GetFullPath(path));
}
public void ClearStoredSourcePath()
{
EditorPrefs.DeleteKey(EditorPrefKeys.PackageDeploySourcePath);
}
public string GetTargetPath()
{
// Prefer Package Manager resolved path for the installed package
var packageInfo = PackageInfo.FindForAssembly(typeof(PackageDeploymentService).Assembly);
if (packageInfo != null)
{
if (!string.IsNullOrEmpty(packageInfo.resolvedPath) && Directory.Exists(packageInfo.resolvedPath))
{
return packageInfo.resolvedPath;
}
if (!string.IsNullOrEmpty(packageInfo.assetPath))
{
string absoluteFromAsset = MakeAbsolute(packageInfo.assetPath);
if (Directory.Exists(absoluteFromAsset))
{
return absoluteFromAsset;
}
}
}
// Fallback to computed package root
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (!string.IsNullOrEmpty(packageRoot))
{
string absolutePath = MakeAbsolute(packageRoot);
if (Directory.Exists(absolutePath))
{
return absolutePath;
}
}
return null;
}
public string GetTargetDisplayPath()
{
string target = GetTargetPath();
if (string.IsNullOrEmpty(target))
return "Not found (check Packages/manifest.json)";
// Use forward slashes to avoid backslash escape sequence issues in UI text
return target.Replace('\\', '/');
}
public string GetLastBackupPath()
{
return EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty);
}
public bool HasBackup()
{
string path = GetLastBackupPath();
return !string.IsNullOrEmpty(path) && Directory.Exists(path);
}
public PackageDeploymentResult DeployFromStoredSource()
{
string sourcePath = GetStoredSourcePath();
if (string.IsNullOrEmpty(sourcePath))
{
return Fail("Select a MCPForUnity folder first.");
}
string validationError = ValidateSource(sourcePath, throwOnError: false);
if (!string.IsNullOrEmpty(validationError))
{
return Fail(validationError);
}
string targetPath = GetTargetPath();
if (string.IsNullOrEmpty(targetPath))
{
return Fail("Could not locate the installed MCP package. Check Packages/manifest.json.");
}
if (PathsEqual(sourcePath, targetPath))
{
return Fail("Source and target are the same. Choose a different MCPForUnity folder.");
}
try
{
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Creating backup...", 0.25f);
string backupPath = CreateBackup(targetPath);
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Replacing package contents...", 0.7f);
CopyCoreFolders(sourcePath, targetPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastBackupPath, backupPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastTargetPath, targetPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastSourcePath, sourcePath);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
return Success("Deployment completed.", sourcePath, targetPath, backupPath);
}
catch (Exception ex)
{
McpLog.Error($"Deployment failed: {ex.Message}");
return Fail($"Deployment failed: {ex.Message}");
}
finally
{
EditorUtility.ClearProgressBar();
}
}
public PackageDeploymentResult RestoreLastBackup()
{
string backupPath = GetLastBackupPath();
string targetPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastTargetPath, string.Empty);
if (string.IsNullOrEmpty(backupPath) || !Directory.Exists(backupPath))
{
return Fail("No backup available to restore.");
}
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
{
targetPath = GetTargetPath();
}
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
{
return Fail("Could not locate target package path.");
}
try
{
EditorUtility.DisplayProgressBar("Restore MCP for Unity", "Restoring backup...", 0.5f);
ReplaceDirectory(backupPath, targetPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
return Success("Restore completed.", null, targetPath, backupPath);
}
catch (Exception ex)
{
McpLog.Error($"Restore failed: {ex.Message}");
return Fail($"Restore failed: {ex.Message}");
}
finally
{
EditorUtility.ClearProgressBar();
}
}
private void CopyCoreFolders(string sourceRoot, string targetRoot)
{
string sourceEditor = Path.Combine(sourceRoot, "Editor");
string sourceRuntime = Path.Combine(sourceRoot, "Runtime");
ReplaceDirectory(sourceEditor, Path.Combine(targetRoot, "Editor"));
ReplaceDirectory(sourceRuntime, Path.Combine(targetRoot, "Runtime"));
}
private static void ReplaceDirectory(string source, string destination)
{
if (Directory.Exists(destination))
{
FileUtil.DeleteFileOrDirectory(destination);
}
FileUtil.CopyFileOrDirectory(source, destination);
}
private string CreateBackup(string targetPath)
{
string backupRoot = Path.Combine(GetProjectRoot(), "Library", BackupRootFolderName);
Directory.CreateDirectory(backupRoot);
string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string backupPath = Path.Combine(backupRoot, $"backup_{stamp}");
if (Directory.Exists(backupPath))
{
FileUtil.DeleteFileOrDirectory(backupPath);
}
FileUtil.CopyFileOrDirectory(targetPath, backupPath);
return backupPath;
}
private static string ValidateSource(string sourcePath, bool throwOnError = true)
{
if (string.IsNullOrEmpty(sourcePath))
{
if (throwOnError)
{
throw new ArgumentException("Source path cannot be empty.");
}
return "Source path is empty.";
}
if (!Directory.Exists(sourcePath))
{
if (throwOnError)
{
throw new ArgumentException("Selected folder does not exist.");
}
return "Selected folder does not exist.";
}
bool hasEditor = Directory.Exists(Path.Combine(sourcePath, "Editor"));
bool hasRuntime = Directory.Exists(Path.Combine(sourcePath, "Runtime"));
if (!hasEditor || !hasRuntime)
{
string message = "Folder must contain Editor and Runtime subfolders.";
if (throwOnError)
{
throw new ArgumentException(message);
}
return message;
}
return null;
}
private static string MakeAbsolute(string assetPath)
{
assetPath = assetPath.Replace('\\', '/');
if (assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
}
if (assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
}
return Path.GetFullPath(assetPath);
}
private static string GetProjectRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
private static bool PathsEqual(string a, string b)
{
string normA = Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string normB = Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.Equals(normA, normB, StringComparison.OrdinalIgnoreCase);
}
private static PackageDeploymentResult Success(string message, string source, string target, string backup)
{
return new PackageDeploymentResult
{
Success = true,
Message = message,
SourcePath = source,
TargetPath = target,
BackupPath = backup
};
}
private static PackageDeploymentResult Fail(string message)
{
return new PackageDeploymentResult
{
Success = false,
Message = message
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,345 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.PackageManager;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Services
{
internal enum PackageJobStatus { Running, Succeeded, Failed }
internal sealed class PackageJob
{
public string JobId { get; set; }
public PackageJobStatus Status { get; set; }
public string Operation { get; set; }
public string Package { get; set; }
public long StartedUnixMs { get; set; }
public long? FinishedUnixMs { get; set; }
public long LastUpdateUnixMs { get; set; }
public string Error { get; set; }
public string ResultVersion { get; set; }
public string ResultName { get; set; }
}
internal static class PackageJobManager
{
private const string SessionKeyJobs = "MCPForUnity.PackageJobsV1";
private const int MaxJobsToKeep = 10;
private const long DomainReloadTimeoutMs = 120_000;
private static readonly object LockObj = new();
private static readonly Dictionary<string, PackageJob> Jobs = new();
static PackageJobManager()
{
TryRestoreFromSessionState();
}
private sealed class PersistedState
{
public List<PersistedJob> jobs { get; set; }
}
private sealed class PersistedJob
{
public string job_id { get; set; }
public string status { get; set; }
public string operation { get; set; }
public string package_ { get; set; }
public long started_unix_ms { get; set; }
public long? finished_unix_ms { get; set; }
public long last_update_unix_ms { get; set; }
public string error { get; set; }
public string result_version { get; set; }
public string result_name { get; set; }
}
private static PackageJobStatus ParseStatus(string status)
{
if (string.IsNullOrWhiteSpace(status))
return PackageJobStatus.Running;
return status.Trim().ToLowerInvariant() switch
{
"succeeded" => PackageJobStatus.Succeeded,
"failed" => PackageJobStatus.Failed,
_ => PackageJobStatus.Running
};
}
private static void TryRestoreFromSessionState()
{
try
{
string json = SessionState.GetString(SessionKeyJobs, string.Empty);
if (string.IsNullOrWhiteSpace(json))
return;
var state = JsonConvert.DeserializeObject<PersistedState>(json);
if (state?.jobs == null)
return;
lock (LockObj)
{
Jobs.Clear();
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var pj in state.jobs)
{
if (pj == null || string.IsNullOrWhiteSpace(pj.job_id))
continue;
var job = new PackageJob
{
JobId = pj.job_id,
Status = ParseStatus(pj.status),
Operation = pj.operation,
Package = pj.package_,
StartedUnixMs = pj.started_unix_ms,
FinishedUnixMs = pj.finished_unix_ms,
LastUpdateUnixMs = pj.last_update_unix_ms,
Error = pj.error,
ResultVersion = pj.result_version,
ResultName = pj.result_name
};
// Domain reload recovery for running jobs
if (job.Status == PackageJobStatus.Running)
{
TryRecoverJob(job, now);
}
Jobs[pj.job_id] = job;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Failed to restore SessionState: {ex.Message}");
}
}
internal static void TryRecoverJob(PackageJob job, long nowMs)
{
try
{
string packageName = ExtractPackageName(job.Package);
var allPackages = PackageInfo.GetAllRegisteredPackages();
var info = FindPackageInfo(allPackages, packageName, job.Package);
if (job.Operation == "add" || job.Operation == "embed")
{
if (info != null)
{
job.Status = PackageJobStatus.Succeeded;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.ResultVersion = info.version;
job.ResultName = info.name;
McpLog.Info($"[PackageJobManager] Recovered {job.Operation} job {job.JobId}: {info.name}@{info.version} installed.");
}
else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs)
{
job.Status = PackageJobStatus.Failed;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.Error = $"Package {job.Operation} timed out after domain reload.";
McpLog.Warn($"[PackageJobManager] Timed out {job.Operation} job {job.JobId} for '{job.Package}'.");
}
}
else if (job.Operation == "remove")
{
if (info == null)
{
job.Status = PackageJobStatus.Succeeded;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
McpLog.Info($"[PackageJobManager] Recovered remove job {job.JobId}: '{packageName}' is no longer installed.");
}
else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs)
{
job.Status = PackageJobStatus.Failed;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.Error = "Package removal timed out after domain reload.";
McpLog.Warn($"[PackageJobManager] Timed out remove job {job.JobId} for '{job.Package}'.");
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Recovery check failed for job {job.JobId}: {ex.Message}");
}
}
/// <summary>
/// Find a PackageInfo by name, falling back to packageId or git/local source for non-standard identifiers.
/// </summary>
private static PackageInfo FindPackageInfo(PackageInfo[] allPackages, string packageName, string originalIdentifier)
{
// Direct name match (handles normal com.company.package identifiers)
var info = allPackages.FirstOrDefault(p =>
string.Equals(p.name, packageName, StringComparison.OrdinalIgnoreCase));
if (info != null)
return info;
// For git URLs / file: paths, packageName == originalIdentifier and won't match .name.
// Try matching by packageId or source (git/local).
bool isGitOrFile = originalIdentifier.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.StartsWith("git", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.StartsWith("file:", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.EndsWith(".git", StringComparison.OrdinalIgnoreCase);
if (!isGitOrFile)
return null;
return allPackages.FirstOrDefault(p =>
p.source == PackageSource.Git || p.source == PackageSource.Local
? p.packageId != null && p.packageId.Contains(originalIdentifier)
|| p.resolvedPath != null && p.resolvedPath.Contains(originalIdentifier)
: false);
}
internal static string ExtractPackageName(string packageIdentifier)
{
if (string.IsNullOrEmpty(packageIdentifier))
return packageIdentifier;
// Strip version: "com.unity.foo@1.0.0" -> "com.unity.foo"
int atIndex = packageIdentifier.IndexOf('@');
if (atIndex > 0)
return packageIdentifier.Substring(0, atIndex);
// Git URLs and file: paths — can't reliably extract name, return as-is
return packageIdentifier;
}
internal static void PersistToSessionState()
{
try
{
PersistedState snapshot;
lock (LockObj)
{
var jobs = Jobs.Values
.OrderByDescending(j => j.LastUpdateUnixMs)
.Take(MaxJobsToKeep)
.Select(j => new PersistedJob
{
job_id = j.JobId,
status = j.Status.ToString().ToLowerInvariant(),
operation = j.Operation,
package_ = j.Package,
started_unix_ms = j.StartedUnixMs,
finished_unix_ms = j.FinishedUnixMs,
last_update_unix_ms = j.LastUpdateUnixMs,
error = j.Error,
result_version = j.ResultVersion,
result_name = j.ResultName
})
.ToList();
snapshot = new PersistedState { jobs = jobs };
}
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot));
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Failed to persist SessionState: {ex.Message}");
}
}
public static string StartJob(string operation, string package)
{
string jobId = Guid.NewGuid().ToString("N");
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var job = new PackageJob
{
JobId = jobId,
Status = PackageJobStatus.Running,
Operation = operation,
Package = package,
StartedUnixMs = started,
FinishedUnixMs = null,
LastUpdateUnixMs = started,
Error = null,
ResultVersion = null,
ResultName = null
};
lock (LockObj)
{
Jobs[jobId] = job;
}
PersistToSessionState();
return jobId;
}
public static void CompleteJob(string jobId, bool success, string error = null,
string version = null, string name = null)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (!Jobs.TryGetValue(jobId, out var job))
return;
job.Status = success ? PackageJobStatus.Succeeded : PackageJobStatus.Failed;
job.FinishedUnixMs = now;
job.LastUpdateUnixMs = now;
job.Error = error;
job.ResultVersion = version;
job.ResultName = name;
}
PersistToSessionState();
}
public static PackageJob GetJob(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
return null;
lock (LockObj)
{
Jobs.TryGetValue(jobId, out var job);
return job;
}
}
public static PackageJob GetLatestJob()
{
lock (LockObj)
{
return Jobs.Values
.OrderByDescending(j => j.StartedUnixMs)
.FirstOrDefault();
}
}
public static object ToSerializable(PackageJob job)
{
if (job == null)
return null;
return new
{
job_id = job.JobId,
status = job.Status.ToString().ToLowerInvariant(),
operation = job.Operation,
package_ = job.Package,
started_unix_ms = job.StartedUnixMs,
finished_unix_ms = job.FinishedUnixMs,
last_update_unix_ms = job.LastUpdateUnixMs,
error = job.Error,
result_version = job.ResultVersion,
result_name = job.ResultName
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0c8e16aa625e01544beb2468fda53613
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,451 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates from GitHub or Asset Store metadata
/// </summary>
public class PackageUpdateService : IPackageUpdateService
{
private const int DefaultRequestTimeoutMs = 3000;
private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion;
private const string LastBetaCheckDateKey = EditorPrefKeys.LastUpdateCheck + ".beta";
private const string CachedBetaVersionKey = EditorPrefKeys.LatestKnownVersion + ".beta";
private const string LastAssetStoreCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck;
private const string CachedAssetStoreVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion;
private const string MainPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
private const string BetaPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/beta/MCPForUnity/package.json";
private const string AssetStoreVersionUrl = "https://gqoqjkkptwfbkwyssmnj.supabase.co/storage/v1/object/public/coplay-images/assetstoreversion.json";
/// <inheritdoc/>
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
bool isGitInstallation = IsGitInstallation();
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
string lastCheckKey = isGitInstallation
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
: LastAssetStoreCheckDateKey;
string cachedVersionKey = isGitInstallation
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
: CachedAssetStoreVersionKey;
string lastCheckDate = EditorPrefs.GetString(lastCheckKey, "");
string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, "");
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = cachedLatestVersion,
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
Message = "Using cached version check"
};
}
string latestVersion = isGitInstallation
? FetchLatestVersionFromGitHub(gitBranch)
: FetchLatestVersionFromAssetStoreJson();
if (!string.IsNullOrEmpty(latestVersion))
{
// Cache the result
EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(cachedVersionKey, latestVersion);
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = latestVersion,
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
Message = "Successfully checked for updates"
};
}
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = isGitInstallation
? "Failed to check for updates (network issue or offline)"
: "Failed to check for Asset Store updates (network issue or offline)"
};
}
/// <inheritdoc/>
public UpdateCheckResult TryGetCachedResult(string currentVersion)
{
bool isGitInstallation = IsGitInstallation();
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
string lastCheckKey = isGitInstallation
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
: LastAssetStoreCheckDateKey;
string cachedVersionKey = isGitInstallation
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
: CachedAssetStoreVersionKey;
string lastCheckDate = EditorPrefs.GetString(lastCheckKey, "");
string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, "");
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = cachedLatestVersion,
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
Message = "Using cached version check"
};
}
return null;
}
/// <inheritdoc/>
public UpdateCheckResult FetchAndCompare(string currentVersion)
{
bool isGitInstallation = IsGitInstallation();
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
return FetchAndCompare(currentVersion, isGitInstallation, gitBranch);
}
/// <inheritdoc/>
public UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch)
{
string latestVersion = isGitInstallation
? FetchLatestVersionFromGitHub(gitBranch)
: FetchLatestVersionFromAssetStoreJson();
if (!string.IsNullOrEmpty(latestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = latestVersion,
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
Message = "Successfully checked for updates"
};
}
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = isGitInstallation
? "Failed to check for updates (network issue or offline)"
: "Failed to check for Asset Store updates (network issue or offline)"
};
}
/// <inheritdoc/>
public void CacheFetchResult(string currentVersion, string fetchedVersion)
{
if (string.IsNullOrEmpty(fetchedVersion)) return;
bool isGitInstallation = IsGitInstallation();
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
string lastCheckKey = isGitInstallation
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
: LastAssetStoreCheckDateKey;
string cachedVersionKey = isGitInstallation
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
: CachedAssetStoreVersionKey;
EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(cachedVersionKey, fetchedVersion);
}
/// <inheritdoc/>
public bool IsNewerVersion(string version1, string version2)
{
if (!TryParseVersion(version1, out var left) || !TryParseVersion(version2, out var right))
{
return false;
}
return CompareVersions(left, right) > 0;
}
private static int CompareVersions(ParsedVersion left, ParsedVersion right)
{
int cmp = left.Major.CompareTo(right.Major);
if (cmp != 0) return cmp;
cmp = left.Minor.CompareTo(right.Minor);
if (cmp != 0) return cmp;
cmp = left.Patch.CompareTo(right.Patch);
if (cmp != 0) return cmp;
// Stable is newer than prerelease when core version matches.
if (!left.IsPrerelease && right.IsPrerelease) return 1;
if (left.IsPrerelease && !right.IsPrerelease) return -1;
if (!left.IsPrerelease && !right.IsPrerelease) return 0;
cmp = GetPrereleaseRank(left.PrereleaseLabel).CompareTo(GetPrereleaseRank(right.PrereleaseLabel));
if (cmp != 0) return cmp;
cmp = left.PrereleaseNumber.CompareTo(right.PrereleaseNumber);
if (cmp != 0) return cmp;
return string.Compare(left.PrereleaseLabel, right.PrereleaseLabel, StringComparison.OrdinalIgnoreCase);
}
private static int GetPrereleaseRank(string label)
{
if (string.IsNullOrEmpty(label))
{
return 0;
}
switch (label.ToLowerInvariant())
{
case "a":
case "alpha":
return 1;
case "b":
case "beta":
return 2;
case "rc":
return 3;
case "preview":
case "pre":
return 4;
default:
return 5;
}
}
private static bool TryParseVersion(string version, out ParsedVersion parsed)
{
parsed = default;
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
string normalized = version.Trim().TrimStart('v', 'V');
var match = Regex.Match(
normalized,
@"^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<label>[A-Za-z]+)(?:\.(?<number>\d+))?)?$");
if (!match.Success)
{
return false;
}
if (!int.TryParse(match.Groups["major"].Value, out int major) ||
!int.TryParse(match.Groups["minor"].Value, out int minor) ||
!int.TryParse(match.Groups["patch"].Value, out int patch))
{
return false;
}
string prereleaseLabel = match.Groups["label"].Success ? match.Groups["label"].Value : string.Empty;
int prereleaseNumber = 0;
if (match.Groups["number"].Success)
{
int.TryParse(match.Groups["number"].Value, out prereleaseNumber);
}
parsed = new ParsedVersion
{
Major = major,
Minor = minor,
Patch = patch,
PrereleaseLabel = prereleaseLabel,
PrereleaseNumber = prereleaseNumber,
IsPrerelease = !string.IsNullOrEmpty(prereleaseLabel)
};
return true;
}
private static bool IsPreReleaseVersion(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return AssetPathUtility.IsPreReleaseVersion();
}
return version.IndexOf('-', StringComparison.Ordinal) >= 0;
}
/// <inheritdoc/>
public string GetGitUpdateBranch(string currentVersion)
{
try
{
var packageInfo = PackageInfo.FindForAssembly(typeof(PackageUpdateService).Assembly);
string packageId = packageInfo?.packageId ?? string.Empty;
if (packageId.IndexOf("#beta", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "beta";
}
if (packageId.IndexOf("#main", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "main";
}
}
catch
{
// Fall back to version-based inference below.
}
return IsPreReleaseVersion(currentVersion) ? "beta" : "main";
}
/// <inheritdoc/>
public virtual bool IsGitInstallation()
{
// Git packages are installed via Package Manager and have a package.json in Packages/
// Asset Store packages are in Assets/
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return false;
}
// If the package is in Packages/ it's a PM install (likely Git)
// If it's in Assets/ it's an Asset Store install
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public void ClearCache()
{
EditorPrefs.DeleteKey(LastCheckDateKey);
EditorPrefs.DeleteKey(CachedVersionKey);
EditorPrefs.DeleteKey(LastBetaCheckDateKey);
EditorPrefs.DeleteKey(CachedBetaVersionKey);
EditorPrefs.DeleteKey(LastAssetStoreCheckDateKey);
EditorPrefs.DeleteKey(CachedAssetStoreVersionKey);
}
/// <summary>
/// Fetches the latest version from GitHub package.json for the requested branch.
/// </summary>
protected virtual string FetchLatestVersionFromGitHub(string branch)
{
try
{
// GitHub API endpoint (Option 1 - has rate limits):
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
//
// We use Option 2 (package.json directly) because:
// - No API rate limits (GitHub serves raw files freely)
// - Simpler - just parse JSON for version field
// - More reliable - doesn't require releases to be published
// - Direct source of truth from the main branch
using (var client = CreateWebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
string packageJsonUrl = string.Equals(branch, "beta", StringComparison.OrdinalIgnoreCase)
? BetaPackageJsonUrl
: MainPackageJsonUrl;
string jsonContent = client.DownloadString(packageJsonUrl);
var packageJson = JObject.Parse(jsonContent);
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
private struct ParsedVersion
{
public int Major;
public int Minor;
public int Patch;
public string PrereleaseLabel;
public int PrereleaseNumber;
public bool IsPrerelease;
}
/// <summary>
/// Fetches the latest Asset Store version from a hosted JSON file.
/// </summary>
protected virtual string FetchLatestVersionFromAssetStoreJson()
{
try
{
using (var client = CreateWebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-AssetStoreUpdateChecker");
string jsonContent = client.DownloadString(AssetStoreVersionUrl);
var versionJson = JObject.Parse(jsonContent);
string version = versionJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Asset Store update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
protected virtual WebClient CreateWebClient()
{
return new TimeoutWebClient(GetRequestTimeoutMs());
}
protected virtual int GetRequestTimeoutMs()
{
return DefaultRequestTimeoutMs;
}
private sealed class TimeoutWebClient : WebClient
{
private readonly int _timeoutMs;
public TimeoutWebClient(int timeoutMs)
{
_timeoutMs = timeoutMs;
}
protected override WebRequest GetWebRequest(Uri address)
{
var request = base.GetWebRequest(address);
if (request != null)
{
request.Timeout = _timeoutMs;
if (request is HttpWebRequest httpRequest)
{
httpRequest.ReadWriteTimeout = _timeoutMs;
}
}
return request;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c3c2304b14e9485ca54182fad73b035
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,317 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Implementation of path resolver service with override support
/// </summary>
public class PathResolverService : IPathResolverService
{
private bool _hasUvxPathFallback;
public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null));
public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null));
public bool HasUvxPathFallback => _hasUvxPathFallback;
public string GetUvxPath()
{
// Reset fallback flag at the start of each resolution
_hasUvxPathFallback = false;
// Check override first - only validate if explicitly set
if (HasUvxPathOverride)
{
string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
// Validate the override - if invalid, fall back to system discovery
if (TryValidateUvxExecutable(overridePath, out string version))
{
return overridePath;
}
// Override is set but invalid - fall back to system discovery
string fallbackPath = ResolveUvxFromSystem();
if (!string.IsNullOrEmpty(fallbackPath))
{
_hasUvxPathFallback = true;
return fallbackPath;
}
// Return null to indicate override is invalid and no system fallback found
return null;
}
// No override set - try discovery (uvx first, then uv)
string discovered = ResolveUvxFromSystem();
if (!string.IsNullOrEmpty(discovered))
{
return discovered;
}
// Fallback to bare command
return "uvx";
}
/// <summary>
/// Resolves uv/uvx from system by trying both commands.
/// Returns the full path if found, null otherwise.
/// </summary>
private static string ResolveUvxFromSystem()
{
try
{
// Try uvx first, then uv
string[] commandNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new[] { "uvx.exe", "uv.exe" }
: new[] { "uvx", "uv" };
foreach (string commandName in commandNames)
{
foreach (string candidate in EnumerateCommandCandidates(commandName))
{
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
{
return candidate;
}
}
}
}
catch (Exception ex)
{
McpLog.Debug($"PathResolver error: {ex.Message}");
}
return null;
}
public string GetClaudeCliPath()
{
// Check override first - only validate if explicitly set
if (HasClaudeCliPathOverride)
{
string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
// Validate the override - if invalid, don't fall back to discovery
if (File.Exists(overridePath))
{
return overridePath;
}
// Override is set but invalid - return null (no fallback)
return null;
}
// No override: delegate to the shared discovery in ExecPath, which covers the
// native-installer, npm, NVM and PATH-scan locations for every platform. Kept in
// one place so both call sites (this and ExecPath's own callers) stay in sync.
return ExecPath.ResolveClaude();
}
public bool IsPythonDetected()
{
return ExecPath.TryRun(
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3",
"--version",
null,
out _,
out _,
2000);
}
public bool IsClaudeCliDetected()
{
return !string.IsNullOrEmpty(GetClaudeCliPath());
}
public void SetUvxPathOverride(string path)
{
if (string.IsNullOrEmpty(path))
{
ClearUvxPathOverride();
return;
}
if (!File.Exists(path))
{
throw new ArgumentException("The selected uvx executable does not exist");
}
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path);
}
public void SetClaudeCliPathOverride(string path)
{
if (string.IsNullOrEmpty(path))
{
ClearClaudeCliPathOverride();
return;
}
if (!File.Exists(path))
{
throw new ArgumentException("The selected Claude CLI executable does not exist");
}
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path);
}
public void ClearUvxPathOverride()
{
EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride);
}
public void ClearClaudeCliPathOverride()
{
EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride);
}
/// <summary>
/// Validates the provided uv executable by running "--version" and parsing the output.
/// </summary>
/// <param name="uvxPath">Absolute or relative path to the uv/uvx executable.</param>
/// <param name="version">Parsed version string if successful.</param>
/// <returns>True when the executable runs and returns a uvx version string.</returns>
public bool TryValidateUvxExecutable(string uvxPath, out string version)
{
version = null;
if (string.IsNullOrEmpty(uvxPath))
return false;
try
{
// Check if the path is just a command name (no directory separator)
bool isBareCommand = !uvxPath.Contains('/') && !uvxPath.Contains('\\');
if (isBareCommand)
{
// For bare commands like "uvx" or "uv", use EnumerateCommandCandidates to find full path first
string fullPath = FindUvxExecutableInPath(uvxPath);
if (string.IsNullOrEmpty(fullPath))
return false;
uvxPath = fullPath;
}
// Use ExecPath.TryRun which properly handles async output reading and timeouts
if (!ExecPath.TryRun(uvxPath, "--version", null, out string stdout, out string stderr, 5000))
return false;
// Check stdout first, then stderr (some tools output to stderr)
string versionOutput = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
// uv/uvx outputs "uv x.y.z" or "uvx x.y.z", extract version number
if (versionOutput.StartsWith("uvx ") || versionOutput.StartsWith("uv "))
{
// Extract version: "uv 0.9.18 (hash date)" -> "0.9.18"
int spaceIndex = versionOutput.IndexOf(' ');
if (spaceIndex >= 0)
{
string afterCommand = versionOutput.Substring(spaceIndex + 1).Trim();
// Version is up to the first space or parenthesis
int nextSpace = afterCommand.IndexOf(' ');
int parenIndex = afterCommand.IndexOf('(');
int endIndex = Math.Min(
nextSpace >= 0 ? nextSpace : int.MaxValue,
parenIndex >= 0 ? parenIndex : int.MaxValue
);
version = endIndex < int.MaxValue ? afterCommand.Substring(0, endIndex).Trim() : afterCommand;
return true;
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
private string FindUvxExecutableInPath(string commandName)
{
try
{
// Generic search for any command in PATH and common locations
foreach (string candidate in EnumerateCommandCandidates(commandName))
{
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
{
return candidate;
}
}
}
catch
{
// Ignore errors
}
return null;
}
/// <summary>
/// Enumerates candidate paths for a generic command name.
/// Searches PATH and common locations.
/// </summary>
private static IEnumerable<string> EnumerateCommandCandidates(string commandName)
{
string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !commandName.EndsWith(".exe")
? commandName + ".exe"
: commandName;
// Search PATH first
string pathEnv = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(pathEnv))
{
foreach (string rawDir in pathEnv.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(rawDir)) continue;
string dir = rawDir.Trim();
yield return Path.Combine(dir, exeName);
}
}
// User-local binary directories
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(home))
{
yield return Path.Combine(home, ".local", "bin", exeName);
yield return Path.Combine(home, ".cargo", "bin", exeName);
}
// System directories (platform-specific)
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
yield return "/opt/homebrew/bin/" + exeName;
yield return "/usr/local/bin/" + exeName;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
yield return "/usr/local/bin/" + exeName;
yield return "/usr/bin/" + exeName;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrEmpty(localAppData))
{
yield return Path.Combine(localAppData, "Programs", "uv", exeName);
// WinGet creates shim files in this location
yield return Path.Combine(localAppData, "Microsoft", "WinGet", "Links", exeName);
}
if (!string.IsNullOrEmpty(programFiles))
{
yield return Path.Combine(programFiles, "uv", exeName);
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00a6188fd15a847fa8cc7cb7a4ce3dce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
using System;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Default implementation of platform detection service
/// </summary>
public class PlatformService : IPlatformService
{
/// <summary>
/// Checks if the current platform is Windows
/// </summary>
/// <returns>True if running on Windows</returns>
public bool IsWindows()
{
return Environment.OSVersion.Platform == PlatformID.Win32NT;
}
/// <summary>
/// Gets the SystemRoot environment variable (Windows-specific)
/// </summary>
/// <returns>SystemRoot path, or "C:\\Windows" as fallback on Windows, null on other platforms</returns>
public string GetSystemRoot()
{
if (!IsWindows())
return null;
return Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3b2d7f32a595c45dd8c01f141c69761c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
public class ResourceDiscoveryService : IResourceDiscoveryService
{
private Dictionary<string, ResourceMetadata> _cachedResources;
public List<ResourceMetadata> DiscoverAllResources()
{
if (_cachedResources != null)
{
return _cachedResources.Values.ToList();
}
_cachedResources = new Dictionary<string, ResourceMetadata>();
var resourceTypes = TypeCache.GetTypesWithAttribute<McpForUnityResourceAttribute>();
foreach (var type in resourceTypes)
{
McpForUnityResourceAttribute resourceAttr;
try
{
resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read [McpForUnityResource] for {type.FullName}: {ex.Message}");
continue;
}
if (resourceAttr == null)
{
continue;
}
var metadata = ExtractResourceMetadata(type, resourceAttr);
if (metadata != null)
{
if (_cachedResources.ContainsKey(metadata.Name))
{
McpLog.Warn($"Duplicate resource name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
}
_cachedResources[metadata.Name] = metadata;
EnsurePreferenceInitialized(metadata);
}
}
McpLog.Info($"Discovered {_cachedResources.Count} MCP resources via reflection", false);
return _cachedResources.Values.ToList();
}
public ResourceMetadata GetResourceMetadata(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return null;
}
if (_cachedResources == null)
{
DiscoverAllResources();
}
return _cachedResources.TryGetValue(resourceName, out var metadata) ? metadata : null;
}
public List<ResourceMetadata> GetEnabledResources()
{
return DiscoverAllResources()
.Where(r => IsResourceEnabled(r.Name))
.ToList();
}
public bool IsResourceEnabled(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return false;
}
string key = GetResourcePreferenceKey(resourceName);
if (EditorPrefs.HasKey(key))
{
return EditorPrefs.GetBool(key, true);
}
// Default: all resources enabled
return true;
}
public void SetResourceEnabled(string resourceName, bool enabled)
{
if (string.IsNullOrEmpty(resourceName))
{
return;
}
string key = GetResourcePreferenceKey(resourceName);
EditorPrefs.SetBool(key, enabled);
}
public void InvalidateCache()
{
_cachedResources = null;
}
private ResourceMetadata ExtractResourceMetadata(Type type, McpForUnityResourceAttribute resourceAttr)
{
try
{
string resourceName = resourceAttr.ResourceName;
if (string.IsNullOrEmpty(resourceName))
{
resourceName = StringCaseUtility.ToSnakeCase(type.Name);
}
string description = resourceAttr.Description ?? $"Resource: {resourceName}";
var metadata = new ResourceMetadata
{
Name = resourceName,
Description = description,
ClassName = type.Name,
Namespace = type.Namespace ?? "",
AssemblyName = type.Assembly.GetName().Name
};
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
type, metadata.AssemblyName, "MCPForUnity.Editor.Resources");
return metadata;
}
catch (Exception ex)
{
McpLog.Error($"Failed to extract metadata for resource {type.Name}: {ex.Message}");
return null;
}
}
private void EnsurePreferenceInitialized(ResourceMetadata metadata)
{
if (metadata == null || string.IsNullOrEmpty(metadata.Name))
{
return;
}
string key = GetResourcePreferenceKey(metadata.Name);
if (!EditorPrefs.HasKey(key))
{
EditorPrefs.SetBool(key, true);
}
}
private static string GetResourcePreferenceKey(string resourceName)
{
return EditorPrefKeys.ResourceEnabledPrefix + resourceName;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66ce49d2cc47a4bd3aa85ac9f099b757
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1bb072befc9fe4242a501f46dce3fea1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,94 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for managing PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// </summary>
public interface IPidFileManager
{
/// <summary>
/// Gets the directory where PID files are stored.
/// </summary>
/// <returns>Path to the PID file directory</returns>
string GetPidDirectory();
/// <summary>
/// Gets the path to the PID file for a specific port.
/// </summary>
/// <param name="port">The port number</param>
/// <returns>Full path to the PID file</returns>
string GetPidFilePath(int port);
/// <summary>
/// Attempts to read the PID from a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="pid">Output: the process ID if found</param>
/// <returns>True if a valid PID was read</returns>
bool TryReadPid(string pidFilePath, out int pid);
/// <summary>
/// Attempts to extract the port number from a PID file path.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="port">Output: the port number</param>
/// <returns>True if the port was extracted successfully</returns>
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
/// <summary>
/// Deletes a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file to delete</param>
void DeletePidFile(string pidFilePath);
/// <summary>
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="instanceToken">Unique instance token for the server</param>
void StoreHandshake(string pidFilePath, string instanceToken);
/// <summary>
/// Attempts to retrieve stored handshake information from EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Output: stored PID file path</param>
/// <param name="instanceToken">Output: stored instance token</param>
/// <returns>True if valid handshake information was found</returns>
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
/// <summary>
/// Stores PID tracking information in EditorPrefs.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="port">The port number</param>
/// <param name="argsHash">Optional hash of the command arguments</param>
void StoreTracking(int pid, int port, string argsHash = null);
/// <summary>
/// Attempts to retrieve a stored PID for the expected port.
/// Validates that the stored information is still valid (within 6-hour window).
/// </summary>
/// <param name="expectedPort">The expected port number</param>
/// <param name="pid">Output: the stored process ID</param>
/// <returns>True if a valid stored PID was found</returns>
bool TryGetStoredPid(int expectedPort, out int pid);
/// <summary>
/// Gets the stored args hash for the tracked server.
/// </summary>
/// <returns>The stored args hash, or empty string if not found</returns>
string GetStoredArgsHash();
/// <summary>
/// Clears all PID tracking information from EditorPrefs.
/// </summary>
void ClearTracking();
/// <summary>
/// Computes a short hash of the input string for fingerprinting.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>A short hash string (16 hex characters)</returns>
string ComputeShortHash(string input);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4a4c5d093da74ce79fb29a0670a58a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process inspection operations.
/// Provides methods to detect MCP server processes, query process command lines,
/// and find processes listening on specific ports.
/// </summary>
public interface IProcessDetector
{
/// <summary>
/// Determines if a process looks like an MCP server process based on its command line.
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process appears to be an MCP server</returns>
bool LooksLikeMcpServerProcess(int pid);
/// <summary>
/// Attempts to get the command line arguments for a Unix process.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
/// <returns>True if the command line was retrieved successfully</returns>
bool TryGetProcessCommandLine(int pid, out string argsLower);
/// <summary>
/// Gets the process IDs of all processes listening on a specific TCP port.
/// </summary>
/// <param name="port">The port number to check</param>
/// <returns>List of process IDs listening on the port</returns>
List<int> GetListeningProcessIdsForPort(int port);
/// <summary>
/// Gets the current Unity Editor process ID safely.
/// </summary>
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
int GetCurrentProcessId();
/// <summary>
/// Checks if a process exists on Unix systems.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process exists</returns>
bool ProcessExists(int pid);
/// <summary>
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>Normalized string for matching</returns>
string NormalizeForMatch(string input);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 25f32875fb87541b69ead19c08520836
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process termination.
/// Provides methods to terminate processes gracefully or forcefully.
/// </summary>
public interface IProcessTerminator
{
/// <summary>
/// Terminates a process using platform-appropriate methods.
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
/// On Windows: Tries taskkill, then taskkill /F.
/// </summary>
/// <param name="pid">The process ID to terminate</param>
/// <returns>True if the process was terminated successfully</returns>
bool Terminate(int pid);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a55c18e08b534afa85654410da8a463
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,39 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for building uvx/server command strings.
/// Handles platform-specific command construction for starting the MCP HTTP server.
/// </summary>
public interface IServerCommandBuilder
{
/// <summary>
/// Attempts to build the command parts for starting the local HTTP server.
/// </summary>
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
/// <param name="arguments">Output: the command arguments</param>
/// <param name="displayCommand">Output: the full command string for display</param>
/// <param name="error">Output: error message if the command cannot be built</param>
/// <returns>True if the command was built successfully</returns>
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
/// <summary>
/// Builds the uv path from the uvx path by replacing uvx with uv.
/// </summary>
/// <param name="uvxPath">Path to uvx executable</param>
/// <returns>Path to uv executable</returns>
string BuildUvPathFromUvx(string uvxPath);
/// <summary>
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
/// </summary>
/// <returns>Paths to prepend to PATH environment variable</returns>
string GetPlatformSpecificPathPrepend();
/// <summary>
/// Quotes a string if it contains spaces.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
string QuoteIfNeeded(string input);
}
}

Some files were not shown because too many files have changed in this diff Show More