using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Clients { /// Shared base class for MCP configurators. public abstract class McpClientConfiguratorBase : IMcpClientConfigurator { protected readonly McpClient client; protected McpClientConfiguratorBase(McpClient client) { this.client = client; } internal McpClient Client => client; public string Id => client.name.Replace(" ", "").ToLowerInvariant(); public virtual string DisplayName => client.name; public McpStatus Status => client.status; public ConfiguredTransport ConfiguredTransport => client.configuredTransport; public virtual bool SupportsAutoConfigure => true; // Default to a filesystem check on the configured path. Concrete configurators // whose presence isn't path-based (CLI binaries, etc.) override this. This makes // any future configurator that forgets to override fail-closed rather than be // treated as "detected" by ConfigureAllDetectedClients. public virtual bool IsInstalled => ParentDirectoryExists(GetConfigPath()); private static readonly ConfiguredTransport[] DefaultTransports = { ConfiguredTransport.Stdio, ConfiguredTransport.Http }; public virtual IReadOnlyList SupportedTransports => DefaultTransports; public virtual bool SupportsSkills => false; public virtual string GetConfigureActionLabel() => "Configure"; public virtual string GetSkillInstallPath() => null; public abstract string GetConfigPath(); public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true); public abstract void Configure(); /// Default Unregister is a no-op. Override in JsonFileMcpConfigurator / /// ClaudeCliMcpConfigurator etc. where removal has a concrete implementation. public virtual void Unregister() { } public abstract string GetManualSnippet(); public abstract IList GetInstallationSteps(); protected string GetUvxPathOrError() { string uvx = MCPServiceLocator.Paths.GetUvxPath(); if (string.IsNullOrEmpty(uvx)) { throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); } return uvx; } protected string CurrentOsPath() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return client.windowsConfigPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return client.macConfigPath; return client.linuxConfigPath; } protected static bool ParentDirectoryExists(string configPath) { try { if (string.IsNullOrEmpty(configPath)) return false; string parent = Path.GetDirectoryName(configPath); return !string.IsNullOrEmpty(parent) && Directory.Exists(parent); } catch { return false; } } protected bool UrlsEqual(string a, string b) { if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) { return false; } if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) { return Uri.Compare( uriA, uriB, UriComponents.HttpRequestUrl, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0; } string Normalize(string value) => value.Trim().TrimEnd('/'); return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); } /// /// Gets the expected package source for validation based on the installed package version. /// This should match what Configure() would actually use for the --from argument. /// MUST be called from the main thread due to EditorPrefs access. /// protected static string GetExpectedPackageSourceForValidation() { // Includes explicit override, stable pin, or prerelease range depending on package version. return AssetPathUtility.GetMcpServerPackageSource(); } /// /// Checks if a package source string represents a beta/prerelease version. /// Beta versions include: /// - PyPI beta: "mcpforunityserver==9.4.0b20250203..." (contains 'b' before timestamp) /// - PyPI prerelease range: "mcpforunityserver>=0.0.0a0" (used for prerelease package builds) /// - Git beta branch: contains "@beta" or "-beta" /// protected static bool IsBetaPackageSource(string packageSource) { if (string.IsNullOrEmpty(packageSource)) return false; // PyPI beta format: mcpforunityserver==X.Y.Zb // The 'b' suffix before numbers indicates a PEP 440 beta version if (System.Text.RegularExpressions.Regex.IsMatch(packageSource, @"==\d+\.\d+\.\d+b\d+")) return true; // PyPI prerelease range: >=0.0.0a0 (used for prerelease package builds) if (packageSource.Contains(">=0.0.0a0", StringComparison.OrdinalIgnoreCase)) return true; // Git-based beta references if (packageSource.Contains("@beta", StringComparison.OrdinalIgnoreCase)) return true; if (packageSource.Contains("-beta", StringComparison.OrdinalIgnoreCase)) return true; return false; } } /// JSON-file based configurator (Cursor, Windsurf, VS Code, etc.). public abstract class JsonFileMcpConfigurator : McpClientConfiguratorBase { public JsonFileMcpConfigurator(McpClient client) : base(client) { } public override string GetConfigPath() => CurrentOsPath(); public override bool IsInstalled => ParentDirectoryExists(GetConfigPath()); public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { try { string path = GetConfigPath(); if (!File.Exists(path)) { client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } string configJson = File.ReadAllText(path); string[] args = null; string configuredUrl = null; bool configExists = false; { var rootConfig = JsonConvert.DeserializeObject(configJson) as JObject; JToken unityToken = null; if (rootConfig != null) { string containerKey = string.IsNullOrEmpty(client.ServerContainerKey) ? "mcpServers" : client.ServerContainerKey; unityToken = client.IsVsCodeLayout ? rootConfig["servers"]?["unityMCP"] ?? rootConfig["mcp"]?["servers"]?["unityMCP"] : rootConfig[containerKey]?["unityMCP"]; } if (unityToken is JObject unityObj) { configExists = true; var argsToken = unityObj["args"]; if (argsToken is JArray) { args = argsToken.ToObject(); } // Clients diverge on the HTTP URL property name: "url" (Cursor/VSCode/Claude), // "serverUrl" (Antigravity/Windsurf), "httpUrl" (Gemini CLI). Accept all three // so CheckStatus matches what Configure() actually wrote. var urlToken = unityObj["url"] ?? unityObj["serverUrl"] ?? unityObj["httpUrl"]; if (urlToken != null && urlToken.Type != JTokenType.Null) { configuredUrl = urlToken.ToString(); } } } if (!configExists) { client.SetStatus(McpStatus.MissingConfig); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } // Determine and set the configured transport type if (args != null && args.Length > 0) { client.configuredTransport = Models.ConfiguredTransport.Stdio; } else if (!string.IsNullOrEmpty(configuredUrl)) { // Distinguish HTTP Local from HTTP Remote by matching against both URLs string localRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl(); string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(configuredUrl, remoteRpcUrl)) { client.configuredTransport = Models.ConfiguredTransport.HttpRemote; } else { client.configuredTransport = Models.ConfiguredTransport.Http; } } else { client.configuredTransport = Models.ConfiguredTransport.Unknown; } bool matches = false; bool hasVersionMismatch = false; string mismatchReason = null; if (args != null && args.Length > 0) { // Use beta-aware expected package source for comparison string expectedUvxUrl = GetExpectedPackageSourceForValidation(); string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); if (!string.IsNullOrEmpty(configuredUvxUrl) && !string.IsNullOrEmpty(expectedUvxUrl)) { if (McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl)) { matches = true; } else { // Check for beta/stable mismatch bool configuredIsBeta = IsBetaPackageSource(configuredUvxUrl); bool expectedIsBeta = IsBetaPackageSource(expectedUvxUrl); if (configuredIsBeta && !expectedIsBeta) { hasVersionMismatch = true; mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; } else if (!configuredIsBeta && expectedIsBeta) { hasVersionMismatch = true; mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; } else { hasVersionMismatch = true; mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; } } } } else if (!string.IsNullOrEmpty(configuredUrl)) { // Match against the active scope's URL string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); matches = UrlsEqual(configuredUrl, expectedUrl); } if (matches) { client.SetStatus(McpStatus.Configured); return client.status; } if (hasVersionMismatch) { if (attemptAutoRewrite) { var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { client.SetStatus(McpStatus.VersionMismatch, mismatchReason); } } else { client.SetStatus(McpStatus.VersionMismatch, mismatchReason); } } else if (attemptAutoRewrite) { var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { client.SetStatus(McpStatus.IncorrectPath); } } else { client.SetStatus(McpStatus.IncorrectPath); } } catch (Exception ex) { client.SetStatus(McpStatus.Error, ex.Message); client.configuredTransport = Models.ConfiguredTransport.Unknown; } return client.status; } public override void Configure() { // Always idempotent-write. The per-client UI button routes through Unregister // when the user clicks while the client is already Configured; the bulk // "Configure All" path calls this directly and expects an unconditional write. string path = GetConfigPath(); McpConfigurationHelper.EnsureConfigDirectoryExists(path); string result = McpConfigurationHelper.WriteMcpConfiguration(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { throw new InvalidOperationException(result); } } public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure"; /// /// Removes the unityMCP entry from the client's JSON config (VS Code-style /// `servers` / `mcp.servers` layouts, the standard `mcpServers` layout, or a /// client-specific container such as Kilo's `mcp`). Leaves the file in place so we /// don't clobber other servers the user has configured. /// public override void Unregister() { string path = GetConfigPath(); try { if (!File.Exists(path)) { client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; return; } var root = JsonConvert.DeserializeObject(File.ReadAllText(path)) as JObject; if (root == null) { client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; return; } bool removed = false; if (client.IsVsCodeLayout) { if ((root["servers"] as JObject)?.Remove("unityMCP") == true) removed = true; if ((root["mcp"]?["servers"] as JObject)?.Remove("unityMCP") == true) removed = true; } else { string containerKey = string.IsNullOrEmpty(client.ServerContainerKey) ? "mcpServers" : client.ServerContainerKey; if ((root[containerKey] as JObject)?.Remove("unityMCP") == true) removed = true; } if (removed) { File.WriteAllText(path, root.ToString(Formatting.Indented)); } client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; } catch (Exception ex) { throw new InvalidOperationException($"Failed to unregister: {ex.Message}", ex); } } public override string GetManualSnippet() { try { string uvx = GetUvxPathOrError(); return ConfigJsonBuilder.BuildManualConfigJson(uvx, client); } catch (Exception ex) { var errorObj = new { error = ex.Message }; return JsonConvert.SerializeObject(errorObj); } } public override IList GetInstallationSteps() => new List { "Configuration steps not available for this client." }; } /// Codex (TOML) configurator. public abstract class CodexMcpConfigurator : McpClientConfiguratorBase { public CodexMcpConfigurator(McpClient client) : base(client) { } public override string GetConfigPath() => CurrentOsPath(); public override bool IsInstalled => ParentDirectoryExists(GetConfigPath()); public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { try { string path = GetConfigPath(); if (!File.Exists(path)) { client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } string toml = File.ReadAllText(path); if (CodexConfigHelper.TryParseCodexServer(toml, out _, out var args, out var url)) { // Determine and set the configured transport type if (!string.IsNullOrEmpty(url)) { // Distinguish HTTP Local from HTTP Remote string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(url, remoteRpcUrl)) { client.configuredTransport = Models.ConfiguredTransport.HttpRemote; } else { client.configuredTransport = Models.ConfiguredTransport.Http; } } else if (args != null && args.Length > 0) { client.configuredTransport = Models.ConfiguredTransport.Stdio; } else { client.configuredTransport = Models.ConfiguredTransport.Unknown; } bool matches = false; bool hasVersionMismatch = false; string mismatchReason = null; if (!string.IsNullOrEmpty(url)) { // Match against the active scope's URL matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl()); } else if (args != null && args.Length > 0) { // Use beta-aware expected package source for comparison string expected = GetExpectedPackageSourceForValidation(); string configured = McpConfigurationHelper.ExtractUvxUrl(args); if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected)) { if (McpConfigurationHelper.PathsEqual(configured, expected)) { matches = true; } else { // Check for beta/stable mismatch bool configuredIsBeta = IsBetaPackageSource(configured); bool expectedIsBeta = IsBetaPackageSource(expected); if (configuredIsBeta && !expectedIsBeta) { hasVersionMismatch = true; mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; } else if (!configuredIsBeta && expectedIsBeta) { hasVersionMismatch = true; mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; } else { hasVersionMismatch = true; mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; } } } } if (matches) { client.SetStatus(McpStatus.Configured); return client.status; } if (hasVersionMismatch) { if (attemptAutoRewrite) { string result = McpConfigurationHelper.ConfigureCodexClient(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); return client.status; } } client.SetStatus(McpStatus.VersionMismatch, mismatchReason); return client.status; } } else { client.configuredTransport = Models.ConfiguredTransport.Unknown; } if (attemptAutoRewrite) { string result = McpConfigurationHelper.ConfigureCodexClient(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { client.SetStatus(McpStatus.IncorrectPath); } } else { client.SetStatus(McpStatus.IncorrectPath); } } catch (Exception ex) { client.SetStatus(McpStatus.Error, ex.Message); client.configuredTransport = Models.ConfiguredTransport.Unknown; } return client.status; } public override void Configure() { string path = GetConfigPath(); McpConfigurationHelper.EnsureConfigDirectoryExists(path); string result = McpConfigurationHelper.ConfigureCodexClient(path, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { throw new InvalidOperationException(result); } } public override string GetManualSnippet() { try { string uvx = GetUvxPathOrError(); return CodexConfigHelper.BuildCodexServerBlock(uvx); } catch (Exception ex) { return $"# error: {ex.Message}"; } } public override IList GetInstallationSteps() => new List { "Run 'codex config edit' or open the config path", "Paste the TOML", "Save and restart Codex" }; } /// CLI-based configurator (Claude Code). public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase { public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } public override bool SupportsAutoConfigure => true; public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure"; public override string GetConfigPath() => "Managed via Claude CLI"; public override bool IsInstalled => MCPServiceLocator.Paths.IsClaudeCliDetected(); /// /// Returns the project directory that CLI-based configurators will use as the working directory /// for `claude mcp add/remove --scope local`. Checks for an explicit override in EditorPrefs /// first, then falls back to the current Unity project directory. /// The override is useful when the Claude Code workspace is at a different path than the Unity project /// (e.g., plugin developers running CC from the repo root while Unity is open with a test project). /// MUST be called from the main Unity thread (accesses Application.dataPath and EditorPrefs). /// internal static string GetClientProjectDir() { string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty); if (!string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir)) return overrideDir; return Path.GetDirectoryName(Application.dataPath); } /// /// Returns true if a valid client project directory override is set. /// internal static bool HasClientProjectDirOverride { get { string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty); return !string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir); } } /// Checks the Claude CLI registration status. /// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access. /// public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { // Capture main-thread-only values before delegating to thread-safe method string projectDir = GetClientProjectDir(); bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; // Resolve claudePath on the main thread (EditorPrefs access) string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); RuntimePlatform platform = Application.platform; bool isRemoteScope = HttpEndpointUtility.IsRemoteScope(); // Get expected package source for the installed package version (matches what Register() would use) string expectedPackageSource = GetExpectedPackageSourceForValidation(); return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite, HasClientProjectDirOverride); } /// /// Internal thread-safe version of CheckStatus. /// Can be called from background threads because all main-thread-only values are passed as parameters. /// projectDir, useHttpTransport, claudePath, platform, isRemoteScope, and expectedPackageSource are REQUIRED /// (non-nullable where applicable) to enforce thread safety at compile time. /// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread. /// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration /// on the main thread based on the returned status. /// internal McpStatus CheckStatusWithProjectDir( string projectDir, bool useHttpTransport, string claudePath, RuntimePlatform platform, bool isRemoteScope, string expectedPackageSource, bool attemptAutoRewrite = false, bool hasProjectDirOverride = false) { try { if (string.IsNullOrEmpty(claudePath)) { client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } // 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"); } // Read Claude Code config directly from ~/.claude.json instead of using slow CLI // This is instant vs 15+ seconds for `claude mcp list` which does health checks var configResult = ReadClaudeCodeConfig(projectDir); if (configResult.error != null) { client.SetStatus(McpStatus.NotConfigured, configResult.error); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } if (configResult.serverConfig == null) { // UnityMCP not found in config client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } // UnityMCP is registered - check transport and version bool currentUseHttp = useHttpTransport; var serverConfig = configResult.serverConfig; // Determine registered transport type string registeredType = serverConfig["type"]?.ToString()?.ToLowerInvariant() ?? ""; bool registeredWithHttp = registeredType == "http"; bool registeredWithStdio = registeredType == "stdio"; // Set the configured transport based on what we detected if (registeredWithHttp) { client.configuredTransport = isRemoteScope ? Models.ConfiguredTransport.HttpRemote : Models.ConfiguredTransport.Http; } else if (registeredWithStdio) { client.configuredTransport = Models.ConfiguredTransport.Stdio; } else { client.configuredTransport = Models.ConfiguredTransport.Unknown; } // Check for transport mismatch. // When a project dir override is active, the local UseHttpTransport // GUI setting may legitimately differ from the registered transport // in the overridden project, so skip this check. bool hasTransportMismatch = !hasProjectDirOverride && ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp)); // For stdio transport, also check package version bool hasVersionMismatch = false; string configuredPackageSource = null; string mismatchReason = null; if (registeredWithStdio) { configuredPackageSource = ExtractPackageSourceFromConfig(serverConfig); if (!string.IsNullOrEmpty(configuredPackageSource) && !string.IsNullOrEmpty(expectedPackageSource)) { // Check for exact match first if (!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase)) { hasVersionMismatch = true; // Provide more specific mismatch reason for beta/stable differences bool configuredIsBeta = IsBetaPackageSource(configuredPackageSource); bool expectedIsBeta = IsBetaPackageSource(expectedPackageSource); if (configuredIsBeta && !expectedIsBeta) { mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; } else if (!configuredIsBeta && expectedIsBeta) { mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; } else { mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; } } } } // If there's any mismatch and auto-rewrite is enabled, re-register if (hasTransportMismatch || hasVersionMismatch) { // Configure() requires main thread (accesses EditorPrefs, Application.dataPath) // Only attempt auto-rewrite if we're on the main thread bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1; if (attemptAutoRewrite && isMainThread) { string reason = hasTransportMismatch ? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})" : mismatchReason ?? $"Package version mismatch"; McpLog.Info($"{reason}. Re-registering..."); try { // Force re-register by ensuring status is not Configured (which would toggle to Unregister) client.SetStatus(McpStatus.IncorrectPath); Configure(); return client.status; } catch (Exception ex) { McpLog.Warn($"Auto-reregister failed: {ex.Message}"); client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register."); return client.status; } } else { if (hasTransportMismatch) { string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register."; client.SetStatus(McpStatus.Error, errorMsg); McpLog.Warn(errorMsg); } else { client.SetStatus(McpStatus.VersionMismatch, mismatchReason); } return client.status; } } client.SetStatus(McpStatus.Configured); return client.status; } catch (Exception ex) { McpLog.Warn($"[Claude Code] CheckStatus exception: {ex.GetType().Name}: {ex.Message}"); client.SetStatus(McpStatus.Error, ex.Message); client.configuredTransport = Models.ConfiguredTransport.Unknown; } return client.status; } public override void Configure() { if (client.status == McpStatus.Configured) { Unregister(); } else { Register(); } } /// /// Thread-safe version of Configure that uses pre-captured main-thread values. /// All parameters must be captured on the main thread before calling this method. /// public void ConfigureWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, string uvxPath, string fromArgs, string packageName, string uvxDevFlags, string apiKey, Models.ConfiguredTransport serverTransport) { if (client.status == McpStatus.Configured) { UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend); } else { RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, useHttpTransport, httpUrl, uvxPath, fromArgs, packageName, uvxDevFlags, apiKey, serverTransport); } } /// /// Thread-safe registration using pre-captured values. /// private void RegisterWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, string uvxPath, string fromArgs, string packageName, string uvxDevFlags, string apiKey, Models.ConfiguredTransport serverTransport) { if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } string args; if (useHttpTransport) { // Only include API key header for remote-hosted mode // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey)) { string safeKey = SanitizeShellHeaderValue(apiKey); args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; } else { args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else { // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {uvxDevFlags}{fromArgs} {packageName}"; } // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); RemoveFromAllScopes(claudePath, projectDir, pathPrepend); // Now add the registration if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); } McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); client.SetStatus(McpStatus.Configured); client.configuredTransport = serverTransport; } /// /// Thread-safe unregistration using pre-captured values. /// private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend) { if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } // Remove from ALL scopes to ensure complete cleanup (#664) McpLog.Info("Removing all UnityMCP registrations from all scopes..."); RemoveFromAllScopes(claudePath, projectDir, pathPrepend); McpLog.Info("MCP server successfully unregistered from Claude Code."); client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; } private void Register() { var pathService = MCPServiceLocator.Paths; string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; string args; if (useHttpTransport) { string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); // Only include API key header for remote-hosted mode // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) if (HttpEndpointUtility.IsRemoteScope()) { string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); if (!string.IsNullOrEmpty(apiKey)) { string safeKey = SanitizeShellHeaderValue(apiKey); args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; } else { args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else { args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; } } else { var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); string devFlags = AssetPathUtility.GetUvxDevFlags(); string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}"; } string projectDir = GetClientProjectDir(); 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 { } // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); RemoveFromAllScopes(claudePath, projectDir, pathPrepend); // Now add the registration with the current transport mode if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); } 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); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } public override void Unregister() { var pathService = MCPServiceLocator.Paths; string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } string projectDir = GetClientProjectDir(); 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"; } // Remove from ALL scopes to ensure complete cleanup (#664) McpLog.Info("Removing all UnityMCP registrations from all scopes..."); RemoveFromAllScopes(claudePath, projectDir, pathPrepend); McpLog.Info("MCP server successfully unregistered from Claude Code."); client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; } public override string GetManualSnippet() { string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttpTransport) { string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); // Only include API key header for remote-hosted mode string headerArg = ""; if (HttpEndpointUtility.IsRemoteScope()) { string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : ""; } return "# Register the MCP server with Claude Code:\n" + $"claude mcp add --scope local --transport http UnityMCP {httpUrl}{headerArg}\n\n" + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + "claude mcp remove --scope local UnityMCP\n" + "claude mcp remove --scope user UnityMCP\n" + "claude mcp remove --scope project UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list"; } if (string.IsNullOrEmpty(uvxPath)) { return "# Error: Configuration not available - check paths in Advanced Settings"; } string devFlags = AssetPathUtility.GetUvxDevFlags(); string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); return "# Register the MCP server with Claude Code:\n" + $"claude mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} mcp-for-unity\n\n" + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + "claude mcp remove --scope local UnityMCP\n" + "claude mcp remove --scope user UnityMCP\n" + "claude mcp remove --scope project UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list"; } public override IList GetInstallationSteps() => new List { "Ensure Claude CLI is installed", "Use Configure to add UnityMCP (or run claude mcp add UnityMCP)", "Restart Claude Code" }; /// /// Removes UnityMCP registration from all Claude Code configuration scopes (local, user, project). /// Also removes legacy entries from ~/.claude.json that the CLI scoped removal can't touch. /// This ensures no stale or conflicting configurations remain across different scopes. /// Also handles legacy "unityMCP" naming convention. /// private static void RemoveFromAllScopes(string claudePath, string projectDir, string pathPrepend) { // Remove from all three scopes to prevent stale configs causing connection issues. // See GitHub issue #664 - conflicting configs at different scopes can cause // Claude Code to connect with outdated/incorrect configuration. string[] scopes = { "local", "user", "project" }; string[] names = { "UnityMCP", "unityMCP" }; // Include legacy naming foreach (var scope in scopes) { foreach (var name in names) { ExecPath.TryRun(claudePath, $"mcp remove --scope {scope} {name}", projectDir, out _, out _, 5000, pathPrepend); } } // Also remove legacy entries directly from ~/.claude.json. // Older versions and manual CLI commands without --scope wrote mcpServers entries // into the projects section of ~/.claude.json. The scoped `claude mcp remove` commands // above won't touch these, leaving stale/conflicting configs behind. RemoveLegacyUserConfigEntries(projectDir); } /// /// Removes UnityMCP entries from the projects section of ~/.claude.json. /// These are legacy entries that were created by older versions or manual commands /// that didn't use --scope. The scoped `claude mcp remove` commands don't clean these up. /// private static void RemoveLegacyUserConfigEntries(string projectDir) { try { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string configPath = Path.Combine(homeDir, ".claude.json"); if (!File.Exists(configPath)) return; string json = File.ReadAllText(configPath); var config = JObject.Parse(json); var projects = config["projects"] as JObject; if (projects == null) return; string normalizedProjectDir = NormalizePath(projectDir); bool modified = false; // Walk all project entries looking for ones that match our project path foreach (var project in projects.Properties()) { string normalizedKey = NormalizePath(project.Name); // Match exact path or parent paths (same logic as ReadUserScopeConfig) if (!string.Equals(normalizedKey, normalizedProjectDir, StringComparison.OrdinalIgnoreCase)) { // Also check if projectDir is a child of this config entry if (!normalizedProjectDir.StartsWith(normalizedKey + "/", StringComparison.OrdinalIgnoreCase)) continue; } var mcpServers = project.Value?["mcpServers"] as JObject; if (mcpServers == null) continue; // Remove UnityMCP/unityMCP entries (case-insensitive) var toRemove = new List(); foreach (var server in mcpServers.Properties()) { if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) { toRemove.Add(server.Name); } } foreach (var name in toRemove) { mcpServers.Remove(name); modified = true; McpLog.Info($"Removed legacy '{name}' entry from ~/.claude.json for project '{project.Name}'"); } } if (modified) { File.WriteAllText(configPath, config.ToString(Formatting.Indented)); } } catch (Exception ex) { McpLog.Warn($"Failed to clean up legacy ~/.claude.json entries: {ex.Message}"); } } /// /// Sanitizes a value for safe inclusion inside a double-quoted shell argument. /// Escapes characters that are special within double quotes (", \, `, $, !) /// to prevent shell injection or argument splitting. /// private static string SanitizeShellHeaderValue(string value) { if (string.IsNullOrEmpty(value)) return value; var sb = new System.Text.StringBuilder(value.Length); foreach (char c in value) { switch (c) { case '"': case '\\': case '`': case '$': case '!': sb.Append('\\'); sb.Append(c); break; default: sb.Append(c); break; } } return sb.ToString(); } /// /// Extracts the package source (--from argument value) from claude mcp get output. /// The output format includes args like: --from "mcpforunityserver==9.0.1" /// private static string ExtractPackageSourceFromCliOutput(string cliOutput) { if (string.IsNullOrEmpty(cliOutput)) return null; // Look for --from followed by the package source // The CLI output may have it quoted or unquoted int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase); if (fromIndex < 0) return null; // Move past "--from" and any whitespace int startIndex = fromIndex + 6; while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex])) startIndex++; if (startIndex >= cliOutput.Length) return null; // Check if value is quoted char quoteChar = cliOutput[startIndex]; if (quoteChar == '"' || quoteChar == '\'') { startIndex++; int endIndex = cliOutput.IndexOf(quoteChar, startIndex); if (endIndex > startIndex) return cliOutput.Substring(startIndex, endIndex - startIndex); } else { // Unquoted - read until whitespace or end of line int endIndex = startIndex; while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex])) endIndex++; if (endIndex > startIndex) return cliOutput.Substring(startIndex, endIndex - startIndex); } return null; } /// /// Reads Claude Code configuration from both local-scope (.claude/mcp.json in the project) /// and user-scope (~/.claude.json). Local scope takes precedence, matching Claude Code's /// own config resolution order. /// This is much faster than running `claude mcp list` which does health checks on all servers. /// private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string projectDir) { try { // 1. Check local-scope config first: {projectDir}/.claude/mcp.json // This is where `claude mcp add --scope local` writes. var localResult = ReadLocalScopeConfig(projectDir); if (localResult.serverConfig != null) return localResult; if (localResult.error != null) return localResult; // 2. Fall back to user-scope config: ~/.claude.json return ReadUserScopeConfig(projectDir); } catch (Exception ex) { return (null, $"Error reading Claude config: {ex.Message}"); } } /// /// Reads UnityMCP config from the local-scope file: {projectDir}/.claude/mcp.json. /// This is where `claude mcp add --scope local` stores registrations. /// private static (JObject serverConfig, string error) ReadLocalScopeConfig(string projectDir) { try { if (string.IsNullOrEmpty(projectDir)) return (null, null); string localConfigPath = Path.Combine(projectDir, ".claude", "mcp.json"); if (!File.Exists(localConfigPath)) return (null, null); string json = File.ReadAllText(localConfigPath); var config = JObject.Parse(json); var mcpServers = config["mcpServers"] as JObject; if (mcpServers == null) return (null, null); foreach (var server in mcpServers.Properties()) { if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) { return (server.Value as JObject, null); } } return (null, null); } catch (Exception ex) { return (null, $"Error reading local Claude config: {ex.Message}"); } } /// /// Reads UnityMCP config from the user-scope file: ~/.claude.json (projects section). /// This handles legacy configurations and direct user-level entries. /// private static (JObject serverConfig, string error) ReadUserScopeConfig(string projectDir) { try { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string configPath = Path.Combine(homeDir, ".claude.json"); if (!File.Exists(configPath)) return (null, null); string configJson = File.ReadAllText(configPath); var config = JObject.Parse(configJson); var projects = config["projects"] as JObject; if (projects == null) return (null, null); // Build a dictionary of normalized paths for quick lookup // Use last entry for duplicates (forward/backslash variants) as it's typically more recent var normalizedProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var project in projects.Properties()) { string normalizedPath = NormalizePath(project.Name); normalizedProjects[normalizedPath] = project.Value as JObject; } // Walk up the directory tree to find a matching project config // Claude Code may be configured at a parent directory (e.g., repo root) // while Unity project is in a subdirectory (e.g., TestProjects/UnityMCPTests) string currentDir = NormalizePath(projectDir); while (!string.IsNullOrEmpty(currentDir)) { if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) { var mcpServers = projectConfig?["mcpServers"] as JObject; if (mcpServers != null) { foreach (var server in mcpServers.Properties()) { if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) { return (server.Value as JObject, null); } } } // Found the project but no UnityMCP - don't continue walking up return (null, null); } // Move up one directory int lastSlash = currentDir.LastIndexOf('/'); if (lastSlash <= 0) break; currentDir = currentDir.Substring(0, lastSlash); } return (null, null); } catch (Exception ex) { return (null, $"Error reading user Claude config: {ex.Message}"); } } /// /// Normalizes a file path for comparison (handles forward/back slashes, trailing slashes). /// private static string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) return path; // Replace backslashes with forward slashes and remove trailing slashes return path.Replace('\\', '/').TrimEnd('/'); } /// /// Extracts the package source from Claude Code JSON config. /// For stdio servers, this is in the args array after "--from". /// private static string ExtractPackageSourceFromConfig(JObject serverConfig) { if (serverConfig == null) return null; var args = serverConfig["args"] as JArray; if (args == null) return null; // Look for --from argument (either "--from VALUE" or "--from=VALUE" format) bool foundFrom = false; foreach (var arg in args) { string argStr = arg?.ToString(); if (argStr == null) continue; if (foundFrom) { // This is the package source following --from return argStr; } if (argStr == "--from") { foundFrom = true; } else if (argStr.StartsWith("--from=", StringComparison.OrdinalIgnoreCase)) { // Handle --from=VALUE format return argStr.Substring(7).Trim('"', '\''); } } return null; } } }