Async Test Infrastructure & Editor Readiness Status + new refresh_unity tool (#507)

* Add editor readiness v2, refresh tool, and preflight guards

* Detect external package changes and harden refresh retry

* feat: add TestRunnerNoThrottle and async test running with background stall prevention

- Add TestRunnerNoThrottle.cs: Sets editor to 'No Throttling' mode during test runs
  with SessionState persistence across domain reload
- Add run_tests_async and get_test_job tools for non-blocking test execution
- Add TestJobManager for async test job tracking with progress monitoring
- Add ForceSynchronousImport to all AssetDatabase.Refresh() calls to prevent stalls
- Mark DomainReloadResilienceTests as [Explicit] with documentation explaining
  the test infrastructure limitation (internal coroutine waits vs MCP socket polling)
- MCP workflow is unaffected - socket messages provide external stimulus that
  keeps Unity responsive even when backgrounded

* refactor: simplify and clean up code

- Remove unused Newtonsoft.Json.Linq import from TestJobManager
- Add throttling to SessionState persistence (once per second) to reduce overhead
- Critical job state changes (start/finish) still persist immediately
- Fix duplicate XML summary tag in DomainReloadResilienceTests

* docs: add async test tools to README, document domain reload limitation

- Add run_tests_async and get_test_job to main README tools list
- Document background stall limitation for domain reload tests in DEV readme

* ci: add separate job for domain reload tests

Run [Explicit] domain_reload tests in their own job using -testCategory

* ci: run domain reload tests in same job as regular tests

Combines into single job with two test steps to reuse cached Library

* fix: address coderabbit review issues

- Fix TOCTOU race in TestJobManager.StartJob (single lock scope for check-and-set)
- Store TestRunnerApi reference with HideAndDontSave to prevent GC/serialization issues

* docs: update tool descriptions to prefer run_tests_async

- run_tests_async is now marked as preferred for long-running suites
- run_tests description notes it blocks and suggests async alternative

* docs: update README screenshot to v8.6 UI

* docs: add v8.6 UI screenshot

* Update README for MCP version and instructions for v8.7

* fix: handle preflight busy signals and derive job status from test results

- manage_asset, manage_gameobject, manage_scene now check preflight return
  value and propagate busy/retry signals to clients (fixes Sourcery #1)
- TestJobManager.FinalizeCurrentJobFromRunFinished now sets job status to
  Failed when resultPayload.Failed > 0, not always Succeeded (fixes Sourcery #2)

* fix: increase HTTP server startup timeout for dev mode

When 'Force fresh server install' is enabled, uvx uses --no-cache --refresh
which rebuilds the package and takes significantly longer to start.

- Increase timeout from 10s to 45s when dev mode is enabled
- Add informative log message explaining the longer startup time
- Show actual timeout value in warning message

* fix: derive job status from test results in FinalizeFromTask fallback

Apply same logic as FinalizeCurrentJobFromRunFinished: check result.Failed > 0
to correctly mark jobs as Failed when tests fail, even in the fallback path
when RunFinished callback is not delivered.
main
dsarno 2026-01-03 12:42:32 -08:00 committed by GitHub
parent 191b730a47
commit 711768d064
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2861 additions and 57 deletions

View File

@ -25,13 +25,11 @@ jobs:
unityVersion: unityVersion:
- 2021.3.45f2 - 2021.3.45f2
steps: steps:
# Checkout
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
lfs: true lfs: true
# Cache
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ${{ matrix.projectPath }}/Library path: ${{ matrix.projectPath }}/Library
@ -40,7 +38,20 @@ jobs:
Library-${{ matrix.projectPath }}- Library-${{ matrix.projectPath }}-
Library- Library-
# Test # Run domain reload tests first (they're [Explicit] so need explicit category)
- name: Run domain reload tests
uses: game-ci/unity-test-runner@v4
id: domain-tests
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
testMode: ${{ matrix.testMode }}
customParameters: -testCategory domain_reload
- name: Run tests - name: Run tests
uses: game-ci/unity-test-runner@v4 uses: game-ci/unity-test-runner@v4
id: tests id: tests
@ -53,7 +64,6 @@ jobs:
unityVersion: ${{ matrix.unityVersion }} unityVersion: ${{ matrix.unityVersion }}
testMode: ${{ matrix.testMode }} testMode: ${{ matrix.testMode }}
# Upload test results
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:

View File

@ -0,0 +1,29 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Resources.Editor
{
/// <summary>
/// Provides a cached, v2 readiness snapshot. This is designed to remain responsive even when Unity is busy.
/// </summary>
[McpForUnityResource("get_editor_state_v2")]
public static class EditorStateV2
{
public static object HandleCommand(JObject @params)
{
try
{
var snapshot = EditorStateCache.GetSnapshot();
return new SuccessResponse("Retrieved editor state (v2).", snapshot);
}
catch (Exception e)
{
return new ErrorResponse($"Error getting editor state (v2): {e.Message}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5514ec4eb8a294a55892a13194e250e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,234 @@
using System;
using MCPForUnity.Editor.Helpers;
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 = 0.25;
private static JObject _cached;
static EditorStateCache()
{
try
{
_sequence = 0;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_cached = BuildSnapshot("init");
EditorApplication.update += OnUpdate;
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
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;
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
// Still update on compilation edge transitions to keep timestamps meaningful.
bool isCompiling = EditorApplication.isCompiling;
if (isCompiling == _lastIsCompiling)
{
return;
}
}
_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 = EditorApplication.isCompiling;
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";
}
// Keep this as a plain JSON object for minimal friction with transports.
return JObject.FromObject(new
{
schema_version = "unity-mcp/editor_state@2",
observed_at_unix_ms = _observedUnixMs,
sequence = _sequence,
unity = new
{
instance_id = (string)null,
unity_version = Application.unityVersion,
project_id = (string)null,
platform = Application.platform.ToString(),
is_batch_mode = Application.isBatchMode
},
editor = new
{
is_focused = isFocused,
play_mode = new
{
is_playing = EditorApplication.isPlaying,
is_paused = EditorApplication.isPaused,
is_changing = EditorApplication.isPlayingOrWillChangePlaymode
},
active_scene = new
{
path = scenePath,
guid = sceneGuid,
name = scene.name ?? string.Empty
}
},
activity = new
{
phase = activityPhase,
since_unix_ms = _observedUnixMs,
reasons = new[] { reason }
},
compilation = new
{
is_compiling = isCompiling,
is_domain_reload_pending = _domainReloadPending,
last_compile_started_unix_ms = _lastCompileStartedUnixMs,
last_compile_finished_unix_ms = _lastCompileFinishedUnixMs,
last_domain_reload_before_unix_ms = _domainReloadBeforeUnixMs,
last_domain_reload_after_unix_ms = _domainReloadAfterUnixMs
},
assets = new
{
is_updating = EditorApplication.isUpdating,
external_changes_dirty = false,
external_changes_last_seen_unix_ms = (long?)null,
refresh = new
{
is_refresh_in_progress = false,
last_refresh_requested_unix_ms = (long?)null,
last_refresh_finished_unix_ms = (long?)null
}
},
tests = new
{
is_running = testsRunning,
mode = testsMode,
current_job_id = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
started_unix_ms = TestRunStatus.StartedUnixMs,
started_by = "unknown",
last_run = TestRunStatus.FinishedUnixMs.HasValue
? new
{
finished_unix_ms = TestRunStatus.FinishedUnixMs,
result = "unknown",
counts = (object)null
}
: null
},
transport = new
{
unity_bridge_connected = (bool?)null,
last_message_unix_ms = (long?)null
}
});
}
public static JObject GetSnapshot()
{
lock (LockObj)
{
// Defensive: if something went wrong early, rebuild once.
if (_cached == null)
{
_cached = BuildSnapshot("rebuild");
}
return (JObject)_cached.DeepClone();
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa7909967ce3c48c493181c978782a54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,586 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditorInternal;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Services
{
internal enum TestJobStatus
{
Running,
Succeeded,
Failed
}
internal sealed class TestJobFailure
{
public string FullName { get; set; }
public string Message { get; set; }
}
internal sealed class TestJob
{
public string JobId { get; set; }
public TestJobStatus Status { get; set; }
public string Mode { get; set; }
public long StartedUnixMs { get; set; }
public long? FinishedUnixMs { get; set; }
public long LastUpdateUnixMs { get; set; }
public int? TotalTests { get; set; }
public int CompletedTests { get; set; }
public string CurrentTestFullName { get; set; }
public long? CurrentTestStartedUnixMs { get; set; }
public string LastFinishedTestFullName { get; set; }
public long? LastFinishedUnixMs { get; set; }
public List<TestJobFailure> FailuresSoFar { get; set; }
public string Error { get; set; }
public TestRunResult Result { get; set; }
}
/// <summary>
/// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs.
/// </summary>
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 int MaxJobsToKeep = 10;
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead
// SessionState survives domain reloads within the same Unity Editor session.
private const string SessionKeyJobs = "MCPForUnity.TestJobsV1";
private const string SessionKeyCurrentJobId = "MCPForUnity.CurrentTestJobIdV1";
private static readonly object LockObj = new();
private static readonly Dictionary<string, TestJob> Jobs = new();
private static string _currentJobId;
private static long _lastPersistUnixMs;
static TestJobManager()
{
// Restore after domain reloads (e.g., compilation while a job is running).
TryRestoreFromSessionState();
}
public static string CurrentJobId
{
get { lock (LockObj) return _currentJobId; }
}
public static bool HasRunningJob
{
get
{
lock (LockObj)
{
return !string.IsNullOrEmpty(_currentJobId);
}
}
}
private sealed class PersistedState
{
public string current_job_id { get; set; }
public List<PersistedJob> jobs { get; set; }
}
private sealed class PersistedJob
{
public string job_id { get; set; }
public string status { get; set; }
public string mode { 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 int? total_tests { get; set; }
public int completed_tests { get; set; }
public string current_test_full_name { get; set; }
public long? current_test_started_unix_ms { get; set; }
public string last_finished_test_full_name { get; set; }
public long? last_finished_unix_ms { get; set; }
public List<TestJobFailure> failures_so_far { get; set; }
public string error { get; set; }
}
private static TestJobStatus ParseStatus(string status)
{
if (string.IsNullOrWhiteSpace(status))
{
return TestJobStatus.Running;
}
string s = status.Trim().ToLowerInvariant();
return s switch
{
"succeeded" => TestJobStatus.Succeeded,
"failed" => TestJobStatus.Failed,
_ => TestJobStatus.Running
};
}
private static void TryRestoreFromSessionState()
{
try
{
string json = SessionState.GetString(SessionKeyJobs, string.Empty);
if (string.IsNullOrWhiteSpace(json))
{
var legacy = SessionState.GetString(SessionKeyCurrentJobId, string.Empty);
_currentJobId = string.IsNullOrWhiteSpace(legacy) ? null : legacy;
return;
}
var state = JsonConvert.DeserializeObject<PersistedState>(json);
if (state?.jobs == null)
{
return;
}
lock (LockObj)
{
Jobs.Clear();
foreach (var pj in state.jobs)
{
if (pj == null || string.IsNullOrWhiteSpace(pj.job_id))
{
continue;
}
Jobs[pj.job_id] = new TestJob
{
JobId = pj.job_id,
Status = ParseStatus(pj.status),
Mode = pj.mode,
StartedUnixMs = pj.started_unix_ms,
FinishedUnixMs = pj.finished_unix_ms,
LastUpdateUnixMs = pj.last_update_unix_ms,
TotalTests = pj.total_tests,
CompletedTests = pj.completed_tests,
CurrentTestFullName = pj.current_test_full_name,
CurrentTestStartedUnixMs = pj.current_test_started_unix_ms,
LastFinishedTestFullName = pj.last_finished_test_full_name,
LastFinishedUnixMs = pj.last_finished_unix_ms,
FailuresSoFar = pj.failures_so_far ?? new List<TestJobFailure>(),
Error = pj.error,
// Intentionally not persisted to avoid ballooning SessionState.
Result = null
};
}
_currentJobId = string.IsNullOrWhiteSpace(state.current_job_id) ? null : state.current_job_id;
if (!string.IsNullOrEmpty(_currentJobId) && !Jobs.ContainsKey(_currentJobId))
{
_currentJobId = null;
}
}
}
catch (Exception ex)
{
// Restoration is best-effort; never block editor load.
McpLog.Warn($"[TestJobManager] Failed to restore SessionState: {ex.Message}");
}
}
private static void PersistToSessionState(bool force = false)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Throttle non-critical updates to reduce overhead during large test runs
if (!force && (now - _lastPersistUnixMs) < MinPersistIntervalMs)
{
return;
}
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(),
mode = j.Mode,
started_unix_ms = j.StartedUnixMs,
finished_unix_ms = j.FinishedUnixMs,
last_update_unix_ms = j.LastUpdateUnixMs,
total_tests = j.TotalTests,
completed_tests = j.CompletedTests,
current_test_full_name = j.CurrentTestFullName,
current_test_started_unix_ms = j.CurrentTestStartedUnixMs,
last_finished_test_full_name = j.LastFinishedTestFullName,
last_finished_unix_ms = j.LastFinishedUnixMs,
failures_so_far = (j.FailuresSoFar ?? new List<TestJobFailure>()).Take(FailureCap).ToList(),
error = j.Error
})
.ToList();
snapshot = new PersistedState
{
current_job_id = _currentJobId,
jobs = jobs
};
}
SessionState.SetString(SessionKeyCurrentJobId, snapshot.current_job_id ?? string.Empty);
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot));
_lastPersistUnixMs = now;
}
catch (Exception ex)
{
McpLog.Warn($"[TestJobManager] Failed to persist SessionState: {ex.Message}");
}
}
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
{
string jobId = Guid.NewGuid().ToString("N");
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string modeStr = mode.ToString();
var job = new TestJob
{
JobId = jobId,
Status = TestJobStatus.Running,
Mode = modeStr,
StartedUnixMs = started,
FinishedUnixMs = null,
LastUpdateUnixMs = started,
TotalTests = null,
CompletedTests = 0,
CurrentTestFullName = null,
CurrentTestStartedUnixMs = null,
LastFinishedTestFullName = null,
LastFinishedUnixMs = null,
FailuresSoFar = new List<TestJobFailure>(),
Error = null,
Result = null
};
// Single lock scope for check-and-set to avoid TOCTOU race
lock (LockObj)
{
if (!string.IsNullOrEmpty(_currentJobId))
{
throw new InvalidOperationException("A Unity test run is already in progress.");
}
Jobs[jobId] = job;
_currentJobId = jobId;
}
PersistToSessionState(force: true);
// Kick the run (must be called on main thread; our command handlers already run there).
Task<TestRunResult> task = MCPServiceLocator.Tests.RunTestsAsync(mode, filterOptions);
void FinalizeJob(Action finalize)
{
// Ensure state mutation happens on main thread to avoid Unity API surprises.
EditorApplication.delayCall += () =>
{
try { finalize(); }
catch (Exception ex) { McpLog.Error($"[TestJobManager] Finalize failed: {ex.Message}\n{ex.StackTrace}"); }
};
}
task.ContinueWith(t =>
{
// NOTE: We now finalize jobs deterministically from the TestRunnerService RunFinished callback.
// This continuation is retained as a safety net in case RunFinished is not delivered.
FinalizeJob(() => FinalizeFromTask(jobId, t));
}, TaskScheduler.Default);
return jobId;
}
public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.LastUpdateUnixMs = now;
job.FinishedUnixMs = now;
job.Status = resultPayload != null && resultPayload.Failed > 0
? TestJobStatus.Failed
: TestJobStatus.Succeeded;
job.Error = null;
job.Result = resultPayload;
job.CurrentTestFullName = null;
_currentJobId = null;
}
PersistToSessionState(force: true);
}
public static void OnRunStarted(int? totalTests)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.LastUpdateUnixMs = now;
job.TotalTests = totalTests;
job.CompletedTests = 0;
job.CurrentTestFullName = null;
job.CurrentTestStartedUnixMs = null;
job.LastFinishedTestFullName = null;
job.LastFinishedUnixMs = null;
job.FailuresSoFar ??= new List<TestJobFailure>();
job.FailuresSoFar.Clear();
}
PersistToSessionState(force: true);
}
public static void OnTestStarted(string testFullName)
{
if (string.IsNullOrWhiteSpace(testFullName))
{
return;
}
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.LastUpdateUnixMs = now;
job.CurrentTestFullName = testFullName;
job.CurrentTestStartedUnixMs = now;
}
PersistToSessionState();
}
public static void OnLeafTestFinished(string testFullName, bool isFailure, string message)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.LastUpdateUnixMs = now;
job.CompletedTests = Math.Max(0, job.CompletedTests + 1);
job.LastFinishedTestFullName = testFullName;
job.LastFinishedUnixMs = now;
if (isFailure)
{
job.FailuresSoFar ??= new List<TestJobFailure>();
if (job.FailuresSoFar.Count < FailureCap)
{
job.FailuresSoFar.Add(new TestJobFailure
{
FullName = testFullName,
Message = string.IsNullOrWhiteSpace(message) ? "Test failed" : message
});
}
}
}
PersistToSessionState();
}
public static void OnRunFinished()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.LastUpdateUnixMs = now;
job.CurrentTestFullName = null;
}
PersistToSessionState(force: true);
}
public static TestJob GetJob(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
return null;
}
lock (LockObj)
{
return Jobs.TryGetValue(jobId, out var job) ? job : null;
}
}
public static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests)
{
if (job == null)
{
return null;
}
object resultPayload = null;
if (job.Status == TestJobStatus.Succeeded && job.Result != null)
{
resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests);
}
return new
{
job_id = job.JobId,
status = job.Status.ToString().ToLowerInvariant(),
mode = job.Mode,
started_unix_ms = job.StartedUnixMs,
finished_unix_ms = job.FinishedUnixMs,
last_update_unix_ms = job.LastUpdateUnixMs,
progress = new
{
completed = job.CompletedTests,
total = job.TotalTests,
current_test_full_name = job.CurrentTestFullName,
current_test_started_unix_ms = job.CurrentTestStartedUnixMs,
last_finished_test_full_name = job.LastFinishedTestFullName,
last_finished_unix_ms = job.LastFinishedUnixMs,
stuck_suspected = IsStuck(job),
editor_is_focused = InternalEditorUtility.isApplicationActive,
blocked_reason = GetBlockedReason(job),
failures_so_far = BuildFailuresPayload(job.FailuresSoFar),
failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap)
},
error = job.Error,
result = resultPayload
};
}
private static string GetBlockedReason(TestJob job)
{
if (job == null || job.Status != TestJobStatus.Running)
{
return null;
}
if (!IsStuck(job))
{
return null;
}
// This matches the real-world symptom you observed: background Unity can get heavily throttled by OS/Editor.
if (!InternalEditorUtility.isApplicationActive)
{
return "editor_unfocused";
}
if (EditorApplication.isCompiling)
{
return "compiling";
}
if (EditorApplication.isUpdating)
{
return "asset_import";
}
return "unknown";
}
private static bool IsStuck(TestJob job)
{
if (job == null || job.Status != TestJobStatus.Running)
{
return false;
}
if (string.IsNullOrWhiteSpace(job.CurrentTestFullName) || !job.CurrentTestStartedUnixMs.HasValue)
{
return false;
}
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return (now - job.CurrentTestStartedUnixMs.Value) > StuckThresholdMs;
}
private static object[] BuildFailuresPayload(List<TestJobFailure> failures)
{
if (failures == null || failures.Count == 0)
{
return Array.Empty<object>();
}
var list = new object[failures.Count];
for (int i = 0; i < failures.Count; i++)
{
var f = failures[i];
list[i] = new { full_name = f?.FullName, message = f?.Message };
}
return list;
}
private static void FinalizeFromTask(string jobId, Task<TestRunResult> task)
{
lock (LockObj)
{
if (!Jobs.TryGetValue(jobId, out var existing))
{
if (_currentJobId == jobId) _currentJobId = null;
return;
}
// If RunFinished already finalized the job, do nothing.
if (existing.Status != TestJobStatus.Running)
{
if (_currentJobId == jobId) _currentJobId = null;
return;
}
existing.LastUpdateUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
existing.FinishedUnixMs = existing.LastUpdateUnixMs;
if (task.IsFaulted)
{
existing.Status = TestJobStatus.Failed;
existing.Error = task.Exception?.GetBaseException()?.Message ?? "Unknown test job failure";
existing.Result = null;
}
else if (task.IsCanceled)
{
existing.Status = TestJobStatus.Failed;
existing.Error = "Test job canceled";
existing.Result = null;
}
else
{
var result = task.Result;
existing.Status = result != null && result.Failed > 0
? TestJobStatus.Failed
: TestJobStatus.Succeeded;
existing.Error = null;
existing.Result = result;
}
if (_currentJobId == jobId)
{
_currentJobId = null;
}
}
PersistToSessionState(force: true);
}
}
}

View File

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 2d7a9b8c0e1f4a6b9c3d2e1f0a9b8c7d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,62 @@
using System;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Thread-safe, minimal shared status for Unity Test Runner execution.
/// Used by editor readiness snapshots so callers can avoid starting overlapping runs.
/// </summary>
internal static class TestRunStatus
{
private static readonly object LockObj = new();
private static bool _isRunning;
private static TestMode? _mode;
private static long? _startedUnixMs;
private static long? _finishedUnixMs;
public static bool IsRunning
{
get { lock (LockObj) return _isRunning; }
}
public static TestMode? Mode
{
get { lock (LockObj) return _mode; }
}
public static long? StartedUnixMs
{
get { lock (LockObj) return _startedUnixMs; }
}
public static long? FinishedUnixMs
{
get { lock (LockObj) return _finishedUnixMs; }
}
public static void MarkStarted(TestMode mode)
{
lock (LockObj)
{
_isRunning = true;
_mode = mode;
_startedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_finishedUnixMs = null;
}
}
public static void MarkFinished()
{
lock (LockObj)
{
_isRunning = false;
_finishedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_mode = null;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3d140c288f6e4b6aa2b7e8181a09c1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,139 @@
// TestRunnerNoThrottle.cs
// Sets Unity Editor to "No Throttling" mode during test runs.
// This helps tests that don't trigger compilation run smoothly in the background.
// Note: Tests that trigger mid-run compilation may still stall due to OS-level throttling.
using System;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Automatically sets the editor to "No Throttling" mode during test runs.
///
/// This helps prevent background stalls for normal tests. However, tests that trigger
/// script compilation mid-run may still stall because:
/// - Internal Unity coroutine waits rely on editor ticks
/// - OS-level throttling affects the main thread when Unity is backgrounded
/// - No amount of internal nudging can overcome OS thread scheduling
///
/// The MCP workflow is unaffected because socket messages provide external stimulus
/// that wakes Unity's main thread.
/// </summary>
[InitializeOnLoad]
public static class TestRunnerNoThrottle
{
private const string ApplicationIdleTimeKey = "ApplicationIdleTime";
private const string InteractionModeKey = "InteractionMode";
// SessionState keys to persist across domain reload
private const string SessionKey_TestRunActive = "TestRunnerNoThrottle_TestRunActive";
private const string SessionKey_PrevIdleTime = "TestRunnerNoThrottle_PrevIdleTime";
private const string SessionKey_PrevInteractionMode = "TestRunnerNoThrottle_PrevInteractionMode";
private const string SessionKey_SettingsCaptured = "TestRunnerNoThrottle_SettingsCaptured";
// Keep reference to avoid GC and set HideFlags to avoid serialization issues
private static TestRunnerApi _api;
static TestRunnerNoThrottle()
{
try
{
_api = ScriptableObject.CreateInstance<TestRunnerApi>();
_api.hideFlags = HideFlags.HideAndDontSave;
_api.RegisterCallbacks(new TestCallbacks());
// Check if recovering from domain reload during an active test run
if (IsTestRunActive())
{
McpLog.Info("[TestRunnerNoThrottle] Recovered from domain reload - reapplying No Throttling.");
ApplyNoThrottling();
}
}
catch (Exception e)
{
McpLog.Warn($"[TestRunnerNoThrottle] Failed to register callbacks: {e}");
}
}
#region State Persistence
private static bool IsTestRunActive() => SessionState.GetBool(SessionKey_TestRunActive, false);
private static void SetTestRunActive(bool active) => SessionState.SetBool(SessionKey_TestRunActive, active);
private static bool AreSettingsCaptured() => SessionState.GetBool(SessionKey_SettingsCaptured, false);
private static void SetSettingsCaptured(bool captured) => SessionState.SetBool(SessionKey_SettingsCaptured, captured);
private static int GetPrevIdleTime() => SessionState.GetInt(SessionKey_PrevIdleTime, 4);
private static void SetPrevIdleTime(int value) => SessionState.SetInt(SessionKey_PrevIdleTime, value);
private static int GetPrevInteractionMode() => SessionState.GetInt(SessionKey_PrevInteractionMode, 0);
private static void SetPrevInteractionMode(int value) => SessionState.SetInt(SessionKey_PrevInteractionMode, value);
#endregion
private static void ApplyNoThrottling()
{
if (!AreSettingsCaptured())
{
SetPrevIdleTime(EditorPrefs.GetInt(ApplicationIdleTimeKey, 4));
SetPrevInteractionMode(EditorPrefs.GetInt(InteractionModeKey, 0));
SetSettingsCaptured(true);
}
// 0ms idle + InteractionMode=1 (No Throttling)
EditorPrefs.SetInt(ApplicationIdleTimeKey, 0);
EditorPrefs.SetInt(InteractionModeKey, 1);
ForceEditorToApplyInteractionPrefs();
McpLog.Info("[TestRunnerNoThrottle] Applied No Throttling for test run.");
}
private static void RestoreThrottling()
{
if (!AreSettingsCaptured()) return;
EditorPrefs.SetInt(ApplicationIdleTimeKey, GetPrevIdleTime());
EditorPrefs.SetInt(InteractionModeKey, GetPrevInteractionMode());
ForceEditorToApplyInteractionPrefs();
SetSettingsCaptured(false);
SetTestRunActive(false);
McpLog.Info("[TestRunnerNoThrottle] Restored Interaction Mode after test run.");
}
private static void ForceEditorToApplyInteractionPrefs()
{
try
{
var method = typeof(EditorApplication).GetMethod(
"UpdateInteractionModeSettings",
BindingFlags.Static | BindingFlags.NonPublic
);
method?.Invoke(null, null);
}
catch
{
// Ignore reflection errors
}
}
private sealed class TestCallbacks : ICallbacks
{
public void RunStarted(ITestAdaptor testsToRun)
{
SetTestRunActive(true);
ApplyNoThrottling();
}
public void RunFinished(ITestResultAdaptor result)
{
RestoreThrottling();
}
public void TestStarted(ITestAdaptor test) { }
public void TestFinished(ITestResultAdaptor result) { }
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 07a60b029782d464a9506fa520d2a8c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -93,6 +93,8 @@ namespace MCPForUnity.Editor.Services
_leafResults.Clear(); _leafResults.Clear();
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously); _runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);
// Mark running immediately so readiness snapshots reflect the busy state even before callbacks fire.
TestRunStatus.MarkStarted(mode);
var filter = new Filter var filter = new Filter
{ {
@ -115,6 +117,8 @@ namespace MCPForUnity.Editor.Services
} }
catch catch
{ {
// Ensure the status is cleared if we failed to start the run.
TestRunStatus.MarkFinished();
if (adjustedPlayModeOptions) if (adjustedPlayModeOptions)
{ {
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions); RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
@ -163,6 +167,20 @@ namespace MCPForUnity.Editor.Services
public void RunStarted(ITestAdaptor testsToRun) public void RunStarted(ITestAdaptor testsToRun)
{ {
_leafResults.Clear(); _leafResults.Clear();
try
{
// Best-effort progress info for async polling (avoid heavy payloads).
int? total = null;
if (testsToRun != null)
{
total = CountLeafTests(testsToRun);
}
TestJobManager.OnRunStarted(total);
}
catch
{
TestJobManager.OnRunStarted(null);
}
} }
public void RunFinished(ITestResultAdaptor result) public void RunFinished(ITestResultAdaptor result)
@ -175,11 +193,27 @@ namespace MCPForUnity.Editor.Services
var payload = TestRunResult.Create(result, _leafResults); var payload = TestRunResult.Create(result, _leafResults);
_runCompletionSource.TrySetResult(payload); _runCompletionSource.TrySetResult(payload);
_runCompletionSource = null; _runCompletionSource = null;
TestRunStatus.MarkFinished();
TestJobManager.OnRunFinished();
TestJobManager.FinalizeCurrentJobFromRunFinished(payload);
} }
public void TestStarted(ITestAdaptor test) public void TestStarted(ITestAdaptor test)
{ {
// No-op try
{
// Prefer FullName for uniqueness; fall back to Name.
string fullName = test?.FullName;
if (string.IsNullOrWhiteSpace(fullName))
{
fullName = test?.Name;
}
TestJobManager.OnTestStarted(fullName);
}
catch
{
// ignore
}
} }
public void TestFinished(ITestResultAdaptor result) public void TestFinished(ITestResultAdaptor result)
@ -192,11 +226,72 @@ namespace MCPForUnity.Editor.Services
if (!result.HasChildren) if (!result.HasChildren)
{ {
_leafResults.Add(result); _leafResults.Add(result);
try
{
string fullName = result.Test?.FullName;
if (string.IsNullOrWhiteSpace(fullName))
{
fullName = result.Test?.Name;
}
bool isFailure = false;
string message = null;
try
{
// NUnit outcomes are strings in the adaptor; keep it simple.
string outcome = result.ResultState;
if (!string.IsNullOrWhiteSpace(outcome))
{
var o = outcome.Trim().ToLowerInvariant();
isFailure = o.Contains("failed") || o.Contains("error");
}
message = result.Message;
}
catch
{
// ignore adaptor quirks
}
TestJobManager.OnLeafTestFinished(fullName, isFailure, message);
}
catch
{
// ignore
}
} }
} }
#endregion #endregion
private static int CountLeafTests(ITestAdaptor node)
{
if (node == null)
{
return 0;
}
if (!node.HasChildren)
{
return 1;
}
int total = 0;
try
{
foreach (var child in node.Children)
{
total += CountLeafTests(child);
}
}
catch
{
// If Unity changes the adaptor behavior, treat it as "unknown total".
return 0;
}
return total;
}
private static bool EnsurePlayModeRunsWithoutDomainReload( private static bool EnsurePlayModeRunsWithoutDomainReload(
out bool originalEnterPlayModeOptionsEnabled, out bool originalEnterPlayModeOptionsEnabled,
out EnterPlayModeOptions originalEnterPlayModeOptions) out EnterPlayModeOptions originalEnterPlayModeOptions)

View File

@ -0,0 +1,54 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Poll a previously started async test job by job_id.
/// </summary>
[McpForUnityTool("get_test_job", AutoRegister = false)]
public static class GetTestJob
{
public static object HandleCommand(JObject @params)
{
string jobId = @params?["job_id"]?.ToString() ?? @params?["jobId"]?.ToString();
if (string.IsNullOrWhiteSpace(jobId))
{
return new ErrorResponse("Missing required parameter 'job_id'.");
}
bool includeDetails = false;
bool includeFailedTests = false;
try
{
var includeDetailsToken = @params?["includeDetails"];
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
{
includeDetails = parsedIncludeDetails;
}
var includeFailedTestsToken = @params?["includeFailedTests"];
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
{
includeFailedTests = parsedIncludeFailedTests;
}
}
catch
{
// ignore parse failures
}
var job = TestJobManager.GetJob(jobId);
if (job == null)
{
return new ErrorResponse("Unknown job_id.");
}
var payload = TestJobManager.ToSerializable(job, includeDetails, includeFailedTests);
return new SuccessResponse("Test job status retrieved.", payload);
}
}
}

View File

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 7f92c2b67a2c4b5c9d1a3c0e6f9b2d10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -180,7 +180,7 @@ namespace MCPForUnity.Editor.Tools
if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory)))
{ {
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory));
AssetDatabase.Refresh(); // Make sure Unity knows about the new folder AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Make sure Unity knows about the new folder
} }
if (AssetExists(fullPath)) if (AssetExists(fullPath))
@ -869,7 +869,7 @@ namespace MCPForUnity.Editor.Tools
if (!Directory.Exists(fullDirPath)) if (!Directory.Exists(fullDirPath))
{ {
Directory.CreateDirectory(fullDirPath); Directory.CreateDirectory(fullDirPath);
AssetDatabase.Refresh(); // Let Unity know about the new folder AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Let Unity know about the new folder
} }
} }

View File

@ -590,7 +590,7 @@ namespace MCPForUnity.Editor.Tools
) )
{ {
System.IO.Directory.CreateDirectory(directoryPath); System.IO.Directory.CreateDirectory(directoryPath);
AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Refresh asset database to recognize the new folder
Debug.Log( Debug.Log(
$"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"
); );

View File

@ -219,7 +219,7 @@ namespace MCPForUnity.Editor.Tools
if (saved) if (saved)
{ {
AssetDatabase.Refresh(); // Ensure Unity sees the new scene file AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity sees the new scene file
return new SuccessResponse( return new SuccessResponse(
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
new { path = relativePath } new { path = relativePath }
@ -362,7 +362,7 @@ namespace MCPForUnity.Editor.Tools
if (saved) if (saved)
{ {
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
return new SuccessResponse( return new SuccessResponse(
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
new { path = finalPath, name = currentScene.name } new { path = finalPath, name = currentScene.name }
@ -408,7 +408,7 @@ namespace MCPForUnity.Editor.Tools
result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true); result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true);
} }
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath})."; string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";

View File

@ -978,7 +978,7 @@ namespace MCPForUnity.Editor.Tools
bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); bool deleted = AssetDatabase.MoveAssetToTrash(relativePath);
if (deleted) if (deleted)
{ {
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
return new SuccessResponse( return new SuccessResponse(
$"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.",
new { deleted = true } new { deleted = true }

View File

@ -94,7 +94,7 @@ namespace MCPForUnity.Editor.Tools
{ {
Directory.CreateDirectory(fullPathDir); Directory.CreateDirectory(fullPathDir);
// Refresh AssetDatabase to recognize new folders // Refresh AssetDatabase to recognize new folders
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
} }
} }
catch (Exception e) catch (Exception e)
@ -174,7 +174,7 @@ namespace MCPForUnity.Editor.Tools
{ {
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath); AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity recognizes the new shader
return new SuccessResponse( return new SuccessResponse(
$"Shader '{name}.shader' created successfully at '{relativePath}'.", $"Shader '{name}.shader' created successfully at '{relativePath}'.",
new { path = relativePath } new { path = relativePath }
@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Tools
{ {
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath); AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
return new SuccessResponse( return new SuccessResponse(
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.", $"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
new { path = relativePath } new { path = relativePath }

View File

@ -223,7 +223,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs
if (!Directory.Exists(fullDirectory)) if (!Directory.Exists(fullDirectory))
{ {
Directory.CreateDirectory(fullDirectory); Directory.CreateDirectory(fullDirectory);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
} }
} }

View File

@ -0,0 +1,175 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.Compilation;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Explicitly refreshes Unity's asset database and optionally requests a script compilation.
/// This is side-effectful and should be treated as a tool.
/// </summary>
[McpForUnityTool("refresh_unity", AutoRegister = false)]
public static class RefreshUnity
{
private const int DefaultWaitTimeoutSeconds = 60;
public static async Task<object> HandleCommand(JObject @params)
{
string mode = @params?["mode"]?.ToString() ?? "if_dirty";
string scope = @params?["scope"]?.ToString() ?? "all";
string compile = @params?["compile"]?.ToString() ?? "none";
bool waitForReady = false;
try
{
var waitToken = @params?["wait_for_ready"];
if (waitToken != null && bool.TryParse(waitToken.ToString(), out var parsed))
{
waitForReady = parsed;
}
}
catch
{
// ignore parse failures
}
if (TestRunStatus.IsRunning)
{
return new ErrorResponse("tests_running", new
{
reason = "tests_running",
retry_after_ms = 5000
});
}
bool refreshTriggered = false;
bool compileRequested = false;
try
{
// Best-effort semantics: if_dirty currently behaves like force unless future dirty signals are added.
bool shouldRefresh = string.Equals(mode, "force", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mode, "if_dirty", StringComparison.OrdinalIgnoreCase);
if (shouldRefresh)
{
if (string.Equals(scope, "scripts", StringComparison.OrdinalIgnoreCase))
{
// For scripts, requesting compilation is usually the meaningful action.
// We avoid a heavyweight full refresh by default.
}
else
{
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
refreshTriggered = true;
}
}
if (string.Equals(compile, "request", StringComparison.OrdinalIgnoreCase))
{
CompilationPipeline.RequestScriptCompilation();
compileRequested = true;
}
if (string.Equals(scope, "all", StringComparison.OrdinalIgnoreCase) && !refreshTriggered)
{
// If the caller asked for "all" and we skipped refresh above (e.g., scripts-only path),
// do a lightweight refresh now. Use ForceSynchronousImport to ensure the refresh
// completes before returning, preventing stalls when Unity is backgrounded.
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
refreshTriggered = true;
}
}
catch (Exception ex)
{
return new ErrorResponse($"refresh_failed: {ex.Message}");
}
if (waitForReady)
{
try
{
await WaitForUnityReadyAsync(TimeSpan.FromSeconds(DefaultWaitTimeoutSeconds)).ConfigureAwait(true);
}
catch (TimeoutException)
{
return new ErrorResponse("refresh_timeout_waiting_for_ready", new
{
refresh_triggered = refreshTriggered,
compile_requested = compileRequested,
resulting_state = "unknown",
});
}
catch (Exception ex)
{
return new ErrorResponse($"refresh_wait_failed: {ex.Message}");
}
}
string resultingState = EditorApplication.isCompiling
? "compiling"
: (EditorApplication.isUpdating ? "asset_import" : "idle");
return new SuccessResponse("Refresh requested.", new
{
refresh_triggered = refreshTriggered,
compile_requested = compileRequested,
resulting_state = resultingState,
hint = waitForReady
? "Unity refresh completed; editor should be ready."
: "If Unity enters compilation/domain reload, poll editor_state until ready_for_tools is true."
});
}
private static Task WaitForUnityReadyAsync(TimeSpan timeout)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var start = DateTime.UtcNow;
void Tick()
{
try
{
if (tcs.Task.IsCompleted)
{
EditorApplication.update -= Tick;
return;
}
if ((DateTime.UtcNow - start) > timeout)
{
EditorApplication.update -= Tick;
tcs.TrySetException(new TimeoutException());
return;
}
if (!EditorApplication.isCompiling
&& !EditorApplication.isUpdating
&& !TestRunStatus.IsRunning
&& !EditorApplication.isPlayingOrWillChangePlaymode)
{
EditorApplication.update -= Tick;
tcs.TrySetResult(true);
}
}
catch (Exception ex)
{
EditorApplication.update -= Tick;
tcs.TrySetException(ex);
}
}
EditorApplication.update += Tick;
// Nudge Unity to pump once in case update is throttled.
try { EditorApplication.QueuePlayerLoopUpdate(); } catch { }
return tcs.Task;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2c02170faca940d09c813706493ecb3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,128 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
/// Use get_test_job(job_id) to poll status/results.
/// </summary>
[McpForUnityTool("run_tests_async", AutoRegister = false)]
public static class RunTestsAsync
{
public static Task<object> HandleCommand(JObject @params)
{
try
{
string modeStr = @params?["mode"]?.ToString();
if (string.IsNullOrWhiteSpace(modeStr))
{
modeStr = "EditMode";
}
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{
return Task.FromResult<object>(new ErrorResponse(parseError));
}
bool includeDetails = false;
bool includeFailedTests = false;
try
{
var includeDetailsToken = @params?["includeDetails"];
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
{
includeDetails = parsedIncludeDetails;
}
var includeFailedTestsToken = @params?["includeFailedTests"];
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
{
includeFailedTests = parsedIncludeFailedTests;
}
}
catch
{
// ignore parse failures
}
var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
return Task.FromResult<object>(new SuccessResponse("Test job started.", new
{
job_id = jobId,
status = "running",
mode = parsedMode.Value.ToString(),
include_details = includeDetails,
include_failed_tests = includeFailedTests
}));
}
catch (Exception ex)
{
// Normalize the already-running case to a stable error token.
if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0)
{
return Task.FromResult<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
}
return Task.FromResult<object>(new ErrorResponse($"Failed to start test job: {ex.Message}"));
}
}
private static TestFilterOptions GetFilterOptions(JObject @params)
{
if (@params == null)
{
return null;
}
string[] ParseStringArray(string key)
{
var token = @params[key];
if (token == null) return null;
if (token.Type == JTokenType.String)
{
var value = token.ToString();
return string.IsNullOrWhiteSpace(value) ? null : new[] { value };
}
if (token.Type == JTokenType.Array)
{
var array = token as JArray;
if (array == null || array.Count == 0) return null;
var values = array
.Values<string>()
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
var testNames = ParseStringArray("testNames");
var groupNames = ParseStringArray("groupNames");
var categoryNames = ParseStringArray("categoryNames");
var assemblyNames = ParseStringArray("assemblyNames");
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
{
return null;
}
return new TestFilterOptions
{
TestNames = testNames,
GroupNames = groupNames,
CategoryNames = categoryNames,
AssemblyNames = assemblyNames
};
}
}
}

View File

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -627,10 +627,20 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{ {
// Wait until the HTTP server is actually accepting connections to reduce transient "unable to connect then recovers" // Wait until the HTTP server is actually accepting connections to reduce transient "unable to connect then recovers"
// behavior (session start attempts can race the server startup). // behavior (session start attempts can race the server startup).
bool serverReady = await WaitForHttpServerAcceptingConnectionsAsync(TimeSpan.FromSeconds(10)); // Dev mode uses --no-cache --refresh which causes uvx to rebuild the package, taking significantly longer.
bool devModeEnabled = false;
try { devModeEnabled = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }
var startupTimeout = devModeEnabled ? TimeSpan.FromSeconds(45) : TimeSpan.FromSeconds(10);
if (devModeEnabled)
{
McpLog.Info("Dev mode enabled: server startup may take longer while uvx rebuilds the package...");
}
bool serverReady = await WaitForHttpServerAcceptingConnectionsAsync(startupTimeout);
if (!serverReady) if (!serverReady)
{ {
McpLog.Warn("HTTP server did not become reachable within the expected startup window; will still attempt to start the session."); McpLog.Warn($"HTTP server did not become reachable within {startupTimeout.TotalSeconds}s; will still attempt to start the session.");
} }
for (int attempt = 0; attempt < maxAttempts; attempt++) for (int attempt = 0; attempt < maxAttempts; attempt++)

View File

@ -16,9 +16,9 @@
**Create your Unity apps with LLMs!** **Create your Unity apps with LLMs!**
MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity. MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigravity, VS Code, etc) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity.
<img width="406" height="704" alt="MCP for Unity screenshot" src="docs/images/readme_ui.png"> <img width="406" height="704" alt="MCP for Unity screenshot" src="docs/images/unity-mcp-ui-v8.6.png">
--- ---
@ -51,7 +51,9 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths. * `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths.
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `read_console`: Gets messages from or clears the console. * `read_console`: Gets messages from or clears the console.
* `run_tests`: Runs tests in the Unity Editor. * `run_tests_async`: Starts tests asynchronously and returns a job_id for polling (preferred).
* `get_test_job`: Polls an async test job for progress and results.
* `run_tests`: Runs tests synchronously (blocks until complete; prefer `run_tests_async` for long suites).
* `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity. * `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity.
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`. * `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`.
@ -152,7 +154,7 @@ MCP for Unity connects your tools using two components:
**Need a stable/fixed version?** Use a tagged URL instead (updates require uninstalling and re-installing): **Need a stable/fixed version?** Use a tagged URL instead (updates require uninstalling and re-installing):
``` ```
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.2.1 https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.6.0
``` ```
#### To install via OpenUPM #### To install via OpenUPM
@ -168,8 +170,8 @@ https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.2.1
HTTP transport is enabled out of the box. The Unity window can launch the FastMCP server for you: HTTP transport is enabled out of the box. The Unity window can launch the FastMCP server for you:
1. Open `Window > MCP for Unity`. 1. Open `Window > MCP for Unity`.
2. Make sure the **Transport** dropdown is set to `HTTP` (default) and the **HTTP URL** is what you want (defaults to `http://localhost:8080`). 2. Make sure the **Transport** dropdown is set to `HTTP Local` (default) and the **HTTP URL** is what you want (defaults to `http://localhost:8080`).
3. Click **Start Local HTTP Server**. Unity spawns a new operating-system terminal running `uv ... server.py --transport http`. 3. Click **Start Server**. Unity spawns a new operating-system terminal running `uv ... server.py --transport http`.
4. Keep that terminal window open while you work; closing it stops the server. Use the **Stop Session** button in the Unity window if you need to tear it down cleanly. 4. Keep that terminal window open while you work; closing it stops the server. Use the **Stop Session** button in the Unity window if you need to tear it down cleanly.
> Prefer stdio? Change the transport dropdown to `Stdio` and Unity will fall back to the embedded TCP bridge instead of launching the HTTP server. > Prefer stdio? Change the transport dropdown to `Stdio` and Unity will fall back to the embedded TCP bridge instead of launching the HTTP server.
@ -179,7 +181,7 @@ HTTP transport is enabled out of the box. The Unity window can launch the FastMC
You can also start the server yourself from a terminal—useful for CI or when you want to see raw logs: You can also start the server yourself from a terminal—useful for CI or when you want to see raw logs:
```bash ```bash
uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.1.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080 uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080
``` ```
Keep the process running while clients are connected. Keep the process running while clients are connected.
@ -407,7 +409,7 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo
- Check the status window: Window > MCP for Unity. - Check the status window: Window > MCP for Unity.
- Restart Unity. - Restart Unity.
- **MCP Client Not Connecting / Server Not Starting:** - **MCP Client Not Connecting / Server Not Starting:**
- Make sure the local HTTP server is running (Window > MCP for Unity > Start Local HTTP Server). Keep the spawned terminal window open. - Make sure the local HTTP server is running (Window > MCP for Unity > Start Server). Keep the spawned terminal window open.
- **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the installation location: - **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the installation location:
- **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src` - **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
- **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src` - **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`

View File

@ -0,0 +1,270 @@
import time
import os
from typing import Any
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
from services.state.external_changes_scanner import external_changes_scanner
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Avoid instance-discovery side effects during the Python integration test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
async def _infer_single_instance_id(ctx: Context) -> str | None:
"""
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
This makes editor_state outputs self-describing even when no explicit active instance is set.
"""
if _in_pytest():
return None
try:
transport = unity_transport._current_transport()
except Exception:
transport = None
if transport == "http":
# HTTP/WebSocket transport: derive from PluginHub sessions.
try:
from transport.plugin_hub import PluginHub
sessions_data = await PluginHub.get_sessions()
sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {}
if isinstance(sessions, dict) and len(sessions) == 1:
session = next(iter(sessions.values()))
project = getattr(session, "project", None)
project_hash = getattr(session, "hash", None)
if project and project_hash:
return f"{project}@{project_hash}"
except Exception:
return None
return None
# Stdio/TCP transport: derive from connection pool discovery.
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if isinstance(instances, list) and len(instances) == 1:
inst = instances[0]
inst_id = getattr(inst, "id", None)
return str(inst_id) if inst_id else None
except Exception:
return None
return None
def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]:
"""
Best-effort mapping from legacy get_editor_state payload into the v2 contract.
Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...}
"""
now_ms = _now_unix_ms()
# legacy may arrive already wrapped as MCPResponse-like {success,data:{...}}
state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {}
return {
"schema_version": "unity-mcp/editor_state@2",
"observed_at_unix_ms": now_ms,
"sequence": 0,
"unity": {
"instance_id": None,
"unity_version": None,
"project_id": None,
"platform": None,
"is_batch_mode": None,
},
"editor": {
"is_focused": None,
"play_mode": {
"is_playing": bool(state.get("isPlaying", False)),
"is_paused": bool(state.get("isPaused", False)),
"is_changing": None,
},
"active_scene": {
"path": None,
"guid": None,
"name": state.get("activeSceneName", "") or "",
},
"selection": {
"count": int(state.get("selectionCount", 0) or 0),
"active_object_name": state.get("activeObjectName", None),
},
},
"activity": {
"phase": "unknown",
"since_unix_ms": now_ms,
"reasons": ["legacy_fallback"],
},
"compilation": {
"is_compiling": bool(state.get("isCompiling", False)),
"is_domain_reload_pending": None,
"last_compile_started_unix_ms": None,
"last_compile_finished_unix_ms": None,
},
"assets": {
"is_updating": bool(state.get("isUpdating", False)),
"external_changes_dirty": False,
"external_changes_last_seen_unix_ms": None,
"refresh": {
"is_refresh_in_progress": False,
"last_refresh_requested_unix_ms": None,
"last_refresh_finished_unix_ms": None,
},
},
"tests": {
"is_running": False,
"mode": None,
"started_unix_ms": None,
"started_by": "unknown",
"last_run": None,
},
"transport": {
"unity_bridge_connected": None,
"last_message_unix_ms": None,
},
}
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
now_ms = _now_unix_ms()
observed = state_v2.get("observed_at_unix_ms")
try:
observed_ms = int(observed)
except Exception:
observed_ms = now_ms
age_ms = max(0, now_ms - observed_ms)
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
is_stale = age_ms > 2000
compilation = state_v2.get("compilation") or {}
tests = state_v2.get("tests") or {}
assets = state_v2.get("assets") or {}
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
blocking: list[str] = []
if compilation.get("is_compiling") is True:
blocking.append("compiling")
if compilation.get("is_domain_reload_pending") is True:
blocking.append("domain_reload")
if tests.get("is_running") is True:
blocking.append("running_tests")
if refresh.get("is_refresh_in_progress") is True:
blocking.append("asset_refresh")
if is_stale:
blocking.append("stale_status")
ready_for_tools = len(blocking) == 0
state_v2["advice"] = {
"ready_for_tools": ready_for_tools,
"blocking_reasons": blocking,
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
"recommended_next_action": "none" if ready_for_tools else "retry_later",
}
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
return state_v2
@mcp_for_unity_resource(
uri="unity://editor_state",
name="editor_state_v2",
description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.",
)
async def get_editor_state_v2(ctx: Context) -> MCPResponse:
unity_instance = get_unity_instance_from_context(ctx)
# Try v2 snapshot first (Unity-side cache will make this fast once implemented).
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state_v2",
{},
)
# If Unity returns a structured retry hint or error, surface it directly.
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
# If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map.
if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")):
legacy = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state",
{},
)
if isinstance(legacy, dict) and not legacy.get("success", True):
return MCPResponse(**legacy)
state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {})
else:
state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {}
# Ensure required v2 marker exists even if Unity returns partial.
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
state_v2.setdefault("sequence", 0)
# Ensure the returned snapshot is clearly associated with the targeted instance.
# (This matters when multiple Unity instances are connected and the client is polling readiness.)
unity_section = state_v2.get("unity")
if not isinstance(unity_section, dict):
unity_section = {}
state_v2["unity"] = unity_section
current_instance_id = unity_section.get("instance_id")
if current_instance_id in (None, ""):
if unity_instance:
unity_section["instance_id"] = unity_instance
else:
inferred = await _infer_single_instance_id(ctx)
if inferred:
unity_section["instance_id"] = inferred
# External change detection (server-side): compute per instance based on project root path.
# This helps detect stale assets when external tools edit the filesystem.
try:
instance_id = unity_section.get("instance_id")
if isinstance(instance_id, str) and instance_id.strip():
from services.resources.project_info import get_project_info
# Cache the project root for this instance (best-effort).
proj_resp = await get_project_info(ctx)
proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp
proj_data = proj.get("data") if isinstance(proj, dict) else None
project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None
if isinstance(project_root, str) and project_root.strip():
external_changes_scanner.set_project_root(instance_id, project_root)
ext = external_changes_scanner.update_and_get(instance_id)
assets = state_v2.get("assets")
if not isinstance(assets, dict):
assets = {}
state_v2["assets"] = assets
# IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative
# for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner.
assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False))
assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms")
# Extra bookkeeping fields (server-only) are safe to add under assets.
assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms")
assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms")
except Exception:
# Best-effort; do not fail readiness resource if filesystem scan can't run.
pass
state_v2 = _enrich_advice_and_staleness(state_v2)
return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2)

View File

@ -0,0 +1,246 @@
from __future__ import annotations
import os
import json
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Keep scanner inert during the Python integration suite unless explicitly invoked.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
@dataclass
class ExternalChangesState:
project_root: str | None = None
last_scan_unix_ms: int | None = None
last_seen_mtime_ns: int | None = None
dirty: bool = False
dirty_since_unix_ms: int | None = None
external_changes_last_seen_unix_ms: int | None = None
last_cleared_unix_ms: int | None = None
# Cached package roots referenced by Packages/manifest.json "file:" dependencies
extra_roots: list[str] | None = None
manifest_last_mtime_ns: int | None = None
class ExternalChangesScanner:
"""
Lightweight external-changes detector using recursive max-mtime scan.
This is intentionally conservative:
- It only marks dirty when it sees a strictly newer mtime than the baseline.
- It scans at most once per scan_interval_ms per instance to keep overhead bounded.
"""
def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
self._states: dict[str, ExternalChangesState] = {}
self._scan_interval_ms = int(scan_interval_ms)
self._max_entries = int(max_entries)
def _get_state(self, instance_id: str) -> ExternalChangesState:
return self._states.setdefault(instance_id, ExternalChangesState())
def set_project_root(self, instance_id: str, project_root: str | None) -> None:
st = self._get_state(instance_id)
if project_root:
st.project_root = project_root
def clear_dirty(self, instance_id: str) -> None:
st = self._get_state(instance_id)
st.dirty = False
st.dirty_since_unix_ms = None
st.last_cleared_unix_ms = _now_unix_ms()
# Reset baseline to “now” on next scan.
st.last_seen_mtime_ns = None
def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
newest: int | None = None
entries = 0
for root in roots:
if not root.exists():
continue
# Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
for dirpath, dirnames, filenames in os.walk(str(root)):
entries += 1
if entries > self._max_entries:
return newest
dp = Path(dirpath)
name = dp.name.lower()
if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
dirnames[:] = []
continue
# Allow skipping hidden directories quickly
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fn in filenames:
if fn.startswith("."):
continue
entries += 1
if entries > self._max_entries:
return newest
p = dp / fn
try:
stat = p.stat()
except OSError:
continue
m = getattr(stat, "st_mtime_ns", None)
if m is None:
# Fallback when st_mtime_ns is unavailable
m = int(stat.st_mtime * 1_000_000_000)
newest = m if newest is None else max(newest, int(m))
return newest
def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
"""
Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
Returns a list of Paths that exist and are directories.
"""
manifest_path = project_root / "Packages" / "manifest.json"
try:
stat = manifest_path.stat()
except OSError:
st.extra_roots = []
st.manifest_last_mtime_ns = None
return []
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
return [Path(p) for p in st.extra_roots if p]
try:
raw = manifest_path.read_text(encoding="utf-8")
doc = json.loads(raw)
except Exception:
st.extra_roots = []
st.manifest_last_mtime_ns = mtime_ns
return []
deps = doc.get("dependencies") if isinstance(doc, dict) else None
if not isinstance(deps, dict):
st.extra_roots = []
st.manifest_last_mtime_ns = mtime_ns
return []
roots: list[str] = []
base_dir = manifest_path.parent
for _, ver in deps.items():
if not isinstance(ver, str):
continue
v = ver.strip()
if not v.startswith("file:"):
continue
suffix = v[len("file:") :].strip()
# Handle file:///abs/path or file:/abs/path
if suffix.startswith("///"):
candidate = Path("/" + suffix.lstrip("/"))
elif suffix.startswith("/"):
candidate = Path(suffix)
else:
candidate = (base_dir / suffix).resolve()
try:
if candidate.exists() and candidate.is_dir():
roots.append(str(candidate))
except OSError:
continue
# De-dupe, preserve order
deduped: list[str] = []
seen = set()
for r in roots:
if r not in seen:
seen.add(r)
deduped.append(r)
st.extra_roots = deduped
st.manifest_last_mtime_ns = mtime_ns
return [Path(p) for p in deduped if p]
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
"""
Returns a small dict suitable for embedding in editor_state_v2.assets:
- external_changes_dirty
- external_changes_last_seen_unix_ms
- dirty_since_unix_ms
- last_cleared_unix_ms
"""
st = self._get_state(instance_id)
if _in_pytest():
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
now = _now_unix_ms()
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
st.last_scan_unix_ms = now
project_root = st.project_root
if not project_root:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
root = Path(project_root)
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
# Include any local package roots referenced by file: deps in Packages/manifest.json
try:
paths.extend(self._resolve_manifest_extra_roots(root, st))
except Exception:
pass
newest = self._scan_paths_max_mtime_ns(paths)
if newest is None:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
if st.last_seen_mtime_ns is None:
st.last_seen_mtime_ns = newest
elif newest > st.last_seen_mtime_ns:
st.last_seen_mtime_ns = newest
st.external_changes_last_seen_unix_ms = now
if not st.dirty:
st.dirty = True
st.dirty_since_unix_ms = now
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
# Global singleton (simple, process-local)
external_changes_scanner = ExternalChangesScanner()

View File

@ -12,6 +12,7 @@ from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload, coerce_int from services.tools.utils import parse_json_payload, coerce_int
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.preflight import preflight
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -47,6 +48,12 @@ async def manage_asset(
) -> dict[str, Any]: ) -> dict[str, Any]:
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
# wait/refresh to avoid stale reads and flaky timeouts.
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]: def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)

View File

@ -8,6 +8,7 @@ from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
from services.tools.preflight import preflight
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -92,6 +93,10 @@ async def manage_gameobject(
# Removed session_state import # Removed session_state import
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
if action is None: if action is None:
return { return {
"success": False, "success": False,

View File

@ -6,6 +6,7 @@ from services.tools import get_unity_instance_from_context
from services.tools.utils import coerce_int, coerce_bool from services.tools.utils import coerce_int, coerce_bool
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.preflight import preflight
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -40,6 +41,9 @@ async def manage_scene(
# Get active instance from session state # Get active instance from session state
# Removed session_state import # Removed session_state import
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
try: try:
coerced_build_index = coerce_int(build_index, default=None) coerced_build_index = coerce_int(build_index, default=None)
coerced_super_size = coerce_int(screenshot_super_size, default=None) coerced_super_size = coerce_int(screenshot_super_size, default=None)

View File

@ -0,0 +1,107 @@
from __future__ import annotations
import asyncio
import os
import time
from typing import Any
from models import MCPResponse
def _in_pytest() -> bool:
# Integration tests in this repo stub transports and do not run against a live Unity editor.
# Preflight must be a no-op in that environment to avoid breaking the existing test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
def _busy(reason: str, retry_after_ms: int) -> MCPResponse:
return MCPResponse(
success=False,
error="busy",
message=reason,
hint="retry",
data={"reason": reason, "retry_after_ms": int(retry_after_ms)},
)
async def preflight(
ctx,
*,
requires_no_tests: bool = False,
wait_for_no_compile: bool = False,
refresh_if_dirty: bool = False,
max_wait_s: float = 30.0,
) -> MCPResponse | None:
"""
Server-side preflight guard used by tools so they behave safely even if the client never reads resources.
Returns:
- MCPResponse busy/retry payload when the tool should not proceed right now
- None when the tool should proceed normally
"""
if _in_pytest():
return None
# Load canonical v2 state (server enriches advice + staleness).
try:
from services.resources.editor_state_v2 import get_editor_state_v2
state_resp = await get_editor_state_v2(ctx)
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
except Exception:
# If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
return None
if not isinstance(state, dict) or not state.get("success", False):
# Unknown state; proceed rather than blocking (avoids false positives when Unity is reachable but status isn't).
return None
data = state.get("data")
if not isinstance(data, dict):
return None
# Optional refresh-if-dirty
if refresh_if_dirty:
assets = data.get("assets")
if isinstance(assets, dict) and assets.get("external_changes_dirty") is True:
try:
from services.tools.refresh_unity import refresh_unity
await refresh_unity(ctx, mode="if_dirty", scope="all", compile="request", wait_for_ready=True)
except Exception:
# Best-effort only; fall through to normal tool dispatch.
pass
# Tests running: fail fast for tools that require exclusivity.
if requires_no_tests:
tests = data.get("tests")
if isinstance(tests, dict) and tests.get("is_running") is True:
return _busy("tests_running", 5000)
# Compilation: optionally wait for a bounded time.
if wait_for_no_compile:
deadline = time.monotonic() + float(max_wait_s)
while True:
compilation = data.get("compilation") if isinstance(data, dict) else None
is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True
is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True
if not is_compiling and not is_domain_reload_pending:
break
if time.monotonic() >= deadline:
return _busy("compiling", 500)
await asyncio.sleep(0.25)
# Refresh state for the next loop iteration.
try:
from services.resources.editor_state_v2 import get_editor_state_v2
state_resp = await get_editor_state_v2(ctx)
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
data = state.get("data") if isinstance(state, dict) else None
if not isinstance(data, dict):
return None
except Exception:
return None
# Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
# In future we may make this strict for some tools.
return None

View File

@ -0,0 +1,90 @@
from __future__ import annotations
import asyncio
import time
from typing import Annotated, Any, Literal
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
from services.state.external_changes_scanner import external_changes_scanner
@mcp_for_unity_tool(
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
)
async def refresh_unity(
ctx: Context,
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all",
compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none",
wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
) -> MCPResponse | dict[str, Any]:
unity_instance = get_unity_instance_from_context(ctx)
params: dict[str, Any] = {
"mode": mode,
"scope": scope,
"compile": compile,
"wait_for_ready": bool(wait_for_ready),
}
recovered_from_disconnect = False
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"refresh_unity",
params,
)
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
# interpret that as a hard failure (#503-style loops).
if isinstance(response, dict) and not response.get("success", True):
hint = response.get("hint")
err = (response.get("error") or response.get("message") or "")
is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
if (not wait_for_ready) or (not is_retryable):
return MCPResponse(**response)
recovered_from_disconnect = True
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
# poll the canonical editor_state v2 resource until ready or timeout.
if wait_for_ready:
timeout_s = 60.0
start = time.monotonic()
from services.resources.editor_state_v2 import get_editor_state_v2
while time.monotonic() - start < timeout_s:
state_resp = await get_editor_state_v2(ctx)
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
data = (state or {}).get("data") if isinstance(state, dict) else None
advice = (data or {}).get("advice") if isinstance(data, dict) else None
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
break
await asyncio.sleep(0.25)
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
try:
from services.resources.editor_state_v2 import _infer_single_instance_id
inst = unity_instance or await _infer_single_instance_id(ctx)
if inst:
external_changes_scanner.clear_dirty(inst)
except Exception:
pass
if recovered_from_disconnect:
return MCPResponse(
success=True,
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
data={"recovered_from_disconnect": True},
)
return MCPResponse(**response) if isinstance(response, dict) else response

View File

@ -10,6 +10,7 @@ from services.tools import get_unity_instance_from_context
from services.tools.utils import coerce_int from services.tools.utils import coerce_int
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.preflight import preflight
class RunTestsSummary(BaseModel): class RunTestsSummary(BaseModel):
@ -42,7 +43,7 @@ class RunTestsResponse(MCPResponse):
@mcp_for_unity_tool( @mcp_for_unity_tool(
description="Runs Unity tests for the specified mode" description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
) )
async def run_tests( async def run_tests(
ctx: Context, ctx: Context,
@ -54,9 +55,13 @@ async def run_tests(
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None, assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False, include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
) -> RunTestsResponse: ) -> RunTestsResponse | MCPResponse:
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
if isinstance(gate, MCPResponse):
return gate
# Coerce string or list to list of strings # Coerce string or list to list of strings
def _coerce_string_list(value) -> list[str] | None: def _coerce_string_list(value) -> list[str] | None:
if value is None: if value is None:
@ -97,5 +102,19 @@ async def run_tests(
params["includeDetails"] = True params["includeDetails"] = True
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params) response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
await ctx.info(f'Response {response}')
# If Unity indicates a run is already active, return a structured "busy" response rather than
# letting clients interpret this as a generic failure (avoids #503 retry loops).
if isinstance(response, dict) and not response.get("success", True):
err = (response.get("error") or response.get("message") or "").strip()
if "test run is already in progress" in err.lower():
return MCPResponse(
success=False,
error="tests_running",
message=err,
hint="retry",
data={"reason": "tests_running", "retry_after_ms": 5000},
)
return MCPResponse(**response)
return RunTestsResponse(**response) if isinstance(response, dict) else response return RunTestsResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,94 @@
"""Async Unity Test Runner jobs: start + poll."""
from __future__ import annotations
from typing import Annotated, Any, Literal
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from services.tools.preflight import preflight
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.")
async def run_tests_async(
ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | None = None,
group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
category_names: Annotated[list[str] | str, "NUnit category names to filter by"] | None = None,
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
) -> dict[str, Any] | MCPResponse:
unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
if isinstance(gate, MCPResponse):
return gate
def _coerce_string_list(value) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value] if value.strip() else None
if isinstance(value, list):
result = [str(v).strip() for v in value if v and str(v).strip()]
return result if result else None
return None
params: dict[str, Any] = {"mode": mode}
if (t := _coerce_string_list(test_names)):
params["testNames"] = t
if (g := _coerce_string_list(group_names)):
params["groupNames"] = g
if (c := _coerce_string_list(category_names)):
params["categoryNames"] = c
if (a := _coerce_string_list(assembly_names)):
params["assemblyNames"] = a
if include_failed_tests:
params["includeFailedTests"] = True
if include_details:
params["includeDetails"] = True
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"run_tests_async",
params,
)
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
@mcp_for_unity_tool(description="Polls an async Unity test job by job_id.")
async def get_test_job(
ctx: Context,
job_id: Annotated[str, "Job id returned by run_tests_async"],
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
) -> dict[str, Any] | MCPResponse:
unity_instance = get_unity_instance_from_context(ctx)
params: dict[str, Any] = {"job_id": job_id}
if include_failed_tests:
params["includeFailedTests"] = True
if include_details:
params["includeDetails"] = True
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_test_job",
params,
)
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()

View File

@ -0,0 +1,58 @@
import pytest
from services.registry import get_registered_resources
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch):
"""
Red test: we expect a canonical v2 resource `unity://editor_state` with required top-level fields.
Today, only `unity://editor/state` exists and is minimal.
"""
# Import the v2 module to ensure it registers its decorator without disturbing global registry state.
import services.resources.editor_state_v2 # noqa: F401
resources = get_registered_resources()
v2 = next((r for r in resources if r.get("uri") == "unity://editor_state"), None)
assert v2 is not None, (
"Expected canonical readiness resource `unity://editor_state` to be registered. "
"This is required so clients can poll readiness/staleness and avoid tool loops."
)
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
# Minimal stub payload for v2 resource tests. The server layer should enrich with staleness/advice.
assert command_type in {"get_editor_state_v2", "get_editor_state"}
return {
"success": True,
"data": {
"schema_version": "unity-mcp/editor_state@2",
"observed_at_unix_ms": 1730000000000,
"sequence": 1,
"compilation": {"is_compiling": False, "is_domain_reload_pending": False},
"tests": {"is_running": False},
},
}
# Patch transport so the resource can be invoked without Unity running.
import transport.unity_transport as unity_transport
monkeypatch.setattr(unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
result = await v2["func"](DummyContext())
payload = result.model_dump() if hasattr(result, "model_dump") else result
assert isinstance(payload, dict)
# Contract assertions (top-level)
assert payload.get("success") is True
data = payload.get("data")
assert isinstance(data, dict)
assert data.get("schema_version") == "unity-mcp/editor_state@2"
assert "observed_at_unix_ms" in data
assert "sequence" in data
assert "advice" in data
assert "staleness" in data

View File

@ -0,0 +1,86 @@
import os
import time
from pathlib import Path
def test_external_changes_scanner_marks_dirty_and_clears(tmp_path, monkeypatch):
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.state.external_changes_scanner import ExternalChangesScanner
# Create a minimal Unity-like layout
root = tmp_path / "Project"
(root / "Assets").mkdir(parents=True)
(root / "ProjectSettings").mkdir(parents=True)
(root / "Packages").mkdir(parents=True)
inst = "Test@deadbeef"
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
s.set_project_root(inst, str(root))
# Create a file before baseline so the initial scan establishes a stable reference point.
p = root / "Assets" / "x.txt"
p.write_text("hi")
# Baseline scan: should not be dirty.
first = s.update_and_get(inst)
assert first["external_changes_dirty"] is False
# Touch the file and scan again: should become dirty.
now = time.time()
os.utime(p, (now + 10.0, now + 10.0))
second = s.update_and_get(inst)
assert second["external_changes_dirty"] is True
assert isinstance(second["external_changes_last_seen_unix_ms"], int)
assert isinstance(second["dirty_since_unix_ms"], int)
# Clear and confirm dirty flag resets.
s.clear_dirty(inst)
third = s.update_and_get(inst)
assert third["external_changes_dirty"] is False
assert isinstance(third["last_cleared_unix_ms"], int)
def test_external_changes_scanner_includes_file_dependency_roots(tmp_path, monkeypatch):
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.state.external_changes_scanner import ExternalChangesScanner
# Unity project root
root = tmp_path / "Project"
(root / "Assets").mkdir(parents=True)
(root / "ProjectSettings").mkdir(parents=True)
(root / "Packages").mkdir(parents=True)
# External local package root (outside project root)
pkg = tmp_path / "ExternalPkg"
(pkg / "Editor").mkdir(parents=True)
target = pkg / "Editor" / "Some.cs"
target.write_text("// v1")
# manifest.json referencing file: dependency
manifest = root / "Packages" / "manifest.json"
manifest.write_text(
'{\n "dependencies": {\n "com.example.pkg": "file:../../ExternalPkg"\n }\n}\n',
encoding="utf-8",
)
inst = "Test@deadbeef"
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
s.set_project_root(inst, str(root))
# Baseline scan captures current mtimes across project + external pkg
baseline = s.update_and_get(inst)
assert baseline["external_changes_dirty"] is False
# Touch external package file and scan again -> should mark dirty
now = time.time()
os.utime(target, (now + 10.0, now + 10.0))
changed = s.update_and_get(inst)
assert changed["external_changes_dirty"] is True

View File

@ -0,0 +1,14 @@
from services.registry import get_registered_tools
def test_refresh_unity_tool_is_registered():
"""
Red test: we expect an explicit refresh tool to exist so callers can force an import/refresh/compile cycle.
"""
# Import the specific module to ensure it registers its decorator without disturbing global registry state.
import services.tools.refresh_unity # noqa: F401
names = {t.get("name") for t in get_registered_tools()}
assert "refresh_unity" in names

View File

@ -0,0 +1,46 @@
import pytest
from models import MCPResponse
from services.state.external_changes_scanner import external_changes_scanner
from services.state.external_changes_scanner import ExternalChangesState
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_refresh_unity_recovers_from_retry_disconnect(monkeypatch):
"""
Option A: if Unity disconnects and the transport returns hint=retry, refresh_unity(wait_for_ready=true)
should poll readiness and then return success + clear external dirty.
"""
from services.tools.refresh_unity import refresh_unity
ctx = DummyContext()
ctx.set_state("unity_instance", "UnityMCPTests@cc8756d4cce0805a")
# Seed dirty state
inst = "UnityMCPTests@cc8756d4cce0805a"
external_changes_scanner._states[inst] = ExternalChangesState(dirty=True, dirty_since_unix_ms=1)
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
assert command_type == "refresh_unity"
return {"success": False, "error": "disconnected", "hint": "retry"}
async def fake_get_editor_state_v2(_ctx):
return MCPResponse(success=True, data={"advice": {"ready_for_tools": True}})
import services.tools.refresh_unity as refresh_mod
monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
import services.resources.editor_state_v2 as esv2_mod
monkeypatch.setattr(esv2_mod, "get_editor_state_v2", fake_get_editor_state_v2)
resp = await refresh_unity(ctx, wait_for_ready=True)
payload = resp.model_dump() if hasattr(resp, "model_dump") else resp
assert payload["success"] is True
assert payload.get("data", {}).get("recovered_from_disconnect") is True
# Dirty should be cleared
assert external_changes_scanner._states[inst].dirty is False

View File

@ -0,0 +1,36 @@
import pytest
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_run_tests_returns_busy_when_unity_reports_already_in_progress(monkeypatch):
"""
Red test (#503): if Unity reports a test run is already in progress, the tool should return a
structured Busy response quickly (retry hint + retry_after_ms) rather than looking like a generic failure.
"""
import services.tools.run_tests as run_tests_mod
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
assert command_type == "run_tests"
# This mirrors the Unity-side exception message thrown by TestRunnerService today.
return {
"success": False,
"error": "A Unity test run is already in progress.",
}
monkeypatch.setattr(run_tests_mod, "send_with_unity_instance", fake_send_with_unity_instance)
result = await run_tests_mod.run_tests(ctx=DummyContext(), mode="EditMode")
payload = result.model_dump() if hasattr(result, "model_dump") else result
assert payload.get("success") is False
# Desired new behavior: provide an explicit retry hint + suggested backoff.
assert payload.get("hint") == "retry"
data = payload.get("data") or {}
assert isinstance(data, dict)
assert data.get("reason") in {"tests_running", "busy"}
assert isinstance(data.get("retry_after_ms"), int)
assert data.get("retry_after_ms") >= 500

View File

@ -0,0 +1,52 @@
import pytest
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_run_tests_async_forwards_params(monkeypatch):
from services.tools.test_jobs import run_tests_async
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running"}}
import services.tools.test_jobs as mod
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await run_tests_async(
DummyContext(),
mode="EditMode",
test_names="MyNamespace.MyTests.TestA",
include_details=True,
)
assert captured["command_type"] == "run_tests_async"
assert captured["params"]["mode"] == "EditMode"
assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"]
assert captured["params"]["includeDetails"] is True
assert resp["success"] is True
@pytest.mark.asyncio
async def test_get_test_job_forwards_job_id(monkeypatch):
from services.tools.test_jobs import get_test_job
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
return {"success": True, "data": {"job_id": params["job_id"], "status": "running"}}
import services.tools.test_jobs as mod
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await get_test_job(DummyContext(), job_id="job-1")
assert captured["command_type"] == "get_test_job"
assert captured["params"]["job_id"] == "job-1"
assert resp["data"]["job_id"] == "job-1"

View File

@ -809,7 +809,7 @@ wheels = [
[[package]] [[package]]
name = "mcpforunityserver" name = "mcpforunityserver"
version = "8.3.0" version = "8.6.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },

View File

@ -11,9 +11,15 @@ namespace MCPForUnityTests.Editor.Tools
{ {
/// <summary> /// <summary>
/// Tests for domain reload resilience - ensuring MCP requests succeed even during Unity domain reloads. /// Tests for domain reload resilience - ensuring MCP requests succeed even during Unity domain reloads.
///
/// These tests are marked [Explicit] because they trigger script compilation which can stall
/// subsequent tests' internal coroutine waits when Unity is backgrounded. The MCP workflow
/// itself is unaffected - socket messages provide external stimulus that keeps Unity responsive.
///
/// Run these explicitly when needed, ideally with Unity foregrounded or first in the run.
/// </summary> /// </summary>
[Category("domain_reload")] [Category("domain_reload")]
[Explicit("Intentionally triggers script compilation/domain reload; run explicitly to avoid slowing/flaking cold-start EditMode runs.")] [Explicit("Triggers compilation that can stall subsequent tests. MCP workflow unaffected - see class docs.")]
public class DomainReloadResilienceTests public class DomainReloadResilienceTests
{ {
private const string TempDir = "Assets/Temp/DomainReloadTests"; private const string TempDir = "Assets/Temp/DomainReloadTests";
@ -25,14 +31,14 @@ namespace MCPForUnityTests.Editor.Tools
if (!AssetDatabase.IsValidFolder(TempDir)) if (!AssetDatabase.IsValidFolder(TempDir))
{ {
Directory.CreateDirectory(TempDir); Directory.CreateDirectory(TempDir);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
} }
} }
[TearDown] [TearDown]
public void TearDown() public void TearDown()
{ {
// Clean up temp directory // Clean up temp directory - this deletes any scripts we created
if (AssetDatabase.IsValidFolder(TempDir)) if (AssetDatabase.IsValidFolder(TempDir))
{ {
AssetDatabase.DeleteAsset(TempDir); AssetDatabase.DeleteAsset(TempDir);
@ -48,6 +54,10 @@ namespace MCPForUnityTests.Editor.Tools
AssetDatabase.DeleteAsset("Assets/Temp"); AssetDatabase.DeleteAsset("Assets/Temp");
} }
} }
// CRITICAL: Force a synchronous refresh and wait for any pending compilation to finish.
// This prevents leaving compilation running that could stall subsequent tests.
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
} }
/// <summary> /// <summary>
@ -72,7 +82,7 @@ public class StressTestScript : MonoBehaviour
// Write script file // Write script file
File.WriteAllText(scriptPath, scriptContent); File.WriteAllText(scriptPath, scriptContent);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
Debug.Log("[DomainReloadTest] Script created, domain reload triggered"); Debug.Log("[DomainReloadTest] Script created, domain reload triggered");
@ -163,7 +173,7 @@ public class TestScript1 : MonoBehaviour
}"; }";
File.WriteAllText(scriptPath, scriptContent); File.WriteAllText(scriptPath, scriptContent);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
Debug.Log("[DomainReloadTest] Script created"); Debug.Log("[DomainReloadTest] Script created");
@ -211,7 +221,7 @@ public class RapidScript{i} : MonoBehaviour
}}"; }}";
File.WriteAllText(scriptPath, scriptContent); File.WriteAllText(scriptPath, scriptContent);
AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
Debug.Log($"[DomainReloadTest] Created script {i+1}/{scriptCount}"); Debug.Log($"[DomainReloadTest] Created script {i+1}/{scriptCount}");

View File

@ -14,9 +14,10 @@ namespace MCPForUnityTests.Editor.Tools
public class ManageScriptableObjectTests public class ManageScriptableObjectTests
{ {
private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests"; private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests";
private const string NestedFolder = TempRoot + "/Nested/Deeper";
private const double UnityReadyTimeoutSeconds = 180.0; private const double UnityReadyTimeoutSeconds = 180.0;
private string _runRoot;
private string _nestedFolder;
private string _createdAssetPath; private string _createdAssetPath;
private string _createdGuid; private string _createdGuid;
private string _matAPath; private string _matAPath;
@ -27,12 +28,12 @@ namespace MCPForUnityTests.Editor.Tools
{ {
yield return WaitForUnityReady(UnityReadyTimeoutSeconds); yield return WaitForUnityReady(UnityReadyTimeoutSeconds);
EnsureFolder("Assets/Temp"); EnsureFolder("Assets/Temp");
// Start from a clean slate every time (prevents intermittent setup failures). // Avoid deleting/recreating the entire TempRoot each test (can trigger heavy reimport churn).
if (AssetDatabase.IsValidFolder(TempRoot)) // Instead, isolate each test in its own unique subfolder under TempRoot.
{
AssetDatabase.DeleteAsset(TempRoot);
}
EnsureFolder(TempRoot); EnsureFolder(TempRoot);
_runRoot = $"{TempRoot}/Run_{Guid.NewGuid():N}";
EnsureFolder(_runRoot);
_nestedFolder = _runRoot + "/Nested/Deeper";
_createdAssetPath = null; _createdAssetPath = null;
_createdGuid = null; _createdGuid = null;
@ -69,9 +70,9 @@ namespace MCPForUnityTests.Editor.Tools
AssetDatabase.DeleteAsset(_matBPath); AssetDatabase.DeleteAsset(_matBPath);
} }
if (AssetDatabase.IsValidFolder(TempRoot)) if (!string.IsNullOrEmpty(_runRoot) && AssetDatabase.IsValidFolder(_runRoot))
{ {
AssetDatabase.DeleteAsset(TempRoot); AssetDatabase.DeleteAsset(_runRoot);
} }
// Clean up parent Temp folder if empty // Clean up parent Temp folder if empty
@ -89,21 +90,15 @@ namespace MCPForUnityTests.Editor.Tools
} }
[Test] [Test]
public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches() public void Create_CreatesNestedFolders_PlacesAssetCorrectly()
{ {
var create = new JObject var create = new JObject
{ {
["action"] = "create", ["action"] = "create",
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
["folderPath"] = NestedFolder, ["folderPath"] = _nestedFolder,
["assetName"] = "My_Test_Def", ["assetName"] = "My_Test_Def_Placement",
["overwrite"] = true, ["overwrite"] = true,
["patches"] = new JArray
{
new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Hello" },
new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 42 },
new JObject { ["propertyPath"] = "nested.note", ["op"] = "set", ["value"] = "note!" }
}
}; };
var raw = ManageScriptableObject.HandleCommand(create); var raw = ManageScriptableObject.HandleCommand(create);
@ -116,11 +111,47 @@ namespace MCPForUnityTests.Editor.Tools
_createdGuid = data!["guid"]?.ToString(); _createdGuid = data!["guid"]?.ToString();
_createdAssetPath = data["path"]?.ToString(); _createdAssetPath = data["path"]?.ToString();
Assert.IsTrue(AssetDatabase.IsValidFolder(NestedFolder), "Nested folder should be created."); Assert.IsTrue(AssetDatabase.IsValidFolder(_nestedFolder), "Nested folder should be created.");
Assert.IsTrue(_createdAssetPath!.StartsWith(NestedFolder, StringComparison.Ordinal), $"Asset should be created under {NestedFolder}: {_createdAssetPath}"); Assert.IsTrue(_createdAssetPath!.StartsWith(_nestedFolder, StringComparison.Ordinal), $"Asset should be created under {_nestedFolder}: {_createdAssetPath}");
Assert.IsTrue(_createdAssetPath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension."); Assert.IsTrue(_createdAssetPath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension.");
Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response."); Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response.");
var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath);
Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
}
[Test]
public void Create_AppliesPatches_ToCreatedAsset()
{
var create = new JObject
{
["action"] = "create",
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
// Patching correctness does not depend on nested folder creation; keep this lightweight.
["folderPath"] = _runRoot,
["assetName"] = "My_Test_Def_Patches",
["overwrite"] = true,
["patches"] = new JArray
{
new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Hello" },
new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 42 },
new JObject { ["propertyPath"] = "nested.note", ["op"] = "set", ["value"] = "note!" }
}
};
var raw = ManageScriptableObject.HandleCommand(create);
var result = raw as JObject ?? JObject.FromObject(raw);
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
var data = result["data"] as JObject;
Assert.IsNotNull(data, "Expected data payload");
_createdGuid = data!["guid"]?.ToString();
_createdAssetPath = data["path"]?.ToString();
Assert.IsTrue(_createdAssetPath!.StartsWith(_runRoot, StringComparison.Ordinal), $"Asset should be created under {_runRoot}: {_createdAssetPath}");
Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response.");
var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath); var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath);
Assert.IsNotNull(asset, "Created asset should load as TestDefinition."); Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty."); Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty.");
@ -136,7 +167,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
["action"] = "create", ["action"] = "create",
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
["folderPath"] = TempRoot, ["folderPath"] = _runRoot,
["assetName"] = "Modify_Target", ["assetName"] = "Modify_Target",
["overwrite"] = true ["overwrite"] = true
}; };

View File

@ -336,3 +336,14 @@ We provide a CI job to run a Natural Language Editing suite against the Unity te
### Windows uv path issues ### Windows uv path issues
- On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose `uv` Install Location" to pin the Links shim. - On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose `uv` Install Location" to pin the Links shim.
### Domain Reload Tests Stall When Unity is Backgrounded
Tests that trigger script compilation mid-run (e.g., `DomainReloadResilienceTests`) may stall when Unity is not the active window. This is an OS-level limitation—macOS throttles background application main threads, preventing compilation from completing.
**Workarounds:**
- Run domain reload tests with Unity foregrounded
- Run them first in the test suite (before backgrounding Unity)
- Use the `[Explicit]` attribute to exclude them from default runs
**Note:** The MCP workflow itself is unaffected—socket messages provide external stimulus that keeps Unity responsive even when backgrounded. This limitation only affects Unity's internal test coroutine waits.

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB