HTTP setup overhaul: transport selection (HTTP local/remote vs stdio), safer lifecycle, cleaner UI, better Claude Code integration (#499)
* Avoid blocking Claude CLI status checks on focus
* Fix Claude Code registration to remove existing server before re-registering
When registering with Claude Code, if a UnityMCP server already exists,
remove it first before adding the new registration. This ensures the
transport mode (HTTP vs stdio) is always updated to match the current
UseHttpTransport EditorPref setting.
Previously, if a stdio registration existed and the user tried to register
with HTTP, the command would fail with 'already exists' and the old stdio
configuration would remain unchanged.
* Fix Claude Code transport validation to parse CLI output format correctly
The validation code was incorrectly parsing the output of 'claude mcp get UnityMCP' by looking for JSON format ("transport": "http"), but the CLI actually returns human-readable text format ("Type: http"). This caused the transport mismatch detection to never trigger, allowing stdio to be selected in the UI while HTTP was registered with Claude Code.
Changes:
- Fix parsing logic to check for "Type: http" or "Type: stdio" in CLI output
- Add OnTransportChanged event to refresh client status when transport changes
- Wire up event handler to trigger client status refresh on transport dropdown change
This ensures that when the transport mode in Unity doesn't match what's registered with Claude Code, the UI will correctly show an error status with instructions to re-register.
* Fix Claude Code registration UI blocking and thread safety issues
This commit resolves three issues with Claude Code registration:
1. UI blocking: Removed synchronous CheckStatus() call after registration
that was blocking the editor. Status is now set immediately with async
verification happening in the background.
2. Thread safety: Fixed "can only be called from the main thread" errors
by capturing Application.dataPath and EditorPrefs.GetBool() on the main
thread before spawning async status check tasks.
3. Transport mismatch detection: Transport mode changes now trigger immediate
status checks to detect HTTP/stdio mismatches, instead of waiting for the
45-second refresh interval.
The registration button now turns green immediately after successful
registration without blocking, and properly detects transport mismatches
when switching between HTTP and stdio modes.
* Enforce thread safety for Claude Code status checks at compile time
Address code review feedback by making CheckStatusWithProjectDir thread-safe
by design rather than by convention:
1. Made projectDir and useHttpTransport parameters non-nullable to prevent
accidental background thread calls without captured values
2. Removed nullable fallback to EditorPrefs.GetBool() which would cause
thread safety violations if called from background threads
3. Added ArgumentNullException for null projectDir instead of falling back
to Application.dataPath (which is main-thread only)
4. Added XML documentation clearly stating threading contracts:
- CheckStatus() must be called from main thread
- CheckStatusWithProjectDir() is safe for background threads
5. Removed unreachable else branch in async status check code
These changes make it impossible to misuse the API from background threads,
with compile-time enforcement instead of runtime errors.
* Consolidate local HTTP Start/Stop and auto-start session
* HTTP improvements: Unity-owned server lifecycle + UI polish
* Deterministic HTTP stop via pidfile+token; spawn server in terminal
* Fix review feedback: token validation, host normalization, safer casts
* Fix stop heuristics edge cases; remove dead pid capture
* Fix unity substring guard in stop heuristics
* Fix local server cleanup and connection checks
* Fix read_console default limits; cleanup Unity-managed server vestiges
* Fix unfocused reconnect stalls; fast-fail retryable Unity commands
* Simplify PluginHub reload handling; honor run_tests timeout
* Fix Windows Claude CLI status check threading
main
parent
b866a4cc42
commit
c2a6b0ac7a
|
|
@ -334,21 +334,40 @@ namespace MCPForUnity.Editor.Clients
|
||||||
|
|
||||||
public override string GetConfigPath() => "Managed via Claude CLI";
|
public override string GetConfigPath() => "Managed via Claude CLI";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the Claude CLI registration status.
|
||||||
|
/// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access.
|
||||||
|
/// </summary>
|
||||||
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
|
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
|
||||||
|
{
|
||||||
|
// Capture main-thread-only values before delegating to thread-safe method
|
||||||
|
string projectDir = Path.GetDirectoryName(Application.dataPath);
|
||||||
|
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||||
|
// Resolve claudePath on the main thread (EditorPrefs access)
|
||||||
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
||||||
|
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal thread-safe version of CheckStatus.
|
||||||
|
/// Can be called from background threads because all main-thread-only values are passed as parameters.
|
||||||
|
/// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time.
|
||||||
|
/// </summary>
|
||||||
|
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = true)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pathService = MCPServiceLocator.Paths;
|
|
||||||
string claudePath = pathService.GetClaudeCliPath();
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(claudePath))
|
if (string.IsNullOrEmpty(claudePath))
|
||||||
{
|
{
|
||||||
client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found");
|
client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found");
|
||||||
return client.status;
|
return client.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
string args = "mcp list";
|
// projectDir is required - no fallback to Application.dataPath
|
||||||
string projectDir = Path.GetDirectoryName(Application.dataPath);
|
if (string.IsNullOrEmpty(projectDir))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution");
|
||||||
|
}
|
||||||
|
|
||||||
string pathPrepend = null;
|
string pathPrepend = null;
|
||||||
if (Application.platform == RuntimePlatform.OSXEditor)
|
if (Application.platform == RuntimePlatform.OSXEditor)
|
||||||
|
|
@ -372,10 +391,35 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out _, 10000, pathPrepend))
|
// Check if UnityMCP exists
|
||||||
|
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
|
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
{
|
{
|
||||||
|
// UnityMCP is registered - now verify transport mode matches
|
||||||
|
// useHttpTransport parameter is required (non-nullable) to ensure thread safety
|
||||||
|
bool currentUseHttp = useHttpTransport;
|
||||||
|
|
||||||
|
// Get detailed info about the registration to check transport type
|
||||||
|
if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
|
||||||
|
{
|
||||||
|
// Parse the output to determine registered transport mode
|
||||||
|
// The CLI output format contains "Type: http" or "Type: stdio"
|
||||||
|
bool registeredWithHttp = getStdout.Contains("Type: http", StringComparison.OrdinalIgnoreCase);
|
||||||
|
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Check for transport mismatch
|
||||||
|
if ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp))
|
||||||
|
{
|
||||||
|
string registeredTransport = registeredWithHttp ? "HTTP" : "stdio";
|
||||||
|
string currentTransport = currentUseHttp ? "HTTP" : "stdio";
|
||||||
|
string errorMsg = $"Transport mismatch: Claude Code is registered with {registeredTransport} but current setting is {currentTransport}. Click Configure to re-register.";
|
||||||
|
client.SetStatus(McpStatus.Error, errorMsg);
|
||||||
|
McpLog.Warn(errorMsg);
|
||||||
|
return client.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
client.SetStatus(McpStatus.Configured);
|
client.SetStatus(McpStatus.Configured);
|
||||||
return client.status;
|
return client.status;
|
||||||
}
|
}
|
||||||
|
|
@ -452,26 +496,29 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
bool already = false;
|
// Check if UnityMCP already exists and remove it first to ensure clean registration
|
||||||
|
// This ensures we always use the current transport mode setting
|
||||||
|
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
|
||||||
|
if (serverExists)
|
||||||
|
{
|
||||||
|
McpLog.Info("Existing UnityMCP registration found - removing to ensure transport mode is up-to-date");
|
||||||
|
if (!ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var removeStdout, out var removeStderr, 10000, pathPrepend))
|
||||||
|
{
|
||||||
|
McpLog.Warn($"Failed to remove existing UnityMCP registration: {removeStderr}. Attempting to register anyway...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now add the registration with the current transport mode
|
||||||
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
|
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
|
||||||
{
|
{
|
||||||
string combined = ($"{stdout}\n{stderr}") ?? string.Empty;
|
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
|
||||||
if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
||||||
{
|
|
||||||
already = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!already)
|
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");
|
||||||
{
|
|
||||||
McpLog.Info("Successfully registered with Claude Code.");
|
|
||||||
}
|
|
||||||
|
|
||||||
CheckStatus();
|
// Set status to Configured immediately after successful registration
|
||||||
|
// The UI will trigger an async verification check separately to avoid blocking
|
||||||
|
client.SetStatus(McpStatus.Configured);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Unregister()
|
private void Unregister()
|
||||||
|
|
@ -514,7 +561,7 @@ namespace MCPForUnity.Editor.Clients
|
||||||
}
|
}
|
||||||
|
|
||||||
client.SetStatus(McpStatus.NotConfigured);
|
client.SetStatus(McpStatus.NotConfigured);
|
||||||
CheckStatus();
|
// Status is already set - no need for blocking CheckStatus() call
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string GetManualSnippet()
|
public override string GetManualSnippet()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ namespace MCPForUnity.Editor.Constants
|
||||||
internal static class EditorPrefKeys
|
internal static class EditorPrefKeys
|
||||||
{
|
{
|
||||||
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
|
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
|
||||||
|
internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote"
|
||||||
|
internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid";
|
||||||
|
internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort";
|
||||||
|
internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc";
|
||||||
|
internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash";
|
||||||
|
internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath";
|
||||||
|
internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken";
|
||||||
internal const string DebugLogs = "MCPForUnity.DebugLogs";
|
internal const string DebugLogs = "MCPForUnity.DebugLogs";
|
||||||
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
|
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
|
||||||
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
|
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,24 @@ namespace MCPForUnity.Editor.Services
|
||||||
var mode = ResolvePreferredMode();
|
var mode = ResolvePreferredMode();
|
||||||
try
|
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);
|
bool started = await _transportManager.StartAsync(mode);
|
||||||
if (!started)
|
if (!started)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,18 @@ namespace MCPForUnity.Editor.Services
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool StopLocalHttpServer();
|
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>
|
/// <summary>
|
||||||
/// Attempts to get the command that will be executed when starting the local HTTP server
|
/// Attempts to get the command that will be executed when starting the local HTTP server
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -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 = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||||
|
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:
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -16,8 +16,13 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
/// Guarantees that MCP commands are executed on the Unity main thread while preserving
|
/// Guarantees that MCP commands are executed on the Unity main thread while preserving
|
||||||
/// the legacy response format expected by the server.
|
/// the legacy response format expected by the server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[InitializeOnLoad]
|
||||||
internal static class TransportCommandDispatcher
|
internal static class TransportCommandDispatcher
|
||||||
{
|
{
|
||||||
|
private static SynchronizationContext _mainThreadContext;
|
||||||
|
private static int _mainThreadId;
|
||||||
|
private static int _processingFlag;
|
||||||
|
|
||||||
private sealed class PendingCommand
|
private sealed class PendingCommand
|
||||||
{
|
{
|
||||||
public PendingCommand(
|
public PendingCommand(
|
||||||
|
|
@ -59,6 +64,23 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
private static bool updateHooked;
|
private static bool updateHooked;
|
||||||
private static bool initialised;
|
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>
|
/// <summary>
|
||||||
/// Schedule a command for execution on the Unity main thread and await its JSON response.
|
/// Schedule a command for execution on the Unity main thread and await its JSON response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -83,12 +105,91 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
lock (PendingLock)
|
lock (PendingLock)
|
||||||
{
|
{
|
||||||
Pending[id] = pending;
|
Pending[id] = pending;
|
||||||
HookUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
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()
|
private static void EnsureInitialised()
|
||||||
{
|
{
|
||||||
if (initialised)
|
if (initialised)
|
||||||
|
|
@ -102,28 +203,28 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
|
|
||||||
private static void HookUpdate()
|
private static void HookUpdate()
|
||||||
{
|
{
|
||||||
if (updateHooked)
|
// Deprecated: we keep the update hook installed permanently (see static ctor).
|
||||||
{
|
if (updateHooked) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHooked = true;
|
updateHooked = true;
|
||||||
EditorApplication.update += ProcessQueue;
|
EditorApplication.update += ProcessQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void UnhookUpdateIfIdle()
|
private static void UnhookUpdateIfIdle()
|
||||||
{
|
{
|
||||||
if (Pending.Count > 0 || !updateHooked)
|
// Intentionally no-op: keep update hook installed so background commands always process.
|
||||||
{
|
// This avoids "must focus Unity to re-establish contact" edge cases.
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
updateHooked = false;
|
|
||||||
EditorApplication.update -= ProcessQueue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessQueue()
|
private static void ProcessQueue()
|
||||||
{
|
{
|
||||||
|
if (Interlocked.Exchange(ref _processingFlag, 1) == 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
List<(string id, PendingCommand pending)> ready;
|
List<(string id, PendingCommand pending)> ready;
|
||||||
|
|
||||||
lock (PendingLock)
|
lock (PendingLock)
|
||||||
|
|
@ -151,6 +252,11 @@ namespace MCPForUnity.Editor.Services.Transport
|
||||||
{
|
{
|
||||||
ProcessCommand(id, pending);
|
ProcessCommand(id, pending);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _processingFlag, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessCommand(string id, PendingCommand pending)
|
private static void ProcessCommand(string id, PendingCommand pending)
|
||||||
|
|
|
||||||
|
|
@ -68,35 +68,9 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
|
||||||
|
|
||||||
private Task<List<ToolMetadata>> GetEnabledToolsOnMainThreadAsync(CancellationToken token)
|
private Task<List<ToolMetadata>> GetEnabledToolsOnMainThreadAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<List<ToolMetadata>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
return TransportCommandDispatcher.RunOnMainThreadAsync(
|
||||||
|
() => _toolDiscoveryService?.GetEnabledTools() ?? new List<ToolMetadata>(),
|
||||||
// Register cancellation to break the deadlock if StopAsync is called while waiting for main thread
|
token);
|
||||||
var registration = token.Register(() => tcs.TrySetCanceled());
|
|
||||||
|
|
||||||
EditorApplication.delayCall += () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (tcs.Task.IsCompleted)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var tools = _toolDiscoveryService?.GetEnabledTools() ?? new List<ToolMetadata>();
|
|
||||||
tcs.TrySetResult(tools);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
tcs.TrySetException(ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Ensure registration is disposed even if discovery throws
|
|
||||||
registration.Dispose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> StartAsync()
|
public async Task<bool> StartAsync()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MCPForUnity.Editor.Clients;
|
using MCPForUnity.Editor.Clients;
|
||||||
|
using MCPForUnity.Editor.Constants;
|
||||||
using MCPForUnity.Editor.Helpers;
|
using MCPForUnity.Editor.Helpers;
|
||||||
using MCPForUnity.Editor.Models;
|
using MCPForUnity.Editor.Models;
|
||||||
using MCPForUnity.Editor.Services;
|
using MCPForUnity.Editor.Services;
|
||||||
|
|
@ -288,12 +289,14 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
McpLog.Info("Configuration copied to clipboard");
|
McpLog.Info("Configuration copied to clipboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshSelectedClient()
|
public void RefreshSelectedClient(bool forceImmediate = false)
|
||||||
{
|
{
|
||||||
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
|
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
|
||||||
{
|
{
|
||||||
var client = configurators[selectedClientIndex];
|
var client = configurators[selectedClientIndex];
|
||||||
RefreshClientStatus(client, forceImmediate: true);
|
// Force immediate for non-Claude CLI, or when explicitly requested
|
||||||
|
bool shouldForceImmediate = forceImmediate || client is not ClaudeCliMcpConfigurator;
|
||||||
|
RefreshClientStatus(client, shouldForceImmediate);
|
||||||
UpdateManualConfiguration();
|
UpdateManualConfiguration();
|
||||||
UpdateClaudeCliPathVisibility();
|
UpdateClaudeCliPathVisibility();
|
||||||
}
|
}
|
||||||
|
|
@ -318,14 +321,6 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
|
|
||||||
private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate)
|
private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate)
|
||||||
{
|
{
|
||||||
if (forceImmediate)
|
|
||||||
{
|
|
||||||
MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false);
|
|
||||||
lastStatusChecks[client] = DateTime.UtcNow;
|
|
||||||
ApplyStatusToUi(client);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasStatus = lastStatusChecks.ContainsKey(client);
|
bool hasStatus = lastStatusChecks.ContainsKey(client);
|
||||||
bool needsRefresh = !hasStatus || ShouldRefreshClient(client);
|
bool needsRefresh = !hasStatus || ShouldRefreshClient(client);
|
||||||
|
|
||||||
|
|
@ -338,14 +333,25 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
||||||
ApplyStatusToUi(client);
|
ApplyStatusToUi(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsRefresh && !statusRefreshInFlight.Contains(client))
|
if ((forceImmediate || needsRefresh) && !statusRefreshInFlight.Contains(client))
|
||||||
{
|
{
|
||||||
statusRefreshInFlight.Add(client);
|
statusRefreshInFlight.Add(client);
|
||||||
ApplyStatusToUi(client, showChecking: true);
|
ApplyStatusToUi(client, showChecking: true);
|
||||||
|
|
||||||
|
// Capture main-thread-only values before async task
|
||||||
|
string projectDir = Path.GetDirectoryName(Application.dataPath);
|
||||||
|
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||||
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
||||||
|
|
||||||
Task.Run(() =>
|
Task.Run(() =>
|
||||||
{
|
{
|
||||||
MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false);
|
// Defensive: RefreshClientStatus routes Claude CLI clients here, but avoid hard-cast
|
||||||
|
// so accidental future call sites can't crash the UI.
|
||||||
|
if (client is ClaudeCliMcpConfigurator claudeConfigurator)
|
||||||
|
{
|
||||||
|
// Use thread-safe version with captured main-thread values
|
||||||
|
claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite: false);
|
||||||
|
}
|
||||||
}).ContinueWith(t =>
|
}).ContinueWith(t =>
|
||||||
{
|
{
|
||||||
bool faulted = false;
|
bool faulted = false;
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,24 @@
|
||||||
background-color: rgba(30, 120, 200, 1);
|
background-color: rgba(30, 120, 200, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Start Server button in the manual config section should align flush left like other full-width controls */
|
||||||
|
.start-server-button {
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the HTTP server/session is running, we show the Start/Stop button as "danger" (red) */
|
||||||
|
.action-button.server-running {
|
||||||
|
background-color: rgba(200, 50, 50, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.server-running:hover {
|
||||||
|
background-color: rgba(220, 60, 60, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.server-running:active {
|
||||||
|
background-color: rgba(170, 40, 40, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
|
@ -359,7 +377,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-parameters {
|
.tool-parameters {
|
||||||
font-style: italic;
|
-unity-font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Advanced Settings */
|
/* Advanced Settings */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MCPForUnity.Editor.Constants;
|
using MCPForUnity.Editor.Constants;
|
||||||
using MCPForUnity.Editor.Helpers;
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
|
@ -20,7 +21,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
// Transport protocol enum
|
// Transport protocol enum
|
||||||
private enum TransportProtocol
|
private enum TransportProtocol
|
||||||
{
|
{
|
||||||
HTTP,
|
HTTPLocal,
|
||||||
|
HTTPRemote,
|
||||||
Stdio
|
Stdio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,11 +43,16 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
private Button connectionToggleButton;
|
private Button connectionToggleButton;
|
||||||
private VisualElement healthIndicator;
|
private VisualElement healthIndicator;
|
||||||
private Label healthStatusLabel;
|
private Label healthStatusLabel;
|
||||||
|
private VisualElement healthRow;
|
||||||
private Button testConnectionButton;
|
private Button testConnectionButton;
|
||||||
|
|
||||||
private bool connectionToggleInProgress;
|
private bool connectionToggleInProgress;
|
||||||
|
private bool autoStartInProgress;
|
||||||
|
private bool httpServerToggleInProgress;
|
||||||
private Task verificationTask;
|
private Task verificationTask;
|
||||||
private string lastHealthStatus;
|
private string lastHealthStatus;
|
||||||
|
private double lastLocalServerRunningPollTime;
|
||||||
|
private bool lastLocalServerRunning;
|
||||||
|
|
||||||
// Health status constants
|
// Health status constants
|
||||||
private const string HealthStatusUnknown = "Unknown";
|
private const string HealthStatusUnknown = "Unknown";
|
||||||
|
|
@ -55,6 +62,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
public event Action OnManualConfigUpdateRequested;
|
public event Action OnManualConfigUpdateRequested;
|
||||||
|
public event Action OnTransportChanged;
|
||||||
|
|
||||||
public VisualElement Root { get; private set; }
|
public VisualElement Root { get; private set; }
|
||||||
|
|
||||||
|
|
@ -84,14 +92,29 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
connectionToggleButton = Root.Q<Button>("connection-toggle");
|
connectionToggleButton = Root.Q<Button>("connection-toggle");
|
||||||
healthIndicator = Root.Q<VisualElement>("health-indicator");
|
healthIndicator = Root.Q<VisualElement>("health-indicator");
|
||||||
healthStatusLabel = Root.Q<Label>("health-status");
|
healthStatusLabel = Root.Q<Label>("health-status");
|
||||||
|
healthRow = Root.Q<VisualElement>("health-row");
|
||||||
testConnectionButton = Root.Q<Button>("test-connection-button");
|
testConnectionButton = Root.Q<Button>("test-connection-button");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeUI()
|
private void InitializeUI()
|
||||||
{
|
{
|
||||||
transportDropdown.Init(TransportProtocol.HTTP);
|
transportDropdown.Init(TransportProtocol.HTTPLocal);
|
||||||
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||||
transportDropdown.value = useHttpTransport ? TransportProtocol.HTTP : TransportProtocol.Stdio;
|
if (!useHttpTransport)
|
||||||
|
{
|
||||||
|
transportDropdown.value = TransportProtocol.Stdio;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Back-compat: if scope pref isn't set yet, infer from current URL.
|
||||||
|
string scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(scope))
|
||||||
|
{
|
||||||
|
scope = MCPServiceLocator.Server.IsLocalUrl() ? "local" : "remote";
|
||||||
|
}
|
||||||
|
|
||||||
|
transportDropdown.value = scope == "remote" ? TransportProtocol.HTTPRemote : TransportProtocol.HTTPLocal;
|
||||||
|
}
|
||||||
|
|
||||||
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
|
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
|
||||||
|
|
||||||
|
|
@ -104,36 +127,98 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
|
|
||||||
UpdateHttpFieldVisibility();
|
UpdateHttpFieldVisibility();
|
||||||
RefreshHttpUi();
|
RefreshHttpUi();
|
||||||
|
UpdateConnectionStatus();
|
||||||
|
|
||||||
|
// Explain what "Health" means (it is a separate verify/ping check and can differ from session state).
|
||||||
|
if (healthStatusLabel != null)
|
||||||
|
{
|
||||||
|
healthStatusLabel.tooltip = "Health is a lightweight verify/ping of the active transport. A session can be active while health is degraded.";
|
||||||
|
}
|
||||||
|
if (healthIndicator != null)
|
||||||
|
{
|
||||||
|
healthIndicator.tooltip = healthStatusLabel?.tooltip;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterCallbacks()
|
private void RegisterCallbacks()
|
||||||
{
|
{
|
||||||
transportDropdown.RegisterValueChangedCallback(evt =>
|
transportDropdown.RegisterValueChangedCallback(evt =>
|
||||||
{
|
{
|
||||||
bool useHttp = (TransportProtocol)evt.newValue == TransportProtocol.HTTP;
|
var previous = (TransportProtocol)evt.previousValue;
|
||||||
|
var selected = (TransportProtocol)evt.newValue;
|
||||||
|
bool useHttp = selected != TransportProtocol.Stdio;
|
||||||
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, useHttp);
|
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, useHttp);
|
||||||
|
|
||||||
|
if (useHttp)
|
||||||
|
{
|
||||||
|
string scope = selected == TransportProtocol.HTTPRemote ? "remote" : "local";
|
||||||
|
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, scope);
|
||||||
|
}
|
||||||
|
|
||||||
UpdateHttpFieldVisibility();
|
UpdateHttpFieldVisibility();
|
||||||
RefreshHttpUi();
|
RefreshHttpUi();
|
||||||
|
UpdateConnectionStatus();
|
||||||
OnManualConfigUpdateRequested?.Invoke();
|
OnManualConfigUpdateRequested?.Invoke();
|
||||||
|
OnTransportChanged?.Invoke();
|
||||||
McpLog.Info($"Transport changed to: {evt.newValue}");
|
McpLog.Info($"Transport changed to: {evt.newValue}");
|
||||||
|
|
||||||
|
// Best-effort: stop the deselected transport to avoid leaving duplicated sessions running.
|
||||||
|
// (Switching between HttpLocal/HttpRemote does not require stopping.)
|
||||||
|
bool prevWasHttp = previous != TransportProtocol.Stdio;
|
||||||
|
bool nextIsHttp = selected != TransportProtocol.Stdio;
|
||||||
|
if (prevWasHttp != nextIsHttp)
|
||||||
|
{
|
||||||
|
var stopMode = nextIsHttp ? TransportMode.Stdio : TransportMode.Http;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stopTask = MCPServiceLocator.TransportManager.StopAsync(stopMode);
|
||||||
|
stopTask.ContinueWith(t =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (t.IsFaulted)
|
||||||
|
{
|
||||||
|
var msg = t.Exception?.GetBaseException()?.Message ?? "Unknown error";
|
||||||
|
McpLog.Warn($"Async stop of {stopMode} transport failed: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
McpLog.Warn($"Failed to stop previous transport ({stopMode}) after selection change: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
httpUrlField.RegisterValueChangedCallback(evt =>
|
// Don't normalize/overwrite the URL on every keystroke (it fights the user and can duplicate schemes).
|
||||||
|
// Instead, persist + normalize on focus-out / Enter, then update UI once.
|
||||||
|
httpUrlField.RegisterCallback<FocusOutEvent>(_ => PersistHttpUrlFromField());
|
||||||
|
httpUrlField.RegisterCallback<KeyDownEvent>(evt =>
|
||||||
{
|
{
|
||||||
HttpEndpointUtility.SaveBaseUrl(evt.newValue);
|
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
|
||||||
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
|
{
|
||||||
OnManualConfigUpdateRequested?.Invoke();
|
PersistHttpUrlFromField();
|
||||||
RefreshHttpUi();
|
evt.StopPropagation();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (startHttpServerButton != null)
|
if (startHttpServerButton != null)
|
||||||
{
|
{
|
||||||
startHttpServerButton.clicked += OnStartLocalHttpServerClicked;
|
startHttpServerButton.clicked += OnHttpServerToggleClicked;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stopHttpServerButton != null)
|
if (stopHttpServerButton != null)
|
||||||
{
|
{
|
||||||
stopHttpServerButton.clicked += OnStopLocalHttpServerClicked;
|
// Stop button removed from UXML as part of consolidated Start/Stop UX.
|
||||||
|
// Kept null-check for backward compatibility if older UXML is loaded.
|
||||||
|
stopHttpServerButton.clicked += () =>
|
||||||
|
{
|
||||||
|
// In older UXML layouts, route the stop button to the consolidated toggle behavior.
|
||||||
|
// If a session is active, this will end it and attempt to stop the local server.
|
||||||
|
OnHttpServerToggleClicked();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyHttpServerCommandButton != null)
|
if (copyHttpServerCommandButton != null)
|
||||||
|
|
@ -162,10 +247,53 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
testConnectionButton.clicked += OnTestConnectionClicked;
|
testConnectionButton.clicked += OnTestConnectionClicked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PersistHttpUrlFromField()
|
||||||
|
{
|
||||||
|
if (httpUrlField == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpEndpointUtility.SaveBaseUrl(httpUrlField.text);
|
||||||
|
// Update displayed value to normalized form without re-triggering callbacks/caret jumps.
|
||||||
|
httpUrlField.SetValueWithoutNotify(HttpEndpointUtility.GetBaseUrl());
|
||||||
|
OnManualConfigUpdateRequested?.Invoke();
|
||||||
|
RefreshHttpUi();
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateConnectionStatus()
|
public void UpdateConnectionStatus()
|
||||||
{
|
{
|
||||||
var bridgeService = MCPServiceLocator.Bridge;
|
var bridgeService = MCPServiceLocator.Bridge;
|
||||||
bool isRunning = bridgeService.IsRunning;
|
bool isRunning = bridgeService.IsRunning;
|
||||||
|
bool showLocalServerControls = IsHttpLocalSelected();
|
||||||
|
bool debugMode = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
|
||||||
|
bool stdioSelected = transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.Stdio;
|
||||||
|
|
||||||
|
// Keep the consolidated Start/Stop Server button label in sync even when the session is not running
|
||||||
|
// (e.g., orphaned server after a domain reload).
|
||||||
|
UpdateStartHttpButtonState();
|
||||||
|
|
||||||
|
// If local-server controls are active, hide the manual session toggle controls and
|
||||||
|
// rely on the Start/Stop Server button. We still keep the session status text visible
|
||||||
|
// next to the dot for clarity.
|
||||||
|
if (connectionToggleButton != null)
|
||||||
|
{
|
||||||
|
connectionToggleButton.style.display = showLocalServerControls ? DisplayStyle.None : DisplayStyle.Flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide "Test" buttons unless Debug Mode is enabled.
|
||||||
|
if (testConnectionButton != null)
|
||||||
|
{
|
||||||
|
testConnectionButton.style.display = debugMode ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health is useful mainly for diagnostics: hide it once we're "Healthy" unless Debug Mode is enabled.
|
||||||
|
// If health is degraded, keep it visible even outside Debug Mode so it can act as a signal.
|
||||||
|
if (healthRow != null)
|
||||||
|
{
|
||||||
|
bool showHealth = debugMode || (isRunning && lastHealthStatus != HealthStatusHealthy);
|
||||||
|
healthRow.style.display = showHealth ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
if (isRunning)
|
if (isRunning)
|
||||||
{
|
{
|
||||||
|
|
@ -197,6 +325,10 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
? bridgeService.CurrentPort
|
? bridgeService.CurrentPort
|
||||||
: savedPort).ToString();
|
: savedPort).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For stdio session toggling, make End Session visually "danger" (red).
|
||||||
|
// (HTTP Local uses the consolidated Start/Stop Server button instead.)
|
||||||
|
connectionToggleButton?.EnableInClassList("server-running", isRunning && stdioSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateHttpServerCommandDisplay()
|
public void UpdateHttpServerCommandDisplay()
|
||||||
|
|
@ -206,9 +338,12 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTP;
|
bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
||||||
|
bool httpLocalSelected = IsHttpLocalSelected();
|
||||||
|
bool isLocalHttpUrl = MCPServiceLocator.Server.IsLocalUrl();
|
||||||
|
|
||||||
if (!useHttp)
|
// Only show the local-server helper UI when HTTP Local is selected.
|
||||||
|
if (!useHttp || !httpLocalSelected)
|
||||||
{
|
{
|
||||||
httpServerCommandSection.style.display = DisplayStyle.None;
|
httpServerCommandSection.style.display = DisplayStyle.None;
|
||||||
httpServerCommandField.value = string.Empty;
|
httpServerCommandField.value = string.Empty;
|
||||||
|
|
@ -226,6 +361,18 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
|
|
||||||
httpServerCommandSection.style.display = DisplayStyle.Flex;
|
httpServerCommandSection.style.display = DisplayStyle.Flex;
|
||||||
|
|
||||||
|
if (!isLocalHttpUrl)
|
||||||
|
{
|
||||||
|
httpServerCommandField.value = string.Empty;
|
||||||
|
httpServerCommandField.tooltip = string.Empty;
|
||||||
|
if (httpServerCommandHint != null)
|
||||||
|
{
|
||||||
|
httpServerCommandHint.text = "HTTP Local requires a localhost URL (localhost/127.0.0.1/0.0.0.0/::1).";
|
||||||
|
}
|
||||||
|
copyHttpServerCommandButton?.SetEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (MCPServiceLocator.Server.TryGetLocalHttpServerCommand(out var command, out var error))
|
if (MCPServiceLocator.Server.TryGetLocalHttpServerCommand(out var command, out var error))
|
||||||
{
|
{
|
||||||
httpServerCommandField.value = command;
|
httpServerCommandField.value = command;
|
||||||
|
|
@ -256,18 +403,23 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
|
|
||||||
private void UpdateHttpFieldVisibility()
|
private void UpdateHttpFieldVisibility()
|
||||||
{
|
{
|
||||||
bool useHttp = (TransportProtocol)transportDropdown.value == TransportProtocol.HTTP;
|
bool useHttp = (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
||||||
|
|
||||||
httpUrlRow.style.display = useHttp ? DisplayStyle.Flex : DisplayStyle.None;
|
httpUrlRow.style.display = useHttp ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
unitySocketPortRow.style.display = useHttp ? DisplayStyle.None : DisplayStyle.Flex;
|
unitySocketPortRow.style.display = useHttp ? DisplayStyle.None : DisplayStyle.Flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsHttpLocalSelected()
|
||||||
|
{
|
||||||
|
return transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTPLocal;
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateStartHttpButtonState()
|
private void UpdateStartHttpButtonState()
|
||||||
{
|
{
|
||||||
if (startHttpServerButton == null)
|
if (startHttpServerButton == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTP;
|
bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
||||||
if (!useHttp)
|
if (!useHttp)
|
||||||
{
|
{
|
||||||
startHttpServerButton.SetEnabled(false);
|
startHttpServerButton.SetEnabled(false);
|
||||||
|
|
@ -275,19 +427,39 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canStart = MCPServiceLocator.Server.CanStartLocalServer();
|
bool httpLocalSelected = IsHttpLocalSelected();
|
||||||
startHttpServerButton.SetEnabled(canStart);
|
bool canStartLocalServer = httpLocalSelected && MCPServiceLocator.Server.IsLocalUrl();
|
||||||
startHttpServerButton.tooltip = canStart
|
bool sessionRunning = MCPServiceLocator.Bridge.IsRunning;
|
||||||
? string.Empty
|
bool localServerRunning = false;
|
||||||
: "Start Local HTTP Server is available only for localhost URLs.";
|
|
||||||
|
|
||||||
if (stopHttpServerButton != null)
|
// Avoid running expensive port/PID checks every UI tick.
|
||||||
|
if (httpLocalSelected)
|
||||||
{
|
{
|
||||||
stopHttpServerButton.SetEnabled(canStart);
|
double now = EditorApplication.timeSinceStartup;
|
||||||
stopHttpServerButton.tooltip = canStart
|
if ((now - lastLocalServerRunningPollTime) > 0.75f || httpServerToggleInProgress)
|
||||||
? string.Empty
|
{
|
||||||
: "Stop Local HTTP Server is available only for localhost URLs.";
|
lastLocalServerRunningPollTime = now;
|
||||||
|
lastLocalServerRunning = MCPServiceLocator.Server.IsLocalHttpServerRunning();
|
||||||
|
}
|
||||||
|
localServerRunning = lastLocalServerRunning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single consolidated button: Start Server (launch local server + start session) or
|
||||||
|
// Stop Server (end session + attempt to stop local server).
|
||||||
|
bool shouldShowStop = sessionRunning || localServerRunning;
|
||||||
|
startHttpServerButton.text = shouldShowStop ? "Stop Server" : "Start Server";
|
||||||
|
// Note: Server logs may contain transient HTTP 400s on /mcp during startup probing and
|
||||||
|
// CancelledError stack traces on shutdown when streaming requests are cancelled; this is expected.
|
||||||
|
startHttpServerButton.EnableInClassList("server-running", shouldShowStop);
|
||||||
|
startHttpServerButton.SetEnabled(
|
||||||
|
(canStartLocalServer && !httpServerToggleInProgress && !autoStartInProgress) ||
|
||||||
|
(shouldShowStop && !httpServerToggleInProgress));
|
||||||
|
startHttpServerButton.tooltip = httpLocalSelected
|
||||||
|
? (canStartLocalServer ? string.Empty : "HTTP Local requires a localhost URL (localhost/127.0.0.1/0.0.0.0/::1).")
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
// Stop button is no longer used; it may be null depending on UXML version.
|
||||||
|
stopHttpServerButton?.SetEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshHttpUi()
|
private void RefreshHttpUi()
|
||||||
|
|
@ -296,46 +468,59 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
UpdateHttpServerCommandDisplay();
|
UpdateHttpServerCommandDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnStartLocalHttpServerClicked()
|
private async void OnHttpServerToggleClicked()
|
||||||
{
|
{
|
||||||
if (startHttpServerButton != null)
|
if (httpServerToggleInProgress)
|
||||||
{
|
{
|
||||||
startHttpServerButton.SetEnabled(false);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bridgeService = MCPServiceLocator.Bridge;
|
||||||
|
httpServerToggleInProgress = true;
|
||||||
|
startHttpServerButton?.SetEnabled(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
MCPServiceLocator.Server.StartLocalHttpServer();
|
// If a session is active, treat this as "Stop Server" (end session first, then try to stop server).
|
||||||
}
|
if (bridgeService.IsRunning)
|
||||||
finally
|
|
||||||
{
|
|
||||||
RefreshHttpUi();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnStopLocalHttpServerClicked()
|
|
||||||
{
|
|
||||||
if (stopHttpServerButton != null)
|
|
||||||
{
|
|
||||||
stopHttpServerButton.SetEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
bool stopped = MCPServiceLocator.Server.StopLocalHttpServer();
|
|
||||||
if (!stopped)
|
|
||||||
{
|
{
|
||||||
McpLog.Warn("Failed to stop HTTP server or no server was running");
|
await bridgeService.StopAsync();
|
||||||
|
bool stopped = MCPServiceLocator.Server.StopLocalHttpServer();
|
||||||
|
if (!stopped)
|
||||||
|
{
|
||||||
|
McpLog.Warn("Failed to stop HTTP server or no server was running");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the session isn't running but the local server is, allow stopping the server directly.
|
||||||
|
if (IsHttpLocalSelected() && MCPServiceLocator.Server.IsLocalHttpServerRunning())
|
||||||
|
{
|
||||||
|
bool stopped = MCPServiceLocator.Server.StopLocalHttpServer();
|
||||||
|
if (!stopped)
|
||||||
|
{
|
||||||
|
McpLog.Warn("Failed to stop HTTP server or no server was running");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, "Start Server" and then auto-start the session.
|
||||||
|
bool started = MCPServiceLocator.Server.StartLocalHttpServer();
|
||||||
|
if (started)
|
||||||
|
{
|
||||||
|
await TryAutoStartSessionAfterHttpServerAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
McpLog.Error($"Failed to stop server: {ex.Message}");
|
McpLog.Error($"HTTP server toggle failed: {ex.Message}");
|
||||||
EditorUtility.DisplayDialog("Error", $"Failed to stop server:\n\n{ex.Message}", "OK");
|
EditorUtility.DisplayDialog("Error", $"Failed to toggle local HTTP server:\n\n{ex.Message}", "OK");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
httpServerToggleInProgress = false;
|
||||||
RefreshHttpUi();
|
RefreshHttpUi();
|
||||||
|
UpdateConnectionStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,6 +605,149 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
await VerifyBridgeConnectionAsync();
|
await VerifyBridgeConnectionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryAutoStartSessionAfterHttpServerAsync()
|
||||||
|
{
|
||||||
|
if (autoStartInProgress)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bridgeService = MCPServiceLocator.Bridge;
|
||||||
|
if (bridgeService.IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStartInProgress = true;
|
||||||
|
connectionToggleButton?.SetEnabled(false);
|
||||||
|
const int maxAttempts = 10;
|
||||||
|
var delay = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Wait until the HTTP server is actually accepting connections to reduce transient "unable to connect then recovers"
|
||||||
|
// behavior (session start attempts can race the server startup).
|
||||||
|
bool serverReady = await WaitForHttpServerAcceptingConnectionsAsync(TimeSpan.FromSeconds(10));
|
||||||
|
if (!serverReady)
|
||||||
|
{
|
||||||
|
McpLog.Warn("HTTP server did not become reachable within the expected startup window; will still attempt to start the session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
bool started = await bridgeService.StartAsync();
|
||||||
|
if (started)
|
||||||
|
{
|
||||||
|
await VerifyBridgeConnectionAsync();
|
||||||
|
UpdateConnectionStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
McpLog.Warn("Failed to auto-start MCP session after launching the HTTP server.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
autoStartInProgress = false;
|
||||||
|
connectionToggleButton?.SetEnabled(true);
|
||||||
|
UpdateConnectionStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> WaitForHttpServerAcceptingConnectionsAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string baseUrl = HttpEndpointUtility.GetBaseUrl();
|
||||||
|
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) || uri.Port <= 0)
|
||||||
|
{
|
||||||
|
return true; // Don't block if URL cannot be parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
string host = uri.Host;
|
||||||
|
int port = uri.Port;
|
||||||
|
|
||||||
|
// Normalize wildcard/bind-all hosts to loopback for readiness checks.
|
||||||
|
// When the server binds to 0.0.0.0 or ::, clients typically connect via localhost/127.0.0.1.
|
||||||
|
string normalizedHost;
|
||||||
|
if (string.IsNullOrWhiteSpace(host)
|
||||||
|
|| string.Equals(host, "0.0.0.0", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(host, "::", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(host, "*", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
normalizedHost = "localhost";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalizedHost = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var connectTask = client.ConnectAsync(normalizedHost, port);
|
||||||
|
var completed = await Task.WhenAny(connectTask, Task.Delay(250));
|
||||||
|
if (completed != connectTask)
|
||||||
|
{
|
||||||
|
// Avoid leaving a long-running ConnectAsync in-flight (default OS connect timeout can be very long),
|
||||||
|
// which can accumulate across retries and impact overall editor/network responsiveness.
|
||||||
|
// Closing the client will cause ConnectAsync to complete quickly (typically with an exception),
|
||||||
|
// which we then attempt to observe (bounded) by awaiting.
|
||||||
|
try { client.Close(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Even after Close(), some platforms may take a moment to complete the connect task.
|
||||||
|
// Keep this bounded so the readiness loop can't hang here.
|
||||||
|
var connectCompleted = await Task.WhenAny(connectTask, Task.Delay(250));
|
||||||
|
if (connectCompleted == connectTask)
|
||||||
|
{
|
||||||
|
await connectTask;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = connectTask.ContinueWith(
|
||||||
|
t => _ = t.Exception,
|
||||||
|
System.Threading.CancellationToken.None,
|
||||||
|
TaskContinuationOptions.OnlyOnFaulted,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore connection exceptions and retry until timeout.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.Connected)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore and retry until timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task VerifyBridgeConnectionAsync()
|
public async Task VerifyBridgeConnectionAsync()
|
||||||
{
|
{
|
||||||
// Prevent concurrent verification calls
|
// Prevent concurrent verification calls
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:Label name="http-server-command-hint" class="help-text" />
|
<ui:Label name="http-server-command-hint" class="help-text" />
|
||||||
<ui:VisualElement style="flex-direction: row; justify-content: flex-start; margin-bottom: 6px;">
|
<ui:VisualElement style="flex-direction: row; justify-content: flex-start; margin-bottom: 6px;">
|
||||||
<ui:Button name="start-http-server-button" text="Start Server" class="secondary-button" style="width: auto; flex-grow: 1; margin-right: 5px;" />
|
<ui:Button name="start-http-server-button" text="Start Server" class="action-button start-server-button" style="width: auto; flex-grow: 1;" />
|
||||||
<ui:Button name="stop-http-server-button" text="Stop Server" class="secondary-button" style="width: auto; flex-grow: 1;" />
|
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement class="setting-row" name="unity-socket-port-row">
|
<ui:VisualElement class="setting-row" name="unity-socket-port-row">
|
||||||
|
|
@ -34,7 +33,7 @@
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:Button name="connection-toggle" text="Start" class="action-button" />
|
<ui:Button name="connection-toggle" text="Start" class="action-button" />
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement class="setting-row">
|
<ui:VisualElement class="setting-row" name="health-row">
|
||||||
<ui:Label text="Health:" class="setting-label" />
|
<ui:Label text="Health:" class="setting-label" />
|
||||||
<ui:VisualElement class="status-container">
|
<ui:VisualElement class="status-container">
|
||||||
<ui:VisualElement name="health-indicator" class="status-dot" />
|
<ui:VisualElement name="health-indicator" class="status-dot" />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<ui:Label text="..." name="version-label" class="setting-value" />
|
<ui:Label text="..." name="version-label" class="setting-value" />
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement class="setting-row">
|
<ui:VisualElement class="setting-row">
|
||||||
<ui:Label text="Show Debug Logs:" class="setting-label" />
|
<ui:Label text="Debug Mode:" class="setting-label" />
|
||||||
<ui:Toggle name="debug-logs-toggle" class="setting-toggle" />
|
<ui:Toggle name="debug-logs-toggle" class="setting-toggle" />
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement class="setting-column">
|
<ui:VisualElement class="setting-column">
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ namespace MCPForUnity.Editor.Windows
|
||||||
{ EditorPrefKeys.ClaudeCliPathOverride, EditorPrefType.String },
|
{ EditorPrefKeys.ClaudeCliPathOverride, EditorPrefType.String },
|
||||||
{ EditorPrefKeys.UvxPathOverride, EditorPrefType.String },
|
{ EditorPrefKeys.UvxPathOverride, EditorPrefType.String },
|
||||||
{ EditorPrefKeys.HttpBaseUrl, EditorPrefType.String },
|
{ EditorPrefKeys.HttpBaseUrl, EditorPrefType.String },
|
||||||
|
{ EditorPrefKeys.HttpTransportScope, EditorPrefType.String },
|
||||||
{ EditorPrefKeys.SessionId, EditorPrefType.String },
|
{ EditorPrefKeys.SessionId, EditorPrefType.String },
|
||||||
{ EditorPrefKeys.WebSocketUrlOverride, EditorPrefType.String },
|
{ EditorPrefKeys.WebSocketUrlOverride, EditorPrefType.String },
|
||||||
{ EditorPrefKeys.GitUrlOverride, EditorPrefType.String },
|
{ EditorPrefKeys.GitUrlOverride, EditorPrefType.String },
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,8 @@ namespace MCPForUnity.Editor.Windows
|
||||||
connectionSection = new McpConnectionSection(connectionRoot);
|
connectionSection = new McpConnectionSection(connectionRoot);
|
||||||
connectionSection.OnManualConfigUpdateRequested += () =>
|
connectionSection.OnManualConfigUpdateRequested += () =>
|
||||||
clientConfigSection?.UpdateManualConfiguration();
|
clientConfigSection?.UpdateManualConfiguration();
|
||||||
|
connectionSection.OnTransportChanged += () =>
|
||||||
|
clientConfigSection?.RefreshSelectedClient(forceImmediate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and initialize Client Configuration section
|
// Load and initialize Client Configuration section
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,22 @@ Examples:
|
||||||
help="HTTP server port (overrides URL port). "
|
help="HTTP server port (overrides URL port). "
|
||||||
"Overrides UNITY_MCP_HTTP_PORT environment variable."
|
"Overrides UNITY_MCP_HTTP_PORT environment variable."
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--unity-instance-token",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
metavar="TOKEN",
|
||||||
|
help="Optional per-launch token set by Unity for deterministic lifecycle management. "
|
||||||
|
"Used by Unity to validate it is stopping the correct process."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pidfile",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="Optional path where the server will write its PID on startup. "
|
||||||
|
"Used by Unity to stop the exact process it launched when running in a terminal."
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
@ -418,6 +434,20 @@ Examples:
|
||||||
os.environ["UNITY_MCP_HTTP_HOST"] = http_host
|
os.environ["UNITY_MCP_HTTP_HOST"] = http_host
|
||||||
os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
|
os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
|
||||||
|
|
||||||
|
# Optional lifecycle handshake for Unity-managed terminal launches
|
||||||
|
if args.unity_instance_token:
|
||||||
|
os.environ["UNITY_MCP_INSTANCE_TOKEN"] = args.unity_instance_token
|
||||||
|
if args.pidfile:
|
||||||
|
try:
|
||||||
|
pid_dir = os.path.dirname(args.pidfile)
|
||||||
|
if pid_dir:
|
||||||
|
os.makedirs(pid_dir, exist_ok=True)
|
||||||
|
with open(args.pidfile, "w", encoding="ascii") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to write pidfile '%s': %s", args.pidfile, exc)
|
||||||
|
|
||||||
if args.http_url != "http://localhost:8080":
|
if args.http_url != "http://localhost:8080":
|
||||||
logger.info(f"HTTP URL set to: {http_url}")
|
logger.info(f"HTTP URL set to: {http_url}")
|
||||||
if args.http_host:
|
if args.http_host:
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,13 @@ async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
|
||||||
"get_editor_state",
|
"get_editor_state",
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
return EditorStateResponse(**response) if isinstance(response, dict) else response
|
# When Unity is reloading/unresponsive (often when unfocused), transports may return
|
||||||
|
# a retryable MCPResponse payload with success=false and no data. Do not attempt to
|
||||||
|
# coerce that into EditorStateResponse (it would fail validation); return it as-is.
|
||||||
|
if isinstance(response, dict):
|
||||||
|
if not response.get("success", True):
|
||||||
|
return MCPResponse(**response)
|
||||||
|
if response.get("data") is None:
|
||||||
|
return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response)
|
||||||
|
return EditorStateResponse(**response)
|
||||||
|
return response
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,18 @@ async def read_console(
|
||||||
if isinstance(action, str):
|
if isinstance(action, str):
|
||||||
action = action.lower()
|
action = action.lower()
|
||||||
|
|
||||||
# Coerce count defensively (string/float -> int)
|
# Coerce count defensively (string/float -> int).
|
||||||
count = coerce_int(count)
|
# Important: leaving count unset previously meant "return all console entries", which can be extremely slow
|
||||||
|
# (and can exceed the plugin command timeout when Unity has a large console).
|
||||||
|
# To keep the tool responsive by default, we cap the default to a reasonable number of most-recent entries.
|
||||||
|
# If a client truly wants everything, it can pass count="all" (or count="*") explicitly.
|
||||||
|
if isinstance(count, str) and count.strip().lower() in ("all", "*"):
|
||||||
|
count = None
|
||||||
|
else:
|
||||||
|
count = coerce_int(count)
|
||||||
|
|
||||||
|
if action == "get" and count is None:
|
||||||
|
count = 200
|
||||||
|
|
||||||
# Prepare parameters for the C# handler
|
# Prepare parameters for the C# handler
|
||||||
params_dict = {
|
params_dict = {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -12,6 +13,7 @@ from starlette.endpoints import WebSocketEndpoint
|
||||||
from starlette.websockets import WebSocket
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
from core.config import config
|
from core.config import config
|
||||||
|
from models.models import MCPResponse
|
||||||
from transport.plugin_registry import PluginRegistry
|
from transport.plugin_registry import PluginRegistry
|
||||||
from transport.models import (
|
from transport.models import (
|
||||||
WelcomeMessage,
|
WelcomeMessage,
|
||||||
|
|
@ -28,6 +30,10 @@ from transport.models import (
|
||||||
logger = logging.getLogger("mcp-for-unity-server")
|
logger = logging.getLogger("mcp-for-unity-server")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginDisconnectedError(RuntimeError):
|
||||||
|
"""Raised when a plugin WebSocket disconnects while commands are in flight."""
|
||||||
|
|
||||||
|
|
||||||
class PluginHub(WebSocketEndpoint):
|
class PluginHub(WebSocketEndpoint):
|
||||||
"""Manages persistent WebSocket connections to Unity plugins."""
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
||||||
|
|
||||||
|
|
@ -35,10 +41,15 @@ class PluginHub(WebSocketEndpoint):
|
||||||
KEEP_ALIVE_INTERVAL = 15
|
KEEP_ALIVE_INTERVAL = 15
|
||||||
SERVER_TIMEOUT = 30
|
SERVER_TIMEOUT = 30
|
||||||
COMMAND_TIMEOUT = 30
|
COMMAND_TIMEOUT = 30
|
||||||
|
# Fast-path commands should never block the client for long; return a retry hint instead.
|
||||||
|
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
||||||
|
# or is throttled while unfocused.
|
||||||
|
_FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
|
||||||
|
|
||||||
_registry: PluginRegistry | None = None
|
_registry: PluginRegistry | None = None
|
||||||
_connections: dict[str, WebSocket] = {}
|
_connections: dict[str, WebSocket] = {}
|
||||||
_pending: dict[str, asyncio.Future] = {}
|
# command_id -> {"future": Future, "session_id": str}
|
||||||
|
_pending: dict[str, dict[str, Any]] = {}
|
||||||
_lock: asyncio.Lock | None = None
|
_lock: asyncio.Lock | None = None
|
||||||
_loop: asyncio.AbstractEventLoop | None = None
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
|
@ -95,6 +106,21 @@ class PluginHub(WebSocketEndpoint):
|
||||||
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
||||||
if session_id:
|
if session_id:
|
||||||
cls._connections.pop(session_id, None)
|
cls._connections.pop(session_id, None)
|
||||||
|
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
|
||||||
|
pending_ids = [
|
||||||
|
command_id
|
||||||
|
for command_id, entry in cls._pending.items()
|
||||||
|
if entry.get("session_id") == session_id
|
||||||
|
]
|
||||||
|
for command_id in pending_ids:
|
||||||
|
entry = cls._pending.get(command_id)
|
||||||
|
future = entry.get("future") if isinstance(entry, dict) else None
|
||||||
|
if future and not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PluginDisconnectedError(
|
||||||
|
f"Unity plugin session {session_id} disconnected while awaiting command_result"
|
||||||
|
)
|
||||||
|
)
|
||||||
if cls._registry:
|
if cls._registry:
|
||||||
await cls._registry.unregister(session_id)
|
await cls._registry.unregister(session_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -108,6 +134,39 @@ class PluginHub(WebSocketEndpoint):
|
||||||
websocket = await cls._get_connection(session_id)
|
websocket = await cls._get_connection(session_id)
|
||||||
command_id = str(uuid.uuid4())
|
command_id = str(uuid.uuid4())
|
||||||
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||||
|
# Compute a per-command timeout:
|
||||||
|
# - fast-path commands: short timeout (encourage retry)
|
||||||
|
# - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
|
||||||
|
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
||||||
|
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
||||||
|
if command_type in cls._FAST_FAIL_COMMANDS:
|
||||||
|
try:
|
||||||
|
fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
|
||||||
|
except Exception:
|
||||||
|
fast_timeout = 3.0
|
||||||
|
unity_timeout_s = fast_timeout
|
||||||
|
server_wait_s = fast_timeout
|
||||||
|
else:
|
||||||
|
# Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
|
||||||
|
requested = None
|
||||||
|
try:
|
||||||
|
if isinstance(params, dict):
|
||||||
|
requested = params.get("timeout_seconds", None)
|
||||||
|
if requested is None:
|
||||||
|
requested = params.get("timeoutSeconds", None)
|
||||||
|
except Exception:
|
||||||
|
requested = None
|
||||||
|
|
||||||
|
if requested is not None:
|
||||||
|
try:
|
||||||
|
requested_s = float(requested)
|
||||||
|
# Clamp to a sane upper bound to avoid accidental infinite hangs.
|
||||||
|
requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
|
||||||
|
unity_timeout_s = max(unity_timeout_s, requested_s)
|
||||||
|
# Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
|
||||||
|
server_wait_s = max(server_wait_s, requested_s + 5.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
lock = cls._lock
|
lock = cls._lock
|
||||||
if lock is None:
|
if lock is None:
|
||||||
|
|
@ -117,18 +176,35 @@ class PluginHub(WebSocketEndpoint):
|
||||||
if command_id in cls._pending:
|
if command_id in cls._pending:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Duplicate command id generated: {command_id}")
|
f"Duplicate command id generated: {command_id}")
|
||||||
cls._pending[command_id] = future
|
cls._pending[command_id] = {"future": future, "session_id": session_id}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ExecuteCommandMessage(
|
msg = ExecuteCommandMessage(
|
||||||
id=command_id,
|
id=command_id,
|
||||||
name=command_type,
|
name=command_type,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=cls.COMMAND_TIMEOUT,
|
timeout=unity_timeout_s,
|
||||||
)
|
)
|
||||||
await websocket.send_json(msg.model_dump())
|
try:
|
||||||
result = await asyncio.wait_for(future, timeout=cls.COMMAND_TIMEOUT)
|
await websocket.send_json(msg.model_dump())
|
||||||
return result
|
except Exception as exc:
|
||||||
|
# If send fails (socket already closing), fail the future so callers don't hang.
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(exc)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(future, timeout=server_wait_s)
|
||||||
|
return result
|
||||||
|
except PluginDisconnectedError as exc:
|
||||||
|
return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if command_type in cls._FAST_FAIL_COMMANDS:
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
|
||||||
|
hint="retry",
|
||||||
|
).model_dump()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
async with lock:
|
async with lock:
|
||||||
cls._pending.pop(command_id, None)
|
cls._pending.pop(command_id, None)
|
||||||
|
|
@ -245,7 +321,8 @@ class PluginHub(WebSocketEndpoint):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
future = cls._pending.get(command_id)
|
entry = cls._pending.get(command_id)
|
||||||
|
future = entry.get("future") if isinstance(entry, dict) else None
|
||||||
if future and not future.done():
|
if future and not future.done():
|
||||||
future.set_result(result)
|
future.set_result(result)
|
||||||
|
|
||||||
|
|
@ -364,6 +441,40 @@ class PluginHub(WebSocketEndpoint):
|
||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
session_id = await cls._resolve_session_id(unity_instance)
|
session_id = await cls._resolve_session_id(unity_instance)
|
||||||
|
|
||||||
|
# During domain reload / immediate reconnect windows, the plugin may be connected but not yet
|
||||||
|
# ready to process execute commands on the Unity main thread (which can be further delayed when
|
||||||
|
# the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
|
||||||
|
# a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
|
||||||
|
# register_tools (which can be delayed by EditorApplication.delayCall).
|
||||||
|
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
||||||
|
try:
|
||||||
|
max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
||||||
|
except Exception:
|
||||||
|
max_wait_s = 6.0
|
||||||
|
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
||||||
|
if max_wait_s > 0:
|
||||||
|
deadline = time.monotonic() + max_wait_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
probe = await cls.send_command(session_id, "ping", {})
|
||||||
|
except Exception:
|
||||||
|
probe = None
|
||||||
|
|
||||||
|
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
||||||
|
if isinstance(probe, dict) and probe.get("status") == "success":
|
||||||
|
result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
|
||||||
|
if result.get("message") == "pong":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
else:
|
||||||
|
# Not ready within the bounded window: return retry hint without sending.
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
|
||||||
|
hint="retry",
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
return await cls.send_command(session_id, command_type, params)
|
return await cls.send_command(session_id, command_type, params)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from typing import Awaitable, Callable, TypeVar
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
from transport.plugin_hub import PluginHub
|
from transport.plugin_hub import PluginHub
|
||||||
|
from models.models import MCPResponse
|
||||||
from models.unity_response import normalize_unity_response
|
from models.unity_response import normalize_unity_response
|
||||||
from services.tools import get_unity_instance_from_context
|
from services.tools import get_unity_instance_from_context
|
||||||
|
|
||||||
|
|
@ -91,12 +92,21 @@ async def send_with_unity_instance(
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"Command parameters must be a dict for HTTP transport")
|
"Command parameters must be a dict for HTTP transport")
|
||||||
raw = await PluginHub.send_command_for_instance(
|
try:
|
||||||
unity_instance,
|
raw = await PluginHub.send_command_for_instance(
|
||||||
command_type,
|
unity_instance,
|
||||||
params,
|
command_type,
|
||||||
)
|
params,
|
||||||
return normalize_unity_response(raw)
|
)
|
||||||
|
return normalize_unity_response(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
# NOTE: asyncio.TimeoutError has an empty str() by default, which is confusing for clients.
|
||||||
|
err = str(exc) or f"{type(exc).__name__}"
|
||||||
|
# Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
|
||||||
|
# The client can decide whether retrying is appropriate for the command.
|
||||||
|
return normalize_unity_response(
|
||||||
|
MCPResponse(success=False, error=err, hint="retry").model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
if unity_instance:
|
if unity_instance:
|
||||||
kwargs.setdefault("instance_id", unity_instance)
|
kwargs.setdefault("instance_id", unity_instance)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue