diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml
index 457dc736b..d5df5a337 100644
--- a/.github/workflows/beta-release.yml
+++ b/.github/workflows/beta-release.yml
@@ -13,9 +13,21 @@ on:
- "MCPForUnity/**"
jobs:
+ unity_tests:
+ name: Unity tests gate
+ if: github.actor != 'github-actions[bot]'
+ uses: ./.github/workflows/unity-tests.yml
+ secrets: inherit
+
+ python_tests:
+ name: Python tests gate
+ if: github.actor != 'github-actions[bot]'
+ uses: ./.github/workflows/python-tests.yml
+
update_unity_beta_version:
name: Update Unity package to beta version
runs-on: ubuntu-latest
+ needs: [unity_tests, python_tests]
# Avoid running when the workflow's own automation merges the PR
# created by this workflow (prevents a version-bump loop).
if: github.actor != 'github-actions[bot]'
@@ -143,6 +155,7 @@ jobs:
publish_pypi_prerelease:
name: Publish beta to PyPI (pre-release)
runs-on: ubuntu-latest
+ needs: [unity_tests, python_tests]
# Avoid double-publish when the bot merges the version bump PR
if: github.actor != 'github-actions[bot]'
environment:
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 55e82c495..134728bec 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -6,7 +6,19 @@ on:
paths:
- Server/**
- .github/workflows/python-tests.yml
+ pull_request:
+ branches: [main, beta]
+ paths:
+ - Server/**
+ - .github/workflows/python-tests.yml
workflow_dispatch: {}
+ workflow_call:
+ inputs:
+ ref:
+ description: "Git ref to test (defaults to the triggering ref)."
+ type: string
+ required: false
+ default: ""
jobs:
test:
@@ -15,6 +27,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.ref || github.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index af5309aed..4815e7ca7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,9 +19,23 @@ on:
required: true
jobs:
+ unity_tests:
+ name: Unity tests gate
+ uses: ./.github/workflows/unity-tests.yml
+ with:
+ ref: beta
+ secrets: inherit
+
+ python_tests:
+ name: Python tests gate
+ uses: ./.github/workflows/python-tests.yml
+ with:
+ ref: beta
+
bump:
name: Bump version, tag, and create release
runs-on: ubuntu-latest
+ needs: [unity_tests, python_tests]
permissions:
contents: write
pull-requests: write
diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml
index fbb96d389..e4fbed4f3 100644
--- a/.github/workflows/unity-tests.yml
+++ b/.github/workflows/unity-tests.yml
@@ -2,17 +2,43 @@ name: Unity Tests
on:
workflow_dispatch: {}
+ workflow_call:
+ inputs:
+ ref:
+ description: "Git ref to test (defaults to the triggering ref)."
+ type: string
+ required: false
+ default: ""
push:
branches: ["**"]
paths:
- TestProjects/UnityMCPTests/**
- MCPForUnity/Editor/**
+ - MCPForUnity/Runtime/**
+ - .github/workflows/unity-tests.yml
+ # Fork PRs: maintainer applies the 'safe-to-test' label after reviewing
+ # the diff. The workflow runs with UNITY_LICENSE in scope against the
+ # PR's head SHA. Re-pushed commits do NOT auto-trigger — maintainer must
+ # remove and re-apply the label to re-run after additional review.
+ pull_request_target:
+ types: [labeled]
+ branches: [main, beta]
+ paths:
+ - TestProjects/UnityMCPTests/**
+ - MCPForUnity/Editor/**
+ - MCPForUnity/Runtime/**
- .github/workflows/unity-tests.yml
jobs:
testAllModes:
name: Test in ${{ matrix.testMode }}
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ if: >
+ github.event_name != 'pull_request_target' ||
+ (github.event.pull_request.head.repo.full_name != github.repository &&
+ github.event.label.name == 'safe-to-test')
strategy:
fail-fast: false
matrix:
@@ -27,6 +53,8 @@ jobs:
uses: actions/checkout@v4
with:
lfs: true
+ ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
+ persist-credentials: false
- name: Detect Unity license secrets
id: detect
@@ -107,7 +135,7 @@ jobs:
fi
- uses: actions/upload-artifact@v4
- if: always() && steps.detect.outputs.unity_ok == 'true'
+ if: always() && steps.detect.outputs.unity_ok == 'true' && steps.tests.outcome != 'skipped'
with:
name: Test results for ${{ matrix.testMode }}
path: ${{ steps.tests.outputs.artifactsPath }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 11c17935e..8b0c0e395 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -140,6 +140,11 @@ Use `CommandRegistry.InvokeCommandAsync` to call other tools from within a handl
var result = await CommandRegistry.InvokeCommandAsync("read_console", consoleParams);
```
+### Unity API Compatibility Shims
+We support a wide Unity version range (2021+ → 6.x → CoreCLR 6.8). When an API is renamed, deprecated, or removed across versions, **don't sprinkle `#if UNITY_x_y_OR_NEWER` at every call site** — add a shim in `MCPForUnity/Runtime/Helpers/Unity*Compat.cs` and route every caller through it.
+
+The catalog of active shims, the policy for when to add one, what does NOT belong in a shim, and the reflection-cache pattern all live in **`MCPForUnity/Runtime/Helpers/UnityCompatShims.cs`** — the XML doc on that empty marker class is the source of truth and ships inside the UPM package, so end-users can `F12`/Go-to-definition into it. Sources for current deprecations: Unity 6.x upgrade guides and the [CoreCLR 2026 thread](https://discussions.unity.com/t/path-to-coreclr-2026-upgrade-guide/1714279).
+
## Commands
### Running Tests
diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs
index 229b9336c..ee688718b 100644
--- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs
+++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs
@@ -16,7 +16,6 @@ namespace MCPForUnity.Editor.Helpers
///
internal static class EditorWindowScreenshotUtility
{
- private const string ScreenshotsFolderName = "Screenshots";
// Keep capture synchronous so callers can immediately return the screenshot payload.
// The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport.
private const int RepaintSettlingDelayMs = 75;
@@ -40,7 +39,7 @@ internal static class EditorWindowScreenshotUtility
/// Maximum edge length for the inline image payload.
/// Captured viewport width in pixels.
/// Captured viewport height in pixels.
- public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets(
+ public static ScreenshotCaptureResult CaptureSceneViewViewportToProject(
SceneView sceneView,
string fileName,
int superSize,
@@ -48,7 +47,8 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets(
bool includeImage,
int maxResolution,
out int viewportWidth,
- out int viewportHeight)
+ out int viewportHeight,
+ string folderOverride = null)
{
if (sceneView == null)
throw new ArgumentNullException(nameof(sceneView));
@@ -70,7 +70,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets(
{
captured = CaptureViewRect(sceneView, viewportRectPixels);
- var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName);
+ var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName, folderOverride);
byte[] png = captured.EncodeToPNG();
File.WriteAllBytes(result.FullPath, png);
@@ -97,7 +97,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets(
return new ScreenshotCaptureResult(
result.FullPath,
- result.AssetsRelativePath,
+ result.ProjectRelativePath,
result.SuperSize,
false,
imageBase64,
@@ -317,11 +317,11 @@ private static void FlipTextureVertically(Texture2D texture)
texture.Apply();
}
- private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName)
+ private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName, string folderOverride)
{
int size = Mathf.Max(1, superSize);
string resolvedName = BuildFileName(fileName);
- string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName);
+ string folder = ScreenshotUtility.ResolveFolderAbsolute(folderOverride);
Directory.CreateDirectory(folder);
string fullPath = Path.Combine(folder, resolvedName);
@@ -331,8 +331,12 @@ private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int
}
string normalizedFullPath = fullPath.Replace('\\', '/');
- string assetsRelativePath = "Assets/" + normalizedFullPath.Substring(Application.dataPath.Length).TrimStart('/');
- return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, false);
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/');
+ string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/";
+ string projectRelativePath = normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)
+ ? normalizedFullPath.Substring(normalizedRoot.Length)
+ : normalizedFullPath;
+ return new ScreenshotCaptureResult(normalizedFullPath, projectRelativePath, size, false);
}
private static string BuildFileName(string fileName)
diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs
index 11a8d7eef..77f824535 100644
--- a/MCPForUnity/Editor/Helpers/PortManager.cs
+++ b/MCPForUnity/Editor/Helpers/PortManager.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
@@ -33,10 +34,13 @@ public class PortConfig
public int unity_port;
public string created_date;
public string project_path;
+ public int pid;
}
///
/// Get the port to use from storage, or return the default if none has been saved yet.
+ /// When the stored port is in use by another process (multi-instance scenario),
+ /// falls back to the default port instead of returning an occupied port.
///
/// Port number to use
public static int GetPortWithFallback()
@@ -46,7 +50,15 @@ public static int GetPortWithFallback()
storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase))
{
- return storedConfig.unity_port;
+ // Only return the stored port if it's actually available.
+ // When another instance of the same project is running, the stored
+ // port will be occupied; falling back to DefaultPort lets Start()'s
+ // SocketException handler pick a truly free port via DiscoverNewPort().
+ if (IsPortAvailable(storedConfig.unity_port))
+ {
+ return storedConfig.unity_port;
+ }
+ if (IsDebugEnabled()) McpLog.Info($"Stored port {storedConfig.unity_port} is occupied, falling back to default");
}
return DefaultPort;
@@ -122,12 +134,10 @@ public static bool IsPortAvailable(int port)
try
{
var testListener = new TcpListener(IPAddress.Loopback, port);
-#if UNITY_EDITOR_OSX
- // On macOS, SO_REUSEADDR (the default) lets multiple processes bind the same
- // port — including AssetImportWorkers. ExclusiveAddressUse prevents this so
- // the test bind fails when another process already holds the port.
+ // ExclusiveAddressUse prevents SO_REUSEADDR from allowing multiple
+ // processes (including AssetImportWorkers) to bind the same port.
+ // The test bind fails when another process already holds the port.
try { testListener.Server.ExclusiveAddressUse = true; } catch { }
-#endif
testListener.Start();
testListener.Stop();
}
@@ -202,23 +212,38 @@ private static void SavePort(int port)
{
try
{
+ int pid = 0;
+ try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { }
+
var portConfig = new PortConfig
{
unity_port = port,
created_date = DateTime.UtcNow.ToString("O"),
- project_path = Application.dataPath
+ project_path = Application.dataPath,
+ pid = pid
};
string registryDir = GetRegistryDirectory();
Directory.CreateDirectory(registryDir);
- string registryFile = GetRegistryFilePath();
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
+ byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json);
+
// Write to hashed, project-scoped file
- File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false));
+ string registryFile = GetRegistryFilePath();
+ File.WriteAllBytes(registryFile, utf8Bytes);
// Also write to legacy stable filename to avoid hash/case drift across reloads
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
- File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false));
+ File.WriteAllBytes(legacy, utf8Bytes);
+
+ // Write instance-scoped file so multiple instances of the same project
+ // can be tracked independently.
+ if (pid > 0)
+ {
+ string hash = ComputeProjectHash(Application.dataPath);
+ string instanceFile = Path.Combine(registryDir, $"unity-mcp-port-{hash}-{pid}.json");
+ File.WriteAllBytes(instanceFile, utf8Bytes);
+ }
if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage");
}
diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs
new file mode 100644
index 000000000..b12c97449
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs
@@ -0,0 +1,49 @@
+using MCPForUnity.Runtime.Helpers;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Per-user EditorPrefs override for the default screenshot output folder.
+ /// Resolution priority used by callers:
+ /// 1. Per-call output_folder tool parameter
+ /// 2. (this preference)
+ /// 3. built-in fallback
+ ///
+ public static class ScreenshotPreferences
+ {
+ public const string EditorPrefsKey = "MCPForUnity_ScreenshotsFolder";
+
+ ///
+ /// User-configured default folder, or empty string when unset.
+ /// Stored as a project-relative path (e.g. "Assets/Screenshots", "Captures").
+ ///
+ public static string DefaultFolder
+ {
+ get => EditorPrefs.GetString(EditorPrefsKey, string.Empty);
+ set
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ EditorPrefs.DeleteKey(EditorPrefsKey);
+ }
+ else
+ {
+ EditorPrefs.SetString(EditorPrefsKey, value.Trim());
+ }
+ }
+ }
+
+ ///
+ /// Resolves the effective folder: caller override → user pref → built-in default.
+ /// Returns a project-relative path string suitable for .
+ ///
+ public static string Resolve(string callerOverride)
+ {
+ if (!string.IsNullOrWhiteSpace(callerOverride)) return callerOverride.Trim();
+ string pref = DefaultFolder;
+ if (!string.IsNullOrWhiteSpace(pref)) return pref;
+ return ScreenshotUtility.DefaultFolder;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta
new file mode 100644
index 000000000..c1668314b
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7c4f1a8e9b3d4d2eaf5c1d7b9e2f4a6c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs
index feb0b5c7a..8a6f80169 100644
--- a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs
+++ b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
+using MCPForUnity.Runtime.Helpers;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
@@ -150,7 +151,7 @@ private static void Cache(Type t)
private static List FindCandidates(string query, Type requiredBaseType)
{
bool isShort = !query.Contains('.');
- var loaded = AppDomain.CurrentDomain.GetAssemblies();
+ var loaded = UnityAssembliesCompat.GetLoadedAssemblies();
#if UNITY_EDITOR
// Names of Player (runtime) script assemblies
diff --git a/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs
index 981dc2141..0a57fc94b 100644
--- a/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs
+++ b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs
@@ -17,37 +17,44 @@ namespace MCPForUnity.Editor.Services
[InitializeOnLoad]
internal static class HttpAutoStartHandler
{
- private const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized";
+ private static bool _autoStartExecuted = false;
+ private static int _retryCount = 0;
+ private const int MaxRetries = 120;
static HttpAutoStartHandler()
{
- // SessionState resets on editor process start but persists across domain reloads.
- // Only run once per session — let HttpBridgeReloadHandler handle reload-resume cases.
- if (SessionState.GetBool(SessionInitKey, false)) return;
-
if (Application.isBatchMode &&
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{
return;
}
- // Only check lightweight EditorPrefs here — services like EditorConfigurationCache
- // and MCPServiceLocator may not be initialized yet on fresh editor launch.
- bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
- if (!autoStartEnabled) return;
-
- SessionState.SetBool(SessionInitKey, true);
-
- // Delay to let the editor and services finish initialization.
- EditorApplication.delayCall += OnEditorReady;
+ // EditorPrefs may not be initialized during [InitializeOnLoad] /
+ // EditorApplication.delayCall. Use update to poll until it's ready.
+ _retryCount = 0;
+ _autoStartExecuted = false;
+ EditorApplication.update += TryAutoStartOnce;
}
- private static void OnEditorReady()
+ private static void TryAutoStartOnce()
{
+ if (_autoStartExecuted) return;
+
try
{
bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
- if (!autoStartEnabled) return;
+ _retryCount++;
+ if (!autoStartEnabled)
+ {
+ if (_retryCount >= MaxRetries)
+ {
+ EditorApplication.update -= TryAutoStartOnce;
+ }
+ return;
+ }
+
+ EditorApplication.update -= TryAutoStartOnce;
+ _autoStartExecuted = true;
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttp) return;
diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs
index b7bb32fc3..d162476e8 100644
--- a/MCPForUnity/Editor/Services/TestJobManager.cs
+++ b/MCPForUnity/Editor/Services/TestJobManager.cs
@@ -40,6 +40,7 @@ internal sealed class TestJob
public List FailuresSoFar { get; set; }
public string Error { get; set; }
public TestRunResult Result { get; set; }
+ public long InitTimeoutMs { get; set; }
}
///
@@ -50,7 +51,8 @@ internal static class TestJobManager
// Keep this small to avoid ballooning payloads during polling.
private const int FailureCap = 25;
private const long StuckThresholdMs = 60_000;
- private const long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail
+ private const long DefaultInitializationTimeoutMs = 15_000; // 15 seconds default; override per-job via run_tests init_timeout param
+ private const long MaxInitializationTimeoutMs = 600_000; // 10 minutes hard cap
private const int MaxJobsToKeep = 10;
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead
@@ -139,6 +141,7 @@ private sealed class PersistedJob
public long? last_finished_unix_ms { get; set; }
public List failures_so_far { get; set; }
public string error { get; set; }
+ public long init_timeout_ms { get; set; }
}
private static TestJobStatus ParseStatus(string status)
@@ -201,6 +204,7 @@ private static void TryRestoreFromSessionState()
LastFinishedUnixMs = pj.last_finished_unix_ms,
FailuresSoFar = pj.failures_so_far ?? new List(),
Error = pj.error,
+ InitTimeoutMs = pj.init_timeout_ms,
// Intentionally not persisted to avoid ballooning SessionState.
Result = null
};
@@ -273,7 +277,8 @@ private static void PersistToSessionState(bool force = false)
last_finished_test_full_name = j.LastFinishedTestFullName,
last_finished_unix_ms = j.LastFinishedUnixMs,
failures_so_far = (j.FailuresSoFar ?? new List()).Take(FailureCap).ToList(),
- error = j.Error
+ error = j.Error,
+ init_timeout_ms = j.InitTimeoutMs
})
.ToList();
@@ -294,8 +299,12 @@ private static void PersistToSessionState(bool force = false)
}
}
- public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
+ public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null, long initTimeoutMs = 0)
{
+ // Clamp to valid range: non-positive values mean "use default", cap at 10 minutes
+ if (initTimeoutMs < 0) initTimeoutMs = 0;
+ if (initTimeoutMs > MaxInitializationTimeoutMs) initTimeoutMs = MaxInitializationTimeoutMs;
+
string jobId = Guid.NewGuid().ToString("N");
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string modeStr = mode.ToString();
@@ -316,7 +325,8 @@ public static string StartJob(TestMode mode, TestFilterOptions filterOptions = n
LastFinishedUnixMs = null,
FailuresSoFar = new List(),
Error = null,
- Result = null
+ Result = null,
+ InitTimeoutMs = initTimeoutMs
};
// Single lock scope for check-and-set to avoid TOCTOU race
@@ -491,9 +501,10 @@ internal static TestJob GetJob(string jobId)
if (job.Status == TestJobStatus.Running && job.TotalTests == null)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
- if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs)
+ long initTimeout = job.InitTimeoutMs > 0 ? job.InitTimeoutMs : DefaultInitializationTimeoutMs;
+ if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > initTimeout)
{
- McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing");
+ McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {initTimeout}ms, auto-failing");
job.Status = TestJobStatus.Failed;
job.Error = "Test job failed to initialize (tests did not start within timeout)";
job.FinishedUnixMs = now;
diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
index 0e9674d65..fde25aa31 100644
--- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
+++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
@@ -334,14 +334,12 @@ public static void Start()
private static TcpListener CreateConfiguredListener(int port)
{
var newListener = new TcpListener(IPAddress.Loopback, port);
-#if UNITY_EDITOR_OSX
- // SO_REUSEADDR is intentionally NOT set. On macOS it allows multiple
- // processes (including AssetImportWorkers) to bind the same port,
- // causing connections to land on a worker that can't process commands.
- // The ExclusiveAddressUse flag prevents this; port-busy conflicts are
- // handled by the retry/fallback logic in Start() and the reload handler.
+ // SO_REUSEADDR allows multiple processes to bind the same port on
+ // Linux/macOS, causing connections to land on a worker/old instance
+ // that can't process commands. ExclusiveAddressUse prevents this;
+ // port-busy conflicts are handled by the retry/fallback logic in
+ // Start() and the reload handler.
try { newListener.Server.ExclusiveAddressUse = true; } catch { }
-#endif
try
{
newListener.Server.LingerState = new LingerOption(true, 0);
@@ -412,11 +410,25 @@ public static void Stop()
{
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
- string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
- if (File.Exists(statusFile))
+ string projectHash = ComputeProjectHash(Application.dataPath);
+
+ string projectFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}.json");
+ if (File.Exists(projectFile))
+ {
+ File.Delete(projectFile);
+ if (IsDebugEnabled()) McpLog.Info($"Deleted project status file: {projectFile}");
+ }
+
+ // Clean up instance-specific file so stale entries don't accumulate.
+ int pid = s_CachedProcessId;
+ if (pid > 0)
{
- File.Delete(statusFile);
- if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}");
+ string instanceFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}-{pid}.json");
+ if (File.Exists(instanceFile))
+ {
+ File.Delete(instanceFile);
+ if (IsDebugEnabled()) McpLog.Info($"Deleted instance status file: {instanceFile}");
+ }
}
}
catch (Exception ex)
@@ -1015,6 +1027,13 @@ private static bool IsValidJson(string text)
}
+ private static readonly int s_CachedProcessId = GetProcessId();
+
+ private static int GetProcessId()
+ {
+ try { return Process.GetCurrentProcess().Id; } catch { return 0; }
+ }
+
public static void WriteHeartbeat(bool reloading, string reason = null)
{
try
@@ -1025,7 +1044,6 @@ public static void WriteHeartbeat(bool reloading, string reason = null)
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
Directory.CreateDirectory(dir);
- string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
string projectName = "Unknown";
try
@@ -1050,6 +1068,7 @@ public static void WriteHeartbeat(bool reloading, string reason = null)
var payload = new
{
unity_port = currentUnityPort,
+ pid = s_CachedProcessId,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
@@ -1058,7 +1077,21 @@ public static void WriteHeartbeat(bool reloading, string reason = null)
unity_version = Application.unityVersion,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
- File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false));
+ string json = JsonConvert.SerializeObject(payload);
+ byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json);
+
+ // Project-scoped file: used by clients that look for any instance of this project.
+ string projectFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
+ File.WriteAllBytes(projectFile, utf8Bytes);
+
+ // Instance-scoped file: allows clients to discover and select specific
+ // instances when multiple copies of the same project are running.
+ if (s_CachedProcessId > 0)
+ {
+ string instanceFile = Path.Combine(dir,
+ $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}-{s_CachedProcessId}.json");
+ File.WriteAllBytes(instanceFile, utf8Bytes);
+ }
}
catch (Exception)
{
diff --git a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs
index 47a386c96..b091dce37 100644
--- a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs
+++ b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs
@@ -4,6 +4,7 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Runtime.Helpers;
using UnityEditor;
using UnityEngine;
@@ -491,7 +492,7 @@ private static Type ResolveType(string typeName)
if (type != null) return type;
// Fallback: search all loaded assemblies
- foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
{
type = assembly.GetType(typeName);
if (type != null) return type;
diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs
index ca39ea51c..c22ab00bb 100644
--- a/MCPForUnity/Editor/Tools/CommandRegistry.cs
+++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs
@@ -5,6 +5,7 @@
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources;
+using MCPForUnity.Runtime.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -59,7 +60,7 @@ private static void AutoDiscoverCommands()
{
try
{
- var allTypes = AppDomain.CurrentDomain.GetAssemblies()
+ var allTypes = UnityAssembliesCompat.GetLoadedAssemblies()
.Where(a => !a.IsDynamic)
.SelectMany(a =>
{
diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs b/MCPForUnity/Editor/Tools/ExecuteCode.cs
index c393e6660..d53c04e28 100644
--- a/MCPForUnity/Editor/Tools/ExecuteCode.cs
+++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs
@@ -6,6 +6,7 @@
using System.Reflection;
using System.Text;
using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Runtime.Helpers;
using Microsoft.CSharp;
using Newtonsoft.Json.Linq;
using UnityEngine;
@@ -360,7 +361,7 @@ private static string[] ResolveAssemblyPaths()
{
var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
{
try
{
diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs
index 7567a78a2..aa3c78602 100644
--- a/MCPForUnity/Editor/Tools/ManageScene.cs
+++ b/MCPForUnity/Editor/Tools/ManageScene.cs
@@ -32,6 +32,7 @@ private sealed class SceneCommand
public string captureSource { get; set; } // "game_view" (default) or "scene_view"
public bool? includeImage { get; set; }
public int? maxResolution { get; set; }
+ public string outputFolder { get; set; } // optional override; null falls back to user pref / Assets/Screenshots
public string batch { get; set; } // "surround" or "orbit" for multi-angle batch capture
public JToken viewTarget { get; set; } // GO reference or [x,y,z] to focus on before capture
public Vector3? viewPosition { get; set; } // camera position for view-based capture
@@ -109,6 +110,7 @@ private static SceneCommand ToSceneCommand(JObject p)
captureSource = toolParams.Get("capture_source"),
includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]),
maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]),
+ outputFolder = (p["outputFolder"] ?? p["output_folder"])?.ToString(),
batch = (p["batch"])?.ToString(),
viewTarget = p["viewTarget"] ?? p["view_target"],
viewPosition = VectorParsing.ParseVector3(p["viewPosition"] ?? p["view_position"]),
@@ -589,36 +591,78 @@ private static object CaptureScreenshot(SceneCommand cmd)
}
}
- // When a specific camera is requested or include_image is true, always use camera-based capture
- // (synchronous, gives us bytes in memory for base64).
- if (targetCamera != null || includeImage)
+ // When include_image is requested but no specific camera, use composited capture
+ // (ScreenCapture.CaptureScreenshotAsTexture) which captures UI Toolkit overlays.
+ // When a specific camera IS requested, use camera-based capture.
+ if (targetCamera != null)
{
+ if (!Application.isBatchMode) EnsureGameView();
+
+ string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ ScreenshotCaptureResult result = ScreenshotUtility.CaptureFromCameraToProjectFolder(
+ targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true,
+ includeImage: includeImage, maxResolution: maxResolution,
+ folderOverride: folderOverride);
+
+ if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath))
+ AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {targetCamera.name}).";
+ return new SuccessResponse(message, BuildScreenshotResponseData(result, targetCamera.name, includeImage));
+ }
+
+ if (includeImage && Application.isPlaying)
+ {
+ if (!Application.isBatchMode) EnsureGameView();
+
+ string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ ScreenshotCaptureResult result = ScreenshotUtility.CaptureComposited(
+ fileName, resolvedSuperSize, ensureUniqueFileName: true,
+ includeImage: true, maxResolution: maxResolution,
+ folderOverride: folderOverride);
+
+ if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath))
+ AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ string cameraName = Camera.main != null ? Camera.main.name : "composited";
+ string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {cameraName}).";
+ return new SuccessResponse(message, BuildScreenshotResponseData(result, cameraName, includeImage: true));
+ }
+
+ if (includeImage)
+ {
+ // Not in play mode — fall back to camera-based capture
+ targetCamera = Camera.main;
if (targetCamera == null)
{
- targetCamera = Camera.main;
- if (targetCamera == null)
- {
- var allCams = UnityFindObjectsCompat.FindAll();
- targetCamera = allCams.Length > 0 ? allCams[0] : null;
- }
+ var allCams = UnityFindObjectsCompat.FindAll();
+ targetCamera = allCams.Length > 0 ? allCams[0] : null;
}
if (targetCamera == null)
{
- return new ErrorResponse("No camera found in the scene. Add a Camera to use screenshot with camera or include_image.");
+ return new ErrorResponse("No camera found in the scene. Add a Camera to use screenshot with include_image outside of Play mode.");
}
if (!Application.isBatchMode) EnsureGameView();
- ScreenshotCaptureResult result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(
- targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true,
- includeImage: includeImage, maxResolution: maxResolution);
-
- AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
- string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {targetCamera.name}).";
+ string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ ScreenshotCaptureResult result;
+ try
+ {
+ result = ScreenshotUtility.CaptureFromCameraToProjectFolder(
+ targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true,
+ includeImage: includeImage, maxResolution: maxResolution,
+ folderOverride: folderOverride);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return new ErrorResponse(ex.Message);
+ }
+ if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath))
+ AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {targetCamera.name}).";
var data = new Dictionary
{
- { "path", result.AssetsRelativePath },
+ { "path", result.ProjectRelativePath },
{ "fullPath", result.FullPath },
{ "superSize", result.SuperSize },
{ "isAsync", false },
@@ -631,7 +675,7 @@ private static object CaptureScreenshot(SceneCommand cmd)
data["imageWidth"] = result.ImageWidth;
data["imageHeight"] = result.ImageHeight;
}
- return new SuccessResponse(message, data);
+ return new SuccessResponse(message, BuildScreenshotResponseData(result, targetCamera.name, includeImage));
}
// Default path: use ScreenCapture API if available, camera fallback otherwise
@@ -662,19 +706,33 @@ private static object CaptureScreenshot(SceneCommand cmd)
if (!Application.isBatchMode) EnsureGameView();
- ScreenshotCaptureResult defaultResult = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true);
+ string defaultFolderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ ScreenshotCaptureResult defaultResult;
+ try
+ {
+ defaultResult = ScreenshotUtility.CaptureToProjectFolder(
+ fileName, resolvedSuperSize, ensureUniqueFileName: true,
+ folderOverride: defaultFolderOverride);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return new ErrorResponse(ex.Message);
+ }
- if (defaultResult.IsAsync)
- ScheduleAssetImportWhenFileExists(defaultResult.AssetsRelativePath, defaultResult.FullPath, timeoutSeconds: 30.0);
- else
- AssetDatabase.ImportAsset(defaultResult.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ if (ScreenshotUtility.IsUnderAssets(defaultResult.ProjectRelativePath))
+ {
+ if (defaultResult.IsAsync)
+ ScheduleAssetImportWhenFileExists(defaultResult.ProjectRelativePath, defaultResult.FullPath, timeoutSeconds: 30.0);
+ else
+ AssetDatabase.ImportAsset(defaultResult.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ }
string verb = defaultResult.IsAsync ? "Screenshot requested" : "Screenshot captured";
return new SuccessResponse(
- $"{verb} to '{defaultResult.AssetsRelativePath}'.",
+ $"{verb} to '{defaultResult.ProjectRelativePath}'.",
new
{
- path = defaultResult.AssetsRelativePath,
+ path = defaultResult.ProjectRelativePath,
fullPath = defaultResult.FullPath,
superSize = defaultResult.SuperSize,
isAsync = defaultResult.IsAsync,
@@ -688,6 +746,31 @@ private static object CaptureScreenshot(SceneCommand cmd)
}
}
+ private static Dictionary BuildScreenshotResponseData(
+ ScreenshotCaptureResult result,
+ string cameraName,
+ bool includeImage)
+ {
+ var data = new Dictionary
+ {
+ { "path", result.ProjectRelativePath },
+ { "fullPath", result.FullPath },
+ { "superSize", result.SuperSize },
+ { "isAsync", false },
+ { "camera", cameraName },
+ { "captureSource", "game_view" },
+ };
+
+ if (includeImage && result.ImageBase64 != null)
+ {
+ data["imageBase64"] = result.ImageBase64;
+ data["imageWidth"] = result.ImageWidth;
+ data["imageHeight"] = result.ImageHeight;
+ }
+
+ return data;
+ }
+
private static object CaptureSceneViewScreenshot(
SceneCommand cmd,
string fileName,
@@ -718,22 +801,35 @@ private static object CaptureSceneViewScreenshot(
try
{
- ScreenshotCaptureResult result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToAssets(
- sceneView,
- fileName,
- resolvedSuperSize,
- ensureUniqueFileName: true,
- includeImage: includeImage,
- maxResolution: maxResolution,
- out int viewportWidth,
- out int viewportHeight);
-
- AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ string sceneViewFolderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ ScreenshotCaptureResult result;
+ int viewportWidth;
+ int viewportHeight;
+ try
+ {
+ result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToProject(
+ sceneView,
+ fileName,
+ resolvedSuperSize,
+ ensureUniqueFileName: true,
+ includeImage: includeImage,
+ maxResolution: maxResolution,
+ out viewportWidth,
+ out viewportHeight,
+ folderOverride: sceneViewFolderOverride);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.StartsWith("Screenshot folder", StringComparison.Ordinal))
+ {
+ return new ErrorResponse(ex.Message);
+ }
+
+ if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath))
+ AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport);
string sceneViewName = sceneView.titleContent?.text ?? "Scene";
var data = new Dictionary
{
- { "path", result.AssetsRelativePath },
+ { "path", result.ProjectRelativePath },
{ "fullPath", result.FullPath },
{ "superSize", result.SuperSize },
{ "isAsync", false },
@@ -758,7 +854,7 @@ private static object CaptureSceneViewScreenshot(
}
return new SuccessResponse(
- $"Scene View screenshot captured to '{result.AssetsRelativePath}' (scene view: {sceneViewName}).",
+ $"Scene View screenshot captured to '{result.ProjectRelativePath}' (scene view: {sceneViewName}).",
data);
}
catch (Exception e)
@@ -879,14 +975,14 @@ private static object CaptureSurroundBatch(SceneCommand cmd)
var (compositeB64, compW, compH) = ScreenshotUtility.ComposeContactSheet(tiles, tileLabels);
- string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots");
+ string outputFolder = ResolveAbsoluteOutputFolder(cmd.outputFolder);
return new SuccessResponse(
$"Captured {shotMeta.Count} multi-angle screenshots as contact sheet ({compW}x{compH}). Scene bounds center: ({center.x:F1}, {center.y:F1}, {center.z:F1}), radius: {radius:F1}.",
new
{
sceneCenter = new[] { center.x, center.y, center.z },
sceneRadius = radius,
- screenshotsFolder = screenshotsFolder,
+ outputFolder = outputFolder,
imageBase64 = compositeB64,
imageWidth = compW,
imageHeight = compH,
@@ -1026,7 +1122,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd)
// Compose all tiles into a single contact-sheet grid image
var (compositeB64, compW, compH) = ScreenshotUtility.ComposeContactSheet(tiles, tileLabels);
- string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots");
+ string outputFolder = ResolveAbsoluteOutputFolder(cmd.outputFolder);
return new SuccessResponse(
$"Captured {shotMeta.Count} orbit screenshots as contact sheet ({compW}x{compH}, {azimuthCount} azimuths x {elevations.Length} elevations). Center: ({center.x:F1}, {center.y:F1}, {center.z:F1}), radius: {radius:F1}.",
new
@@ -1036,7 +1132,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd)
orbitAngles = azimuthCount,
orbitElevations = elevations,
orbitFov = fov,
- screenshotsFolder = screenshotsFolder,
+ outputFolder = outputFolder,
imageBase64 = compositeB64,
imageWidth = compW,
imageHeight = compH,
@@ -1057,7 +1153,8 @@ private static object CaptureOrbitBatch(SceneCommand cmd)
///
/// Captures a single screenshot from a temporary camera placed at view_position and aimed at view_target.
- /// Returns inline base64 PNG and also saves the image to Assets/Screenshots/.
+ /// Returns inline base64 PNG and also saves the image to the resolved screenshot folder
+ /// (caller's output_folder override -> ScreenshotPreferences.DefaultFolder -> built-in Assets/Screenshots).
///
private static object CapturePositionedScreenshot(SceneCommand cmd)
{
@@ -1118,13 +1215,23 @@ private static object CapturePositionedScreenshot(SceneCommand cmd)
var (b64, w, h) = ScreenshotUtility.RenderCameraToBase64(tempCam, maxRes);
- // Save to disk
- string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots");
- Directory.CreateDirectory(screenshotsFolder);
+ // Resolve output folder (per-call override → user pref → built-in default).
+ string resolvedFolderSpec = ScreenshotPreferences.Resolve(cmd.outputFolder);
+ string folderAbsolute;
+ try
+ {
+ folderAbsolute = ScreenshotUtility.ResolveFolderAbsolute(resolvedFolderSpec);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return new ErrorResponse(ex.Message);
+ }
+ Directory.CreateDirectory(folderAbsolute);
+
string fileName = !string.IsNullOrEmpty(cmd.fileName)
? (cmd.fileName.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase) ? cmd.fileName : cmd.fileName + ".png")
: $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png";
- string fullPath = Path.Combine(screenshotsFolder, fileName);
+ string fullPath = Path.Combine(folderAbsolute, fileName);
// Ensure unique filename
if (File.Exists(fullPath))
{
@@ -1133,15 +1240,22 @@ private static object CapturePositionedScreenshot(SceneCommand cmd)
int counter = 1;
while (File.Exists(fullPath))
{
- fullPath = Path.Combine(screenshotsFolder, $"{baseName}_{counter}{ext}");
+ fullPath = Path.Combine(folderAbsolute, $"{baseName}_{counter}{ext}");
counter++;
}
}
byte[] pngBytes = System.Convert.FromBase64String(b64);
File.WriteAllBytes(fullPath, pngBytes);
- string assetsRelativePath = "Assets/Screenshots/" + Path.GetFileName(fullPath);
- AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/');
+ string normalizedFull = fullPath.Replace('\\', '/');
+ string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/";
+ string projectRelativePath = normalizedFull.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)
+ ? normalizedFull.Substring(normalizedRoot.Length)
+ : normalizedFull;
+
+ if (ScreenshotUtility.IsUnderAssets(projectRelativePath))
+ AssetDatabase.ImportAsset(projectRelativePath, ImportAssetOptions.ForceSynchronousImport);
var data = new Dictionary
{
@@ -1149,14 +1263,15 @@ private static object CapturePositionedScreenshot(SceneCommand cmd)
{ "imageWidth", w },
{ "imageHeight", h },
{ "viewPosition", new[] { camPos.x, camPos.y, camPos.z } },
- { "screenshotsFolder", screenshotsFolder },
- { "path", assetsRelativePath },
+ { "outputFolder", folderAbsolute.Replace('\\', '/') },
+ { "path", projectRelativePath },
+ { "fullPath", normalizedFull },
};
if (targetPos.HasValue)
data["viewTarget"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z };
return new SuccessResponse(
- $"Positioned screenshot captured (max {maxRes}px) and saved to '{assetsRelativePath}'.",
+ $"Positioned screenshot captured (max {maxRes}px) and saved to '{projectRelativePath}'.",
data
);
}
@@ -1171,6 +1286,17 @@ private static object CapturePositionedScreenshot(SceneCommand cmd)
}
}
+ ///
+ /// Resolves the per-call/per-pref/built-in screenshot folder spec to an absolute path.
+ /// Propagates validation errors from
+ /// so callers can surface them rather than silently writing somewhere else.
+ ///
+ private static string ResolveAbsoluteOutputFolder(string callerOverride)
+ {
+ string spec = ScreenshotPreferences.Resolve(callerOverride);
+ return ScreenshotUtility.ResolveFolderAbsolute(spec).Replace('\\', '/');
+ }
+
private static string GetDirectionLabel(float azimuthDeg)
{
float a = ((azimuthDeg % 360f) + 360f) % 360f;
@@ -1457,7 +1583,6 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath,
if (File.Exists(fullPath))
{
hasSeenFile = true;
-
AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'.");
EditorApplication.update -= tick;
@@ -1467,7 +1592,6 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath,
catch (Exception e)
{
failureCount++;
-
if (failureCount <= maxLoggedFailures)
{
McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}");
@@ -1477,14 +1601,9 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath,
if (EditorApplication.timeSinceStartup - start > timeoutSeconds)
{
if (!hasSeenFile)
- {
McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported.");
- }
else
- {
McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported.");
- }
-
EditorApplication.update -= tick;
}
};
@@ -1492,6 +1611,7 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath,
EditorApplication.update += tick;
}
+
// ── Multi-scene editing ────────────────────────────────────────────
private static object LoadSceneAdditive(string scenePath)
diff --git a/MCPForUnity/Editor/Tools/ManageUI.cs b/MCPForUnity/Editor/Tools/ManageUI.cs
index ec1a57c81..f67bcff40 100644
--- a/MCPForUnity/Editor/Tools/ManageUI.cs
+++ b/MCPForUnity/Editor/Tools/ManageUI.cs
@@ -860,12 +860,24 @@ private static object RenderUI(JObject @params)
bool includeImage = p.GetBool("include_image") || p.GetBool("includeImage");
int maxResolution = p.GetInt("max_resolution") ?? p.GetInt("maxResolution") ?? 640;
string fileName = p.Get("file_name") ?? p.Get("fileName");
+ string outputFolderOverride = p.Get("output_folder") ?? p.Get("outputFolder");
if (string.IsNullOrEmpty(target) && string.IsNullOrEmpty(uxmlPath))
{
return new ErrorResponse("Either 'target' (GameObject with UIDocument) or 'path' (UXML asset path) is required.");
}
+ string resolvedFolderSpec = ScreenshotPreferences.Resolve(outputFolderOverride);
+ string resolvedFolderAbs;
+ try
+ {
+ resolvedFolderAbs = ScreenshotUtility.ResolveFolderAbsolute(resolvedFolderSpec);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return new ErrorResponse(ex.Message);
+ }
+
// ── Play-mode capture via ScreenCapture coroutine ──────────────────────
// PanelSettings.targetTexture is read in the same frame it is assigned,
// so the RT is always blank in a synchronous tool call. In play mode we
@@ -882,11 +894,10 @@ private static object RenderUI(JObject @params)
if (!resolvedPlayName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
resolvedPlayName += ".png";
- string playFolder = Path.Combine(Application.dataPath, "Screenshots");
- Directory.CreateDirectory(playFolder);
- string playFullPath = Path.Combine(playFolder, resolvedPlayName).Replace('\\', '/');
+ Directory.CreateDirectory(resolvedFolderAbs);
+ string playFullPath = Path.Combine(resolvedFolderAbs, resolvedPlayName).Replace('\\', '/');
playFullPath = EnsureUniqueFilePath(playFullPath);
- string playAssetsRelPath = "Assets/Screenshots/" + Path.GetFileName(playFullPath);
+ string playProjectRelPath = ScreenshotUtility.ToProjectRelativePath(playFullPath);
// ── Case 1: capture is ready ──────────────────────────────────────
if (s_pendingCaptureDone && s_pendingCaptureTex != null)
@@ -901,11 +912,12 @@ private static object RenderUI(JObject @params)
UnityEngine.Object.DestroyImmediate(captureTex);
File.WriteAllBytes(playFullPath, capturePng);
- AssetDatabase.ImportAsset(playAssetsRelPath, ImportAssetOptions.ForceSynchronousImport);
+ if (ScreenshotUtility.IsUnderAssets(playProjectRelPath))
+ AssetDatabase.ImportAsset(playProjectRelPath, ImportAssetOptions.ForceSynchronousImport);
var playData = new Dictionary
{
- { "path", playAssetsRelPath },
+ { "path", playProjectRelPath },
{ "fullPath", playFullPath },
{ "width", captureW },
{ "height", captureH },
@@ -944,7 +956,7 @@ private static object RenderUI(JObject @params)
}
}
- return new SuccessResponse($"UI render saved to '{playAssetsRelPath}'.", playData);
+ return new SuccessResponse($"UI render saved to '{playProjectRelPath}'.", playData);
}
// ── Case 2: start a new capture ───────────────────────────────────
@@ -1134,20 +1146,20 @@ private static object RenderUI(JObject @params)
if (!resolvedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
resolvedName += ".png";
- string folder = Path.Combine(Application.dataPath, "Screenshots");
- Directory.CreateDirectory(folder);
- string fullPath = Path.Combine(folder, resolvedName).Replace('\\', '/');
+ Directory.CreateDirectory(resolvedFolderAbs);
+ string fullPath = Path.Combine(resolvedFolderAbs, resolvedName).Replace('\\', '/');
fullPath = EnsureUniqueFilePath(fullPath);
byte[] png = tex.EncodeToPNG();
File.WriteAllBytes(fullPath, png);
- string assetsRelPath = "Assets/Screenshots/" + Path.GetFileName(fullPath);
- AssetDatabase.ImportAsset(assetsRelPath, ImportAssetOptions.ForceSynchronousImport);
+ string projectRelPath = ScreenshotUtility.ToProjectRelativePath(fullPath);
+ if (ScreenshotUtility.IsUnderAssets(projectRelPath))
+ AssetDatabase.ImportAsset(projectRelPath, ImportAssetOptions.ForceSynchronousImport);
var data = new Dictionary
{
- { "path", assetsRelPath },
+ { "path", projectRelPath },
{ "fullPath", fullPath },
{ "width", width },
{ "height", height },
@@ -1191,10 +1203,10 @@ private static object RenderUI(JObject @params)
UnityEngine.Object.DestroyImmediate(tex);
string msg = hasContent
- ? $"UI rendered to '{assetsRelPath}'."
+ ? $"UI rendered to '{projectRelPath}'."
: rtJustAssigned
? $"RenderTexture assigned to PanelSettings. Call render_ui again to capture the rendered content."
- : $"UI render saved to '{assetsRelPath}' (no visible content detected).";
+ : $"UI render saved to '{projectRelPath}' (no visible content detected).";
return new SuccessResponse(msg, data);
}
diff --git a/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs b/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs
index 69bc247e6..52af8145d 100644
--- a/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs
+++ b/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs
@@ -3,6 +3,7 @@
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Physics
{
@@ -12,12 +13,7 @@ public static object Ping(JObject @params)
{
var gravity3d = UnityEngine.Physics.gravity;
var gravity2d = Physics2D.gravity;
-
-#if UNITY_2022_2_OR_NEWER
- var simMode = UnityEngine.Physics.simulationMode.ToString();
-#else
- var simMode = UnityEngine.Physics.autoSimulation ? "FixedUpdate" : "Script";
-#endif
+ var simMode = UnityPhysicsCompat.GetPhysicsSimulationMode().ToString();
return new
{
@@ -59,7 +55,7 @@ public static object GetSettings(JObject @params)
queriesHitTriggers = Physics2D.queriesHitTriggers,
queriesStartInColliders = Physics2D.queriesStartInColliders,
callbacksOnDisable = Physics2D.callbacksOnDisable,
- autoSyncTransforms = Physics2D.autoSyncTransforms
+ autoSyncTransforms = UnityPhysicsCompat.GetPhysics2DAutoSyncTransforms()
}
};
}
@@ -68,11 +64,7 @@ public static object GetSettings(JObject @params)
return new ErrorResponse($"Invalid dimension: '{dimension}'. Use '3d' or '2d'.");
var g3 = UnityEngine.Physics.gravity;
-#if UNITY_2022_2_OR_NEWER
- var simMode = UnityEngine.Physics.simulationMode.ToString();
-#else
- var simMode = UnityEngine.Physics.autoSimulation ? "FixedUpdate" : "Script";
-#endif
+ var simMode = UnityPhysicsCompat.GetPhysicsSimulationMode().ToString();
return new
{
@@ -90,7 +82,8 @@ public static object GetSettings(JObject @params)
defaultMaxAngularSpeed = UnityEngine.Physics.defaultMaxAngularSpeed,
queriesHitTriggers = UnityEngine.Physics.queriesHitTriggers,
queriesHitBackfaces = UnityEngine.Physics.queriesHitBackfaces,
- simulationMode = simMode
+ simulationMode = simMode,
+ autoSyncTransforms = UnityPhysicsCompat.GetPhysicsAutoSyncTransforms()
}
};
}
@@ -118,7 +111,8 @@ public static object SetSettings(JObject @params)
"gravity", "defaultcontactoffset", "sleepthreshold",
"defaultsolveriterations", "defaultsolvervelocityiterations",
"bouncethreshold", "defaultmaxangularspeed",
- "querieshittriggers", "querieshitbackfaces", "simulationmode"
+ "querieshittriggers", "querieshitbackfaces", "simulationmode",
+ "autosynctransforms"
};
private static object SetSettings3D(JObject settings)
@@ -184,32 +178,28 @@ private static object SetSettings3D(JObject settings)
changed.Add("queriesHitBackfaces");
break;
case "simulationmode":
-#if UNITY_2022_2_OR_NEWER
{
string modeStr = prop.Value.ToString();
- if (System.Enum.TryParse(modeStr, true, out var mode))
+ if (!System.Enum.TryParse(modeStr, true, out var mode)
+ || mode == UnityPhysicsCompat.SimulationMode.Unknown)
{
- UnityEngine.Physics.simulationMode = mode;
- changed.Add("simulationMode");
+ return new ErrorResponse(
+ $"Invalid simulationMode: '{modeStr}'. Valid: FixedUpdate, Update, Script.");
}
- else
+ if (!UnityPhysicsCompat.TrySetPhysicsSimulationMode(mode))
{
return new ErrorResponse(
- $"Invalid simulationMode: '{modeStr}'. Valid: FixedUpdate, Update, Script.");
+ $"simulationMode '{modeStr}' is not supported on this Unity version.");
}
- break;
- }
-#else
- {
- string modeStr = prop.Value.ToString().ToLowerInvariant();
- if (modeStr == "fixedupdate") UnityEngine.Physics.autoSimulation = true;
- else if (modeStr == "script") UnityEngine.Physics.autoSimulation = false;
- else return new ErrorResponse(
- $"Invalid simulationMode: '{prop.Value}'. Valid: FixedUpdate, Script.");
changed.Add("simulationMode");
break;
}
-#endif
+ case "autosynctransforms":
+ if (UnityPhysicsCompat.TrySetPhysicsAutoSyncTransforms(prop.Value.Value()))
+ {
+ changed.Add("autoSyncTransforms");
+ }
+ break;
}
}
@@ -281,8 +271,10 @@ private static object SetSettings2D(JObject settings)
changed.Add("callbacksOnDisable");
break;
case "autosynctransforms":
- Physics2D.autoSyncTransforms = prop.Value.Value();
- changed.Add("autoSyncTransforms");
+ if (UnityPhysicsCompat.TrySetPhysics2DAutoSyncTransforms(prop.Value.Value()))
+ {
+ changed.Add("autoSyncTransforms");
+ }
break;
}
}
diff --git a/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs b/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs
index 8bf4d752a..fc922110f 100644
--- a/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs
+++ b/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs
@@ -29,10 +29,9 @@ public static object SimulateStep(JObject @params)
else
{
UnityEngine.Physics.SyncTransforms();
-#if UNITY_2022_2_OR_NEWER
- var prevMode = UnityEngine.Physics.simulationMode;
- if (prevMode != SimulationMode.Script)
- UnityEngine.Physics.simulationMode = SimulationMode.Script;
+ var prevMode = UnityPhysicsCompat.GetPhysicsSimulationMode();
+ if (prevMode != UnityPhysicsCompat.SimulationMode.Script)
+ UnityPhysicsCompat.TrySetPhysicsSimulationMode(UnityPhysicsCompat.SimulationMode.Script);
try
{
for (int i = 0; i < steps; i++)
@@ -40,22 +39,12 @@ public static object SimulateStep(JObject @params)
}
finally
{
- UnityEngine.Physics.simulationMode = prevMode;
- }
-#else
- bool wasAuto = UnityEngine.Physics.autoSimulation;
- if (wasAuto)
- UnityEngine.Physics.autoSimulation = false;
- try
- {
- for (int i = 0; i < steps; i++)
- UnityEngine.Physics.Simulate(stepSize);
- }
- finally
- {
- UnityEngine.Physics.autoSimulation = wasAuto;
+ if (prevMode != UnityPhysicsCompat.SimulationMode.Unknown
+ && prevMode != UnityPhysicsCompat.SimulationMode.Script)
+ {
+ UnityPhysicsCompat.TrySetPhysicsSimulationMode(prevMode);
+ }
}
-#endif
}
// Collect rigidbody states after simulation
diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs
index 3c93e97d2..14e229c8a 100644
--- a/MCPForUnity/Editor/Tools/RunTests.cs
+++ b/MCPForUnity/Editor/Tools/RunTests.cs
@@ -45,7 +45,8 @@ public static Task