Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2958bfc
Avoid blocking Claude CLI status checks on focus
dsarno Dec 30, 2025
dec7be0
Fix Claude Code registration to remove existing server before re-regi…
dsarno Dec 30, 2025
6aa4238
Fix Claude Code transport validation to parse CLI output format corre…
dsarno Dec 30, 2025
55a5f87
Merge codex/investigate-claude-code-repaint-delay: Add non-blocking C…
dsarno Dec 30, 2025
5122d8b
Fix Claude Code registration UI blocking and thread safety issues
dsarno Dec 30, 2025
0413c45
Enforce thread safety for Claude Code status checks at compile time
dsarno Dec 30, 2025
3749d4d
Consolidate local HTTP Start/Stop and auto-start session
dsarno Dec 30, 2025
bc8f451
HTTP improvements: Unity-owned server lifecycle + UI polish
dsarno Dec 30, 2025
784dc8c
Deterministic HTTP stop via pidfile+token; spawn server in terminal
dsarno Dec 31, 2025
85932f6
Fix review feedback: token validation, host normalization, safer casts
dsarno Dec 31, 2025
9b6e198
Fix stop heuristics edge cases; remove dead pid capture
dsarno Dec 31, 2025
3d3fa8e
Fix unity substring guard in stop heuristics
dsarno Dec 31, 2025
d5eabc6
Fix local server cleanup and connection checks
dsarno Dec 31, 2025
275093a
Fix read_console default limits; cleanup Unity-managed server vestiges
dsarno Dec 31, 2025
8dae8e2
Merge pull request #107 from dsarno/codex/fix-unobserved-connectasync…
dsarno Dec 31, 2025
02902f7
Fix unfocused reconnect stalls; fast-fail retryable Unity commands
dsarno Dec 31, 2025
1794038
Simplify PluginHub reload handling; honor run_tests timeout
dsarno Dec 31, 2025
6b219b1
Fix manage_material create to apply color
dsarno Dec 31, 2025
d68989c
Enhance add_component to support componentProperties with string arra…
dsarno Jan 1, 2026
b2f4bc1
Optimize run_tests to return summary by default, reducing token usage…
dsarno Jan 1, 2026
227b370
Add warning when run_tests filters match no tests; fix test organization
dsarno Jan 1, 2026
ae52fe5
Refactor test result message formatting
dsarno Jan 1, 2026
74adad3
Merge pull request #111 from dsarno/codex/refactor-formattestresultme…
dsarno Jan 1, 2026
7ebdc17
Simplify RunTests warning assertions
dsarno Jan 1, 2026
007f354
Merge pull request #112 from dsarno/codex/refactor-assertions-in-runt…
dsarno Jan 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 65 additions & 17 deletions MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,24 @@ public ClaudeCliMcpConfigurator(McpClient client) : base(client) { }

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)
{
// Capture main-thread-only values before delegating to thread-safe method
string projectDir = Path.GetDirectoryName(Application.dataPath);
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
return CheckStatusWithProjectDir(projectDir, useHttpTransport, attemptAutoRewrite);
}

/// <summary>
/// Internal thread-safe version of CheckStatus.
/// Can be called from background threads because all main-thread-only values are passed as parameters.
/// Both projectDir and useHttpTransport are REQUIRED (non-nullable) to enforce thread safety at compile time.
/// </summary>
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, bool attemptAutoRewrite = true)
{
try
{
Expand All @@ -347,8 +364,11 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
return client.status;
}

string args = "mcp list";
string projectDir = Path.GetDirectoryName(Application.dataPath);
// projectDir is required - no fallback to Application.dataPath
if (string.IsNullOrEmpty(projectDir))
{
throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution");
}

string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
Expand All @@ -372,10 +392,35 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
}
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);
return client.status;
}
Expand Down Expand Up @@ -452,26 +497,29 @@ private void Register()
}
catch { }

bool already = false;
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
// 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)
{
string combined = ($"{stdout}\n{stderr}") ?? string.Empty;
if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0)
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))
{
already = true;
}
else
{
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
McpLog.Warn($"Failed to remove existing UnityMCP registration: {removeStderr}. Attempting to register anyway...");
}
}

if (!already)
// Now add the registration with the current transport mode
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
{
McpLog.Info("Successfully registered with Claude Code.");
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
}

CheckStatus();
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");

// 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()
Expand Down Expand Up @@ -514,7 +562,7 @@ private void Unregister()
}

client.SetStatus(McpStatus.NotConfigured);
CheckStatus();
// Status is already set - no need for blocking CheckStatus() call
}

public override string GetManualSnippet()
Expand Down
7 changes: 7 additions & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ namespace MCPForUnity.Editor.Constants
internal static class EditorPrefKeys
{
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 ValidationLevel = "MCPForUnity.ValidationLevel";
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
Expand Down
18 changes: 18 additions & 0 deletions MCPForUnity/Editor/Services/BridgeControlService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ 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)
{
Expand Down
12 changes: 12 additions & 0 deletions MCPForUnity/Editor/Services/IServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ public interface IServerManagementService
/// </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>
/// Attempts to get the command that will be executed when starting the local HTTP server
/// </summary>
Expand Down
77 changes: 77 additions & 0 deletions MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
}
}

11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading