升级XR插件版本

This commit is contained in:
Sora丶kong
2026-03-02 17:56:21 +08:00
parent 8962657674
commit 60f512a9bc
1317 changed files with 110305 additions and 48249 deletions

View File

@@ -0,0 +1,157 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
/// </summary>
public class BridgeControlService : IBridgeControlService
{
private readonly TransportManager _transportManager;
private TransportMode _preferredMode = TransportMode.Http;
public BridgeControlService()
{
_transportManager = MCPServiceLocator.TransportManager;
}
private TransportMode ResolvePreferredMode()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
_preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio;
return _preferredMode;
}
private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null)
{
bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true);
string transportLabel = string.IsNullOrWhiteSpace(state.TransportName)
? mode.ToString().ToLowerInvariant()
: state.TransportName;
string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]";
string message = messageOverride
?? state.Error
?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}");
return new BridgeVerificationResult
{
Success = pingSucceeded && handshakeValid,
HandshakeValid = handshakeValid,
PingSucceeded = pingSucceeded,
Message = message
};
}
public bool IsRunning
{
get
{
var mode = ResolvePreferredMode();
return _transportManager.IsRunning(mode);
}
}
public int CurrentPort
{
get
{
var mode = ResolvePreferredMode();
var state = _transportManager.GetState(mode);
if (state.Port.HasValue)
{
return state.Port.Value;
}
// Legacy fallback while the stdio bridge is still in play
return StdioBridgeHost.GetCurrentPort();
}
}
public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode();
public TransportMode? ActiveMode => ResolvePreferredMode();
public async Task<bool> StartAsync()
{
var mode = ResolvePreferredMode();
try
{
// Treat transports as mutually exclusive for user-driven session starts:
// stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP).
var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http;
try
{
await _transportManager.StopAsync(otherMode);
}
catch (Exception ex)
{
McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}");
}
// Legacy safety: stdio may have been started outside TransportManager state.
if (otherMode == TransportMode.Stdio)
{
try { StdioBridgeHost.Stop(); } catch { }
}
bool started = await _transportManager.StartAsync(mode);
if (!started)
{
McpLog.Warn($"Failed to start MCP transport: {mode}");
}
return started;
}
catch (Exception ex)
{
McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}");
return false;
}
}
public async Task StopAsync()
{
try
{
var mode = ResolvePreferredMode();
await _transportManager.StopAsync(mode);
}
catch (Exception ex)
{
McpLog.Warn($"Error stopping MCP transport: {ex.Message}");
}
}
public async Task<BridgeVerificationResult> VerifyAsync()
{
var mode = ResolvePreferredMode();
bool pingSucceeded = await _transportManager.VerifyAsync(mode);
var state = _transportManager.GetState(mode);
return BuildVerificationResult(state, mode, pingSucceeded);
}
public BridgeVerificationResult Verify(int port)
{
var mode = ResolvePreferredMode();
bool pingSucceeded = _transportManager.VerifyAsync(mode).GetAwaiter().GetResult();
var state = _transportManager.GetState(mode);
if (mode == TransportMode.Stdio)
{
bool handshakeValid = state.IsConnected && port == CurrentPort;
string message = handshakeValid
? $"STDIO transport listening on port {CurrentPort}"
: $"STDIO transport port mismatch (expected {CurrentPort}, got {port})";
return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid);
}
return BuildVerificationResult(state, mode, pingSucceeded);
}
}
}

View File

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

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Implementation of client configuration service
/// </summary>
public class ClientConfigurationService : IClientConfigurationService
{
private readonly List<IMcpClientConfigurator> configurators;
public ClientConfigurationService()
{
configurators = McpClientRegistry.All.ToList();
}
public IReadOnlyList<IMcpClientConfigurator> GetAllClients() => configurators;
public void ConfigureClient(IMcpClientConfigurator configurator)
{
// When using a local server path, clean stale build artifacts first.
// This prevents old deleted .py files from being picked up by Python's auto-discovery.
if (AssetPathUtility.IsLocalServerPath())
{
AssetPathUtility.CleanLocalServerBuildArtifacts();
}
configurator.Configure();
}
public ClientConfigurationSummary ConfigureAllDetectedClients()
{
// When using a local server path, clean stale build artifacts once before configuring all clients.
if (AssetPathUtility.IsLocalServerPath())
{
AssetPathUtility.CleanLocalServerBuildArtifacts();
}
var summary = new ClientConfigurationSummary();
foreach (var configurator in configurators)
{
try
{
// Always re-run configuration so core fields stay current
configurator.CheckStatus(attemptAutoRewrite: false);
configurator.Configure();
summary.SuccessCount++;
summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully");
}
catch (Exception ex)
{
summary.FailureCount++;
summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}");
}
}
return summary;
}
public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true)
{
var previous = configurator.Status;
var current = configurator.CheckStatus(attemptAutoRewrite);
return current != previous;
}
}
}

View File

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

View File

@@ -0,0 +1,345 @@
using System;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Centralized cache for frequently-read EditorPrefs values.
/// Reduces scattered EditorPrefs.Get* calls and provides change notification.
///
/// Usage:
/// var config = EditorConfigurationCache.Instance;
/// if (config.UseHttpTransport) { ... }
/// config.OnConfigurationChanged += (key) => { /* refresh UI */ };
/// </summary>
public class EditorConfigurationCache
{
private static EditorConfigurationCache _instance;
private static readonly object _lock = new object();
/// <summary>
/// Singleton instance. Thread-safe lazy initialization.
/// </summary>
public static EditorConfigurationCache Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new EditorConfigurationCache();
}
}
}
return _instance;
}
}
/// <summary>
/// Event fired when any cached configuration value changes.
/// The string parameter is the EditorPrefKeys constant name that changed.
/// </summary>
public event Action<string> OnConfigurationChanged;
// Cached values - most frequently read
private bool _useHttpTransport;
private bool _debugLogs;
private bool _useBetaServer;
private bool _devModeForceServerRefresh;
private string _uvxPathOverride;
private string _gitUrlOverride;
private string _httpBaseUrl;
private string _httpRemoteBaseUrl;
private string _claudeCliPathOverride;
private string _httpTransportScope;
private int _unitySocketPort;
/// <summary>
/// Whether to use HTTP transport (true) or Stdio transport (false).
/// Default: true
/// </summary>
public bool UseHttpTransport => _useHttpTransport;
/// <summary>
/// Whether debug logging is enabled.
/// Default: false
/// </summary>
public bool DebugLogs => _debugLogs;
/// <summary>
/// Whether to use the beta server channel.
/// Default: true
/// </summary>
public bool UseBetaServer => _useBetaServer;
/// <summary>
/// Whether to force server refresh in dev mode (--no-cache --refresh).
/// Default: false
/// </summary>
public bool DevModeForceServerRefresh => _devModeForceServerRefresh;
/// <summary>
/// Custom path override for uvx executable.
/// Default: empty string (auto-detect)
/// </summary>
public string UvxPathOverride => _uvxPathOverride;
/// <summary>
/// Custom Git URL override for server installation.
/// Default: empty string (use default)
/// </summary>
public string GitUrlOverride => _gitUrlOverride;
/// <summary>
/// HTTP base URL for the local MCP server.
/// Default: empty string
/// </summary>
public string HttpBaseUrl => _httpBaseUrl;
/// <summary>
/// HTTP base URL for the remote-hosted MCP server.
/// Default: empty string
/// </summary>
public string HttpRemoteBaseUrl => _httpRemoteBaseUrl;
/// <summary>
/// Custom path override for Claude CLI executable.
/// Default: empty string (auto-detect)
/// </summary>
public string ClaudeCliPathOverride => _claudeCliPathOverride;
/// <summary>
/// HTTP transport scope: "local" or "remote".
/// Default: empty string
/// </summary>
public string HttpTransportScope => _httpTransportScope;
/// <summary>
/// Unity socket port for Stdio transport.
/// Default: 0 (auto-assign)
/// </summary>
public int UnitySocketPort => _unitySocketPort;
private EditorConfigurationCache()
{
Refresh();
}
/// <summary>
/// Refresh all cached values from EditorPrefs.
/// Call this after bulk EditorPrefs changes or domain reload.
/// </summary>
public void Refresh()
{
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
_useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
}
/// <summary>
/// Set UseHttpTransport and update cache + EditorPrefs atomically.
/// </summary>
public void SetUseHttpTransport(bool value)
{
if (_useHttpTransport != value)
{
_useHttpTransport = value;
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value);
OnConfigurationChanged?.Invoke(nameof(UseHttpTransport));
}
}
/// <summary>
/// Set DebugLogs and update cache + EditorPrefs atomically.
/// </summary>
public void SetDebugLogs(bool value)
{
if (_debugLogs != value)
{
_debugLogs = value;
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value);
OnConfigurationChanged?.Invoke(nameof(DebugLogs));
}
}
/// <summary>
/// Set UseBetaServer and update cache + EditorPrefs atomically.
/// </summary>
public void SetUseBetaServer(bool value)
{
if (_useBetaServer != value)
{
_useBetaServer = value;
EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, value);
OnConfigurationChanged?.Invoke(nameof(UseBetaServer));
}
}
/// <summary>
/// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically.
/// </summary>
public void SetDevModeForceServerRefresh(bool value)
{
if (_devModeForceServerRefresh != value)
{
_devModeForceServerRefresh = value;
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value);
OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh));
}
}
/// <summary>
/// Set UvxPathOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetUvxPathOverride(string value)
{
value = value ?? string.Empty;
if (_uvxPathOverride != value)
{
_uvxPathOverride = value;
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value);
OnConfigurationChanged?.Invoke(nameof(UvxPathOverride));
}
}
/// <summary>
/// Set GitUrlOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetGitUrlOverride(string value)
{
value = value ?? string.Empty;
if (_gitUrlOverride != value)
{
_gitUrlOverride = value;
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value);
OnConfigurationChanged?.Invoke(nameof(GitUrlOverride));
}
}
/// <summary>
/// Set HttpBaseUrl and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpBaseUrl(string value)
{
value = value ?? string.Empty;
if (_httpBaseUrl != value)
{
_httpBaseUrl = value;
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value);
OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl));
}
}
/// <summary>
/// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpRemoteBaseUrl(string value)
{
value = value ?? string.Empty;
if (_httpRemoteBaseUrl != value)
{
_httpRemoteBaseUrl = value;
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value);
OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl));
}
}
/// <summary>
/// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically.
/// </summary>
public void SetClaudeCliPathOverride(string value)
{
value = value ?? string.Empty;
if (_claudeCliPathOverride != value)
{
_claudeCliPathOverride = value;
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value);
OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride));
}
}
/// <summary>
/// Set HttpTransportScope and update cache + EditorPrefs atomically.
/// </summary>
public void SetHttpTransportScope(string value)
{
value = value ?? string.Empty;
if (_httpTransportScope != value)
{
_httpTransportScope = value;
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value);
OnConfigurationChanged?.Invoke(nameof(HttpTransportScope));
}
}
/// <summary>
/// Set UnitySocketPort and update cache + EditorPrefs atomically.
/// </summary>
public void SetUnitySocketPort(int value)
{
if (_unitySocketPort != value)
{
_unitySocketPort = value;
EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value);
OnConfigurationChanged?.Invoke(nameof(UnitySocketPort));
}
}
/// <summary>
/// Force refresh of a single cached value from EditorPrefs.
/// Useful when external code modifies EditorPrefs directly.
/// </summary>
public void InvalidateKey(string keyName)
{
switch (keyName)
{
case nameof(UseHttpTransport):
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
break;
case nameof(DebugLogs):
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
break;
case nameof(UseBetaServer):
_useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
break;
case nameof(DevModeForceServerRefresh):
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
break;
case nameof(UvxPathOverride):
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
break;
case nameof(GitUrlOverride):
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
break;
case nameof(HttpBaseUrl):
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
break;
case nameof(HttpRemoteBaseUrl):
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
break;
case nameof(ClaudeCliPathOverride):
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
break;
case nameof(HttpTransportScope):
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
break;
case nameof(UnitySocketPort):
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
break;
}
OnConfigurationChanged?.Invoke(keyName);
}
}
}

View File

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

View File

@@ -0,0 +1,54 @@
using System;
using MCPForUnity.Editor.Windows;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for managing the EditorPrefs window
/// Follows the Class-level Singleton pattern
/// </summary>
public class EditorPrefsWindowService
{
private static EditorPrefsWindowService _instance;
/// <summary>
/// Get the singleton instance
/// </summary>
public static EditorPrefsWindowService Instance
{
get
{
if (_instance == null)
{
throw new Exception("EditorPrefsWindowService not initialized");
}
return _instance;
}
}
/// <summary>
/// Initialize the service
/// </summary>
public static void Initialize()
{
if (_instance == null)
{
_instance = new EditorPrefsWindowService();
}
}
private EditorPrefsWindowService()
{
// Private constructor for singleton
}
/// <summary>
/// Show the EditorPrefs window
/// </summary>
public void ShowWindow()
{
EditorPrefsWindow.ShowWindow();
}
}
}

View File

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

View File

@@ -0,0 +1,546 @@
using System;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
/// Updated on the main thread via Editor callbacks and periodic update ticks.
/// </summary>
[InitializeOnLoad]
internal static class EditorStateCache
{
private static readonly object LockObj = new();
private static long _sequence;
private static long _observedUnixMs;
private static bool _lastIsCompiling;
private static long? _lastCompileStartedUnixMs;
private static long? _lastCompileFinishedUnixMs;
private static bool _domainReloadPending;
private static long? _domainReloadBeforeUnixMs;
private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
// State tracking to detect when snapshot actually changes (checked BEFORE building)
private static string _lastTrackedScenePath;
private static string _lastTrackedSceneName;
private static bool _lastTrackedIsFocused;
private static bool _lastTrackedIsPlaying;
private static bool _lastTrackedIsPaused;
private static bool _lastTrackedIsUpdating;
private static bool _lastTrackedTestsRunning;
private static string _lastTrackedActivityPhase;
private static JObject _cached;
private sealed class EditorStateSnapshot
{
[JsonProperty("schema_version")]
public string SchemaVersion { get; set; }
[JsonProperty("observed_at_unix_ms")]
public long ObservedAtUnixMs { get; set; }
[JsonProperty("sequence")]
public long Sequence { get; set; }
[JsonProperty("unity")]
public EditorStateUnity Unity { get; set; }
[JsonProperty("editor")]
public EditorStateEditor Editor { get; set; }
[JsonProperty("activity")]
public EditorStateActivity Activity { get; set; }
[JsonProperty("compilation")]
public EditorStateCompilation Compilation { get; set; }
[JsonProperty("assets")]
public EditorStateAssets Assets { get; set; }
[JsonProperty("tests")]
public EditorStateTests Tests { get; set; }
[JsonProperty("transport")]
public EditorStateTransport Transport { get; set; }
}
private sealed class EditorStateUnity
{
[JsonProperty("instance_id")]
public string InstanceId { get; set; }
[JsonProperty("unity_version")]
public string UnityVersion { get; set; }
[JsonProperty("project_id")]
public string ProjectId { get; set; }
[JsonProperty("platform")]
public string Platform { get; set; }
[JsonProperty("is_batch_mode")]
public bool? IsBatchMode { get; set; }
}
private sealed class EditorStateEditor
{
[JsonProperty("is_focused")]
public bool? IsFocused { get; set; }
[JsonProperty("play_mode")]
public EditorStatePlayMode PlayMode { get; set; }
[JsonProperty("active_scene")]
public EditorStateActiveScene ActiveScene { get; set; }
}
private sealed class EditorStatePlayMode
{
[JsonProperty("is_playing")]
public bool? IsPlaying { get; set; }
[JsonProperty("is_paused")]
public bool? IsPaused { get; set; }
[JsonProperty("is_changing")]
public bool? IsChanging { get; set; }
}
private sealed class EditorStateActiveScene
{
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("guid")]
public string Guid { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
}
private sealed class EditorStateActivity
{
[JsonProperty("phase")]
public string Phase { get; set; }
[JsonProperty("since_unix_ms")]
public long SinceUnixMs { get; set; }
[JsonProperty("reasons")]
public string[] Reasons { get; set; }
}
private sealed class EditorStateCompilation
{
[JsonProperty("is_compiling")]
public bool? IsCompiling { get; set; }
[JsonProperty("is_domain_reload_pending")]
public bool? IsDomainReloadPending { get; set; }
[JsonProperty("last_compile_started_unix_ms")]
public long? LastCompileStartedUnixMs { get; set; }
[JsonProperty("last_compile_finished_unix_ms")]
public long? LastCompileFinishedUnixMs { get; set; }
[JsonProperty("last_domain_reload_before_unix_ms")]
public long? LastDomainReloadBeforeUnixMs { get; set; }
[JsonProperty("last_domain_reload_after_unix_ms")]
public long? LastDomainReloadAfterUnixMs { get; set; }
}
private sealed class EditorStateAssets
{
[JsonProperty("is_updating")]
public bool? IsUpdating { get; set; }
[JsonProperty("external_changes_dirty")]
public bool? ExternalChangesDirty { get; set; }
[JsonProperty("external_changes_last_seen_unix_ms")]
public long? ExternalChangesLastSeenUnixMs { get; set; }
[JsonProperty("external_changes_dirty_since_unix_ms")]
public long? ExternalChangesDirtySinceUnixMs { get; set; }
[JsonProperty("external_changes_last_cleared_unix_ms")]
public long? ExternalChangesLastClearedUnixMs { get; set; }
[JsonProperty("refresh")]
public EditorStateRefresh Refresh { get; set; }
}
private sealed class EditorStateRefresh
{
[JsonProperty("is_refresh_in_progress")]
public bool? IsRefreshInProgress { get; set; }
[JsonProperty("last_refresh_requested_unix_ms")]
public long? LastRefreshRequestedUnixMs { get; set; }
[JsonProperty("last_refresh_finished_unix_ms")]
public long? LastRefreshFinishedUnixMs { get; set; }
}
private sealed class EditorStateTests
{
[JsonProperty("is_running")]
public bool? IsRunning { get; set; }
[JsonProperty("mode")]
public string Mode { get; set; }
[JsonProperty("current_job_id")]
public string CurrentJobId { get; set; }
[JsonProperty("started_unix_ms")]
public long? StartedUnixMs { get; set; }
[JsonProperty("started_by")]
public string StartedBy { get; set; }
[JsonProperty("last_run")]
public EditorStateLastRun LastRun { get; set; }
}
private sealed class EditorStateLastRun
{
[JsonProperty("finished_unix_ms")]
public long? FinishedUnixMs { get; set; }
[JsonProperty("result")]
public string Result { get; set; }
[JsonProperty("counts")]
public object Counts { get; set; }
}
private sealed class EditorStateTransport
{
[JsonProperty("unity_bridge_connected")]
public bool? UnityBridgeConnected { get; set; }
[JsonProperty("last_message_unix_ms")]
public long? LastMessageUnixMs { get; set; }
}
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;
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();
// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
return;
}
// Fast state-change detection BEFORE building snapshot.
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
// These checks are much cheaper than building a full JSON snapshot.
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneName = scene.name ?? string.Empty;
bool isFocused = InternalEditorUtility.isApplicationActive;
bool isPlaying = EditorApplication.isPlaying;
bool isPaused = EditorApplication.isPaused;
bool isUpdating = EditorApplication.isUpdating;
bool testsRunning = TestRunStatus.IsRunning;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
bool hasChanges = compilationEdge
|| _lastTrackedScenePath != scenePath
|| _lastTrackedSceneName != sceneName
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsPaused != isPaused
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedTestsRunning != testsRunning
|| _lastTrackedActivityPhase != activityPhase;
if (!hasChanges)
{
// No state change - skip the expensive BuildSnapshot entirely.
// This is the key optimization that prevents the 28ms GC spikes.
return;
}
// Update tracked state
_lastTrackedScenePath = scenePath;
_lastTrackedSceneName = sceneName;
_lastTrackedIsFocused = isFocused;
_lastTrackedIsPlaying = isPlaying;
_lastTrackedIsPaused = isPaused;
_lastTrackedIsUpdating = isUpdating;
_lastTrackedTestsRunning = testsRunning;
_lastTrackedActivityPhase = activityPhase;
_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
}
private static void ForceUpdate(string reason)
{
lock (LockObj)
{
_cached = BuildSnapshot(reason);
}
}
private static JObject BuildSnapshot(string reason)
{
_sequence++;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool isCompiling = GetActualIsCompiling();
if (isCompiling && !_lastIsCompiling)
{
_lastCompileStartedUnixMs = _observedUnixMs;
}
else if (!isCompiling && _lastIsCompiling)
{
_lastCompileFinishedUnixMs = _observedUnixMs;
}
_lastIsCompiling = isCompiling;
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
bool testsRunning = TestRunStatus.IsRunning;
var testsMode = TestRunStatus.Mode?.ToString();
string currentJobId = TestJobManager.CurrentJobId;
bool isFocused = InternalEditorUtility.isApplicationActive;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (EditorApplication.isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
var snapshot = new EditorStateSnapshot
{
SchemaVersion = "unity-mcp/editor_state@2",
ObservedAtUnixMs = _observedUnixMs,
Sequence = _sequence,
Unity = new EditorStateUnity
{
InstanceId = null,
UnityVersion = Application.unityVersion,
ProjectId = null,
Platform = Application.platform.ToString(),
IsBatchMode = Application.isBatchMode
},
Editor = new EditorStateEditor
{
IsFocused = isFocused,
PlayMode = new EditorStatePlayMode
{
IsPlaying = EditorApplication.isPlaying,
IsPaused = EditorApplication.isPaused,
IsChanging = EditorApplication.isPlayingOrWillChangePlaymode
},
ActiveScene = new EditorStateActiveScene
{
Path = scenePath,
Guid = sceneGuid,
Name = scene.name ?? string.Empty
}
},
Activity = new EditorStateActivity
{
Phase = activityPhase,
SinceUnixMs = _observedUnixMs,
Reasons = new[] { reason }
},
Compilation = new EditorStateCompilation
{
IsCompiling = isCompiling,
IsDomainReloadPending = _domainReloadPending,
LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
},
Assets = new EditorStateAssets
{
IsUpdating = EditorApplication.isUpdating,
ExternalChangesDirty = false,
ExternalChangesLastSeenUnixMs = null,
ExternalChangesDirtySinceUnixMs = null,
ExternalChangesLastClearedUnixMs = null,
Refresh = new EditorStateRefresh
{
IsRefreshInProgress = false,
LastRefreshRequestedUnixMs = null,
LastRefreshFinishedUnixMs = null
}
},
Tests = new EditorStateTests
{
IsRunning = testsRunning,
Mode = testsMode,
CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
StartedUnixMs = TestRunStatus.StartedUnixMs,
StartedBy = "unknown",
LastRun = TestRunStatus.FinishedUnixMs.HasValue
? new EditorStateLastRun
{
FinishedUnixMs = TestRunStatus.FinishedUnixMs,
Result = "unknown",
Counts = null
}
: null
},
Transport = new EditorStateTransport
{
UnityBridgeConnected = null,
LastMessageUnixMs = null
}
};
return JObject.FromObject(snapshot);
}
public static JObject GetSnapshot()
{
lock (LockObj)
{
// Defensive: if something went wrong early, rebuild once.
if (_cached == null)
{
_cached = BuildSnapshot("rebuild");
}
// Always return a fresh clone to prevent mutation bugs.
// The main GC optimization comes from state-change detection (OnUpdate)
// which prevents unnecessary _cached rebuilds, not from caching the clone.
return (JObject)_cached.DeepClone();
}
}
/// <summary>
/// Returns the actual compilation state, working around a known Unity quirk where
/// EditorApplication.isCompiling can return false positives in Play mode.
/// See: https://github.com/CoplayDev/unity-mcp/issues/549
/// </summary>
private static bool GetActualIsCompiling()
{
// If EditorApplication.isCompiling is false, Unity is definitely not compiling
if (!EditorApplication.isCompiling)
{
return false;
}
// In Play mode, EditorApplication.isCompiling can have false positives.
// Double-check with CompilationPipeline.isCompiling via reflection.
if (EditorApplication.isPlaying)
{
try
{
Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", BindingFlags.Public | BindingFlags.Static);
if (prop != null)
{
return (bool)prop.GetValue(null);
}
}
catch
{
// If reflection fails, fall back to EditorApplication.isCompiling
}
}
// Outside Play mode or if reflection failed, trust EditorApplication.isCompiling
return true;
}
}
}

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,145 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Windows;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge.
/// </summary>
[InitializeOnLoad]
internal static class HttpBridgeReloadHandler
{
static HttpBridgeReloadHandler()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
}
private static void OnBeforeAssemblyReload()
{
try
{
var transport = MCPServiceLocator.TransportManager;
bool shouldResume = transport.IsRunning(TransportMode.Http);
if (shouldResume)
{
EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload);
}
if (shouldResume)
{
var stopTask = transport.StopAsync(TransportMode.Http);
stopTask.ContinueWith(t =>
{
if (t.IsFaulted && t.Exception != null)
{
McpLog.Warn($"Error stopping MCP bridge before reload: {t.Exception.GetBaseException().Message}");
}
}, TaskScheduler.Default);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}");
}
}
private static void OnAfterAssemblyReload()
{
bool resume = false;
try
{
// Only resume HTTP if it is still the selected transport.
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
resume = useHttp && EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false);
if (resume)
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}");
resume = false;
}
if (!resume)
{
return;
}
// If the editor is not compiling, attempt an immediate restart without relying on editor focus.
bool isCompiling = EditorApplication.isCompiling;
try
{
var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop != null) isCompiling |= (bool)prop.GetValue(null);
}
catch { }
if (!isCompiling)
{
try
{
var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
startTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
var baseEx = t.Exception?.GetBaseException();
McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {baseEx?.Message}");
return;
}
bool started = t.Result;
if (!started)
{
McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
}
else
{
MCPForUnityEditorWindow.RequestHealthVerification();
}
}, TaskScheduler.Default);
return;
}
catch (Exception ex)
{
McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}");
return;
}
}
// Fallback when compiling: schedule on the editor loop
EditorApplication.delayCall += async () =>
{
try
{
bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
if (!started)
{
McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
}
else
{
MCPForUnityEditorWindow.RequestHealthVerification();
}
}
catch (Exception ex)
{
McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}");
}
};
}
}
}

View File

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

View File

@@ -0,0 +1,82 @@
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.Transport;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for controlling the MCP for Unity Bridge connection
/// </summary>
public interface IBridgeControlService
{
/// <summary>
/// Gets whether the bridge is currently running
/// </summary>
bool IsRunning { get; }
/// <summary>
/// Gets the current port the bridge is listening on
/// </summary>
int CurrentPort { get; }
/// <summary>
/// Gets whether the bridge is in auto-connect mode
/// </summary>
bool IsAutoConnectMode { get; }
/// <summary>
/// Gets the currently active transport mode, if any
/// </summary>
TransportMode? ActiveMode { get; }
/// <summary>
/// Starts the MCP for Unity Bridge asynchronously
/// </summary>
/// <returns>True if the bridge started successfully</returns>
Task<bool> StartAsync();
/// <summary>
/// Stops the MCP for Unity Bridge asynchronously
/// </summary>
Task StopAsync();
/// <summary>
/// Verifies the bridge connection by sending a ping and waiting for a pong response
/// </summary>
/// <param name="port">The port to verify</param>
/// <returns>Verification result with detailed status</returns>
BridgeVerificationResult Verify(int port);
/// <summary>
/// Verifies the connection asynchronously (works for both HTTP and stdio transports)
/// </summary>
/// <returns>Verification result with detailed status</returns>
Task<BridgeVerificationResult> VerifyAsync();
}
/// <summary>
/// Result of a bridge verification attempt
/// </summary>
public class BridgeVerificationResult
{
/// <summary>
/// Whether the verification was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Human-readable message about the verification result
/// </summary>
public string Message { get; set; }
/// <summary>
/// Whether the handshake was valid (FRAMING=1 protocol)
/// </summary>
public bool HandshakeValid { get; set; }
/// <summary>
/// Whether the ping/pong exchange succeeded
/// </summary>
public bool PingSucceeded { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,69 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for configuring MCP clients
/// </summary>
public interface IClientConfigurationService
{
/// <summary>
/// Configures a specific MCP client
/// </summary>
/// <param name="client">The client to configure</param>
void ConfigureClient(IMcpClientConfigurator configurator);
/// <summary>
/// Configures all detected/installed MCP clients (skips clients where CLI/tools not found)
/// </summary>
/// <returns>Summary of configuration results</returns>
ClientConfigurationSummary ConfigureAllDetectedClients();
/// <summary>
/// Checks the configuration status of a client
/// </summary>
/// <param name="client">The client to check</param>
/// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param>
/// <returns>True if status changed, false otherwise</returns>
bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true);
/// <summary>Gets the registry of discovered configurators.</summary>
IReadOnlyList<IMcpClientConfigurator> GetAllClients();
}
/// <summary>
/// Summary of configuration results for multiple clients
/// </summary>
public class ClientConfigurationSummary
{
/// <summary>
/// Number of clients successfully configured
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// Number of clients that failed to configure
/// </summary>
public int FailureCount { get; set; }
/// <summary>
/// Number of clients skipped (already configured or tool not found)
/// </summary>
public int SkippedCount { get; set; }
/// <summary>
/// Detailed messages for each client
/// </summary>
public System.Collections.Generic.List<string> Messages { get; set; } = new();
/// <summary>
/// Gets a human-readable summary message
/// </summary>
public string GetSummaryMessage()
{
return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped";
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
namespace MCPForUnity.Editor.Services
{
public interface IPackageDeploymentService
{
string GetStoredSourcePath();
void SetStoredSourcePath(string path);
void ClearStoredSourcePath();
string GetTargetPath();
string GetTargetDisplayPath();
string GetLastBackupPath();
bool HasBackup();
PackageDeploymentResult DeployFromStoredSource();
PackageDeploymentResult RestoreLastBackup();
}
public class PackageDeploymentResult
{
public bool Success { get; set; }
public string Message { get; set; }
public string SourcePath { get; set; }
public string TargetPath { get; set; }
public string BackupPath { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,60 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates and version information
/// </summary>
public interface IPackageUpdateService
{
/// <summary>
/// Checks if a newer version of the package is available
/// </summary>
/// <param name="currentVersion">The current package version</param>
/// <returns>Update check result containing availability and latest version info</returns>
UpdateCheckResult CheckForUpdate(string currentVersion);
/// <summary>
/// Compares two version strings to determine if the first is newer than the second
/// </summary>
/// <param name="version1">First version string</param>
/// <param name="version2">Second version string</param>
/// <returns>True if version1 is newer than version2</returns>
bool IsNewerVersion(string version1, string version2);
/// <summary>
/// Determines if the package was installed via Git or Asset Store
/// </summary>
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
bool IsGitInstallation();
/// <summary>
/// Clears the cached update check data, forcing a fresh check on next request
/// </summary>
void ClearCache();
}
/// <summary>
/// Result of an update check operation
/// </summary>
public class UpdateCheckResult
{
/// <summary>
/// Whether an update is available
/// </summary>
public bool UpdateAvailable { get; set; }
/// <summary>
/// The latest version available (null if check failed or no update)
/// </summary>
public string LatestVersion { get; set; }
/// <summary>
/// Whether the check was successful (false if network error, etc.)
/// </summary>
public bool CheckSucceeded { get; set; }
/// <summary>
/// Optional message about the check result
/// </summary>
public string Message { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,77 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for resolving paths to required tools and supporting user overrides
/// </summary>
public interface IPathResolverService
{
/// <summary>
/// Gets the uvx package manager path (respects override if set)
/// </summary>
/// <returns>Path to the uvx executable, or null if not found</returns>
string GetUvxPath();
/// <summary>
/// Gets the Claude CLI path (respects override if set)
/// </summary>
/// <returns>Path to the claude executable, or null if not found</returns>
string GetClaudeCliPath();
/// <summary>
/// Checks if Python is detected on the system
/// </summary>
/// <returns>True if Python is found</returns>
bool IsPythonDetected();
/// <summary>
/// Checks if Claude CLI is detected on the system
/// </summary>
/// <returns>True if Claude CLI is found</returns>
bool IsClaudeCliDetected();
/// <summary>
/// Sets an override for the uvx path
/// </summary>
/// <param name="path">Path to override with</param>
void SetUvxPathOverride(string path);
/// <summary>
/// Sets an override for the Claude CLI path
/// </summary>
/// <param name="path">Path to override with</param>
void SetClaudeCliPathOverride(string path);
/// <summary>
/// Clears the uvx path override
/// </summary>
void ClearUvxPathOverride();
/// <summary>
/// Clears the Claude CLI path override
/// </summary>
void ClearClaudeCliPathOverride();
/// <summary>
/// Gets whether a uvx path override is active
/// </summary>
bool HasUvxPathOverride { get; }
/// <summary>
/// Gets whether a Claude CLI path override is active
/// </summary>
bool HasClaudeCliPathOverride { get; }
/// <summary>
/// Gets whether the uvx path used a fallback from override to system path
/// </summary>
bool HasUvxPathFallback { get; }
/// <summary>
/// Validates the provided uv executable by running "--version" and parsing the output.
/// </summary>
/// <param name="uvPath">Absolute or relative path to the uv/uvx executable.</param>
/// <param name="version">Parsed version string if successful.</param>
/// <returns>True when the executable runs and returns a uv version string.</returns>
bool TryValidateUvxExecutable(string uvPath, out string version);
}
}

View File

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

View File

@@ -0,0 +1,20 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for platform detection and platform-specific environment access
/// </summary>
public interface IPlatformService
{
/// <summary>
/// Checks if the current platform is Windows
/// </summary>
/// <returns>True if running on Windows</returns>
bool IsWindows();
/// <summary>
/// Gets the SystemRoot environment variable (Windows-specific)
/// </summary>
/// <returns>SystemRoot path, or null if not available</returns>
string GetSystemRoot();
}
}

View File

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

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Metadata for a discovered resource
/// </summary>
public class ResourceMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string ClassName { get; set; }
public string Namespace { get; set; }
public string AssemblyName { get; set; }
public bool IsBuiltIn { get; set; }
}
/// <summary>
/// Service for discovering MCP resources via reflection
/// </summary>
public interface IResourceDiscoveryService
{
/// <summary>
/// Discovers all resources marked with [McpForUnityResource]
/// </summary>
List<ResourceMetadata> DiscoverAllResources();
/// <summary>
/// Gets metadata for a specific resource
/// </summary>
ResourceMetadata GetResourceMetadata(string resourceName);
/// <summary>
/// Returns only the resources currently enabled
/// </summary>
List<ResourceMetadata> GetEnabledResources();
/// <summary>
/// Checks whether a resource is currently enabled
/// </summary>
bool IsResourceEnabled(string resourceName);
/// <summary>
/// Updates the enabled state for a resource
/// </summary>
void SetResourceEnabled(string resourceName, bool enabled);
/// <summary>
/// Invalidates the resource discovery cache
/// </summary>
void InvalidateCache();
}
}

View File

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

View File

@@ -0,0 +1,64 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Interface for server management operations
/// </summary>
public interface IServerManagementService
{
/// <summary>
/// Clear the local uvx cache for the MCP server package
/// </summary>
/// <returns>True if successful, false otherwise</returns>
bool ClearUvxCache();
/// <summary>
/// Start the local HTTP server in a new terminal window.
/// Stops any existing server on the port and clears the uvx cache first.
/// </summary>
/// <returns>True if server was started successfully, false otherwise</returns>
bool StartLocalHttpServer();
/// <summary>
/// Stop the local HTTP server by finding the process listening on the configured port
/// </summary>
bool StopLocalHttpServer();
/// <summary>
/// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
/// even if the current transport selection has changed.
/// </summary>
bool StopManagedLocalHttpServer();
/// <summary>
/// Best-effort detection: returns true if a local MCP HTTP server appears to be running
/// on the configured local URL/port (used to drive UI state even if the session is not active).
/// </summary>
bool IsLocalHttpServerRunning();
/// <summary>
/// Fast reachability check: returns true if a local TCP listener is accepting connections
/// for the configured local URL/port (used for UI state without process inspection).
/// </summary>
bool IsLocalHttpServerReachable();
/// <summary>
/// Attempts to get the command that will be executed when starting the local HTTP server
/// </summary>
/// <param name="command">The command that will be executed when available</param>
/// <param name="error">Reason why a command could not be produced</param>
/// <returns>True if a command is available, false otherwise</returns>
bool TryGetLocalHttpServerCommand(out string command, out string error);
/// <summary>
/// Check if the configured HTTP URL is a local address
/// </summary>
/// <returns>True if URL is local (localhost, 127.0.0.1, etc.)</returns>
bool IsLocalUrl();
/// <summary>
/// Check if the local HTTP server can be started
/// </summary>
/// <returns>True if HTTP transport is enabled and URL is local</returns>
bool CanStartLocalServer();
}
}

View File

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

View File

@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Options for filtering which tests to run.
/// All properties are optional - null or empty arrays are ignored.
/// </summary>
public class TestFilterOptions
{
/// <summary>
/// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod").
/// </summary>
public string[] TestNames { get; set; }
/// <summary>
/// Same as TestNames, except it allows for Regex.
/// </summary>
public string[] GroupNames { get; set; }
/// <summary>
/// NUnit category names to filter by (tests marked with [Category] attribute).
/// </summary>
public string[] CategoryNames { get; set; }
/// <summary>
/// Assembly names to filter tests by.
/// </summary>
public string[] AssemblyNames { get; set; }
}
/// <summary>
/// Provides access to Unity Test Runner data and execution.
/// </summary>
public interface ITestRunnerService
{
/// <summary>
/// Retrieve the list of tests for the requested mode(s).
/// When <paramref name="mode"/> is null, tests for both EditMode and PlayMode are returned.
/// </summary>
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);
/// <summary>
/// Execute tests for the supplied mode with optional filtering.
/// </summary>
/// <param name="mode">The test mode (EditMode or PlayMode).</param>
/// <param name="filterOptions">Optional filter options to run specific tests. Pass null to run all tests.</param>
Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null);
}
}

View File

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

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Metadata for a discovered tool
/// </summary>
public class ToolMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public bool StructuredOutput { get; set; }
public List<ParameterMetadata> Parameters { get; set; }
public string ClassName { get; set; }
public string Namespace { get; set; }
public string AssemblyName { get; set; }
public bool AutoRegister { get; set; } = true;
public bool RequiresPolling { get; set; } = false;
public string PollAction { get; set; } = "status";
public bool IsBuiltIn { get; set; }
}
/// <summary>
/// Metadata for a tool parameter
/// </summary>
public class ParameterMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string Type { get; set; } // "string", "int", "bool", "float", etc.
public bool Required { get; set; }
public string DefaultValue { get; set; }
}
/// <summary>
/// Service for discovering MCP tools via reflection
/// </summary>
public interface IToolDiscoveryService
{
/// <summary>
/// Discovers all tools marked with [McpForUnityTool]
/// </summary>
List<ToolMetadata> DiscoverAllTools();
/// <summary>
/// Gets metadata for a specific tool
/// </summary>
ToolMetadata GetToolMetadata(string toolName);
/// <summary>
/// Returns only the tools currently enabled for registration
/// </summary>
List<ToolMetadata> GetEnabledTools();
/// <summary>
/// Checks whether a tool is currently enabled for registration
/// </summary>
bool IsToolEnabled(string toolName);
/// <summary>
/// Updates the enabled state for a tool
/// </summary>
void SetToolEnabled(string toolName, bool enabled);
/// <summary>
/// Invalidates the tool discovery cache
/// </summary>
void InvalidateCache();
}
}

View File

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

View File

@@ -0,0 +1,98 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service locator for accessing MCP services without dependency injection
/// </summary>
public static class MCPServiceLocator
{
private static IBridgeControlService _bridgeService;
private static IClientConfigurationService _clientService;
private static IPathResolverService _pathService;
private static ITestRunnerService _testRunnerService;
private static IPackageUpdateService _packageUpdateService;
private static IPlatformService _platformService;
private static IToolDiscoveryService _toolDiscoveryService;
private static IResourceDiscoveryService _resourceDiscoveryService;
private static IServerManagementService _serverManagementService;
private static TransportManager _transportManager;
private static IPackageDeploymentService _packageDeploymentService;
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
public static IPlatformService Platform => _platformService ??= new PlatformService();
public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService();
public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService();
public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService();
public static TransportManager TransportManager => _transportManager ??= new TransportManager();
public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService();
/// <summary>
/// Registers a custom implementation for a service (useful for testing)
/// </summary>
/// <typeparam name="T">The service interface type</typeparam>
/// <param name="implementation">The implementation to register</param>
public static void Register<T>(T implementation) where T : class
{
if (implementation is IBridgeControlService b)
_bridgeService = b;
else if (implementation is IClientConfigurationService c)
_clientService = c;
else if (implementation is IPathResolverService p)
_pathService = p;
else if (implementation is ITestRunnerService t)
_testRunnerService = t;
else if (implementation is IPackageUpdateService pu)
_packageUpdateService = pu;
else if (implementation is IPlatformService ps)
_platformService = ps;
else if (implementation is IToolDiscoveryService td)
_toolDiscoveryService = td;
else if (implementation is IResourceDiscoveryService rd)
_resourceDiscoveryService = rd;
else if (implementation is IServerManagementService sm)
_serverManagementService = sm;
else if (implementation is IPackageDeploymentService pd)
_packageDeploymentService = pd;
else if (implementation is TransportManager tm)
_transportManager = tm;
}
/// <summary>
/// Resets all services to their default implementations (useful for testing)
/// </summary>
public static void Reset()
{
(_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
(_packageUpdateService as IDisposable)?.Dispose();
(_platformService as IDisposable)?.Dispose();
(_toolDiscoveryService as IDisposable)?.Dispose();
(_resourceDiscoveryService as IDisposable)?.Dispose();
(_serverManagementService as IDisposable)?.Dispose();
(_transportManager as IDisposable)?.Dispose();
(_packageDeploymentService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
_pathService = null;
_testRunnerService = null;
_packageUpdateService = null;
_platformService = null;
_toolDiscoveryService = null;
_resourceDiscoveryService = null;
_serverManagementService = null;
_transportManager = null;
_packageDeploymentService = null;
}
}
}

View File

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

View File

@@ -0,0 +1,77 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Best-effort cleanup when the Unity Editor is quitting.
/// - Stops active transports so clients don't see a "hung" session longer than necessary.
/// - If HTTP Local is selected, attempts to stop the local HTTP server (guarded by PID heuristics).
/// </summary>
[InitializeOnLoad]
internal static class McpEditorShutdownCleanup
{
static McpEditorShutdownCleanup()
{
// Guard against duplicate subscriptions across domain reloads.
try { EditorApplication.quitting -= OnEditorQuitting; } catch { }
EditorApplication.quitting += OnEditorQuitting;
}
private static void OnEditorQuitting()
{
// 1) Stop transports (best-effort, bounded wait).
try
{
var transport = MCPServiceLocator.TransportManager;
Task stopHttp = transport.StopAsync(TransportMode.Http);
Task stopStdio = transport.StopAsync(TransportMode.Stdio);
try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { }
}
catch (Exception ex)
{
// Avoid hard failures on quit.
McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}");
}
// 2) Stop local HTTP server if it was Unity-managed (best-effort).
try
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
string scope = string.Empty;
try { scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); } catch { }
bool stopped = false;
bool httpLocalSelected =
useHttp &&
(string.Equals(scope, "local", StringComparison.OrdinalIgnoreCase)
|| (string.IsNullOrEmpty(scope) && MCPServiceLocator.Server.IsLocalUrl()));
if (httpLocalSelected)
{
// StopLocalHttpServer is already guarded to only terminate processes that look like mcp-for-unity.
// If it refuses to stop (e.g. URL was edited away from local), fall back to the Unity-managed stop.
stopped = MCPServiceLocator.Server.StopLocalHttpServer();
}
// Always attempt to stop a Unity-managed server if one exists.
// This covers cases where the user switched transports (e.g. to stdio) or StopLocalHttpServer refused.
if (!stopped)
{
MCPServiceLocator.Server.StopManagedLocalHttpServer();
}
}
catch (Exception ex)
{
McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}");
}
}
}
}

View File

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

View File

@@ -0,0 +1,304 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Handles copying a local MCPForUnity folder into the current project's package location with backup/restore support.
/// </summary>
public class PackageDeploymentService : IPackageDeploymentService
{
private const string BackupRootFolderName = "MCPForUnityDeployBackups";
public string GetStoredSourcePath()
{
return EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty);
}
public void SetStoredSourcePath(string path)
{
ValidateSource(path);
EditorPrefs.SetString(EditorPrefKeys.PackageDeploySourcePath, Path.GetFullPath(path));
}
public void ClearStoredSourcePath()
{
EditorPrefs.DeleteKey(EditorPrefKeys.PackageDeploySourcePath);
}
public string GetTargetPath()
{
// Prefer Package Manager resolved path for the installed package
var packageInfo = PackageInfo.FindForAssembly(typeof(PackageDeploymentService).Assembly);
if (packageInfo != null)
{
if (!string.IsNullOrEmpty(packageInfo.resolvedPath) && Directory.Exists(packageInfo.resolvedPath))
{
return packageInfo.resolvedPath;
}
if (!string.IsNullOrEmpty(packageInfo.assetPath))
{
string absoluteFromAsset = MakeAbsolute(packageInfo.assetPath);
if (Directory.Exists(absoluteFromAsset))
{
return absoluteFromAsset;
}
}
}
// Fallback to computed package root
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (!string.IsNullOrEmpty(packageRoot))
{
string absolutePath = MakeAbsolute(packageRoot);
if (Directory.Exists(absolutePath))
{
return absolutePath;
}
}
return null;
}
public string GetTargetDisplayPath()
{
string target = GetTargetPath();
if (string.IsNullOrEmpty(target))
return "Not found (check Packages/manifest.json)";
// Use forward slashes to avoid backslash escape sequence issues in UI text
return target.Replace('\\', '/');
}
public string GetLastBackupPath()
{
return EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty);
}
public bool HasBackup()
{
string path = GetLastBackupPath();
return !string.IsNullOrEmpty(path) && Directory.Exists(path);
}
public PackageDeploymentResult DeployFromStoredSource()
{
string sourcePath = GetStoredSourcePath();
if (string.IsNullOrEmpty(sourcePath))
{
return Fail("Select a MCPForUnity folder first.");
}
string validationError = ValidateSource(sourcePath, throwOnError: false);
if (!string.IsNullOrEmpty(validationError))
{
return Fail(validationError);
}
string targetPath = GetTargetPath();
if (string.IsNullOrEmpty(targetPath))
{
return Fail("Could not locate the installed MCP package. Check Packages/manifest.json.");
}
if (PathsEqual(sourcePath, targetPath))
{
return Fail("Source and target are the same. Choose a different MCPForUnity folder.");
}
try
{
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Creating backup...", 0.25f);
string backupPath = CreateBackup(targetPath);
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Replacing package contents...", 0.7f);
CopyCoreFolders(sourcePath, targetPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastBackupPath, backupPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastTargetPath, targetPath);
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastSourcePath, sourcePath);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
return Success("Deployment completed.", sourcePath, targetPath, backupPath);
}
catch (Exception ex)
{
McpLog.Error($"Deployment failed: {ex.Message}");
return Fail($"Deployment failed: {ex.Message}");
}
finally
{
EditorUtility.ClearProgressBar();
}
}
public PackageDeploymentResult RestoreLastBackup()
{
string backupPath = GetLastBackupPath();
string targetPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastTargetPath, string.Empty);
if (string.IsNullOrEmpty(backupPath) || !Directory.Exists(backupPath))
{
return Fail("No backup available to restore.");
}
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
{
targetPath = GetTargetPath();
}
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
{
return Fail("Could not locate target package path.");
}
try
{
EditorUtility.DisplayProgressBar("Restore MCP for Unity", "Restoring backup...", 0.5f);
ReplaceDirectory(backupPath, targetPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
return Success("Restore completed.", null, targetPath, backupPath);
}
catch (Exception ex)
{
McpLog.Error($"Restore failed: {ex.Message}");
return Fail($"Restore failed: {ex.Message}");
}
finally
{
EditorUtility.ClearProgressBar();
}
}
private void CopyCoreFolders(string sourceRoot, string targetRoot)
{
string sourceEditor = Path.Combine(sourceRoot, "Editor");
string sourceRuntime = Path.Combine(sourceRoot, "Runtime");
ReplaceDirectory(sourceEditor, Path.Combine(targetRoot, "Editor"));
ReplaceDirectory(sourceRuntime, Path.Combine(targetRoot, "Runtime"));
}
private static void ReplaceDirectory(string source, string destination)
{
if (Directory.Exists(destination))
{
FileUtil.DeleteFileOrDirectory(destination);
}
FileUtil.CopyFileOrDirectory(source, destination);
}
private string CreateBackup(string targetPath)
{
string backupRoot = Path.Combine(GetProjectRoot(), "Library", BackupRootFolderName);
Directory.CreateDirectory(backupRoot);
string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string backupPath = Path.Combine(backupRoot, $"backup_{stamp}");
if (Directory.Exists(backupPath))
{
FileUtil.DeleteFileOrDirectory(backupPath);
}
FileUtil.CopyFileOrDirectory(targetPath, backupPath);
return backupPath;
}
private static string ValidateSource(string sourcePath, bool throwOnError = true)
{
if (string.IsNullOrEmpty(sourcePath))
{
if (throwOnError)
{
throw new ArgumentException("Source path cannot be empty.");
}
return "Source path is empty.";
}
if (!Directory.Exists(sourcePath))
{
if (throwOnError)
{
throw new ArgumentException("Selected folder does not exist.");
}
return "Selected folder does not exist.";
}
bool hasEditor = Directory.Exists(Path.Combine(sourcePath, "Editor"));
bool hasRuntime = Directory.Exists(Path.Combine(sourcePath, "Runtime"));
if (!hasEditor || !hasRuntime)
{
string message = "Folder must contain Editor and Runtime subfolders.";
if (throwOnError)
{
throw new ArgumentException(message);
}
return message;
}
return null;
}
private static string MakeAbsolute(string assetPath)
{
assetPath = assetPath.Replace('\\', '/');
if (assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
}
if (assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
}
return Path.GetFullPath(assetPath);
}
private static string GetProjectRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
private static bool PathsEqual(string a, string b)
{
string normA = Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string normB = Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.Equals(normA, normB, StringComparison.OrdinalIgnoreCase);
}
private static PackageDeploymentResult Success(string message, string source, string target, string backup)
{
return new PackageDeploymentResult
{
Success = true,
Message = message,
SourcePath = source,
TargetPath = target,
BackupPath = backup
};
}
private static PackageDeploymentResult Fail(string message)
{
return new PackageDeploymentResult
{
Success = false,
Message = message
};
}
}
}

View File

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

View File

@@ -0,0 +1,185 @@
using System;
using System.Net;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates from GitHub or Asset Store metadata
/// </summary>
public class PackageUpdateService : IPackageUpdateService
{
private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion;
private const string LastAssetStoreCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck;
private const string CachedAssetStoreVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion;
private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
private const string AssetStoreVersionUrl = "https://gqoqjkkptwfbkwyssmnj.supabase.co/storage/v1/object/public/coplay-images/assetstoreversion.json";
/// <inheritdoc/>
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
bool isGitInstallation = IsGitInstallation();
string lastCheckDate = EditorPrefs.GetString(isGitInstallation ? LastCheckDateKey : LastAssetStoreCheckDateKey, "");
string cachedLatestVersion = EditorPrefs.GetString(isGitInstallation ? CachedVersionKey : CachedAssetStoreVersionKey, "");
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = cachedLatestVersion,
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
Message = "Using cached version check"
};
}
string latestVersion = isGitInstallation
? FetchLatestVersionFromGitHub()
: FetchLatestVersionFromAssetStoreJson();
if (!string.IsNullOrEmpty(latestVersion))
{
// Cache the result
EditorPrefs.SetString(isGitInstallation ? LastCheckDateKey : LastAssetStoreCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(isGitInstallation ? CachedVersionKey : CachedAssetStoreVersionKey, latestVersion);
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = latestVersion,
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
Message = "Successfully checked for updates"
};
}
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = isGitInstallation
? "Failed to check for updates (network issue or offline)"
: "Failed to check for Asset Store updates (network issue or offline)"
};
}
/// <inheritdoc/>
public bool IsNewerVersion(string version1, string version2)
{
try
{
// Remove any "v" prefix
version1 = version1.TrimStart('v', 'V');
version2 = version2.TrimStart('v', 'V');
var version1Parts = version1.Split('.');
var version2Parts = version2.Split('.');
for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++)
{
if (int.TryParse(version1Parts[i], out int v1Num) &&
int.TryParse(version2Parts[i], out int v2Num))
{
if (v1Num > v2Num) return true;
if (v1Num < v2Num) return false;
}
}
return false;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public virtual bool IsGitInstallation()
{
// Git packages are installed via Package Manager and have a package.json in Packages/
// Asset Store packages are in Assets/
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return false;
}
// If the package is in Packages/ it's a PM install (likely Git)
// If it's in Assets/ it's an Asset Store install
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public void ClearCache()
{
EditorPrefs.DeleteKey(LastCheckDateKey);
EditorPrefs.DeleteKey(CachedVersionKey);
EditorPrefs.DeleteKey(LastAssetStoreCheckDateKey);
EditorPrefs.DeleteKey(CachedAssetStoreVersionKey);
}
/// <summary>
/// Fetches the latest version from GitHub's main branch package.json
/// </summary>
protected virtual string FetchLatestVersionFromGitHub()
{
try
{
// GitHub API endpoint (Option 1 - has rate limits):
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
//
// We use Option 2 (package.json directly) because:
// - No API rate limits (GitHub serves raw files freely)
// - Simpler - just parse JSON for version field
// - More reliable - doesn't require releases to be published
// - Direct source of truth from the main branch
using (var client = new WebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
string jsonContent = client.DownloadString(PackageJsonUrl);
var packageJson = JObject.Parse(jsonContent);
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
/// <summary>
/// Fetches the latest Asset Store version from a hosted JSON file.
/// </summary>
protected virtual string FetchLatestVersionFromAssetStoreJson()
{
try
{
using (var client = new WebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-AssetStoreUpdateChecker");
string jsonContent = client.DownloadString(AssetStoreVersionUrl);
var versionJson = JObject.Parse(jsonContent);
string version = versionJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Asset Store update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
}
}

View File

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

View File

@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Implementation of path resolver service with override support
/// </summary>
public class PathResolverService : IPathResolverService
{
private bool _hasUvxPathFallback;
public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null));
public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null));
public bool HasUvxPathFallback => _hasUvxPathFallback;
public string GetUvxPath()
{
// Reset fallback flag at the start of each resolution
_hasUvxPathFallback = false;
// Check override first - only validate if explicitly set
if (HasUvxPathOverride)
{
string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
// Validate the override - if invalid, fall back to system discovery
if (TryValidateUvxExecutable(overridePath, out string version))
{
return overridePath;
}
// Override is set but invalid - fall back to system discovery
string fallbackPath = ResolveUvxFromSystem();
if (!string.IsNullOrEmpty(fallbackPath))
{
_hasUvxPathFallback = true;
return fallbackPath;
}
// Return null to indicate override is invalid and no system fallback found
return null;
}
// No override set - try discovery (uvx first, then uv)
string discovered = ResolveUvxFromSystem();
if (!string.IsNullOrEmpty(discovered))
{
return discovered;
}
// Fallback to bare command
return "uvx";
}
/// <summary>
/// Resolves uv/uvx from system by trying both commands.
/// Returns the full path if found, null otherwise.
/// </summary>
private static string ResolveUvxFromSystem()
{
try
{
// Try uvx first, then uv
string[] commandNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new[] { "uvx.exe", "uv.exe" }
: new[] { "uvx", "uv" };
foreach (string commandName in commandNames)
{
foreach (string candidate in EnumerateCommandCandidates(commandName))
{
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
{
return candidate;
}
}
}
}
catch (Exception ex)
{
McpLog.Debug($"PathResolver error: {ex.Message}");
}
return null;
}
public string GetClaudeCliPath()
{
// Check override first - only validate if explicitly set
if (HasClaudeCliPathOverride)
{
string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
// Validate the override - if invalid, don't fall back to discovery
if (File.Exists(overridePath))
{
return overridePath;
}
// Override is set but invalid - return null (no fallback)
return null;
}
// No override - use platform-specific discovery
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string[] candidates = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"),
"claude.exe"
};
foreach (var c in candidates)
{
if (File.Exists(c)) return c;
}
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
string[] candidates = new[]
{
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude")
};
foreach (var c in candidates)
{
if (File.Exists(c)) return c;
}
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
string[] candidates = new[]
{
"/usr/bin/claude",
"/usr/local/bin/claude",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude")
};
foreach (var c in candidates)
{
if (File.Exists(c)) return c;
}
}
return null;
}
public bool IsPythonDetected()
{
return ExecPath.TryRun(
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3",
"--version",
null,
out _,
out _,
2000);
}
public bool IsClaudeCliDetected()
{
return !string.IsNullOrEmpty(GetClaudeCliPath());
}
public void SetUvxPathOverride(string path)
{
if (string.IsNullOrEmpty(path))
{
ClearUvxPathOverride();
return;
}
if (!File.Exists(path))
{
throw new ArgumentException("The selected uvx executable does not exist");
}
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path);
}
public void SetClaudeCliPathOverride(string path)
{
if (string.IsNullOrEmpty(path))
{
ClearClaudeCliPathOverride();
return;
}
if (!File.Exists(path))
{
throw new ArgumentException("The selected Claude CLI executable does not exist");
}
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path);
}
public void ClearUvxPathOverride()
{
EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride);
}
public void ClearClaudeCliPathOverride()
{
EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride);
}
/// <summary>
/// Validates the provided uv executable by running "--version" and parsing the output.
/// </summary>
/// <param name="uvxPath">Absolute or relative path to the uv/uvx executable.</param>
/// <param name="version">Parsed version string if successful.</param>
/// <returns>True when the executable runs and returns a uvx version string.</returns>
public bool TryValidateUvxExecutable(string uvxPath, out string version)
{
version = null;
if (string.IsNullOrEmpty(uvxPath))
return false;
try
{
// Check if the path is just a command name (no directory separator)
bool isBareCommand = !uvxPath.Contains('/') && !uvxPath.Contains('\\');
if (isBareCommand)
{
// For bare commands like "uvx" or "uv", use EnumerateCommandCandidates to find full path first
string fullPath = FindUvxExecutableInPath(uvxPath);
if (string.IsNullOrEmpty(fullPath))
return false;
uvxPath = fullPath;
}
// Use ExecPath.TryRun which properly handles async output reading and timeouts
if (!ExecPath.TryRun(uvxPath, "--version", null, out string stdout, out string stderr, 5000))
return false;
// Check stdout first, then stderr (some tools output to stderr)
string versionOutput = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
// uv/uvx outputs "uv x.y.z" or "uvx x.y.z", extract version number
if (versionOutput.StartsWith("uvx ") || versionOutput.StartsWith("uv "))
{
// Extract version: "uv 0.9.18 (hash date)" -> "0.9.18"
int spaceIndex = versionOutput.IndexOf(' ');
if (spaceIndex >= 0)
{
string afterCommand = versionOutput.Substring(spaceIndex + 1).Trim();
// Version is up to the first space or parenthesis
int nextSpace = afterCommand.IndexOf(' ');
int parenIndex = afterCommand.IndexOf('(');
int endIndex = Math.Min(
nextSpace >= 0 ? nextSpace : int.MaxValue,
parenIndex >= 0 ? parenIndex : int.MaxValue
);
version = endIndex < int.MaxValue ? afterCommand.Substring(0, endIndex).Trim() : afterCommand;
return true;
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
private string FindUvxExecutableInPath(string commandName)
{
try
{
// Generic search for any command in PATH and common locations
foreach (string candidate in EnumerateCommandCandidates(commandName))
{
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
{
return candidate;
}
}
}
catch
{
// Ignore errors
}
return null;
}
/// <summary>
/// Enumerates candidate paths for a generic command name.
/// Searches PATH and common locations.
/// </summary>
private static IEnumerable<string> EnumerateCommandCandidates(string commandName)
{
string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !commandName.EndsWith(".exe")
? commandName + ".exe"
: commandName;
// Search PATH first
string pathEnv = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(pathEnv))
{
foreach (string rawDir in pathEnv.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(rawDir)) continue;
string dir = rawDir.Trim();
yield return Path.Combine(dir, exeName);
}
}
// User-local binary directories
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(home))
{
yield return Path.Combine(home, ".local", "bin", exeName);
yield return Path.Combine(home, ".cargo", "bin", exeName);
}
// System directories (platform-specific)
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
yield return "/opt/homebrew/bin/" + exeName;
yield return "/usr/local/bin/" + exeName;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
yield return "/usr/local/bin/" + exeName;
yield return "/usr/bin/" + exeName;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrEmpty(localAppData))
{
yield return Path.Combine(localAppData, "Programs", "uv", exeName);
// WinGet creates shim files in this location
yield return Path.Combine(localAppData, "Microsoft", "WinGet", "Links", exeName);
}
if (!string.IsNullOrEmpty(programFiles))
{
yield return Path.Combine(programFiles, "uv", exeName);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using System;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Default implementation of platform detection service
/// </summary>
public class PlatformService : IPlatformService
{
/// <summary>
/// Checks if the current platform is Windows
/// </summary>
/// <returns>True if running on Windows</returns>
public bool IsWindows()
{
return Environment.OSVersion.Platform == PlatformID.Win32NT;
}
/// <summary>
/// Gets the SystemRoot environment variable (Windows-specific)
/// </summary>
/// <returns>SystemRoot path, or "C:\\Windows" as fallback on Windows, null on other platforms</returns>
public string GetSystemRoot()
{
if (!IsWindows())
return null;
return Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows";
}
}
}

View File

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

View File

@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
public class ResourceDiscoveryService : IResourceDiscoveryService
{
private Dictionary<string, ResourceMetadata> _cachedResources;
public List<ResourceMetadata> DiscoverAllResources()
{
if (_cachedResources != null)
{
return _cachedResources.Values.ToList();
}
_cachedResources = new Dictionary<string, ResourceMetadata>();
var resourceTypes = TypeCache.GetTypesWithAttribute<McpForUnityResourceAttribute>();
foreach (var type in resourceTypes)
{
McpForUnityResourceAttribute resourceAttr;
try
{
resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read [McpForUnityResource] for {type.FullName}: {ex.Message}");
continue;
}
if (resourceAttr == null)
{
continue;
}
var metadata = ExtractResourceMetadata(type, resourceAttr);
if (metadata != null)
{
if (_cachedResources.ContainsKey(metadata.Name))
{
McpLog.Warn($"Duplicate resource name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
}
_cachedResources[metadata.Name] = metadata;
EnsurePreferenceInitialized(metadata);
}
}
McpLog.Info($"Discovered {_cachedResources.Count} MCP resources via reflection", false);
return _cachedResources.Values.ToList();
}
public ResourceMetadata GetResourceMetadata(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return null;
}
if (_cachedResources == null)
{
DiscoverAllResources();
}
return _cachedResources.TryGetValue(resourceName, out var metadata) ? metadata : null;
}
public List<ResourceMetadata> GetEnabledResources()
{
return DiscoverAllResources()
.Where(r => IsResourceEnabled(r.Name))
.ToList();
}
public bool IsResourceEnabled(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return false;
}
string key = GetResourcePreferenceKey(resourceName);
if (EditorPrefs.HasKey(key))
{
return EditorPrefs.GetBool(key, true);
}
// Default: all resources enabled
return true;
}
public void SetResourceEnabled(string resourceName, bool enabled)
{
if (string.IsNullOrEmpty(resourceName))
{
return;
}
string key = GetResourcePreferenceKey(resourceName);
EditorPrefs.SetBool(key, enabled);
}
public void InvalidateCache()
{
_cachedResources = null;
}
private ResourceMetadata ExtractResourceMetadata(Type type, McpForUnityResourceAttribute resourceAttr)
{
try
{
string resourceName = resourceAttr.ResourceName;
if (string.IsNullOrEmpty(resourceName))
{
resourceName = StringCaseUtility.ToSnakeCase(type.Name);
}
string description = resourceAttr.Description ?? $"Resource: {resourceName}";
var metadata = new ResourceMetadata
{
Name = resourceName,
Description = description,
ClassName = type.Name,
Namespace = type.Namespace ?? "",
AssemblyName = type.Assembly.GetName().Name
};
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
type, metadata.AssemblyName, "MCPForUnity.Editor.Resources");
return metadata;
}
catch (Exception ex)
{
McpLog.Error($"Failed to extract metadata for resource {type.Name}: {ex.Message}");
return null;
}
}
private void EnsurePreferenceInitialized(ResourceMetadata metadata)
{
if (metadata == null || string.IsNullOrEmpty(metadata.Name))
{
return;
}
string key = GetResourcePreferenceKey(metadata.Name);
if (!EditorPrefs.HasKey(key))
{
EditorPrefs.SetBool(key, true);
}
}
private static string GetResourcePreferenceKey(string resourceName)
{
return EditorPrefKeys.ResourceEnabledPrefix + resourceName;
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1bb072befc9fe4242a501f46dce3fea1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,94 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for managing PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// </summary>
public interface IPidFileManager
{
/// <summary>
/// Gets the directory where PID files are stored.
/// </summary>
/// <returns>Path to the PID file directory</returns>
string GetPidDirectory();
/// <summary>
/// Gets the path to the PID file for a specific port.
/// </summary>
/// <param name="port">The port number</param>
/// <returns>Full path to the PID file</returns>
string GetPidFilePath(int port);
/// <summary>
/// Attempts to read the PID from a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="pid">Output: the process ID if found</param>
/// <returns>True if a valid PID was read</returns>
bool TryReadPid(string pidFilePath, out int pid);
/// <summary>
/// Attempts to extract the port number from a PID file path.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="port">Output: the port number</param>
/// <returns>True if the port was extracted successfully</returns>
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
/// <summary>
/// Deletes a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file to delete</param>
void DeletePidFile(string pidFilePath);
/// <summary>
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="instanceToken">Unique instance token for the server</param>
void StoreHandshake(string pidFilePath, string instanceToken);
/// <summary>
/// Attempts to retrieve stored handshake information from EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Output: stored PID file path</param>
/// <param name="instanceToken">Output: stored instance token</param>
/// <returns>True if valid handshake information was found</returns>
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
/// <summary>
/// Stores PID tracking information in EditorPrefs.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="port">The port number</param>
/// <param name="argsHash">Optional hash of the command arguments</param>
void StoreTracking(int pid, int port, string argsHash = null);
/// <summary>
/// Attempts to retrieve a stored PID for the expected port.
/// Validates that the stored information is still valid (within 6-hour window).
/// </summary>
/// <param name="expectedPort">The expected port number</param>
/// <param name="pid">Output: the stored process ID</param>
/// <returns>True if a valid stored PID was found</returns>
bool TryGetStoredPid(int expectedPort, out int pid);
/// <summary>
/// Gets the stored args hash for the tracked server.
/// </summary>
/// <returns>The stored args hash, or empty string if not found</returns>
string GetStoredArgsHash();
/// <summary>
/// Clears all PID tracking information from EditorPrefs.
/// </summary>
void ClearTracking();
/// <summary>
/// Computes a short hash of the input string for fingerprinting.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>A short hash string (16 hex characters)</returns>
string ComputeShortHash(string input);
}
}

View File

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

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process inspection operations.
/// Provides methods to detect MCP server processes, query process command lines,
/// and find processes listening on specific ports.
/// </summary>
public interface IProcessDetector
{
/// <summary>
/// Determines if a process looks like an MCP server process based on its command line.
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process appears to be an MCP server</returns>
bool LooksLikeMcpServerProcess(int pid);
/// <summary>
/// Attempts to get the command line arguments for a Unix process.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
/// <returns>True if the command line was retrieved successfully</returns>
bool TryGetProcessCommandLine(int pid, out string argsLower);
/// <summary>
/// Gets the process IDs of all processes listening on a specific TCP port.
/// </summary>
/// <param name="port">The port number to check</param>
/// <returns>List of process IDs listening on the port</returns>
List<int> GetListeningProcessIdsForPort(int port);
/// <summary>
/// Gets the current Unity Editor process ID safely.
/// </summary>
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
int GetCurrentProcessId();
/// <summary>
/// Checks if a process exists on Unix systems.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process exists</returns>
bool ProcessExists(int pid);
/// <summary>
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>Normalized string for matching</returns>
string NormalizeForMatch(string input);
}
}

View File

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

View File

@@ -0,0 +1,18 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process termination.
/// Provides methods to terminate processes gracefully or forcefully.
/// </summary>
public interface IProcessTerminator
{
/// <summary>
/// Terminates a process using platform-appropriate methods.
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
/// On Windows: Tries taskkill, then taskkill /F.
/// </summary>
/// <param name="pid">The process ID to terminate</param>
/// <returns>True if the process was terminated successfully</returns>
bool Terminate(int pid);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for building uvx/server command strings.
/// Handles platform-specific command construction for starting the MCP HTTP server.
/// </summary>
public interface IServerCommandBuilder
{
/// <summary>
/// Attempts to build the command parts for starting the local HTTP server.
/// </summary>
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
/// <param name="arguments">Output: the command arguments</param>
/// <param name="displayCommand">Output: the full command string for display</param>
/// <param name="error">Output: error message if the command cannot be built</param>
/// <returns>True if the command was built successfully</returns>
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
/// <summary>
/// Builds the uv path from the uvx path by replacing uvx with uv.
/// </summary>
/// <param name="uvxPath">Path to uvx executable</param>
/// <returns>Path to uv executable</returns>
string BuildUvPathFromUvx(string uvxPath);
/// <summary>
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
/// </summary>
/// <returns>Paths to prepend to PATH environment variable</returns>
string GetPlatformSpecificPathPrepend();
/// <summary>
/// Quotes a string if it contains spaces.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
string QuoteIfNeeded(string input);
}
}

View File

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

View File

@@ -0,0 +1,25 @@
using System.Diagnostics;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for launching commands in platform-specific terminal windows.
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
/// </summary>
public interface ITerminalLauncher
{
/// <summary>
/// Creates a ProcessStartInfo for opening a terminal window with the given command.
/// Works cross-platform: macOS, Windows, and Linux.
/// </summary>
/// <param name="command">The command to execute in the terminal</param>
/// <returns>A configured ProcessStartInfo for launching the terminal</returns>
ProcessStartInfo CreateTerminalProcessStartInfo(string command);
/// <summary>
/// Gets the project root path for storing terminal scripts.
/// </summary>
/// <returns>Path to the project root directory</returns>
string GetProjectRootPath();
}
}

View File

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

View File

@@ -0,0 +1,275 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Manages PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// </summary>
public class PidFileManager : IPidFileManager
{
/// <inheritdoc/>
public string GetPidDirectory()
{
return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState");
}
/// <inheritdoc/>
public string GetPidFilePath(int port)
{
string dir = GetPidDirectory();
Directory.CreateDirectory(dir);
return Path.Combine(dir, $"mcp_http_{port}.pid");
}
/// <inheritdoc/>
public bool TryReadPid(string pidFilePath, out int pid)
{
pid = 0;
try
{
if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath))
{
return false;
}
string text = File.ReadAllText(pidFilePath).Trim();
if (int.TryParse(text, out pid))
{
return pid > 0;
}
// Best-effort: tolerate accidental extra whitespace/newlines.
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (int.TryParse(firstLine, out pid))
{
return pid > 0;
}
pid = 0;
return false;
}
catch
{
pid = 0;
return false;
}
}
/// <inheritdoc/>
public bool TryGetPortFromPidFilePath(string pidFilePath, out int port)
{
port = 0;
if (string.IsNullOrEmpty(pidFilePath))
{
return false;
}
try
{
string fileName = Path.GetFileNameWithoutExtension(pidFilePath);
if (string.IsNullOrEmpty(fileName))
{
return false;
}
const string prefix = "mcp_http_";
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string portText = fileName.Substring(prefix.Length);
return int.TryParse(portText, out port) && port > 0;
}
catch
{
port = 0;
return false;
}
}
/// <inheritdoc/>
public void DeletePidFile(string pidFilePath)
{
try
{
if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath))
{
File.Delete(pidFilePath);
}
}
catch { }
}
/// <inheritdoc/>
public void StoreHandshake(string pidFilePath, string instanceToken)
{
try
{
if (!string.IsNullOrEmpty(pidFilePath))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath);
}
}
catch { }
try
{
if (!string.IsNullOrEmpty(instanceToken))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken);
}
}
catch { }
}
/// <inheritdoc/>
public bool TryGetHandshake(out string pidFilePath, out string instanceToken)
{
pidFilePath = null;
instanceToken = null;
try
{
pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty);
instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty);
if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken))
{
pidFilePath = null;
instanceToken = null;
return false;
}
return true;
}
catch
{
pidFilePath = null;
instanceToken = null;
return false;
}
}
/// <inheritdoc/>
public void StoreTracking(int pid, int port, string argsHash = null)
{
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { }
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { }
try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { }
try
{
if (!string.IsNullOrEmpty(argsHash))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash);
}
}
catch { }
}
/// <inheritdoc/>
public bool TryGetStoredPid(int expectedPort, out int pid)
{
pid = 0;
try
{
int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0);
int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0);
string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty);
if (storedPid <= 0 || storedPort != expectedPort)
{
return false;
}
// Only trust the stored PID for a short window to avoid PID reuse issues.
// (We still verify the PID is listening on the expected port before killing.)
if (!string.IsNullOrEmpty(storedUtc)
&& DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt))
{
if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6))
{
return false;
}
}
pid = storedPid;
return true;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public string GetStoredArgsHash()
{
try
{
return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty);
}
catch
{
return string.Empty;
}
}
/// <inheritdoc/>
public void ClearTracking()
{
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { }
}
/// <inheritdoc/>
public string ComputeShortHash(string input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
try
{
using var sha = SHA256.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input);
byte[] hash = sha.ComputeHash(bytes);
// 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes.
var sb = new StringBuilder(16);
for (int i = 0; i < 8 && i < hash.Length; i++)
{
sb.Append(hash[i].ToString("x2"));
}
return sb.ToString();
}
catch
{
return string.Empty;
}
}
private static string GetProjectRootPath()
{
try
{
// Application.dataPath is ".../<Project>/Assets"
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
catch
{
return Application.dataPath;
}
}
}
}

View File

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

View File

@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Platform-specific process inspection for detecting MCP server processes.
/// </summary>
public class ProcessDetector : IProcessDetector
{
/// <inheritdoc/>
public string NormalizeForMatch(string input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
var sb = new StringBuilder(input.Length);
foreach (char c in input)
{
if (char.IsWhiteSpace(c)) continue;
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
/// <inheritdoc/>
public int GetCurrentProcessId()
{
try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
catch { return -1; }
}
/// <inheritdoc/>
public bool ProcessExists(int pid)
{
try
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// On Windows, use tasklist to check if process exists
bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
return ok && combined.Contains(pid.ToString());
}
// Unix: ps exits non-zero when PID is not found.
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var psStdout, out var psStderr, 2000);
string combined2 = ((psStdout ?? string.Empty) + "\n" + (psStderr ?? string.Empty)).Trim();
return !string.IsNullOrEmpty(combined2) && combined2.Any(char.IsDigit);
}
catch
{
return true; // Assume it exists if we cannot verify.
}
}
/// <inheritdoc/>
public bool TryGetProcessCommandLine(int pid, out string argsLower)
{
argsLower = string.Empty;
try
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Windows: use wmic to get command line
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty));
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.ToLowerInvariant().Contains("commandline="))
{
argsLower = NormalizeForMatch(wmicOut ?? string.Empty);
return true;
}
return false;
}
// Unix: ps -p pid -ww -o args=
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000);
if (!ok && string.IsNullOrWhiteSpace(stdout))
{
return false;
}
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim();
if (string.IsNullOrEmpty(combined)) return false;
// Normalize for matching to tolerate ps wrapping/newlines.
argsLower = NormalizeForMatch(combined);
return true;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public List<int> GetListeningProcessIdsForPort(int port)
{
var results = new List<int>();
try
{
string stdout, stderr;
bool success;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Run netstat -ano directly (without findstr) and filter in C#.
// Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found,
// which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success.
success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr);
// Process stdout regardless of success flag - netstat might still produce valid output
if (!string.IsNullOrEmpty(stdout))
{
string portSuffix = $":{port}";
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Windows netstat format: Proto Local Address Foreign Address State PID
// Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345
if (line.Contains("LISTENING") && line.Contains(portSuffix))
{
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
// Verify the local address column actually ends with :{port}
// parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID
if (parts.Length >= 5)
{
string localAddr = parts[1];
if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int parsedPid))
{
results.Add(parsedPid);
}
}
}
}
}
}
else
{
// lsof: only return LISTENers (avoids capturing random clients)
// Use /usr/sbin/lsof directly as it might not be in PATH for Unity
string lsofPath = "/usr/sbin/lsof";
if (!File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback
// -nP: avoid DNS/service name lookups; faster and less error-prone
success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr);
if (success && !string.IsNullOrWhiteSpace(stdout))
{
var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var pidString in pidStrings)
{
if (int.TryParse(pidString.Trim(), out int parsedPid))
{
results.Add(parsedPid);
}
}
}
}
}
catch (Exception ex)
{
McpLog.Warn($"Error checking port {port}: {ex.Message}");
}
return results.Distinct().ToList();
}
/// <inheritdoc/>
public bool LooksLikeMcpServerProcess(int pid)
{
try
{
// Windows best-effort: First check process name with tasklist, then try to get command line with wmic
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Step 1: Check if process name matches known server executables
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000);
string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant();
// Check for common process names
bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe");
if (!isPythonOrUv)
{
return false;
}
// Step 2: Try to get command line with wmic for better validation
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant();
string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty);
// If we can see the command line, validate it's our server
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline="))
{
bool mentionsMcp = wmicCompact.Contains("mcp-for-unity")
|| wmicCompact.Contains("mcp_for_unity")
|| wmicCompact.Contains("mcpforunity")
|| wmicCompact.Contains("mcpforunityserver");
bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http"));
bool mentionsUvicorn = wmicCombined.Contains("uvicorn");
if (mentionsMcp || mentionsTransport || mentionsUvicorn)
{
return true;
}
}
// Fall back to just checking for python/uv processes if wmic didn't give us details
// This is less precise but necessary for cases where wmic access is restricted
return isPythonOrUv;
}
// macOS/Linux: ps -p pid -ww -o comm= -o args=
// Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity').
// Use an absolute ps path to avoid relying on PATH inside the Unity Editor process.
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
// Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful.
// Always parse stdout/stderr regardless of exit code to avoid false negatives.
ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000);
string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim();
string s = raw.ToLowerInvariant();
string sCompact = NormalizeForMatch(raw);
if (!string.IsNullOrEmpty(s))
{
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|| sCompact.Contains("mcp_for_unity")
|| sCompact.Contains("mcpforunity");
// If it explicitly mentions the server package/entrypoint, that is sufficient.
// Note: Check before Unity exclusion since "mcp-for-unity" contains "unity".
if (mentionsMcp)
{
return true;
}
// Explicitly never kill Unity / Unity Hub processes
// Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above.
if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp))
{
return false;
}
// Positive indicators
bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx ");
bool mentionsUv = s.Contains("uv ") || s.Contains("/uv");
bool mentionsPython = s.Contains("python");
bool mentionsUvicorn = s.Contains("uvicorn");
bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http"));
// Accept if it looks like uv/uvx/python launching our server package/entrypoint
if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport)
{
return true;
}
}
}
catch { }
return false;
}
}
}

View File

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

View File

@@ -0,0 +1,89 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Platform-specific process termination for stopping MCP server processes.
/// </summary>
public class ProcessTerminator : IProcessTerminator
{
private readonly IProcessDetector _processDetector;
/// <summary>
/// Creates a new ProcessTerminator with the specified process detector.
/// </summary>
/// <param name="processDetector">Process detector for checking process existence</param>
public ProcessTerminator(IProcessDetector processDetector)
{
_processDetector = processDetector ?? throw new ArgumentNullException(nameof(processDetector));
}
/// <inheritdoc/>
public bool Terminate(int pid)
{
// CRITICAL: Validate PID before any kill operation.
// On Unix, kill(-1) kills ALL processes the user can signal!
// On Unix, kill(0) signals all processes in the process group.
// PID 1 is init/launchd and must never be killed.
// Only positive PIDs > 1 are valid for targeted termination.
if (pid <= 1)
{
return false;
}
// Never kill the current Unity process
int currentPid = _processDetector.GetCurrentProcessId();
if (currentPid > 0 && pid == currentPid)
{
return false;
}
try
{
string stdout, stderr;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// taskkill without /F first; fall back to /F if needed.
bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr);
if (!ok)
{
ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr);
}
return ok;
}
else
{
// Try a graceful termination first, then escalate if the process is still alive.
// Note: `kill -15` can succeed (exit 0) even if the process takes time to exit,
// so we verify and only escalate when needed.
string killPath = "/bin/kill";
if (!File.Exists(killPath)) killPath = "kill";
ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr);
// Wait briefly for graceful shutdown.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8);
while (DateTime.UtcNow < deadline)
{
if (!_processDetector.ProcessExists(pid))
{
return true;
}
System.Threading.Thread.Sleep(100);
}
// Escalate.
ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr);
return !_processDetector.ProcessExists(pid);
}
}
catch (Exception ex)
{
McpLog.Error($"Error killing process {pid}: {ex.Message}");
return false;
}
}
}
}

View File

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

View File

@@ -0,0 +1,151 @@
using System;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Builds uvx/server command strings for starting the MCP HTTP server.
/// Handles platform-specific command construction.
/// </summary>
public class ServerCommandBuilder : IServerCommandBuilder
{
/// <inheritdoc/>
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = null;
arguments = null;
displayCommand = null;
error = null;
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttpTransport)
{
error = "HTTP transport is disabled. Enable it in the MCP For Unity window first.";
return false;
}
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(httpUrl))
{
error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost.";
return false;
}
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
if (string.IsNullOrEmpty(uvxPath))
{
error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings.";
return false;
}
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
bool projectScopedTools = EditorPrefs.GetBool(
EditorPrefKeys.ProjectScopedToolsLocalHttp,
true
);
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
// Use centralized helper for beta server / prerelease args
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
string args = string.IsNullOrEmpty(fromArgs)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
: $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
fileName = uvxPath;
arguments = args;
displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}";
return true;
}
/// <inheritdoc/>
public string BuildUvPathFromUvx(string uvxPath)
{
if (string.IsNullOrWhiteSpace(uvxPath))
{
return uvxPath;
}
string directory = Path.GetDirectoryName(uvxPath);
string extension = Path.GetExtension(uvxPath);
string uvFileName = "uv" + extension;
return string.IsNullOrEmpty(directory)
? uvFileName
: Path.Combine(directory, uvFileName);
}
/// <inheritdoc/>
public string GetPlatformSpecificPathPrepend()
{
if (Application.platform == RuntimePlatform.OSXEditor)
{
return string.Join(Path.PathSeparator.ToString(), new[]
{
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin"
});
}
if (Application.platform == RuntimePlatform.LinuxEditor)
{
return string.Join(Path.PathSeparator.ToString(), new[]
{
"/usr/local/bin",
"/usr/bin",
"/bin"
});
}
if (Application.platform == RuntimePlatform.WindowsEditor)
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
return string.Join(Path.PathSeparator.ToString(), new[]
{
!string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null,
!string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null
}.Where(p => !string.IsNullOrEmpty(p)).ToArray());
}
return null;
}
/// <inheritdoc/>
public string QuoteIfNeeded(string input)
{
if (string.IsNullOrEmpty(input)) return input;
return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input;
}
/// <summary>
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1)
/// </summary>
private static bool IsLocalUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
try
{
var uri = new Uri(url);
string host = uri.Host.ToLower();
return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1";
}
catch
{
return false;
}
}
}
}

View File

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

View File

@@ -0,0 +1,143 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Launches commands in platform-specific terminal windows.
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
/// </summary>
public class TerminalLauncher : ITerminalLauncher
{
/// <inheritdoc/>
public string GetProjectRootPath()
{
try
{
// Application.dataPath is ".../<Project>/Assets"
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
catch
{
return Application.dataPath;
}
}
/// <inheritdoc/>
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
if (string.IsNullOrWhiteSpace(command))
throw new ArgumentException("Command cannot be empty", nameof(command));
command = command.Replace("\r", "").Replace("\n", "");
#if UNITY_EDITOR_OSX
// macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it.
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
Directory.CreateDirectory(scriptsDir);
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command");
File.WriteAllText(
scriptPath,
"#!/bin/bash\n" +
"set -e\n" +
"clear\n" +
$"{command}\n");
ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000);
return new System.Diagnostics.ProcessStartInfo
{
FileName = "/usr/bin/open",
Arguments = $"-a Terminal \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
#elif UNITY_EDITOR_WIN
// Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window.
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
Directory.CreateDirectory(scriptsDir);
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd");
File.WriteAllText(
scriptPath,
"@echo off\r\n" +
"cls\r\n" +
command + "\r\n");
return new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
#else
// Linux: Try common terminal emulators
// We use bash -c to execute the command, so we must properly quote/escape for bash
// Escape single quotes for the inner bash string
string escapedCommandLinux = command.Replace("'", "'\\''");
// Wrap the command in single quotes for bash -c
string script = $"'{escapedCommandLinux}; exec bash'";
// Escape double quotes for the outer Process argument string
string escapedScriptForArg = script.Replace("\"", "\\\"");
string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\"";
string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" };
string terminalCmd = null;
foreach (var term in terminals)
{
try
{
var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "which",
Arguments = term,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous
if (which.ExitCode == 0)
{
terminalCmd = term;
break;
}
}
catch { }
}
if (terminalCmd == null)
{
terminalCmd = "xterm"; // Fallback
}
// Different terminals have different argument formats
string args;
if (terminalCmd == "gnome-terminal")
{
args = $"-- {bashCmdArgs}";
}
else if (terminalCmd == "konsole")
{
args = $"-e {bashCmdArgs}";
}
else if (terminalCmd == "xfce4-terminal")
{
// xfce4-terminal expects -e "command string" or -e command arg
args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\"";
}
else // xterm and others
{
args = $"-hold -e {bashCmdArgs}";
}
return new System.Diagnostics.ProcessStartInfo
{
FileName = terminalCmd,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true
};
#endif
}
}
}

View File

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

View File

@@ -0,0 +1,876 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Server;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for managing MCP server lifecycle
/// </summary>
public class ServerManagementService : IServerManagementService
{
private readonly IProcessDetector _processDetector;
private readonly IPidFileManager _pidFileManager;
private readonly IProcessTerminator _processTerminator;
private readonly IServerCommandBuilder _commandBuilder;
private readonly ITerminalLauncher _terminalLauncher;
/// <summary>
/// Creates a new ServerManagementService with default dependencies.
/// </summary>
public ServerManagementService() : this(null, null, null, null, null) { }
/// <summary>
/// Creates a new ServerManagementService with injected dependencies (for testing).
/// </summary>
/// <param name="processDetector">Process detector implementation (null for default)</param>
/// <param name="pidFileManager">PID file manager implementation (null for default)</param>
/// <param name="processTerminator">Process terminator implementation (null for default)</param>
/// <param name="commandBuilder">Server command builder implementation (null for default)</param>
/// <param name="terminalLauncher">Terminal launcher implementation (null for default)</param>
public ServerManagementService(
IProcessDetector processDetector,
IPidFileManager pidFileManager = null,
IProcessTerminator processTerminator = null,
IServerCommandBuilder commandBuilder = null,
ITerminalLauncher terminalLauncher = null)
{
_processDetector = processDetector ?? new ProcessDetector();
_pidFileManager = pidFileManager ?? new PidFileManager();
_processTerminator = processTerminator ?? new ProcessTerminator(_processDetector);
_commandBuilder = commandBuilder ?? new ServerCommandBuilder();
_terminalLauncher = terminalLauncher ?? new TerminalLauncher();
}
private string QuoteIfNeeded(string s)
{
return _commandBuilder.QuoteIfNeeded(s);
}
private string NormalizeForMatch(string s)
{
return _processDetector.NormalizeForMatch(s);
}
private void ClearLocalServerPidTracking()
{
_pidFileManager.ClearTracking();
}
private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken)
{
_pidFileManager.StoreHandshake(pidFilePath, instanceToken);
}
private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken)
{
return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken);
}
private string GetLocalHttpServerPidFilePath(int port)
{
return _pidFileManager.GetPidFilePath(port);
}
private bool TryReadPidFromPidFile(string pidFilePath, out int pid)
{
return _pidFileManager.TryReadPid(pidFilePath, out pid);
}
private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken)
{
containsToken = false;
if (pid <= 0 || string.IsNullOrEmpty(instanceToken))
{
return false;
}
try
{
string tokenNeedle = instanceToken.ToLowerInvariant();
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Query full command line so we can validate token (reduces PID reuse risk).
// Use CIM via PowerShell (wmic is deprecated).
string ps = $"(Get-CimInstance Win32_Process -Filter \\\"ProcessId={pid}\\\").CommandLine";
bool ok = ExecPath.TryRun("powershell", $"-NoProfile -Command \"{ps}\"", Application.dataPath, out var stdout, out var stderr, 5000);
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
containsToken = combined.Contains(tokenNeedle);
return ok;
}
if (TryGetUnixProcessArgs(pid, out var argsLowerNow))
{
containsToken = argsLowerNow.Contains(NormalizeForMatch(tokenNeedle));
return true;
}
}
catch { }
return false;
}
private string ComputeShortHash(string input)
{
return _pidFileManager.ComputeShortHash(input);
}
private bool TryGetStoredLocalServerPid(int expectedPort, out int pid)
{
return _pidFileManager.TryGetStoredPid(expectedPort, out pid);
}
private string GetStoredArgsHash()
{
return _pidFileManager.GetStoredArgsHash();
}
/// <summary>
/// Clear the local uvx cache for the MCP server package
/// </summary>
/// <returns>True if successful, false otherwise</returns>
public bool ClearUvxCache()
{
try
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvCommand = BuildUvPathFromUvx(uvxPath);
// Get the package name
string packageName = "mcp-for-unity";
// Run uvx cache clean command
string args = $"cache clean {packageName}";
bool success;
string stdout;
string stderr;
success = ExecuteUvCommand(uvCommand, args, out stdout, out stderr);
if (success)
{
McpLog.Info($"uv cache cleared successfully: {stdout}");
return true;
}
string combinedOutput = string.Join(
Environment.NewLine,
new[] { stderr, stdout }.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()));
string lockHint = (!string.IsNullOrEmpty(combinedOutput) &&
combinedOutput.IndexOf("currently in-use", StringComparison.OrdinalIgnoreCase) >= 0)
? "Another uv process may be holding the cache lock; wait a moment and try again or clear with '--force' from a terminal."
: string.Empty;
if (string.IsNullOrEmpty(combinedOutput))
{
combinedOutput = "Command failed with no output. Ensure uv is installed, on PATH, or set an override in Advanced Settings.";
}
McpLog.Error(
$"Failed to clear uv cache using '{uvCommand} {args}'. " +
$"Details: {combinedOutput}{(string.IsNullOrEmpty(lockHint) ? string.Empty : " Hint: " + lockHint)}");
return false;
}
catch (Exception ex)
{
McpLog.Error($"Error clearing uv cache: {ex.Message}");
return false;
}
}
private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, out string stderr)
{
stdout = null;
stderr = null;
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvPath = BuildUvPathFromUvx(uvxPath);
if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase))
{
return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000);
}
string command = $"{uvPath} {args}";
string extraPathPrepend = GetPlatformSpecificPathPrepend();
if (Application.platform == RuntimePlatform.WindowsEditor)
{
return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
}
string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh";
if (!string.IsNullOrEmpty(shell) && File.Exists(shell))
{
string escaped = command.Replace("\"", "\\\"");
return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
}
return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
}
private string BuildUvPathFromUvx(string uvxPath)
{
return _commandBuilder.BuildUvPathFromUvx(uvxPath);
}
private string GetPlatformSpecificPathPrepend()
{
return _commandBuilder.GetPlatformSpecificPathPrepend();
}
/// <summary>
/// Start the local HTTP server in a separate terminal window.
/// Stops any existing server on the port and clears the uvx cache first.
/// </summary>
public bool StartLocalHttpServer()
{
/// Clean stale Python build artifacts when using a local dev server path
AssetPathUtility.CleanLocalServerBuildArtifacts();
if (!TryGetLocalHttpServerCommandParts(out _, out _, out var displayCommand, out var error))
{
EditorUtility.DisplayDialog(
"Cannot Start HTTP Server",
error ?? "The server command could not be constructed with the current settings.",
"OK");
return false;
}
// First, try to stop any existing server (quietly; we'll only warn if the port remains occupied).
StopLocalHttpServerInternal(quiet: true);
// If the port is still occupied, don't start and explain why (avoid confusing "refusing to stop" warnings).
try
{
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) && uri.Port > 0)
{
var remaining = GetListeningProcessIdsForPort(uri.Port);
if (remaining.Count > 0)
{
EditorUtility.DisplayDialog(
"Port In Use",
$"Cannot start the local HTTP server because port {uri.Port} is already in use by PID(s): " +
$"{string.Join(", ", remaining)}\n\n" +
"MCP For Unity will not terminate unrelated processes. Stop the owning process manually or change the HTTP URL.",
"OK");
return false;
}
}
}
catch { }
// Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command.
// Create a per-launch token + pidfile path so Stop can be deterministic without relying on port/PID heuristics.
string baseUrlForPid = HttpEndpointUtility.GetLocalBaseUrl();
Uri.TryCreate(baseUrlForPid, UriKind.Absolute, out var uriForPid);
int portForPid = uriForPid?.Port ?? 0;
string instanceToken = Guid.NewGuid().ToString("N");
string pidFilePath = portForPid > 0 ? GetLocalHttpServerPidFilePath(portForPid) : null;
string launchCommand = displayCommand;
if (!string.IsNullOrEmpty(pidFilePath))
{
launchCommand = $"{displayCommand} --pidfile {QuoteIfNeeded(pidFilePath)} --unity-instance-token {instanceToken}";
}
if (EditorUtility.DisplayDialog(
"Start Local HTTP Server",
$"This will start the MCP server in HTTP mode in a new terminal window:\n\n{launchCommand}\n\n" +
"Continue?",
"Start Server",
"Cancel"))
{
try
{
// Clear any stale handshake state from prior launches.
ClearLocalServerPidTracking();
// Best-effort: delete stale pidfile if it exists.
try
{
if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath))
{
DeletePidFile(pidFilePath);
}
}
catch { }
// Launch the server in a new terminal window (keeps user-visible logs).
var startInfo = CreateTerminalProcessStartInfo(launchCommand);
System.Diagnostics.Process.Start(startInfo);
if (!string.IsNullOrEmpty(pidFilePath))
{
StoreLocalHttpServerHandshake(pidFilePath, instanceToken);
}
McpLog.Info($"Started local HTTP server in terminal: {launchCommand}");
return true;
}
catch (Exception ex)
{
McpLog.Error($"Failed to start server: {ex.Message}");
EditorUtility.DisplayDialog(
"Error",
$"Failed to start server: {ex.Message}",
"OK");
return false;
}
}
return false;
}
/// <summary>
/// Stop the local HTTP server by finding the process listening on the configured port
/// </summary>
public bool StopLocalHttpServer()
{
return StopLocalHttpServerInternal(quiet: false);
}
public bool StopManagedLocalHttpServer()
{
if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _))
{
return false;
}
int port = 0;
if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0)
{
string baseUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (IsLocalUrl(baseUrl)
&& Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
&& uri.Port > 0)
{
port = uri.Port;
}
}
if (port <= 0)
{
return false;
}
return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true);
}
public bool IsLocalHttpServerRunning()
{
try
{
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(httpUrl))
{
return false;
}
if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0)
{
return false;
}
int port = uri.Port;
// Handshake path: if we have a pidfile+token and the PID is still the listener, treat as running.
if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken)
&& TryReadPidFromPidFile(pidFilePath, out var pidFromFile)
&& pidFromFile > 0)
{
var pidsNow = GetListeningProcessIdsForPort(port);
if (pidsNow.Contains(pidFromFile))
{
return true;
}
}
var pids = GetListeningProcessIdsForPort(port);
if (pids.Count == 0)
{
return false;
}
// Strong signal: stored PID is still the listener.
if (TryGetStoredLocalServerPid(port, out int storedPid) && storedPid > 0)
{
if (pids.Contains(storedPid))
{
return true;
}
}
// Best-effort: if anything listening looks like our server, treat as running.
foreach (var pid in pids)
{
if (pid <= 0) continue;
if (LooksLikeMcpServerProcess(pid))
{
return true;
}
}
return false;
}
catch
{
return false;
}
}
public bool IsLocalHttpServerReachable()
{
try
{
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(httpUrl))
{
return false;
}
if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0)
{
return false;
}
return TryConnectToLocalPort(uri.Host, uri.Port, timeoutMs: 50);
}
catch
{
return false;
}
}
private static bool TryConnectToLocalPort(string host, int port, int timeoutMs)
{
try
{
if (string.IsNullOrEmpty(host))
{
host = "127.0.0.1";
}
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { host };
if (host == "localhost" || host == "0.0.0.0")
{
hosts.Add("127.0.0.1");
}
if (host == "::" || host == "0:0:0:0:0:0:0:0")
{
hosts.Add("::1");
}
foreach (var target in hosts)
{
try
{
using (var client = new TcpClient())
{
var connectTask = client.ConnectAsync(target, port);
if (connectTask.Wait(timeoutMs) && client.Connected)
{
return true;
}
}
}
catch
{
// Ignore per-host failures.
}
}
}
catch
{
// Ignore probe failures and treat as unreachable.
}
return false;
}
private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, bool allowNonLocalUrl = false)
{
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!allowNonLocalUrl && !IsLocalUrl(httpUrl))
{
if (!quiet)
{
McpLog.Warn("Cannot stop server: URL is not local.");
}
return false;
}
try
{
int port = 0;
if (portOverride.HasValue)
{
port = portOverride.Value;
}
else
{
var uri = new Uri(httpUrl);
port = uri.Port;
}
if (port <= 0)
{
if (!quiet)
{
McpLog.Warn("Cannot stop server: Invalid port.");
}
return false;
}
// Guardrails:
// - Never terminate the Unity Editor process.
// - Only terminate processes that look like the MCP server (uv/uvx/python running mcp-for-unity).
// This prevents accidental termination of unrelated services (including Unity itself).
int unityPid = GetCurrentProcessIdSafe();
bool stoppedAny = false;
// Preferred deterministic stop path: if we have a pidfile+token from a Unity-managed launch,
// validate and terminate exactly that PID.
if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken))
{
// Prefer deterministic stop when Unity started the server (pidfile+token).
// If the pidfile isn't available yet (fast quit after start), we can optionally fall back
// to port-based heuristics when a port override was supplied (managed-stop path).
if (!TryReadPidFromPidFile(pidFilePath, out var pidFromFile) || pidFromFile <= 0)
{
if (!portOverride.HasValue)
{
if (!quiet)
{
McpLog.Warn(
$"Cannot stop local HTTP server on port {port}: pidfile not available yet at '{pidFilePath}'. " +
"If you just started the server, wait a moment and try again.");
}
return false;
}
// Managed-stop fallback: proceed with port-based heuristics below.
// We intentionally do NOT clear handshake state here; it will be cleared if we successfully
// stop a server process and/or the port is freed.
}
else
{
// Never kill Unity/Hub.
if (unityPid > 0 && pidFromFile == unityPid)
{
if (!quiet)
{
McpLog.Warn($"Refusing to stop port {port}: pidfile PID {pidFromFile} is the Unity Editor process.");
}
}
else
{
var listeners = GetListeningProcessIdsForPort(port);
if (listeners.Count == 0)
{
// Nothing is listening anymore; clear stale handshake state.
try { DeletePidFile(pidFilePath); } catch { }
ClearLocalServerPidTracking();
if (!quiet)
{
McpLog.Info($"No process found listening on port {port}");
}
return false;
}
bool pidIsListener = listeners.Contains(pidFromFile);
bool tokenQueryOk = TryProcessCommandLineContainsInstanceToken(pidFromFile, instanceToken, out bool tokenMatches);
bool allowKill;
if (tokenQueryOk)
{
allowKill = tokenMatches;
}
else
{
// If token validation is unavailable (e.g. Windows CIM permission issues),
// fall back to a stricter heuristic: only allow stop if the PID still looks like our server.
allowKill = LooksLikeMcpServerProcess(pidFromFile);
}
if (pidIsListener && allowKill)
{
if (TerminateProcess(pidFromFile))
{
stoppedAny = true;
try { DeletePidFile(pidFilePath); } catch { }
ClearLocalServerPidTracking();
if (!quiet)
{
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pidFromFile})");
}
return true;
}
if (!quiet)
{
McpLog.Warn($"Failed to terminate local HTTP server on port {port} (PID: {pidFromFile}).");
}
return false;
}
if (!quiet)
{
McpLog.Warn(
$"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " +
$"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}).");
}
return false;
}
}
}
var pids = GetListeningProcessIdsForPort(port);
if (pids.Count == 0)
{
if (stoppedAny)
{
// We stopped what Unity started; the port is now free.
if (!quiet)
{
McpLog.Info($"Stopped local HTTP server on port {port}");
}
ClearLocalServerPidTracking();
return true;
}
if (!quiet)
{
McpLog.Info($"No process found listening on port {port}");
}
ClearLocalServerPidTracking();
return false;
}
// Prefer killing the PID that we previously observed binding this port (if still valid).
if (TryGetStoredLocalServerPid(port, out int storedPid))
{
if (pids.Contains(storedPid))
{
string expectedHash = string.Empty;
expectedHash = GetStoredArgsHash();
// Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs),
// fall back to a looser check to avoid leaving orphaned servers after domain reload.
if (TryGetUnixProcessArgs(storedPid, out var storedArgsLowerNow))
{
// Never kill Unity/Hub.
// Note: "mcp-for-unity" includes "unity", so detect MCP indicators first.
bool storedMentionsMcp = storedArgsLowerNow.Contains("mcp-for-unity")
|| storedArgsLowerNow.Contains("mcp_for_unity")
|| storedArgsLowerNow.Contains("mcpforunity");
if (storedArgsLowerNow.Contains("unityhub")
|| storedArgsLowerNow.Contains("unity hub")
|| (storedArgsLowerNow.Contains("unity") && !storedMentionsMcp))
{
if (!quiet)
{
McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} appears to be a Unity process.");
}
}
else
{
bool allowKill = false;
if (!string.IsNullOrEmpty(expectedHash))
{
allowKill = string.Equals(expectedHash, ComputeShortHash(storedArgsLowerNow), StringComparison.OrdinalIgnoreCase);
}
else
{
// Older versions didn't store a fingerprint; accept common server indicators.
allowKill = storedArgsLowerNow.Contains("uvicorn")
|| storedArgsLowerNow.Contains("fastmcp")
|| storedArgsLowerNow.Contains("mcpforunity")
|| storedArgsLowerNow.Contains("mcp-for-unity")
|| storedArgsLowerNow.Contains("mcp_for_unity")
|| storedArgsLowerNow.Contains("uvx")
|| storedArgsLowerNow.Contains("python");
}
if (allowKill && TerminateProcess(storedPid))
{
if (!quiet)
{
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})");
}
stoppedAny = true;
ClearLocalServerPidTracking();
// Refresh the PID list to avoid double-work.
pids = GetListeningProcessIdsForPort(port);
}
else if (!allowKill && !quiet)
{
McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} did not match expected server fingerprint.");
}
}
}
}
else
{
// Stale PID (no longer listening). Clear.
ClearLocalServerPidTracking();
}
}
foreach (var pid in pids)
{
if (pid <= 0) continue;
if (unityPid > 0 && pid == unityPid)
{
if (!quiet)
{
McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid}).");
}
continue;
}
if (!LooksLikeMcpServerProcess(pid))
{
if (!quiet)
{
McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity.");
}
continue;
}
if (TerminateProcess(pid))
{
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})");
stoppedAny = true;
}
else
{
if (!quiet)
{
McpLog.Warn($"Failed to stop process PID {pid} on port {port}");
}
}
}
if (stoppedAny)
{
ClearLocalServerPidTracking();
}
return stoppedAny;
}
catch (Exception ex)
{
if (!quiet)
{
McpLog.Error($"Failed to stop server: {ex.Message}");
}
return false;
}
}
private bool TryGetUnixProcessArgs(int pid, out string argsLower)
{
return _processDetector.TryGetProcessCommandLine(pid, out argsLower);
}
private bool TryGetPortFromPidFilePath(string pidFilePath, out int port)
{
return _pidFileManager.TryGetPortFromPidFilePath(pidFilePath, out port);
}
private void DeletePidFile(string pidFilePath)
{
_pidFileManager.DeletePidFile(pidFilePath);
}
private List<int> GetListeningProcessIdsForPort(int port)
{
return _processDetector.GetListeningProcessIdsForPort(port);
}
private int GetCurrentProcessIdSafe()
{
return _processDetector.GetCurrentProcessId();
}
private bool LooksLikeMcpServerProcess(int pid)
{
return _processDetector.LooksLikeMcpServerProcess(pid);
}
private bool TerminateProcess(int pid)
{
return _processTerminator.Terminate(pid);
}
/// <summary>
/// Attempts to build the command used for starting the local HTTP server
/// </summary>
public bool TryGetLocalHttpServerCommand(out string command, out string error)
{
command = null;
error = null;
if (!TryGetLocalHttpServerCommandParts(out var fileName, out var args, out var displayCommand, out error))
{
return false;
}
// Maintain existing behavior: return a single command string suitable for display/copy.
command = displayCommand;
return true;
}
private bool TryGetLocalHttpServerCommandParts(out string fileName, out string arguments, out string displayCommand, out string error)
{
return _commandBuilder.TryBuildCommand(out fileName, out arguments, out displayCommand, out error);
}
/// <summary>
/// Check if the configured HTTP URL is a local address
/// </summary>
public bool IsLocalUrl()
{
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
return IsLocalUrl(httpUrl);
}
/// <summary>
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0)
/// </summary>
private static bool IsLocalUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
try
{
var uri = new Uri(url);
string host = uri.Host.ToLower();
return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1";
}
catch
{
return false;
}
}
/// <summary>
/// Check if the local HTTP server can be started
/// </summary>
public bool CanStartLocalServer()
{
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
return useHttpTransport && IsLocalUrl();
}
private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
return _terminalLauncher.CreateTerminalProcessStartInfo(command);
}
}
}

View File

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

View File

@@ -0,0 +1,116 @@
using System;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Ensures the legacy stdio bridge resumes after domain reloads, mirroring the HTTP handler.
/// </summary>
[InitializeOnLoad]
internal static class StdioBridgeReloadHandler
{
static StdioBridgeReloadHandler()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
}
private static void OnBeforeAssemblyReload()
{
try
{
// Only persist resume intent when stdio is the active transport and the bridge is running.
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
// Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost
// bypassing TransportManager state.
bool tmRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio);
bool hostRunning = StdioBridgeHost.IsRunning;
bool isRunning = tmRunning || hostRunning;
bool shouldResume = !useHttp && isRunning;
if (shouldResume)
{
EditorPrefs.SetBool(EditorPrefKeys.ResumeStdioAfterReload, true);
// Stop only the stdio bridge; leave HTTP untouched if it is running concurrently.
var stopTask = MCPServiceLocator.TransportManager.StopAsync(TransportMode.Stdio);
// Wait for stop to complete (which deletes the status file)
try { stopTask.Wait(500); } catch { }
// Write reloading status so clients don't think we vanished
StdioBridgeHost.WriteHeartbeat(true, "reloading");
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to persist stdio reload flag: {ex.Message}");
}
}
private static void OnAfterAssemblyReload()
{
bool resume = false;
try
{
bool resumeFlag = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false);
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
resume = resumeFlag && !useHttp;
// If we're not going to resume, clear the flag immediately to avoid stuck "Resuming..." state
if (!resume)
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read stdio reload flag: {ex.Message}");
}
if (!resume)
{
return;
}
// Restart via TransportManager so state stays in sync; if it fails (port busy), rely on UI to retry.
TryStartBridgeImmediate();
}
private static void TryStartBridgeImmediate()
{
var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio);
startTask.ContinueWith(t =>
{
// Clear the flag after attempting to start (success or failure).
// This prevents getting stuck in "Resuming..." state.
// We do this synchronously on the continuation thread - it's safe because
// EditorPrefs operations are thread-safe and any new reload will set the flag
// fresh in OnBeforeAssemblyReload before we get here.
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
if (t.IsFaulted)
{
var baseEx = t.Exception?.GetBaseException();
McpLog.Warn($"Failed to resume stdio bridge after reload: {baseEx?.Message}");
return;
}
if (!t.Result)
{
McpLog.Warn("Failed to resume stdio bridge after domain reload");
return;
}
MCPForUnity.Editor.Windows.MCPForUnityEditorWindow.RequestHealthVerification();
}, System.Threading.Tasks.TaskScheduler.Default);
}
}
}

View File

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

View File

@@ -0,0 +1,673 @@
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 long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail
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);
}
}
}
/// <summary>
/// Force-clears any stuck or orphaned test job. Call this when tests get stuck due to
/// assembly reloads or other interruptions.
/// </summary>
/// <returns>True if a job was cleared, false if no running job exists.</returns>
public static bool ClearStuckJob()
{
bool cleared = false;
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId))
{
return false;
}
if (Jobs.TryGetValue(_currentJobId, out var job) && job.Status == TestJobStatus.Running)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
job.Status = TestJobStatus.Failed;
job.Error = "Job cleared manually (stuck or orphaned)";
job.FinishedUnixMs = now;
job.LastUpdateUnixMs = now;
McpLog.Warn($"[TestJobManager] Manually cleared stuck job {_currentJobId}");
cleared = true;
}
_currentJobId = null;
}
PersistToSessionState(force: true);
return cleared;
}
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;
}
// Detect and clean up stale "running" jobs that were orphaned by domain reload.
// After a domain reload, TestRunStatus resets to not-running, but _currentJobId
// may still be set. If the job hasn't been updated recently, it's likely orphaned.
if (!string.IsNullOrEmpty(_currentJobId) && Jobs.TryGetValue(_currentJobId, out var currentJob))
{
if (currentJob.Status == TestJobStatus.Running)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long staleCutoffMs = 5 * 60 * 1000; // 5 minutes
if (now - currentJob.LastUpdateUnixMs > staleCutoffMs)
{
McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)");
currentJob.Status = TestJobStatus.Failed;
currentJob.Error = "Job orphaned after domain reload";
currentJob.FinishedUnixMs = now;
_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);
}
internal static TestJob GetJob(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
return null;
}
TestJob jobToReturn = null;
bool shouldPersist = false;
lock (LockObj)
{
if (!Jobs.TryGetValue(jobId, out var job))
{
return null;
}
// Check if job is stuck in "running" state without having called OnRunStarted (TotalTests still null).
// This happens when tests fail to initialize (e.g., unsaved scene, compilation issues).
// After 15 seconds without initialization, auto-fail the job to prevent hanging.
if (job.Status == TestJobStatus.Running && job.TotalTests == null)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs)
{
McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing");
job.Status = TestJobStatus.Failed;
job.Error = "Test job failed to initialize (tests did not start within timeout)";
job.FinishedUnixMs = now;
job.LastUpdateUnixMs = now;
if (_currentJobId == jobId)
{
_currentJobId = null;
}
shouldPersist = true;
}
}
jobToReturn = job;
}
if (shouldPersist)
{
PersistToSessionState(force: true);
}
return jobToReturn;
}
internal 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,150 @@
// 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
/// <summary>
/// Apply no-throttling preemptively before tests start.
/// Call this before Execute() for PlayMode tests to ensure Unity isn't throttled
/// during the Play mode transition (before RunStarted fires).
/// </summary>
public static void ApplyNoThrottlingPreemptive()
{
SetTestRunActive(true);
ApplyNoThrottling();
}
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

@@ -0,0 +1,619 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Concrete implementation of <see cref="ITestRunnerService"/>.
/// Coordinates Unity Test Runner operations and produces structured results.
/// </summary>
internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable
{
private static readonly TestMode[] AllModes = { TestMode.EditMode, TestMode.PlayMode };
private readonly TestRunnerApi _testRunnerApi;
private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1);
private readonly List<ITestResultAdaptor> _leafResults = new List<ITestResultAdaptor>();
private TaskCompletionSource<TestRunResult> _runCompletionSource;
public TestRunnerService()
{
_testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();
_testRunnerApi.RegisterCallbacks(this);
}
public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode)
{
await _operationLock.WaitAsync().ConfigureAwait(true);
try
{
var modes = mode.HasValue ? new[] { mode.Value } : AllModes;
var results = new List<Dictionary<string, string>>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in modes)
{
var root = await RetrieveTestRootAsync(m).ConfigureAwait(true);
if (root != null)
{
CollectFromNode(root, m, results, seen, new List<string>());
}
}
return results;
}
finally
{
_operationLock.Release();
}
}
public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null)
{
await _operationLock.WaitAsync().ConfigureAwait(true);
Task<TestRunResult> runTask;
bool adjustedPlayModeOptions = false;
bool originalEnterPlayModeOptionsEnabled = false;
EnterPlayModeOptions originalEnterPlayModeOptions = EnterPlayModeOptions.None;
try
{
if (_runCompletionSource != null && !_runCompletionSource.Task.IsCompleted)
{
throw new InvalidOperationException("A Unity test run is already in progress.");
}
if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode)
{
throw new InvalidOperationException("Cannot start a test run while the Editor is in or entering Play Mode. Stop Play Mode and try again.");
}
if (mode == TestMode.PlayMode)
{
// PlayMode runs transition the editor into play across multiple update ticks. Unity's
// built-in pipeline schedules SaveModifiedSceneTask early, but that task uses
// EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo which throws once play mode is
// active. To minimize that window we pre-save dirty scenes and disable domain reload (so the
// MCP bridge stays alive). We do NOT force runSynchronously here because that can freeze the
// editor in some projects. If the TestRunner still hits the save task after entering play, the
// run can fail; in that case, rerun from a clean Edit Mode state.
adjustedPlayModeOptions = EnsurePlayModeRunsWithoutDomainReload(
out originalEnterPlayModeOptionsEnabled,
out originalEnterPlayModeOptions);
}
_leafResults.Clear();
_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
{
testMode = mode,
testNames = filterOptions?.TestNames,
groupNames = filterOptions?.GroupNames,
categoryNames = filterOptions?.CategoryNames,
assemblyNames = filterOptions?.AssemblyNames
};
var settings = new ExecutionSettings(filter);
// Save dirty scenes for all test modes to prevent modal dialogs blocking MCP
// (Issue #525: EditMode tests were blocked by save dialog)
SaveDirtyScenesIfNeeded();
// Apply no-throttling preemptively for PlayMode tests. This ensures Unity
// isn't throttled during the Play mode transition (which requires multiple
// editor frames). Without this, unfocused Unity may never reach RunStarted
// where throttling would normally be disabled.
if (mode == TestMode.PlayMode)
{
TestRunnerNoThrottle.ApplyNoThrottlingPreemptive();
}
_testRunnerApi.Execute(settings);
runTask = _runCompletionSource.Task;
}
catch
{
// Ensure the status is cleared if we failed to start the run.
TestRunStatus.MarkFinished();
if (adjustedPlayModeOptions)
{
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
}
_operationLock.Release();
throw;
}
try
{
return await runTask.ConfigureAwait(true);
}
finally
{
if (adjustedPlayModeOptions)
{
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
}
_operationLock.Release();
}
}
public void Dispose()
{
try
{
_testRunnerApi?.UnregisterCallbacks(this);
}
catch
{
// Ignore cleanup errors
}
if (_testRunnerApi != null)
{
ScriptableObject.DestroyImmediate(_testRunnerApi);
}
_operationLock.Dispose();
}
#region TestRunnerApi callbacks
public void RunStarted(ITestAdaptor testsToRun)
{
_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)
{
// Always create payload and clean up job state, even if _runCompletionSource is null.
// This handles domain reload scenarios (e.g., PlayMode tests) where the TestRunnerService
// is recreated and _runCompletionSource is lost, but TestJobManager state persists via
// SessionState and the Test Runner still delivers the RunFinished callback.
var payload = TestRunResult.Create(result, _leafResults);
// Clean up state regardless of _runCompletionSource - these methods safely handle
// the case where no MCP job exists (e.g., manual test runs via Unity UI).
TestRunStatus.MarkFinished();
TestJobManager.OnRunFinished();
TestJobManager.FinalizeCurrentJobFromRunFinished(payload);
// Report result to awaiting caller if we have a completion source
if (_runCompletionSource != null)
{
_runCompletionSource.TrySetResult(payload);
_runCompletionSource = null;
}
}
public void TestStarted(ITestAdaptor test)
{
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)
{
if (result == null)
{
return;
}
if (!result.HasChildren)
{
_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
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(
out bool originalEnterPlayModeOptionsEnabled,
out EnterPlayModeOptions originalEnterPlayModeOptions)
{
originalEnterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled;
originalEnterPlayModeOptions = EditorSettings.enterPlayModeOptions;
// When Play Mode triggers a domain reload, the MCP connection is torn down and the pending
// test run response never makes it back to the caller. To keep the bridge alive for this
// invocation, temporarily enable Enter Play Mode Options with domain reload disabled.
bool domainReloadDisabled = (originalEnterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0;
bool needsChange = !originalEnterPlayModeOptionsEnabled || !domainReloadDisabled;
if (!needsChange)
{
return false;
}
var desired = originalEnterPlayModeOptions | EnterPlayModeOptions.DisableDomainReload;
EditorSettings.enterPlayModeOptionsEnabled = true;
EditorSettings.enterPlayModeOptions = desired;
return true;
}
private static void RestoreEnterPlayModeOptions(bool originalEnabled, EnterPlayModeOptions originalOptions)
{
EditorSettings.enterPlayModeOptions = originalOptions;
EditorSettings.enterPlayModeOptionsEnabled = originalEnabled;
}
private static void SaveDirtyScenesIfNeeded()
{
int sceneCount = SceneManager.sceneCount;
for (int i = 0; i < sceneCount; i++)
{
var scene = SceneManager.GetSceneAt(i);
if (scene.isDirty)
{
if (string.IsNullOrEmpty(scene.path))
{
McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running tests.");
continue;
}
try
{
EditorSceneManager.SaveScene(scene);
}
catch (Exception ex)
{
McpLog.Warn($"[TestRunnerService] Failed to save dirty scene '{scene.name}': {ex.Message}");
}
}
}
}
#region Test list helpers
private async Task<ITestAdaptor> RetrieveTestRootAsync(TestMode mode)
{
var tcs = new TaskCompletionSource<ITestAdaptor>(TaskCreationOptions.RunContinuationsAsynchronously);
_testRunnerApi.RetrieveTestList(mode, root =>
{
tcs.TrySetResult(root);
});
// Ensure the editor pumps at least one additional update in case the window is unfocused.
EditorApplication.QueuePlayerLoopUpdate();
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(true);
if (completed != tcs.Task)
{
McpLog.Warn($"[TestRunnerService] Timeout waiting for test retrieval callback for {mode}");
return null;
}
try
{
return await tcs.Task.ConfigureAwait(true);
}
catch (Exception ex)
{
McpLog.Error($"[TestRunnerService] Error retrieving tests for {mode}: {ex.Message}\n{ex.StackTrace}");
return null;
}
}
private static void CollectFromNode(
ITestAdaptor node,
TestMode mode,
List<Dictionary<string, string>> output,
HashSet<string> seen,
List<string> path)
{
if (node == null)
{
return;
}
bool hasName = !string.IsNullOrEmpty(node.Name);
if (hasName)
{
path.Add(node.Name);
}
bool hasChildren = node.HasChildren && node.Children != null;
if (!hasChildren)
{
string fullName = string.IsNullOrEmpty(node.FullName) ? node.Name ?? string.Empty : node.FullName;
string key = $"{mode}:{fullName}";
if (!string.IsNullOrEmpty(fullName) && seen.Add(key))
{
string computedPath = path.Count > 0 ? string.Join("/", path) : fullName;
output.Add(new Dictionary<string, string>
{
["name"] = node.Name ?? fullName,
["full_name"] = fullName,
["path"] = computedPath,
["mode"] = mode.ToString(),
});
}
}
else if (node.Children != null)
{
foreach (var child in node.Children)
{
CollectFromNode(child, mode, output, seen, path);
}
}
if (hasName && path.Count > 0)
{
path.RemoveAt(path.Count - 1);
}
}
#endregion
}
/// <summary>
/// Summary of a Unity test run.
/// </summary>
public sealed class TestRunResult
{
internal TestRunResult(TestRunSummary summary, IReadOnlyList<TestRunTestResult> results)
{
Summary = summary;
Results = results;
}
public TestRunSummary Summary { get; }
public IReadOnlyList<TestRunTestResult> Results { get; }
public int Total => Summary.Total;
public int Passed => Summary.Passed;
public int Failed => Summary.Failed;
public int Skipped => Summary.Skipped;
public object ToSerializable(string mode, bool includeDetails = false, bool includeFailedTests = false)
{
// Determine which results to include
IEnumerable<object> resultsToSerialize;
if (includeDetails)
{
// Include all test results
resultsToSerialize = Results.Select(r => r.ToSerializable());
}
else if (includeFailedTests)
{
// Include only failed and skipped tests
resultsToSerialize = Results
.Where(r => !string.Equals(r.State, "Passed", StringComparison.OrdinalIgnoreCase))
.Select(r => r.ToSerializable());
}
else
{
// No individual test results
resultsToSerialize = null;
}
return new
{
mode,
summary = Summary.ToSerializable(),
results = resultsToSerialize?.ToList(),
};
}
internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList<ITestResultAdaptor> tests)
{
var materializedTests = tests.Select(TestRunTestResult.FromAdaptor).ToList();
int passed = summary?.PassCount
?? materializedTests.Count(t => string.Equals(t.State, "Passed", StringComparison.OrdinalIgnoreCase));
int failed = summary?.FailCount
?? materializedTests.Count(t => string.Equals(t.State, "Failed", StringComparison.OrdinalIgnoreCase));
int skipped = summary?.SkipCount
?? materializedTests.Count(t => string.Equals(t.State, "Skipped", StringComparison.OrdinalIgnoreCase));
double duration = summary?.Duration
?? materializedTests.Sum(t => t.DurationSeconds);
int total = summary != null ? passed + failed + skipped : materializedTests.Count;
var summaryPayload = new TestRunSummary(
total,
passed,
failed,
skipped,
duration,
summary?.ResultState ?? "Unknown");
return new TestRunResult(summaryPayload, materializedTests);
}
}
public sealed class TestRunSummary
{
internal TestRunSummary(int total, int passed, int failed, int skipped, double durationSeconds, string resultState)
{
Total = total;
Passed = passed;
Failed = failed;
Skipped = skipped;
DurationSeconds = durationSeconds;
ResultState = resultState;
}
public int Total { get; }
public int Passed { get; }
public int Failed { get; }
public int Skipped { get; }
public double DurationSeconds { get; }
public string ResultState { get; }
internal object ToSerializable()
{
return new
{
total = Total,
passed = Passed,
failed = Failed,
skipped = Skipped,
durationSeconds = DurationSeconds,
resultState = ResultState,
};
}
}
public sealed class TestRunTestResult
{
internal TestRunTestResult(
string name,
string fullName,
string state,
double durationSeconds,
string message,
string stackTrace,
string output)
{
Name = name;
FullName = fullName;
State = state;
DurationSeconds = durationSeconds;
Message = message;
StackTrace = stackTrace;
Output = output;
}
public string Name { get; }
public string FullName { get; }
public string State { get; }
public double DurationSeconds { get; }
public string Message { get; }
public string StackTrace { get; }
public string Output { get; }
internal object ToSerializable()
{
return new
{
name = Name,
fullName = FullName,
state = State,
durationSeconds = DurationSeconds,
message = Message,
stackTrace = StackTrace,
output = Output,
};
}
internal static TestRunTestResult FromAdaptor(ITestResultAdaptor adaptor)
{
if (adaptor == null)
{
return new TestRunTestResult(string.Empty, string.Empty, "Unknown", 0.0, string.Empty, string.Empty, string.Empty);
}
return new TestRunTestResult(
adaptor.Name,
adaptor.FullName,
adaptor.ResultState,
adaptor.Duration,
adaptor.Message,
adaptor.StackTrace,
adaptor.Output);
}
}
}

View File

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

View File

@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Tools;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
public class ToolDiscoveryService : IToolDiscoveryService
{
private Dictionary<string, ToolMetadata> _cachedTools;
public List<ToolMetadata> DiscoverAllTools()
{
if (_cachedTools != null)
{
return _cachedTools.Values.ToList();
}
_cachedTools = new Dictionary<string, ToolMetadata>();
var toolTypes = TypeCache.GetTypesWithAttribute<McpForUnityToolAttribute>();
foreach (var type in toolTypes)
{
McpForUnityToolAttribute toolAttr;
try
{
toolAttr = type.GetCustomAttribute<McpForUnityToolAttribute>();
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read [McpForUnityTool] for {type.FullName}: {ex.Message}");
continue;
}
if (toolAttr == null)
{
continue;
}
var metadata = ExtractToolMetadata(type, toolAttr);
if (metadata != null)
{
if (_cachedTools.ContainsKey(metadata.Name))
{
McpLog.Warn($"Duplicate tool name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
}
_cachedTools[metadata.Name] = metadata;
EnsurePreferenceInitialized(metadata);
}
}
McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection", false);
return _cachedTools.Values.ToList();
}
public ToolMetadata GetToolMetadata(string toolName)
{
if (_cachedTools == null)
{
DiscoverAllTools();
}
return _cachedTools.TryGetValue(toolName, out var metadata) ? metadata : null;
}
public List<ToolMetadata> GetEnabledTools()
{
return DiscoverAllTools()
.Where(tool => IsToolEnabled(tool.Name))
.ToList();
}
public bool IsToolEnabled(string toolName)
{
if (string.IsNullOrEmpty(toolName))
{
return false;
}
string key = GetToolPreferenceKey(toolName);
if (EditorPrefs.HasKey(key))
{
return EditorPrefs.GetBool(key, true);
}
var metadata = GetToolMetadata(toolName);
return metadata?.AutoRegister ?? false;
}
public void SetToolEnabled(string toolName, bool enabled)
{
if (string.IsNullOrEmpty(toolName))
{
return;
}
string key = GetToolPreferenceKey(toolName);
EditorPrefs.SetBool(key, enabled);
}
private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute toolAttr)
{
try
{
// Get tool name
string toolName = toolAttr.Name;
if (string.IsNullOrEmpty(toolName))
{
// Derive from class name: CaptureScreenshotTool -> capture_screenshot
toolName = ConvertToSnakeCase(type.Name.Replace("Tool", ""));
}
// Get description
string description = toolAttr.Description ?? $"Tool: {toolName}";
// Extract parameters
var parameters = ExtractParameters(type);
var metadata = new ToolMetadata
{
Name = toolName,
Description = description,
StructuredOutput = toolAttr.StructuredOutput,
Parameters = parameters,
ClassName = type.Name,
Namespace = type.Namespace ?? "",
AssemblyName = type.Assembly.GetName().Name,
AutoRegister = toolAttr.AutoRegister,
RequiresPolling = toolAttr.RequiresPolling,
PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction
};
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
type, metadata.AssemblyName, "MCPForUnity.Editor.Tools");
return metadata;
}
catch (Exception ex)
{
McpLog.Error($"Failed to extract metadata for {type.Name}: {ex.Message}");
return null;
}
}
private List<ParameterMetadata> ExtractParameters(Type type)
{
var parameters = new List<ParameterMetadata>();
// Look for nested Parameters class
var parametersType = type.GetNestedType("Parameters");
if (parametersType == null)
{
return parameters;
}
// Get all properties with [ToolParameter]
var properties = parametersType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
var paramAttr = prop.GetCustomAttribute<ToolParameterAttribute>();
if (paramAttr == null)
continue;
string paramName = prop.Name;
string paramType = GetParameterType(prop.PropertyType);
parameters.Add(new ParameterMetadata
{
Name = paramName,
Description = paramAttr.Description,
Type = paramType,
Required = paramAttr.Required,
DefaultValue = paramAttr.DefaultValue
});
}
return parameters;
}
private string GetParameterType(Type type)
{
// Handle nullable types
if (Nullable.GetUnderlyingType(type) != null)
{
type = Nullable.GetUnderlyingType(type);
}
// Map C# types to JSON schema types
if (type == typeof(string))
return "string";
if (type == typeof(int) || type == typeof(long))
return "integer";
if (type == typeof(float) || type == typeof(double))
return "number";
if (type == typeof(bool))
return "boolean";
if (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type))
return "array";
return "object";
}
private string ConvertToSnakeCase(string input) => StringCaseUtility.ToSnakeCase(input);
public void InvalidateCache()
{
_cachedTools = null;
}
private void EnsurePreferenceInitialized(ToolMetadata metadata)
{
if (metadata == null || string.IsNullOrEmpty(metadata.Name))
{
return;
}
string key = GetToolPreferenceKey(metadata.Name);
if (!EditorPrefs.HasKey(key))
{
bool defaultValue = metadata.AutoRegister || metadata.IsBuiltIn;
EditorPrefs.SetBool(key, defaultValue);
return;
}
if (metadata.IsBuiltIn && !metadata.AutoRegister)
{
bool currentValue = EditorPrefs.GetBool(key, metadata.AutoRegister);
if (currentValue == metadata.AutoRegister)
{
EditorPrefs.SetBool(key, true);
}
}
}
private static string GetToolPreferenceKey(string toolName)
{
return EditorPrefKeys.ToolEnabledPrefix + toolName;
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d189635a5d364f55a810203798c09ba
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
using System.Threading.Tasks;
namespace MCPForUnity.Editor.Services.Transport
{
/// <summary>
/// Abstraction for MCP transport implementations (e.g. WebSocket push, stdio).
/// </summary>
public interface IMcpTransportClient
{
bool IsConnected { get; }
string TransportName { get; }
TransportState State { get; }
Task<bool> StartAsync();
Task StopAsync();
Task<bool> VerifyAsync();
}
}

View File

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

View File

@@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Services.Transport
{
/// <summary>
/// Centralised command execution pipeline shared by all transport implementations.
/// Guarantees that MCP commands are executed on the Unity main thread while preserving
/// the legacy response format expected by the server.
/// </summary>
[InitializeOnLoad]
internal static class TransportCommandDispatcher
{
private static SynchronizationContext _mainThreadContext;
private static int _mainThreadId;
private static int _processingFlag;
private sealed class PendingCommand
{
public PendingCommand(
string commandJson,
TaskCompletionSource<string> completionSource,
CancellationToken cancellationToken,
CancellationTokenRegistration registration)
{
CommandJson = commandJson;
CompletionSource = completionSource;
CancellationToken = cancellationToken;
CancellationRegistration = registration;
QueuedAt = DateTime.UtcNow;
}
public string CommandJson { get; }
public TaskCompletionSource<string> CompletionSource { get; }
public CancellationToken CancellationToken { get; }
public CancellationTokenRegistration CancellationRegistration { get; }
public bool IsExecuting { get; set; }
public DateTime QueuedAt { get; }
public void Dispose()
{
CancellationRegistration.Dispose();
}
public void TrySetResult(string payload)
{
CompletionSource.TrySetResult(payload);
}
public void TrySetCanceled()
{
CompletionSource.TrySetCanceled(CancellationToken);
}
}
private static readonly Dictionary<string, PendingCommand> Pending = new();
private static readonly object PendingLock = new();
private static bool updateHooked;
private static bool initialised;
static TransportCommandDispatcher()
{
// Ensure this runs on the Unity main thread at editor load.
_mainThreadContext = SynchronizationContext.Current;
_mainThreadId = Thread.CurrentThread.ManagedThreadId;
EnsureInitialised();
// Always keep the update hook installed so commands arriving from background
// websocket tasks don't depend on a background-thread event subscription.
if (!updateHooked)
{
updateHooked = true;
EditorApplication.update += ProcessQueue;
}
}
/// <summary>
/// Schedule a command for execution on the Unity main thread and await its JSON response.
/// </summary>
public static Task<string> ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken)
{
if (commandJson is null)
{
throw new ArgumentNullException(nameof(commandJson));
}
EnsureInitialised();
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.CanBeCanceled
? cancellationToken.Register(() => CancelPending(id, cancellationToken))
: default;
var pending = new PendingCommand(commandJson, tcs, cancellationToken, registration);
lock (PendingLock)
{
Pending[id] = pending;
}
// Proactively wake up the main thread execution loop. This improves responsiveness
// in scenarios where EditorApplication.update is throttled or temporarily not firing
// (e.g., Unity unfocused, compiling, or during domain reload transitions).
RequestMainThreadPump();
return tcs.Task;
}
internal static Task<T> RunOnMainThreadAsync<T>(Func<T> func, CancellationToken cancellationToken)
{
if (func is null)
{
throw new ArgumentNullException(nameof(func));
}
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.CanBeCanceled
? cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))
: default;
void Invoke()
{
try
{
if (tcs.Task.IsCompleted)
{
return;
}
var result = func();
tcs.TrySetResult(result);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
finally
{
registration.Dispose();
}
}
// Best-effort nudge: if we're posting from a background thread (e.g., websocket receive),
// encourage Unity to run a loop iteration so the posted callback can execute even when unfocused.
try { EditorApplication.QueuePlayerLoopUpdate(); } catch { }
if (_mainThreadContext != null && Thread.CurrentThread.ManagedThreadId != _mainThreadId)
{
_mainThreadContext.Post(_ => Invoke(), null);
return tcs.Task;
}
Invoke();
return tcs.Task;
}
private static void RequestMainThreadPump()
{
void Pump()
{
try
{
// Hint Unity to run a loop iteration soon.
EditorApplication.QueuePlayerLoopUpdate();
}
catch
{
// Best-effort only.
}
ProcessQueue();
}
if (_mainThreadContext != null && Thread.CurrentThread.ManagedThreadId != _mainThreadId)
{
_mainThreadContext.Post(_ => Pump(), null);
return;
}
Pump();
}
private static void EnsureInitialised()
{
if (initialised)
{
return;
}
CommandRegistry.Initialize();
initialised = true;
}
private static void HookUpdate()
{
// Deprecated: we keep the update hook installed permanently (see static ctor).
if (updateHooked) return;
updateHooked = true;
EditorApplication.update += ProcessQueue;
}
private static void UnhookUpdateIfIdle()
{
// Intentionally no-op: keep update hook installed so background commands always process.
// This avoids "must focus Unity to re-establish contact" edge cases.
return;
}
private static void ProcessQueue()
{
if (Interlocked.Exchange(ref _processingFlag, 1) == 1)
{
return;
}
try
{
List<(string id, PendingCommand pending)> ready;
lock (PendingLock)
{
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
if (Pending.Count == 0)
{
return;
}
ready = new List<(string, PendingCommand)>(Pending.Count);
foreach (var kvp in Pending)
{
if (kvp.Value.IsExecuting)
{
continue;
}
kvp.Value.IsExecuting = true;
ready.Add((kvp.Key, kvp.Value));
}
if (ready.Count == 0)
{
UnhookUpdateIfIdle();
return;
}
}
foreach (var (id, pending) in ready)
{
ProcessCommand(id, pending);
}
}
finally
{
Interlocked.Exchange(ref _processingFlag, 0);
}
}
private static void ProcessCommand(string id, PendingCommand pending)
{
if (pending.CancellationToken.IsCancellationRequested)
{
RemovePending(id, pending);
pending.TrySetCanceled();
return;
}
string commandText = pending.CommandJson?.Trim();
if (string.IsNullOrEmpty(commandText))
{
pending.TrySetResult(SerializeError("Empty command received"));
RemovePending(id, pending);
return;
}
if (string.Equals(commandText, "ping", StringComparison.OrdinalIgnoreCase))
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" }
};
pending.TrySetResult(JsonConvert.SerializeObject(pingResponse));
RemovePending(id, pending);
return;
}
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText
};
pending.TrySetResult(JsonConvert.SerializeObject(invalidJsonResponse));
RemovePending(id, pending);
return;
}
try
{
var command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
pending.TrySetResult(SerializeError("Command deserialized to null", "Unknown", commandText));
RemovePending(id, pending);
return;
}
if (string.IsNullOrWhiteSpace(command.type))
{
pending.TrySetResult(SerializeError("Command type cannot be empty"));
RemovePending(id, pending);
return;
}
if (string.Equals(command.type, "ping", StringComparison.OrdinalIgnoreCase))
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" }
};
pending.TrySetResult(JsonConvert.SerializeObject(pingResponse));
RemovePending(id, pending);
return;
}
var parameters = command.@params ?? new JObject();
// Block execution of disabled resources
var resourceMeta = MCPServiceLocator.ResourceDiscovery.GetResourceMetadata(command.type);
if (resourceMeta != null && !MCPServiceLocator.ResourceDiscovery.IsResourceEnabled(command.type))
{
pending.TrySetResult(SerializeError(
$"Resource '{command.type}' is disabled in the Unity Editor."));
RemovePending(id, pending);
return;
}
// Block execution of disabled tools
var toolMeta = MCPServiceLocator.ToolDiscovery.GetToolMetadata(command.type);
if (toolMeta != null && !MCPServiceLocator.ToolDiscovery.IsToolEnabled(command.type))
{
pending.TrySetResult(SerializeError(
$"Tool '{command.type}' is disabled in the Unity Editor."));
RemovePending(id, pending);
return;
}
var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource);
if (result == null)
{
// Async command cleanup after completion on next editor frame to preserve order.
pending.CompletionSource.Task.ContinueWith(_ =>
{
EditorApplication.delayCall += () => RemovePending(id, pending);
}, TaskScheduler.Default);
return;
}
var response = new { status = "success", result };
pending.TrySetResult(JsonConvert.SerializeObject(response));
RemovePending(id, pending);
}
catch (Exception ex)
{
McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
pending.TrySetResult(SerializeError(ex.Message, "Unknown (error during processing)", ex.StackTrace));
RemovePending(id, pending);
}
}
private static void CancelPending(string id, CancellationToken token)
{
PendingCommand pending = null;
lock (PendingLock)
{
if (Pending.Remove(id, out pending))
{
UnhookUpdateIfIdle();
}
}
pending?.TrySetCanceled();
pending?.Dispose();
}
private static void RemovePending(string id, PendingCommand pending)
{
lock (PendingLock)
{
Pending.Remove(id);
UnhookUpdateIfIdle();
}
pending.Dispose();
}
private static string SerializeError(string message, string commandType = null, string stackTrace = null)
{
var errorResponse = new
{
status = "error",
error = message,
command = commandType ?? "Unknown",
stackTrace
};
return JsonConvert.SerializeObject(errorResponse);
}
private static bool IsValidJson(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
text = text.Trim();
if ((text.StartsWith("{") && text.EndsWith("}")) || (text.StartsWith("[") && text.EndsWith("]")))
{
try
{
JToken.Parse(text);
return true;
}
catch
{
return false;
}
}
return false;
}
}
}

View File

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

View File

@@ -0,0 +1,152 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services.Transport
{
/// <summary>
/// Coordinates the active transport client and exposes lifecycle helpers.
/// </summary>
public class TransportManager
{
private IMcpTransportClient _httpClient;
private IMcpTransportClient _stdioClient;
private TransportState _httpState = TransportState.Disconnected("http");
private TransportState _stdioState = TransportState.Disconnected("stdio");
private Func<IMcpTransportClient> _webSocketFactory;
private Func<IMcpTransportClient> _stdioFactory;
public TransportManager()
{
Configure(
() => new WebSocketTransportClient(MCPServiceLocator.ToolDiscovery),
() => new StdioTransportClient());
}
public void Configure(
Func<IMcpTransportClient> webSocketFactory,
Func<IMcpTransportClient> stdioFactory)
{
_webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory));
_stdioFactory = stdioFactory ?? throw new ArgumentNullException(nameof(stdioFactory));
}
private IMcpTransportClient GetOrCreateClient(TransportMode mode)
{
return mode switch
{
TransportMode.Http => _httpClient ??= _webSocketFactory(),
TransportMode.Stdio => _stdioClient ??= _stdioFactory(),
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"),
};
}
private IMcpTransportClient GetClient(TransportMode mode)
{
return mode switch
{
TransportMode.Http => _httpClient,
TransportMode.Stdio => _stdioClient,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"),
};
}
public async Task<bool> StartAsync(TransportMode mode)
{
IMcpTransportClient client = GetOrCreateClient(mode);
bool started = await client.StartAsync();
if (!started)
{
try
{
await client.StopAsync();
}
catch (Exception ex)
{
McpLog.Warn($"Error while stopping transport {client.TransportName}: {ex.Message}");
}
UpdateState(mode, TransportState.Disconnected(client.TransportName, client.State?.Error ?? "Failed to start"));
return false;
}
UpdateState(mode, client.State ?? TransportState.Connected(client.TransportName));
return true;
}
public async Task StopAsync(TransportMode? mode = null)
{
async Task StopClient(IMcpTransportClient client, TransportMode clientMode)
{
if (client == null) return;
try { await client.StopAsync(); }
catch (Exception ex) { McpLog.Warn($"Error while stopping transport {client.TransportName}: {ex.Message}"); }
finally { UpdateState(clientMode, TransportState.Disconnected(client.TransportName)); }
}
if (mode == null)
{
await StopClient(_httpClient, TransportMode.Http);
await StopClient(_stdioClient, TransportMode.Stdio);
return;
}
if (mode == TransportMode.Http)
{
await StopClient(_httpClient, TransportMode.Http);
}
else
{
await StopClient(_stdioClient, TransportMode.Stdio);
}
}
public async Task<bool> VerifyAsync(TransportMode mode)
{
IMcpTransportClient client = GetClient(mode);
if (client == null)
{
return false;
}
bool ok = await client.VerifyAsync();
var state = client.State ?? TransportState.Disconnected(client.TransportName, "No state reported");
UpdateState(mode, state);
return ok;
}
public TransportState GetState(TransportMode mode)
{
return mode switch
{
TransportMode.Http => _httpState,
TransportMode.Stdio => _stdioState,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"),
};
}
public bool IsRunning(TransportMode mode) => GetState(mode).IsConnected;
private void UpdateState(TransportMode mode, TransportState state)
{
switch (mode)
{
case TransportMode.Http:
_httpState = state;
break;
case TransportMode.Stdio:
_stdioState = state;
break;
default:
throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode");
}
}
}
public enum TransportMode
{
Http,
Stdio
}
}

View File

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

View File

@@ -0,0 +1,52 @@
namespace MCPForUnity.Editor.Services.Transport
{
/// <summary>
/// Lightweight snapshot of a transport's runtime status for editor UI and diagnostics.
/// </summary>
public sealed class TransportState
{
public bool IsConnected { get; }
public string TransportName { get; }
public int? Port { get; }
public string SessionId { get; }
public string Details { get; }
public string Error { get; }
private TransportState(
bool isConnected,
string transportName,
int? port,
string sessionId,
string details,
string error)
{
IsConnected = isConnected;
TransportName = transportName;
Port = port;
SessionId = sessionId;
Details = details;
Error = error;
}
public static TransportState Connected(
string transportName,
int? port = null,
string sessionId = null,
string details = null)
=> new TransportState(true, transportName, port, sessionId, details, null);
public static TransportState Disconnected(
string transportName,
string error = null,
int? port = null)
=> new TransportState(false, transportName, port, null, null, error);
public TransportState WithError(string error) => new TransportState(
IsConnected,
TransportName,
Port,
SessionId,
Details,
error);
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3d467a63b6fad42fa975c731af4b83b3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,50 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Services.Transport.Transports
{
/// <summary>
/// Adapts the existing TCP bridge into the transport abstraction.
/// </summary>
public class StdioTransportClient : IMcpTransportClient
{
private TransportState _state = TransportState.Disconnected("stdio");
public bool IsConnected => StdioBridgeHost.IsRunning;
public string TransportName => "stdio";
public TransportState State => _state;
public Task<bool> StartAsync()
{
try
{
StdioBridgeHost.StartAutoConnect();
_state = TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort());
return Task.FromResult(true);
}
catch (Exception ex)
{
_state = TransportState.Disconnected("stdio", ex.Message);
return Task.FromResult(false);
}
}
public Task StopAsync()
{
StdioBridgeHost.Stop();
_state = TransportState.Disconnected("stdio");
return Task.CompletedTask;
}
public Task<bool> VerifyAsync()
{
bool running = StdioBridgeHost.IsRunning;
_state = running
? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort())
: TransportState.Disconnected("stdio", "Bridge not running");
return Task.FromResult(running);
}
}
}

View File

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

View File

@@ -0,0 +1,741 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Transport.Transports
{
/// <summary>
/// Maintains a persistent WebSocket connection to the MCP server plugin hub.
/// Handles registration, keep-alives, and command dispatch back into Unity via
/// <see cref="TransportCommandDispatcher"/>.
/// </summary>
public class WebSocketTransportClient : IMcpTransportClient, IDisposable
{
private const string TransportDisplayName = "websocket";
private static readonly TimeSpan[] ReconnectSchedule =
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
};
private static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(15);
private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30);
private readonly IToolDiscoveryService _toolDiscoveryService;
private ClientWebSocket _socket;
private CancellationTokenSource _lifecycleCts;
private CancellationTokenSource _connectionCts;
private Task _receiveTask;
private Task _keepAliveTask;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private Uri _endpointUri;
private string _sessionId;
private string _projectHash;
private string _projectName;
private string _projectPath;
private string _unityVersion;
private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval;
private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval;
private volatile bool _isConnected;
private int _isReconnectingFlag;
private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started");
private string _apiKey;
private bool _disposed;
public WebSocketTransportClient(IToolDiscoveryService toolDiscoveryService = null)
{
_toolDiscoveryService = toolDiscoveryService;
}
public bool IsConnected => _isConnected;
public string TransportName => TransportDisplayName;
public TransportState State => _state;
private Task<List<ToolMetadata>> GetEnabledToolsOnMainThreadAsync(CancellationToken token)
{
return TransportCommandDispatcher.RunOnMainThreadAsync(
() => _toolDiscoveryService?.GetEnabledTools() ?? new List<ToolMetadata>(),
token);
}
public async Task<bool> StartAsync()
{
// Capture identity values on the main thread before any async context switching
_projectName = ProjectIdentityUtility.GetProjectName();
_projectHash = ProjectIdentityUtility.GetProjectHash();
_unityVersion = Application.unityVersion;
_apiKey = HttpEndpointUtility.IsRemoteScope()
? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty)
: string.Empty;
// Get project root path (strip /Assets from dataPath) for focus nudging
string dataPath = Application.dataPath;
if (!string.IsNullOrEmpty(dataPath))
{
string normalized = dataPath.TrimEnd('/', '\\');
if (string.Equals(System.IO.Path.GetFileName(normalized), "Assets", StringComparison.Ordinal))
{
_projectPath = System.IO.Path.GetDirectoryName(normalized) ?? normalized;
}
else
{
_projectPath = normalized; // Fallback if path doesn't end with Assets
}
}
await StopAsync();
_lifecycleCts = new CancellationTokenSource();
_endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl());
_sessionId = null;
if (!await EstablishConnectionAsync(_lifecycleCts.Token))
{
await StopAsync();
return false;
}
// State is connected but session ID might be pending until 'registered' message
_state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString());
_isConnected = true;
return true;
}
public async Task StopAsync()
{
if (_lifecycleCts == null)
{
return;
}
try
{
_lifecycleCts.Cancel();
}
catch { }
await StopConnectionLoopsAsync().ConfigureAwait(false);
if (_socket != null)
{
try
{
if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived)
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutdown", CancellationToken.None).ConfigureAwait(false);
}
}
catch { }
finally
{
_socket.Dispose();
_socket = null;
}
}
_isConnected = false;
_state = TransportState.Disconnected(TransportDisplayName);
_lifecycleCts.Dispose();
_lifecycleCts = null;
}
public async Task<bool> VerifyAsync()
{
if (_socket == null || _socket.State != WebSocketState.Open)
{
return false;
}
if (_lifecycleCts == null)
{
return false;
}
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_lifecycleCts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
await SendPongAsync(timeoutCts.Token).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Verify ping failed: {ex.Message}");
return false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
try
{
// Ensure background loops are stopped before disposing shared resources
StopAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Dispose failed to stop cleanly: {ex.Message}");
}
_sendLock?.Dispose();
_socket?.Dispose();
_lifecycleCts?.Dispose();
_disposed = true;
}
private async Task<bool> EstablishConnectionAsync(CancellationToken token)
{
await StopConnectionLoopsAsync().ConfigureAwait(false);
_connectionCts?.Dispose();
_connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token);
CancellationToken connectionToken = _connectionCts.Token;
_socket?.Dispose();
_socket = new ClientWebSocket();
_socket.Options.KeepAliveInterval = _socketKeepAliveInterval;
// Add API key header if configured (for remote-hosted mode)
if (!string.IsNullOrEmpty(_apiKey))
{
_socket.Options.SetRequestHeader(AuthConstants.ApiKeyHeader, _apiKey);
}
try
{
await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false);
}
catch (Exception ex)
{
string errorMsg = "Connection failed. Check that the server URL is correct, the server is running, and your API key (if required) is valid.";
McpLog.Error($"[WebSocket] {errorMsg} (Detail: {ex.Message})");
_state = TransportState.Disconnected(TransportDisplayName, errorMsg);
return false;
}
StartBackgroundLoops(connectionToken);
try
{
await SendRegisterAsync(connectionToken).ConfigureAwait(false);
}
catch (Exception ex)
{
string regMsg = $"Registration with server failed: {ex.Message}";
McpLog.Error($"[WebSocket] {regMsg}");
_state = TransportState.Disconnected(TransportDisplayName, regMsg);
return false;
}
return true;
}
/// <summary>
/// Stops the connection loops and disposes of the connection CTS.
/// Particularly useful when reconnecting, we want to ensure that background loops are cancelled correctly before starting new oens
/// </summary>
/// <param name="awaitTasks">Whether to await the receive and keep alive tasks before disposing.</param>
private async Task StopConnectionLoopsAsync(bool awaitTasks = true)
{
if (_connectionCts != null && !_connectionCts.IsCancellationRequested)
{
try { _connectionCts.Cancel(); } catch { }
}
if (_receiveTask != null)
{
if (awaitTasks)
{
try { await _receiveTask.ConfigureAwait(false); } catch { }
_receiveTask = null;
}
else if (_receiveTask.IsCompleted)
{
_receiveTask = null;
}
}
if (_keepAliveTask != null)
{
if (awaitTasks)
{
try { await _keepAliveTask.ConfigureAwait(false); } catch { }
_keepAliveTask = null;
}
else if (_keepAliveTask.IsCompleted)
{
_keepAliveTask = null;
}
}
if (_connectionCts != null)
{
_connectionCts.Dispose();
_connectionCts = null;
}
}
private void StartBackgroundLoops(CancellationToken token)
{
if ((_receiveTask != null && !_receiveTask.IsCompleted) || (_keepAliveTask != null && !_keepAliveTask.IsCompleted))
{
return;
}
_receiveTask = Task.Run(() => ReceiveLoopAsync(token), CancellationToken.None);
_keepAliveTask = Task.Run(() => KeepAliveLoopAsync(token), CancellationToken.None);
}
private async Task ReceiveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
string message = await ReceiveMessageAsync(token).ConfigureAwait(false);
if (message == null)
{
continue;
}
await HandleMessageAsync(message, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (WebSocketException wse)
{
McpLog.Warn($"[WebSocket] Receive loop error: {wse.Message}");
await HandleSocketClosureAsync(wse.Message).ConfigureAwait(false);
break;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Unexpected receive error: {ex.Message}");
await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false);
break;
}
}
}
private async Task<string> ReceiveMessageAsync(CancellationToken token)
{
if (_socket == null)
{
return null;
}
byte[] rentedBuffer = System.Buffers.ArrayPool<byte>.Shared.Rent(8192);
var buffer = new ArraySegment<byte>(rentedBuffer);
using var ms = new MemoryStream(8192);
try
{
while (!token.IsCancellationRequested)
{
WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, token).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await HandleSocketClosureAsync(result.CloseStatusDescription ?? "Server closed connection").ConfigureAwait(false);
return null;
}
if (result.Count > 0)
{
ms.Write(buffer.Array!, buffer.Offset, result.Count);
}
if (result.EndOfMessage)
{
break;
}
}
if (ms.Length == 0)
{
return null;
}
return Encoding.UTF8.GetString(ms.ToArray());
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
private async Task HandleMessageAsync(string message, CancellationToken token)
{
JObject payload;
try
{
payload = JObject.Parse(message);
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Invalid JSON payload: {ex.Message}");
return;
}
string messageType = payload.Value<string>("type") ?? string.Empty;
switch (messageType)
{
case "welcome":
ApplyWelcome(payload);
break;
case "registered":
await HandleRegisteredAsync(payload, token).ConfigureAwait(false);
break;
case "execute":
await HandleExecuteAsync(payload, token).ConfigureAwait(false);
break;
case "ping":
await SendPongAsync(token).ConfigureAwait(false);
break;
default:
// No-op for unrecognised types (keep-alives, telemetry, etc.)
break;
}
}
private void ApplyWelcome(JObject payload)
{
int? keepAliveSeconds = payload.Value<int?>("keepAliveInterval");
if (keepAliveSeconds.HasValue && keepAliveSeconds.Value > 0)
{
_keepAliveInterval = TimeSpan.FromSeconds(keepAliveSeconds.Value);
_socketKeepAliveInterval = _keepAliveInterval;
}
int? serverTimeoutSeconds = payload.Value<int?>("serverTimeout");
if (serverTimeoutSeconds.HasValue)
{
int sourceSeconds = keepAliveSeconds ?? serverTimeoutSeconds.Value;
int safeSeconds = Math.Max(5, Math.Min(serverTimeoutSeconds.Value, sourceSeconds));
_socketKeepAliveInterval = TimeSpan.FromSeconds(safeSeconds);
}
}
private async Task HandleRegisteredAsync(JObject payload, CancellationToken token)
{
string newSessionId = payload.Value<string>("session_id");
if (!string.IsNullOrEmpty(newSessionId))
{
_sessionId = newSessionId;
ProjectIdentityUtility.SetSessionId(_sessionId);
_state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString());
McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}", false);
await SendRegisterToolsAsync(token).ConfigureAwait(false);
}
}
private async Task SendRegisterToolsAsync(CancellationToken token)
{
if (_toolDiscoveryService == null) return;
token.ThrowIfCancellationRequested();
var tools = await GetEnabledToolsOnMainThreadAsync(token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
McpLog.Info($"[WebSocket] Preparing to register {tools.Count} tool(s) with the bridge.", false);
var toolsArray = new JArray();
foreach (var tool in tools)
{
var toolObj = new JObject
{
["name"] = tool.Name,
["description"] = tool.Description,
["structured_output"] = tool.StructuredOutput,
["requires_polling"] = tool.RequiresPolling,
["poll_action"] = tool.PollAction
};
var paramsArray = new JArray();
if (tool.Parameters != null)
{
foreach (var p in tool.Parameters)
{
paramsArray.Add(new JObject
{
["name"] = p.Name,
["description"] = p.Description,
["type"] = p.Type,
["required"] = p.Required,
["default_value"] = p.DefaultValue
});
}
}
toolObj["parameters"] = paramsArray;
toolsArray.Add(toolObj);
}
var payload = new JObject
{
["type"] = "register_tools",
["tools"] = toolsArray
};
await SendJsonAsync(payload, token).ConfigureAwait(false);
McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration", false);
}
private async Task HandleExecuteAsync(JObject payload, CancellationToken token)
{
string commandId = payload.Value<string>("id");
string commandName = payload.Value<string>("name");
JObject parameters = payload.Value<JObject>("params") ?? new JObject();
int timeoutSeconds = payload.Value<int?>("timeout") ?? (int)DefaultCommandTimeout.TotalSeconds;
if (string.IsNullOrEmpty(commandId) || string.IsNullOrEmpty(commandName))
{
McpLog.Warn("[WebSocket] Invalid execute payload (missing id or name)");
return;
}
var commandEnvelope = new JObject
{
["type"] = commandName,
["params"] = parameters
};
string responseJson;
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
responseJson = JsonConvert.SerializeObject(new
{
status = "error",
error = $"Command '{commandName}' timed out after {timeoutSeconds} seconds"
});
}
catch (Exception ex)
{
responseJson = JsonConvert.SerializeObject(new
{
status = "error",
error = ex.Message
});
}
JToken resultToken;
try
{
resultToken = JToken.Parse(responseJson);
}
catch
{
resultToken = new JObject
{
["status"] = "error",
["error"] = "Invalid response payload"
};
}
var responsePayload = new JObject
{
["type"] = "command_result",
["id"] = commandId,
["result"] = resultToken
};
await SendJsonAsync(responsePayload, token).ConfigureAwait(false);
}
private async Task KeepAliveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_keepAliveInterval, token).ConfigureAwait(false);
if (_socket == null || _socket.State != WebSocketState.Open)
{
break;
}
await SendPongAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Keep-alive failed: {ex.Message}");
await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false);
break;
}
}
}
private async Task SendRegisterAsync(CancellationToken token)
{
var registerPayload = new JObject
{
["type"] = "register",
// session_id is now server-authoritative; omitted here or sent as null
["project_name"] = _projectName,
["project_hash"] = _projectHash,
["unity_version"] = _unityVersion,
["project_path"] = _projectPath
};
await SendJsonAsync(registerPayload, token).ConfigureAwait(false);
}
private Task SendPongAsync(CancellationToken token)
{
var payload = new JObject
{
["type"] = "pong",
["session_id"] = _sessionId // Include session ID for server-side tracking
};
return SendJsonAsync(payload, token);
}
private async Task SendJsonAsync(JObject payload, CancellationToken token)
{
if (_socket == null)
{
throw new InvalidOperationException("WebSocket is not initialised");
}
string json = payload.ToString(Formatting.None);
byte[] bytes = Encoding.UTF8.GetBytes(json);
var buffer = new ArraySegment<byte>(bytes);
await _sendLock.WaitAsync(token).ConfigureAwait(false);
try
{
if (_socket.State != WebSocketState.Open)
{
throw new InvalidOperationException("WebSocket is not open");
}
await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, token).ConfigureAwait(false);
}
finally
{
_sendLock.Release();
}
}
private async Task HandleSocketClosureAsync(string reason)
{
// Capture stack trace for debugging disconnection triggers
var stackTrace = new System.Diagnostics.StackTrace(true);
McpLog.Debug($"[WebSocket] HandleSocketClosureAsync called. Reason: {reason}\nStack trace:\n{stackTrace}");
if (_lifecycleCts == null || _lifecycleCts.IsCancellationRequested)
{
return;
}
if (Interlocked.CompareExchange(ref _isReconnectingFlag, 1, 0) != 0)
{
return;
}
_isConnected = false;
_state = _state.WithError(reason ?? "Connection closed");
McpLog.Warn($"[WebSocket] Connection closed: {reason}");
await StopConnectionLoopsAsync(awaitTasks: false).ConfigureAwait(false);
_ = Task.Run(() => AttemptReconnectAsync(_lifecycleCts.Token), CancellationToken.None);
}
private async Task AttemptReconnectAsync(CancellationToken token)
{
try
{
await StopConnectionLoopsAsync().ConfigureAwait(false);
foreach (TimeSpan delay in ReconnectSchedule)
{
if (token.IsCancellationRequested)
{
return;
}
if (delay > TimeSpan.Zero)
{
try { await Task.Delay(delay, token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
if (await EstablishConnectionAsync(token).ConfigureAwait(false))
{
_state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString());
_isConnected = true;
McpLog.Info("[WebSocket] Reconnected to MCP server", false);
return;
}
}
}
finally
{
Interlocked.Exchange(ref _isReconnectingFlag, 0);
}
_state = TransportState.Disconnected(TransportDisplayName, "Failed to reconnect");
}
private static Uri BuildWebSocketUri(string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var httpUri))
{
throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}");
}
// Replace bind-only addresses with localhost for client connections
// 0.0.0.0 and :: are only valid for server binding, not client connections
string host = httpUri.Host;
if (host == "0.0.0.0" || host == "::")
{
McpLog.Warn($"[WebSocket] Base URL host '{host}' is bind-only; using 'localhost' for client connection.");
host = "localhost";
}
var builder = new UriBuilder(httpUri)
{
Scheme = httpUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws",
Host = host,
Path = httpUri.AbsolutePath.TrimEnd('/') + "/hub/plugin"
};
return builder.Uri;
}
}
}

View File

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