升级XR插件版本
This commit is contained in:
157
Packages/MCPForUnity/Editor/Services/BridgeControlService.cs
Normal file
157
Packages/MCPForUnity/Editor/Services/BridgeControlService.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed4f9f69d84a945248dafc0f0b5a62dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76cad34d10fd24aaa95c4583c1f88fdf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
345
Packages/MCPForUnity/Editor/Services/EditorConfigurationCache.cs
Normal file
345
Packages/MCPForUnity/Editor/Services/EditorConfigurationCache.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4a183ac9b63c408886bce40ae58f462
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for managing the EditorPrefs window
|
||||
/// Follows the Class-level Singleton pattern
|
||||
/// </summary>
|
||||
public class EditorPrefsWindowService
|
||||
{
|
||||
private static EditorPrefsWindowService _instance;
|
||||
|
||||
/// <summary>
|
||||
/// Get the singleton instance
|
||||
/// </summary>
|
||||
public static EditorPrefsWindowService Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
throw new Exception("EditorPrefsWindowService not initialized");
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the service
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EditorPrefsWindowService();
|
||||
}
|
||||
}
|
||||
|
||||
private EditorPrefsWindowService()
|
||||
{
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the EditorPrefs window
|
||||
/// </summary>
|
||||
public void ShowWindow()
|
||||
{
|
||||
EditorPrefsWindow.ShowWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
546
Packages/MCPForUnity/Editor/Services/EditorStateCache.cs
Normal file
546
Packages/MCPForUnity/Editor/Services/EditorStateCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa7909967ce3c48c493181c978782a54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
145
Packages/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs
Normal file
145
Packages/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs
Normal 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}");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c0cf970a7b494a659be151dc0124296
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for controlling the MCP for Unity Bridge connection
|
||||
/// </summary>
|
||||
public interface IBridgeControlService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the bridge is currently running
|
||||
/// </summary>
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current port the bridge is listening on
|
||||
/// </summary>
|
||||
int CurrentPort { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the bridge is in auto-connect mode
|
||||
/// </summary>
|
||||
bool IsAutoConnectMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active transport mode, if any
|
||||
/// </summary>
|
||||
TransportMode? ActiveMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
/// <returns>True if the bridge started successfully</returns>
|
||||
Task<bool> StartAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the bridge connection by sending a ping and waiting for a pong response
|
||||
/// </summary>
|
||||
/// <param name="port">The port to verify</param>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
BridgeVerificationResult Verify(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the connection asynchronously (works for both HTTP and stdio transports)
|
||||
/// </summary>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
Task<BridgeVerificationResult> VerifyAsync();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bridge verification attempt
|
||||
/// </summary>
|
||||
public class BridgeVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification was successful
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the verification result
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the handshake was valid (FRAMING=1 protocol)
|
||||
/// </summary>
|
||||
public bool HandshakeValid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the ping/pong exchange succeeded
|
||||
/// </summary>
|
||||
public bool PingSucceeded { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b5d9f677f6f54fc59e6fe921b260c61
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Clients;
|
||||
using MCPForUnity.Editor.Models;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for configuring MCP clients
|
||||
/// </summary>
|
||||
public interface IClientConfigurationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a specific MCP client
|
||||
/// </summary>
|
||||
/// <param name="client">The client to configure</param>
|
||||
void ConfigureClient(IMcpClientConfigurator configurator);
|
||||
|
||||
/// <summary>
|
||||
/// Configures all detected/installed MCP clients (skips clients where CLI/tools not found)
|
||||
/// </summary>
|
||||
/// <returns>Summary of configuration results</returns>
|
||||
ClientConfigurationSummary ConfigureAllDetectedClients();
|
||||
|
||||
/// <summary>
|
||||
/// Checks the configuration status of a client
|
||||
/// </summary>
|
||||
/// <param name="client">The client to check</param>
|
||||
/// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param>
|
||||
/// <returns>True if status changed, false otherwise</returns>
|
||||
bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true);
|
||||
|
||||
/// <summary>Gets the registry of discovered configurators.</summary>
|
||||
IReadOnlyList<IMcpClientConfigurator> GetAllClients();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of configuration results for multiple clients
|
||||
/// </summary>
|
||||
public class ClientConfigurationSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of clients successfully configured
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of clients that failed to configure
|
||||
/// </summary>
|
||||
public int FailureCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of clients skipped (already configured or tool not found)
|
||||
/// </summary>
|
||||
public int SkippedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed messages for each client
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Messages { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable summary message
|
||||
/// </summary>
|
||||
public string GetSummaryMessage()
|
||||
{
|
||||
return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aae139cfae7ac4044ac52e2658005ea1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public interface IPackageDeploymentService
|
||||
{
|
||||
string GetStoredSourcePath();
|
||||
void SetStoredSourcePath(string path);
|
||||
void ClearStoredSourcePath();
|
||||
|
||||
string GetTargetPath();
|
||||
string GetTargetDisplayPath();
|
||||
|
||||
string GetLastBackupPath();
|
||||
bool HasBackup();
|
||||
|
||||
PackageDeploymentResult DeployFromStoredSource();
|
||||
PackageDeploymentResult RestoreLastBackup();
|
||||
}
|
||||
|
||||
public class PackageDeploymentResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string SourcePath { get; set; }
|
||||
public string TargetPath { get; set; }
|
||||
public string BackupPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e94ae28f193184e4fb5068f62f4f00c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
77
Packages/MCPForUnity/Editor/Services/IPathResolverService.cs
Normal file
77
Packages/MCPForUnity/Editor/Services/IPathResolverService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e8d388be507345aeb0eaf27fbd3c022
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
20
Packages/MCPForUnity/Editor/Services/IPlatformService.cs
Normal file
20
Packages/MCPForUnity/Editor/Services/IPlatformService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata for a discovered resource
|
||||
/// </summary>
|
||||
public class ResourceMetadata
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string ClassName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public string AssemblyName { get; set; }
|
||||
public bool IsBuiltIn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering MCP resources via reflection
|
||||
/// </summary>
|
||||
public interface IResourceDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all resources marked with [McpForUnityResource]
|
||||
/// </summary>
|
||||
List<ResourceMetadata> DiscoverAllResources();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for a specific resource
|
||||
/// </summary>
|
||||
ResourceMetadata GetResourceMetadata(string resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the resources currently enabled
|
||||
/// </summary>
|
||||
List<ResourceMetadata> GetEnabledResources();
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a resource is currently enabled
|
||||
/// </summary>
|
||||
bool IsResourceEnabled(string resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the enabled state for a resource
|
||||
/// </summary>
|
||||
void SetResourceEnabled(string resourceName, bool enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the resource discovery cache
|
||||
/// </summary>
|
||||
void InvalidateCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7afb4739669224c74b4b4d706e6bbb49
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d41bfc9780b774affa6afbffd081eb79
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Packages/MCPForUnity/Editor/Services/ITestRunnerService.cs
Normal file
52
Packages/MCPForUnity/Editor/Services/ITestRunnerService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d23bf32361ff444beaf3510818c94bae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 497592a93fd994b2cb9803e7c8636ff7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
98
Packages/MCPForUnity/Editor/Services/MCPServiceLocator.cs
Normal file
98
Packages/MCPForUnity/Editor/Services/MCPServiceLocator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 276d6a9f9a1714ead91573945de78992
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4150c04e0907c45d7b332260911a0567
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
304
Packages/MCPForUnity/Editor/Services/PackageDeploymentService.cs
Normal file
304
Packages/MCPForUnity/Editor/Services/PackageDeploymentService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
185
Packages/MCPForUnity/Editor/Services/PackageUpdateService.cs
Normal file
185
Packages/MCPForUnity/Editor/Services/PackageUpdateService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3c2304b14e9485ca54182fad73b035
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
358
Packages/MCPForUnity/Editor/Services/PathResolverService.cs
Normal file
358
Packages/MCPForUnity/Editor/Services/PathResolverService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00a6188fd15a847fa8cc7cb7a4ce3dce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
31
Packages/MCPForUnity/Editor/Services/PlatformService.cs
Normal file
31
Packages/MCPForUnity/Editor/Services/PlatformService.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Services/PlatformService.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Services/PlatformService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b2d7f32a595c45dd8c01f141c69761c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
167
Packages/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs
Normal file
167
Packages/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66ce49d2cc47a4bd3aa85ac9f099b757
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Services/Server.meta
Normal file
8
Packages/MCPForUnity/Editor/Services/Server.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bb072befc9fe4242a501f46dce3fea1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for managing PID files and handshake state for the local HTTP server.
|
||||
/// Handles persistence of server process information across Unity domain reloads.
|
||||
/// </summary>
|
||||
public interface IPidFileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the directory where PID files are stored.
|
||||
/// </summary>
|
||||
/// <returns>Path to the PID file directory</returns>
|
||||
string GetPidDirectory();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the PID file for a specific port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <returns>Full path to the PID file</returns>
|
||||
string GetPidFilePath(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the PID from a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="pid">Output: the process ID if found</param>
|
||||
/// <returns>True if a valid PID was read</returns>
|
||||
bool TryReadPid(string pidFilePath, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract the port number from a PID file path.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="port">Output: the port number</param>
|
||||
/// <returns>True if the port was extracted successfully</returns>
|
||||
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file to delete</param>
|
||||
void DeletePidFile(string pidFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="instanceToken">Unique instance token for the server</param>
|
||||
void StoreHandshake(string pidFilePath, string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve stored handshake information from EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Output: stored PID file path</param>
|
||||
/// <param name="instanceToken">Output: stored instance token</param>
|
||||
/// <returns>True if valid handshake information was found</returns>
|
||||
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stores PID tracking information in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <param name="argsHash">Optional hash of the command arguments</param>
|
||||
void StoreTracking(int pid, int port, string argsHash = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a stored PID for the expected port.
|
||||
/// Validates that the stored information is still valid (within 6-hour window).
|
||||
/// </summary>
|
||||
/// <param name="expectedPort">The expected port number</param>
|
||||
/// <param name="pid">Output: the stored process ID</param>
|
||||
/// <returns>True if a valid stored PID was found</returns>
|
||||
bool TryGetStoredPid(int expectedPort, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stored args hash for the tracked server.
|
||||
/// </summary>
|
||||
/// <returns>The stored args hash, or empty string if not found</returns>
|
||||
string GetStoredArgsHash();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all PID tracking information from EditorPrefs.
|
||||
/// </summary>
|
||||
void ClearTracking();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a short hash of the input string for fingerprinting.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>A short hash string (16 hex characters)</returns>
|
||||
string ComputeShortHash(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a4c5d093da74ce79fb29a0670a58a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process inspection operations.
|
||||
/// Provides methods to detect MCP server processes, query process command lines,
|
||||
/// and find processes listening on specific ports.
|
||||
/// </summary>
|
||||
public interface IProcessDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if a process looks like an MCP server process based on its command line.
|
||||
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process appears to be an MCP server</returns>
|
||||
bool LooksLikeMcpServerProcess(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the command line arguments for a Unix process.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
|
||||
/// <returns>True if the command line was retrieved successfully</returns>
|
||||
bool TryGetProcessCommandLine(int pid, out string argsLower);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process IDs of all processes listening on a specific TCP port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to check</param>
|
||||
/// <returns>List of process IDs listening on the port</returns>
|
||||
List<int> GetListeningProcessIdsForPort(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current Unity Editor process ID safely.
|
||||
/// </summary>
|
||||
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
|
||||
int GetCurrentProcessId();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a process exists on Unix systems.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process exists</returns>
|
||||
bool ProcessExists(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>Normalized string for matching</returns>
|
||||
string NormalizeForMatch(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25f32875fb87541b69ead19c08520836
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process termination.
|
||||
/// Provides methods to terminate processes gracefully or forcefully.
|
||||
/// </summary>
|
||||
public interface IProcessTerminator
|
||||
{
|
||||
/// <summary>
|
||||
/// Terminates a process using platform-appropriate methods.
|
||||
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
|
||||
/// On Windows: Tries taskkill, then taskkill /F.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to terminate</param>
|
||||
/// <returns>True if the process was terminated successfully</returns>
|
||||
bool Terminate(int pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a55c18e08b534afa85654410da8a463
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for building uvx/server command strings.
|
||||
/// Handles platform-specific command construction for starting the MCP HTTP server.
|
||||
/// </summary>
|
||||
public interface IServerCommandBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to build the command parts for starting the local HTTP server.
|
||||
/// </summary>
|
||||
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
|
||||
/// <param name="arguments">Output: the command arguments</param>
|
||||
/// <param name="displayCommand">Output: the full command string for display</param>
|
||||
/// <param name="error">Output: error message if the command cannot be built</param>
|
||||
/// <returns>True if the command was built successfully</returns>
|
||||
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uv path from the uvx path by replacing uvx with uv.
|
||||
/// </summary>
|
||||
/// <param name="uvxPath">Path to uvx executable</param>
|
||||
/// <returns>Path to uv executable</returns>
|
||||
string BuildUvPathFromUvx(string uvxPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
|
||||
/// </summary>
|
||||
/// <returns>Paths to prepend to PATH environment variable</returns>
|
||||
string GetPlatformSpecificPathPrepend();
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a string if it contains spaces.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
|
||||
string QuoteIfNeeded(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12e80005e3f5b45239c48db981675ccf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5990e868c0cd4999858ce1c1a2defed
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
275
Packages/MCPForUnity/Editor/Services/Server/PidFileManager.cs
Normal file
275
Packages/MCPForUnity/Editor/Services/Server/PidFileManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57875f281fda94a4ea17cb74d4b13378
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
268
Packages/MCPForUnity/Editor/Services/Server/ProcessDetector.cs
Normal file
268
Packages/MCPForUnity/Editor/Services/Server/ProcessDetector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4df6fa24a35d74d1cb9b67e40e50b45d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 900df88b4d0844704af9cb47633d44a9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db917800a5c2948088ede8a5d230b56e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
143
Packages/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs
Normal file
143
Packages/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9693a18d706548b3aae28ea87f1ed08
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
876
Packages/MCPForUnity/Editor/Services/ServerManagementService.cs
Normal file
876
Packages/MCPForUnity/Editor/Services/ServerManagementService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e60df35c5a76462d8aaa8078da86d75
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
116
Packages/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs
Normal file
116
Packages/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e603c72a87974cf5b495cd683165fbf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
673
Packages/MCPForUnity/Editor/Services/TestJobManager.cs
Normal file
673
Packages/MCPForUnity/Editor/Services/TestJobManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
Packages/MCPForUnity/Editor/Services/TestJobManager.cs.meta
Normal file
13
Packages/MCPForUnity/Editor/Services/TestJobManager.cs.meta
Normal file
@@ -0,0 +1,13 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d7a9b8c0e1f4a6b9c3d2e1f0a9b8c7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
||||
|
||||
62
Packages/MCPForUnity/Editor/Services/TestRunStatus.cs
Normal file
62
Packages/MCPForUnity/Editor/Services/TestRunStatus.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
Packages/MCPForUnity/Editor/Services/TestRunStatus.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Services/TestRunStatus.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3d140c288f6e4b6aa2b7e8181a09c1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
150
Packages/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs
Normal file
150
Packages/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07a60b029782d464a9506fa520d2a8c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
619
Packages/MCPForUnity/Editor/Services/TestRunnerService.cs
Normal file
619
Packages/MCPForUnity/Editor/Services/TestRunnerService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18db1e25b13e14b0b9b186c751e397d0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
248
Packages/MCPForUnity/Editor/Services/ToolDiscoveryService.cs
Normal file
248
Packages/MCPForUnity/Editor/Services/ToolDiscoveryService.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec81a561be4c14c9cb243855d3273a94
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Services/Transport.meta
Normal file
8
Packages/MCPForUnity/Editor/Services/Transport.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d189635a5d364f55a810203798c09ba
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 042446a50a4744170bb294acf827376f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27407cc9c1ea0412d80b9f8964a5a29d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67ab8e43f6a804698bb5b216cdef0645
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd295cefe518e438693c12e9c7f37488
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2743f3468d5f433dbf2220f0838d8d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 044c8f7beb4af4a77a14d677190c21dc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user