Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 9 additions & 12 deletions MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;

namespace MCPForUnity.Editor.Clients.Configurators
{
public class ClaudeCodeConfigurator : JsonFileMcpConfigurator
/// <summary>
/// Claude Code configurator using the CLI-based registration (claude mcp add/remove).
/// This integrates with Claude Code's native MCP management.
/// </summary>
public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator
{
public ClaudeCodeConfigurator() : base(new McpClient
{
name = "Claude Code",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
SupportsHttpTransport = true,
HttpUrlProperty = "url", // Claude Code uses "url" for HTTP servers
IsVsCodeLayout = false, // Claude Code uses standard mcpServers layout
})
{ }

public override IList<string> GetInstallationSteps() => new List<string>
{
"Open your project in Claude Code",
"Click Configure in MCP for Unity (or manually edit ~/.claude.json)",
"The MCP server will be added to the global mcpServers section",
"Restart Claude Code to apply changes"
"Ensure Claude CLI is installed (comes with Claude Code)",
"Click Register to add UnityMCP via 'claude mcp add'",
"The server will be automatically available in Claude Code",
"Use Unregister to remove via 'claude mcp remove'"
};
}
}
242 changes: 201 additions & 41 deletions MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public static (string uvxPath, string fromUrl, string packageName) GetUvxCommand
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
/// Note: --reinstall is not supported by uvx and will cause a warning.
/// </summary>
public static bool ShouldForceUvxRefresh()
{
Expand Down
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ private static void AddDevModeArgs(TomlArray args)
{
if (args == null) return;
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
args.Add(new TomlString { Value = "--no-cache" });
args.Add(new TomlString { Value = "--refresh" });
Expand Down
5 changes: 3 additions & 2 deletions MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,11 @@ private static JObject EnsureObject(JObject parent, string name)
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated.
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
// Note: --reinstall is not supported by uvx and will cause a warning.
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>();

// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
if (AssetPathUtility.ShouldForceUvxRefresh())
{
Expand Down
18 changes: 16 additions & 2 deletions MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,24 @@ private static void RunMigrationIfNeeded()
{
try
{
if (!ConfigUsesStdIo(configurator.Client))
if (!configurator.SupportsAutoConfigure)
continue;

if (!configurator.SupportsAutoConfigure)
// Handle CLI-based configurators (e.g., Claude Code CLI)
// CheckStatus with attemptAutoRewrite=true will auto-reregister if version mismatch
if (configurator is ClaudeCliMcpConfigurator cliConfigurator)
{
var previousStatus = configurator.Status;
configurator.CheckStatus(attemptAutoRewrite: true);
if (configurator.Status != previousStatus)
{
touchedAny = true;
}
continue;
}

// Handle JSON file-based configurators
if (!ConfigUsesStdIo(configurator.Client))
continue;

MCPServiceLocator.Client.ConfigureClient(configurator);
Expand Down
92 changes: 84 additions & 8 deletions MCPForUnity/Editor/Services/EditorStateCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ internal static class EditorStateCache
private static long? _domainReloadAfterUnixMs;

private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 0.25;
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s

// State tracking to detect when snapshot actually changes (checked BEFORE building)
private static string _lastTrackedScenePath;
private static string _lastTrackedSceneName;
private static bool _lastTrackedIsFocused;
private static bool _lastTrackedIsPlaying;
private static bool _lastTrackedIsPaused;
private static bool _lastTrackedIsUpdating;
private static bool _lastTrackedTestsRunning;
private static string _lastTrackedActivityPhase;

private static JObject _cached;

Expand Down Expand Up @@ -263,16 +273,78 @@ private static void OnUpdate()
{
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup;
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();

// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;

if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
// Still update on compilation edge transitions to keep timestamps meaningful.
bool isCompiling = GetActualIsCompiling();
if (isCompiling == _lastIsCompiling)
{
return;
}
return;
}

// Fast state-change detection BEFORE building snapshot.
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
// These checks are much cheaper than building a full JSON snapshot.
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneName = scene.name ?? string.Empty;
bool isFocused = InternalEditorUtility.isApplicationActive;
bool isPlaying = EditorApplication.isPlaying;
bool isPaused = EditorApplication.isPaused;
bool isUpdating = EditorApplication.isUpdating;
bool testsRunning = TestRunStatus.IsRunning;

var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}

bool hasChanges = compilationEdge
|| _lastTrackedScenePath != scenePath
|| _lastTrackedSceneName != sceneName
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsPaused != isPaused
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedTestsRunning != testsRunning
|| _lastTrackedActivityPhase != activityPhase;

if (!hasChanges)
{
// No state change - skip the expensive BuildSnapshot entirely.
// This is the key optimization that prevents the 28ms GC spikes.
return;
}

// Update tracked state
_lastTrackedScenePath = scenePath;
_lastTrackedSceneName = sceneName;
_lastTrackedIsFocused = isFocused;
_lastTrackedIsPlaying = isPlaying;
_lastTrackedIsPaused = isPaused;
_lastTrackedIsUpdating = isUpdating;
_lastTrackedTestsRunning = testsRunning;
_lastTrackedActivityPhase = activityPhase;

_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
}
Expand Down Expand Up @@ -425,6 +497,10 @@ public static JObject GetSnapshot()
{
_cached = BuildSnapshot("rebuild");
}

// Always return a fresh clone to prevent mutation bugs.
// The main GC optimization comes from state-change detection (OnUpdate)
// which prevents unnecessary _cached rebuilds, not from caching the clone.
return (JObject)_cached.DeepClone();
}
}
Expand Down
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Services/ServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,7 @@ private bool TryGetLocalHttpServerCommandParts(out string fileName, out string a
}

// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
string args = string.IsNullOrEmpty(fromUrl)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ private static void ProcessQueue()

lock (PendingLock)
{
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
if (Pending.Count == 0)
{
return;
}

ready = new List<(string, PendingCommand)>(Pending.Count);
foreach (var kvp in Pending)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,12 @@ private static void ProcessCommands()
List<(string id, QueuedCommand command)> work;
lock (lockObj)
{
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
if (commandQueue.Count == 0)
{
return;
}

work = new List<(string, QueuedCommand)>(commandQueue.Count);
foreach (var kvp in commandQueue)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ private void OnConfigureClicked()

var client = configurators[selectedClientIndex];

// Handle CLI configurators asynchronously
if (client is ClaudeCliMcpConfigurator)
{
ConfigureClaudeCliAsync(client);
return;
}

try
{
MCPServiceLocator.Client.ConfigureClient(client);
Expand All @@ -237,6 +244,92 @@ private void OnConfigureClicked()
}
}

private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
{
if (statusRefreshInFlight.Contains(client))
return;

statusRefreshInFlight.Add(client);
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Registering...");

// Capture ALL 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();
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh();

// Compute pathPrepend on main thread
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
else if (Application.platform == RuntimePlatform.LinuxEditor)
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
try
{
string claudeDir = Path.GetDirectoryName(claudePath);
if (!string.IsNullOrEmpty(claudeDir))
pathPrepend = string.IsNullOrEmpty(pathPrepend) ? claudeDir : $"{claudeDir}:{pathPrepend}";
}
catch { }

Task.Run(() =>
{
try
{
if (client is ClaudeCliMcpConfigurator cliConfigurator)
{
cliConfigurator.ConfigureWithCapturedValues(
projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl,
uvxPath, gitUrl, packageName, shouldForceRefresh);
}
return (success: true, error: (string)null);
}
catch (Exception ex)
{
return (success: false, error: ex.Message);
}
}).ContinueWith(t =>
{
string errorMessage = null;
if (t.IsFaulted && t.Exception != null)
{
errorMessage = t.Exception.GetBaseException()?.Message ?? "Configuration failed";
}
else if (!t.Result.success)
{
errorMessage = t.Result.error;
}

EditorApplication.delayCall += () =>
{
statusRefreshInFlight.Remove(client);
lastStatusChecks.Remove(client);

if (errorMessage != null)
{
if (client is McpClientConfiguratorBase baseConfigurator)
{
baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage);
}
McpLog.Error($"Configuration failed: {errorMessage}");
RefreshClientStatus(client, forceImmediate: true);
}
else
{
// Registration succeeded - trust the status set by RegisterWithCapturedValues
// and update UI without re-verifying (which could fail due to CLI timing/scope issues)
lastStatusChecks[client] = DateTime.UtcNow;
ApplyStatusToUi(client);
}
UpdateManualConfiguration();
};
});
}

private void OnBrowseClaudeClicked()
{
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
Expand Down Expand Up @@ -396,7 +489,7 @@ private bool ShouldRefreshClient(IMcpClientConfigurator client)
return (DateTime.UtcNow - last) > StatusRefreshInterval;
}

private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false)
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false, string customMessage = null)
{
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
return;
Expand All @@ -410,7 +503,7 @@ private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking =

if (showChecking)
{
clientStatusLabel.text = "Checking...";
clientStatusLabel.text = customMessage ?? "Checking...";
clientStatusLabel.style.color = StyleKeyword.Null;
clientStatusIndicator.AddToClassList("warning");
configureButton.text = client.GetConfigureActionLabel();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,12 @@ private async void OnConnectionToggleClicked()
{
if (bridgeService.IsRunning)
{
// Clear any resume flags when user manually ends the session to prevent
// getting stuck in "Resuming..." state (the flag may have been set by a
// domain reload that started just before the user clicked End Session)
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }

await bridgeService.StopAsync();
}
else
Expand Down Expand Up @@ -717,6 +723,11 @@ private async Task EndOrphanedSessionAsync()
{
connectionToggleInProgress = true;
connectionToggleButton?.SetEnabled(false);

// Clear resume flags to prevent getting stuck in "Resuming..." state
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }

await MCPServiceLocator.Bridge.StopAsync();
}
catch (Exception ex)
Expand Down
Loading