From 664a43b76cf1233c50e2d75317ac072a2fa85ddf Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 30 Jan 2026 18:39:21 -0400 Subject: [PATCH] Remote server auth (#644) * Disable the gloabl default to first session when hosting remotely * Remove calls to /plugin/sessions The newer /api/instances covers that data, and we want to remove these "expose all" endpoints * Disable CLI routes when running in remote hosted mode * Update server README * feat: add API key authentication support for remote-hosted HTTP transport - Add API key field to connection UI (visible only in HTTP Remote mode) - Add "Get API Key" and "Clear" buttons with login URL retrieval - Include X-API-Key header in WebSocket connections when configured - Add API key to CLI commands (mcp add, claude mcp add) when set - Update config.json generation to include headers with API key - Add API key validation service with caching and configurable endpoints - Add /api/auth/login-url endpoint * feat: add environment variable support for HTTP remote hosted mode - Add UNITY_MCP_HTTP_REMOTE_HOSTED environment variable as alternative to --http-remote-hosted flag - Accept "true", "1", or "yes" values (case-insensitive) - Update CLI help text to document environment variable option * feat: add user isolation enforcement for remote-hosted mode session listing - Raise ValueError when list_sessions() called without user_id in remote-hosted mode - Add comprehensive integration tests for multi-user session isolation - Add unit tests for PluginRegistry user-scoped session filtering - Verify cross-user isolation with same project hash - Test unity_instances resource and set_active_instance user filtering * feat: add comprehensive integration tests for API key authentication - Add ApiKeyService tests covering validation, caching, retries, and singleton lifecycle - Add startup config validation tests for remote-hosted mode requirements - Test cache hit/miss scenarios, TTL expiration, and manual invalidation - Test transient failure handling (5xx, timeouts, connection errors) with retry logic - Test service token header injection and empty key fast-path validation - Test startup validation requiring * test: add autouse fixture to restore config state after startup validation tests Ensures test isolation for config-dependent integration tests * feat: skip user_id resolution in non-remote-hosted mode Prevents unnecessary API key validation when not in remote-hosted mode * test: add missing mock attributes to instance routing tests - Add client_id to test context mock in set_active_instance test - Add get_state mock to context in global instance routing test * Fix broken telemetry test * Add comprehensive API key authentication documentation - Add user guide covering configuration, setup, and troubleshooting - Add architecture reference documenting internal design and request flows * Add remote-hosted mode and API key authentication documentation to server README * Update reference doc for Docker Hub * Specify exception being caught * Ensure caplog handler cleanup in telemetry queue worker test * Use NoUnitySessionError instead of RuntimeError in session isolation test * Remove unusued monkeypatch arg * Use more obviously fake API keys * Reject connections when ApiKeyService is not initialized in remote-hosted mode - Validate that user_id is present after successful key validation - Expand transient error detection to include timeout and service errors - Use consistent 1013 status code for retryable auth failures * Accept "on" for UNITY_MCP_HTTP_REMOTE_HOSTED env var Consistent with repo * Invalidate cached login URL when HTTP base URL changes * Pass API key as parameter instead of reading from EditorPrefs in RegisterWithCapturedValues * Cache API key in field instead of reading from EditorPrefs on each reconnection * Align markdown table formatting in remote server auth documentation * Minor fixes * security: Sanitize API key values in shell commands and fix minor issues Add SanitizeShellHeaderValue() method to escape special shell characters (", \, `, $, !) in API keys before including them in shell command arguments. Apply sanitization to all three locations where API keys are embedded in shell commands (two in RegisterWithCapturedValues, one in GetManualInstructions). Also fix deprecated passwordCharacter property (now maskChar) and improve exception logging in _resolve_user_id_from_request * Consolidate duplicate instance selection error messages into InstanceSelectionRequiredError class Add InstanceSelectionRequiredError exception class with centralized error messages (_SELECTION_REQUIRED and _MULTIPLE_INSTANCES). Replace 4 duplicate RuntimeError raises with new exception type. Update tests to catch InstanceSelectionRequiredError instead of RuntimeError. * Replace hardcoded "X-API-Key" strings with AuthConstants.ApiKeyHeader constant across C# and Python codebases Add AuthConstants class in C# and API_KEY_HEADER constant in Python to centralize the API key header name definition. Update all 8 locations where "X-API-Key" was hardcoded (4 in C#, 4 in Python) to use the new constants instead. * Fix imports * Filter session listing by user_id in all code paths to prevent cross-user session access Remove conditional logic that only filtered sessions by user_id in remote-hosted mode. Now all session listings are filtered by user_id regardless of hosting mode, ensuring users can only see and interact with their own sessions. * Consolidate get_session_id_by_hash methods into single method with optional user_id parameter Merge get_session_id_by_hash and get_session_id_by_user_hash into a single method that accepts an optional user_id parameter. Update all call sites to use the unified method signature with user_id as the second parameter. Update tests and documentation to reflect the simplified API. * Add environment variable support for project-scoped-tools flag [skip ci] Support UNITY_MCP_PROJECT_SCOPED_TOOLS environment variable as alternative to --project-scoped-tools command line flag. Accept "true", "1", "yes", or "on" as truthy values (case-insensitive). Update help text to document the environment variable option. * Fix Python tests * Update validation logic to only require API key validation URL when both http_remote_hosted is enabled AND transport mode is "http", preventing false validation errors in stdio mode. * Update Server/src/main.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refactor HTTP transport configuration to support separate local and remote URLs Split HTTP transport into HttpLocal and HttpRemote modes with separate EditorPrefs storage (HttpBaseUrl and HttpRemoteBaseUrl). Add HttpEndpointUtility methods to get/save local and remote URLs independently, and introduce IsRemoteScope() and GetCurrentServerTransport() helpers to centralize 3-way transport determination (Stdio/Http/HttpRemote). Update all client configuration code to distinguish between local and remote HTTP * Only include API key headers in HTTP/WebSocket configuration when in remote-hosted mode Update all locations where API key headers are added to HTTP/WebSocket configurations to check HttpEndpointUtility.IsRemoteScope() or serverTransport == HttpRemote before including the API key. This prevents local HTTP mode from unnecessarily including API key headers in shell commands, config JSON, and WebSocket connections. * Hide Manual Server Launch foldout when not in HTTP Local mode * Fix failing test * Improve error messaging and API key validation for HTTP Remote transport Add detailed error messages to WebSocket connection failures that guide users to check server URL, server status, and API key validity. Store error state in TransportState for propagation to UI. Disable "Start Session" button when HTTP Remote mode is selected without an API key, with tooltip explaining requirement. Display error dialog on connection failure with specific error message from transport state. Update connection * Add missing .meta file * Store transport mode in ServerConfig instead of environment variable * Add autouse fixture to restore global config state between tests Add restore_global_config fixture in conftest.py that automatically saves and restores global config attributes and UNITY_MCP_TRANSPORT environment variable between tests. Update integration tests to use monkeypatch.setattr on config.transport_mode instead of monkeypatch.setenv to prevent test pollution and ensure clean state isolation. * Fix startup * Replace _current_transport() calls with direct config.transport_mode access * Minor cleanup * Add integration tests for HTTP transport authentication behavior Verify that HTTP local mode allows requests without user_id while HTTP remote-hosted mode rejects them with auth_required error. * Add smoke tests for transport routing paths across HTTP local, HTTP remote, and stdio modes Verify that HTTP local routes through PluginHub without user_id, HTTP remote routes through PluginHub with user_id, and stdio calls legacy send function with instance_id. Each test uses monkeypatch to configure transport mode and mock appropriate transport layer functions. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../CopilotCliConfigurator.cs.meta | 11 + .../Clients/McpClientConfiguratorBase.cs | 134 ++++- MCPForUnity/Editor/Constants/AuthConstants.cs | 10 + .../Editor/Constants/AuthConstants.cs.meta | 11 + .../Editor/Constants/EditorPrefKeys.cs | 3 + .../Editor/Helpers/ConfigJsonBuilder.cs | 20 + .../Editor/Helpers/HttpEndpointUtility.cs | 120 ++++- MCPForUnity/Editor/Models/McpStatus.cs | 7 +- .../Services/EditorConfigurationCache.cs | 27 +- .../Services/Server/ServerCommandBuilder.cs | 2 +- .../Services/ServerManagementService.cs | 34 +- .../Services/Transport/TransportManager.cs | 2 +- .../Transports/WebSocketTransportClient.cs | 19 +- .../ClientConfig/McpClientConfigSection.cs | 10 +- .../Connection/McpConnectionSection.cs | 224 ++++++++- .../Connection/McpConnectionSection.uxml | 10 + .../Windows/EditorPrefs/EditorPrefsWindow.cs | 77 +-- Server/DOCKER_OVERVIEW.md | 48 ++ Server/README.md | 120 ++++- Server/src/cli/utils/connection.py | 56 +-- Server/src/core/config.py | 15 + Server/src/core/constants.py | 4 + Server/src/main.py | 472 +++++++++++------- Server/src/services/api_key_service.py | 235 +++++++++ Server/src/services/resources/editor_state.py | 14 +- .../src/services/resources/unity_instances.py | 9 +- .../src/services/tools/set_active_instance.py | 9 +- Server/src/transport/plugin_hub.py | 148 +++++- Server/src/transport/plugin_registry.py | 94 +++- .../transport/unity_instance_middleware.py | 47 +- Server/src/transport/unity_transport.py | 51 +- Server/tests/conftest.py | 27 + .../tests/integration/test_api_key_service.py | 456 +++++++++++++++++ .../integration/test_auth_config_startup.py | 114 +++++ .../test_domain_reload_resilience.py | 15 +- .../integration/test_instance_autoselect.py | 14 +- .../test_instance_routing_comprehensive.py | 11 +- .../test_middleware_auth_integration.py | 172 +++++++ .../test_multi_user_session_isolation.py | 176 +++++++ .../test_plugin_hub_websocket_auth.py | 183 +++++++ .../test_plugin_registry_user_isolation.py | 112 +++++ .../tests/integration/test_resolve_user_id.py | 114 +++++ .../test_telemetry_queue_worker.py | 83 +-- .../tests/integration/test_transport_smoke.py | 80 +++ Server/tests/test_cli.py | 32 -- .../tests/test_transport_characterization.py | 4 +- docs/guides/REMOTE_SERVER_AUTH.md | 261 ++++++++++ .../REMOTE_SERVER_AUTH_ARCHITECTURE.md | 363 ++++++++++++++ 48 files changed, 3771 insertions(+), 489 deletions(-) create mode 100644 MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Constants/AuthConstants.cs create mode 100644 MCPForUnity/Editor/Constants/AuthConstants.cs.meta create mode 100644 Server/src/core/constants.py create mode 100644 Server/src/services/api_key_service.py create mode 100644 Server/tests/integration/test_api_key_service.py create mode 100644 Server/tests/integration/test_auth_config_startup.py create mode 100644 Server/tests/integration/test_middleware_auth_integration.py create mode 100644 Server/tests/integration/test_multi_user_session_isolation.py create mode 100644 Server/tests/integration/test_plugin_hub_websocket_auth.py create mode 100644 Server/tests/integration/test_plugin_registry_user_isolation.py create mode 100644 Server/tests/integration/test_resolve_user_id.py create mode 100644 Server/tests/integration/test_transport_smoke.py create mode 100644 docs/guides/REMOTE_SERVER_AUTH.md create mode 100644 docs/reference/REMOTE_SERVER_AUTH_ARCHITECTURE.md diff --git a/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta new file mode 100644 index 0000000..d6c740f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 14a4b9a7f749248d496466c2a3a53e56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 20d97a2..0d69caf 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -156,7 +156,17 @@ namespace MCPForUnity.Editor.Clients } else if (!string.IsNullOrEmpty(configuredUrl)) { - client.configuredTransport = Models.ConfiguredTransport.Http; + // 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 { @@ -173,6 +183,7 @@ namespace MCPForUnity.Editor.Clients } else if (!string.IsNullOrEmpty(configuredUrl)) { + // Match against the active scope's URL string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); matches = UrlsEqual(configuredUrl, expectedUrl); } @@ -189,9 +200,7 @@ namespace MCPForUnity.Editor.Clients if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); - // Update transport after rewrite based on current server setting - bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; - client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { @@ -220,9 +229,7 @@ namespace MCPForUnity.Editor.Clients if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); - // Set transport based on current server setting - bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; - client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { @@ -272,7 +279,16 @@ namespace MCPForUnity.Editor.Clients // Determine and set the configured transport type if (!string.IsNullOrEmpty(url)) { - client.configuredTransport = Models.ConfiguredTransport.Http; + // 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) { @@ -286,6 +302,7 @@ namespace MCPForUnity.Editor.Clients bool matches = false; if (!string.IsNullOrEmpty(url)) { + // Match against the active scope's URL matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl()); } else if (args != null && args.Length > 0) @@ -313,9 +330,7 @@ namespace MCPForUnity.Editor.Clients if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); - // Update transport after rewrite based on current server setting - bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; - client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { @@ -344,9 +359,7 @@ namespace MCPForUnity.Editor.Clients if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); - // Set transport based on current server setting - bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; - client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } else { @@ -468,9 +481,13 @@ namespace MCPForUnity.Editor.Clients bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase); // Set the configured transport based on what we detected + // For HTTP, we can't distinguish local/remote from CLI output alone, + // so infer from the current scope setting when HTTP is detected. if (registeredWithHttp) { - client.configuredTransport = Models.ConfiguredTransport.Http; + client.configuredTransport = HttpEndpointUtility.IsRemoteScope() + ? Models.ConfiguredTransport.HttpRemote + : Models.ConfiguredTransport.Http; } else if (registeredWithStdio) { @@ -481,7 +498,7 @@ namespace MCPForUnity.Editor.Clients client.configuredTransport = Models.ConfiguredTransport.Unknown; } - // Check for transport mismatch + // Check for transport mismatch (3-way: Stdio, Http, HttpRemote) bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp); // For stdio transport, also check package version @@ -575,7 +592,9 @@ namespace MCPForUnity.Editor.Clients public void ConfigureWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, - string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh) + string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh, + string apiKey, + Models.ConfiguredTransport serverTransport) { if (client.status == McpStatus.Configured) { @@ -584,7 +603,8 @@ namespace MCPForUnity.Editor.Clients else { RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, - useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh); + useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh, + apiKey, serverTransport); } } @@ -594,7 +614,9 @@ namespace MCPForUnity.Editor.Clients private void RegisterWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, - string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh) + string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh, + string apiKey, + Models.ConfiguredTransport serverTransport) { if (string.IsNullOrEmpty(claudePath)) { @@ -604,7 +626,16 @@ namespace MCPForUnity.Editor.Clients string args; if (useHttpTransport) { - args = $"mcp add --transport http UnityMCP {httpUrl}"; + // Only include API key header for remote-hosted mode + if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } } else { @@ -626,7 +657,7 @@ namespace MCPForUnity.Editor.Clients McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); client.SetStatus(McpStatus.Configured); - client.configuredTransport = useHttpTransport ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = serverTransport; } /// @@ -664,7 +695,24 @@ namespace MCPForUnity.Editor.Clients if (useHttpTransport) { string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); - args = $"mcp add --transport http UnityMCP {httpUrl}"; + // Only include API key header for remote-hosted mode + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } } else { @@ -715,7 +763,7 @@ namespace MCPForUnity.Editor.Clients // 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 = useHttpTransport ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); } private void Unregister() @@ -757,8 +805,15 @@ namespace MCPForUnity.Editor.Clients 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 --transport http UnityMCP {httpUrl}\n\n" + + $"claude mcp add --transport http UnityMCP {httpUrl}{headerArg}\n\n" + "# Unregister the MCP server:\n" + "claude mcp remove UnityMCP\n\n" + "# List registered servers:\n" + @@ -790,6 +845,37 @@ namespace MCPForUnity.Editor.Clients "Restart Claude Code" }; + /// + /// 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" diff --git a/MCPForUnity/Editor/Constants/AuthConstants.cs b/MCPForUnity/Editor/Constants/AuthConstants.cs new file mode 100644 index 0000000..76579e6 --- /dev/null +++ b/MCPForUnity/Editor/Constants/AuthConstants.cs @@ -0,0 +1,10 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Protocol-level constants for API key authentication. + /// + internal static class AuthConstants + { + internal const string ApiKeyHeader = "X-API-Key"; + } +} diff --git a/MCPForUnity/Editor/Constants/AuthConstants.cs.meta b/MCPForUnity/Editor/Constants/AuthConstants.cs.meta new file mode 100644 index 0000000..55af6d2 --- /dev/null +++ b/MCPForUnity/Editor/Constants/AuthConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 96844bc39e9a94cf18b18f8127f3854f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 60f81c1..a08649e 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -24,6 +24,7 @@ namespace MCPForUnity.Editor.Constants internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath"; internal const string HttpBaseUrl = "MCPForUnity.HttpUrl"; + internal const string HttpRemoteBaseUrl = "MCPForUnity.HttpRemoteUrl"; internal const string SessionId = "MCPForUnity.SessionId"; internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; @@ -55,5 +56,7 @@ namespace MCPForUnity.Editor.Constants internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + + internal const string ApiKey = "MCPForUnity.ApiKey"; } } diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 1e40ba9..938d33c 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -71,6 +71,26 @@ namespace MCPForUnity.Editor.Helpers if (unity["command"] != null) unity.Remove("command"); if (unity["args"] != null) unity.Remove("args"); + // Only include API key header for remote-hosted mode + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey }; + unity["headers"] = headers; + } + else + { + if (unity["headers"] != null) unity.Remove("headers"); + } + } + else + { + // Local HTTP doesn't use API keys; remove any stale headers + if (unity["headers"] != null) unity.Remove("headers"); + } + if (isVSCode) { unity["type"] = "http"; diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs index 2fa881b..76b7aef 100644 --- a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -1,5 +1,7 @@ using System; using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; using UnityEditor; namespace MCPForUnity.Editor.Helpers @@ -8,38 +10,113 @@ namespace MCPForUnity.Editor.Helpers /// Helper methods for managing HTTP endpoint URLs used by the MCP bridge. /// Ensures the stored value is always the base URL (without trailing path), /// and provides convenience accessors for specific endpoints. + /// + /// HTTP Local and HTTP Remote use separate EditorPrefs keys so that switching + /// between scopes does not overwrite the other scope's URL. /// public static class HttpEndpointUtility { - private const string PrefKey = EditorPrefKeys.HttpBaseUrl; - private const string DefaultBaseUrl = "http://localhost:8080"; + private const string LocalPrefKey = EditorPrefKeys.HttpBaseUrl; + private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl; + private const string DefaultLocalBaseUrl = "http://localhost:8080"; + private const string DefaultRemoteBaseUrl = ""; /// - /// Returns the normalized base URL currently stored in EditorPrefs. + /// Returns the normalized base URL for the currently active HTTP scope. + /// If the scope is "remote", returns the remote URL; otherwise returns the local URL. /// public static string GetBaseUrl() { - string stored = EditorPrefs.GetString(PrefKey, DefaultBaseUrl); - return NormalizeBaseUrl(stored); + return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl(); } /// - /// Saves a user-provided URL after normalizing it to a base form. + /// Saves a user-provided URL to the currently active HTTP scope's pref. /// public static void SaveBaseUrl(string userValue) { - string normalized = NormalizeBaseUrl(userValue); - EditorPrefs.SetString(PrefKey, normalized); + if (IsRemoteScope()) + { + SaveRemoteBaseUrl(userValue); + } + else + { + SaveLocalBaseUrl(userValue); + } } /// - /// Builds the JSON-RPC endpoint used by FastMCP clients (base + /mcp). + /// Returns the normalized local HTTP base URL (always reads local pref). + /// + public static string GetLocalBaseUrl() + { + string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl); + return NormalizeBaseUrl(stored, DefaultLocalBaseUrl); + } + + /// + /// Saves a user-provided URL to the local HTTP pref. + /// + public static void SaveLocalBaseUrl(string userValue) + { + string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl); + EditorPrefs.SetString(LocalPrefKey, normalized); + } + + /// + /// Returns the normalized remote HTTP base URL (always reads remote pref). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteBaseUrl() + { + string stored = EditorPrefs.GetString(RemotePrefKey, DefaultRemoteBaseUrl); + if (string.IsNullOrWhiteSpace(stored)) + { + return DefaultRemoteBaseUrl; + } + return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl); + } + + /// + /// Saves a user-provided URL to the remote HTTP pref. + /// + public static void SaveRemoteBaseUrl(string userValue) + { + if (string.IsNullOrWhiteSpace(userValue)) + { + EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl); + return; + } + string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl); + EditorPrefs.SetString(RemotePrefKey, normalized); + } + + /// + /// Builds the JSON-RPC endpoint for the currently active scope (base + /mcp). /// public static string GetMcpRpcUrl() { return AppendPathSegment(GetBaseUrl(), "mcp"); } + /// + /// Builds the local JSON-RPC endpoint (local base + /mcp). + /// + public static string GetLocalMcpRpcUrl() + { + return AppendPathSegment(GetLocalBaseUrl(), "mcp"); + } + + /// + /// Builds the remote JSON-RPC endpoint (remote base + /mcp). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteMcpRpcUrl() + { + string remoteBase = GetRemoteBaseUrl(); + return string.IsNullOrEmpty(remoteBase) ? string.Empty : AppendPathSegment(remoteBase, "mcp"); + } + /// /// Builds the endpoint used when POSTing custom-tool registration payloads. /// @@ -48,14 +125,35 @@ namespace MCPForUnity.Editor.Helpers return AppendPathSegment(GetBaseUrl(), "register-tools"); } + /// + /// Returns true if the active HTTP transport scope is "remote". + /// + public static bool IsRemoteScope() + { + string scope = EditorConfigurationCache.Instance.HttpTransportScope; + return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the that matches the current server-side + /// transport selection (Stdio, Http, or HttpRemote). + /// Centralises the 3-way determination so callers avoid duplicated logic. + /// + public static ConfiguredTransport GetCurrentServerTransport() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (!useHttp) return ConfiguredTransport.Stdio; + return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http; + } + /// /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). /// - private static string NormalizeBaseUrl(string value) + private static string NormalizeBaseUrl(string value, string defaultUrl) { if (string.IsNullOrWhiteSpace(value)) { - return DefaultBaseUrl; + return defaultUrl; } string trimmed = value.Trim(); diff --git a/MCPForUnity/Editor/Models/McpStatus.cs b/MCPForUnity/Editor/Models/McpStatus.cs index 4fb8426..c23bc81 100644 --- a/MCPForUnity/Editor/Models/McpStatus.cs +++ b/MCPForUnity/Editor/Models/McpStatus.cs @@ -21,9 +21,10 @@ namespace MCPForUnity.Editor.Models /// public enum ConfiguredTransport { - Unknown, // Could not determine transport type - Stdio, // Client configured for stdio transport - Http // Client configured for HTTP transport + Unknown, // Could not determine transport type + Stdio, // Client configured for stdio transport + Http, // Client configured for HTTP local transport + HttpRemote // Client configured for HTTP remote-hosted transport } } diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs index 86b5df9..40dedd5 100644 --- a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -53,6 +53,7 @@ namespace MCPForUnity.Editor.Services private string _uvxPathOverride; private string _gitUrlOverride; private string _httpBaseUrl; + private string _httpRemoteBaseUrl; private string _claudeCliPathOverride; private string _httpTransportScope; private int _unitySocketPort; @@ -94,11 +95,17 @@ namespace MCPForUnity.Editor.Services public string GitUrlOverride => _gitUrlOverride; /// - /// HTTP base URL for the MCP server. + /// HTTP base URL for the local MCP server. /// Default: empty string /// public string HttpBaseUrl => _httpBaseUrl; + /// + /// HTTP base URL for the remote-hosted MCP server. + /// Default: empty string + /// + public string HttpRemoteBaseUrl => _httpRemoteBaseUrl; + /// /// Custom path override for Claude CLI executable. /// Default: empty string (auto-detect) @@ -135,6 +142,7 @@ namespace MCPForUnity.Editor.Services _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); @@ -234,6 +242,20 @@ namespace MCPForUnity.Editor.Services } } + /// + /// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpRemoteBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpRemoteBaseUrl != value) + { + _httpRemoteBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl)); + } + } + /// /// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically. /// @@ -304,6 +326,9 @@ namespace MCPForUnity.Editor.Services case nameof(HttpBaseUrl): _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); break; + case nameof(HttpRemoteBaseUrl): + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); + break; case nameof(ClaudeCliPathOverride): _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); break; diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs index 47791aa..47b4675 100644 --- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -30,7 +30,7 @@ namespace MCPForUnity.Editor.Services.Server return false; } - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index d67dfad..1df3384 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -1,7 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; -using System.Collections.Generic; using System.Net.Sockets; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; @@ -158,7 +158,7 @@ namespace MCPForUnity.Editor.Services if (success) { - McpLog.Debug($"uv cache cleared successfully: {stdout}"); + McpLog.Info($"uv cache cleared successfully: {stdout}"); return true; } string combinedOutput = string.Join( @@ -253,7 +253,7 @@ namespace MCPForUnity.Editor.Services // If the port is still occupied, don't start and explain why (avoid confusing "refusing to stop" warnings). try { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) && uri.Port > 0) { var remaining = GetListeningProcessIdsForPort(uri.Port); @@ -274,7 +274,7 @@ namespace MCPForUnity.Editor.Services // Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command. // Create a per-launch token + pidfile path so Stop can be deterministic without relying on port/PID heuristics. - string baseUrlForPid = HttpEndpointUtility.GetBaseUrl(); + string baseUrlForPid = HttpEndpointUtility.GetLocalBaseUrl(); Uri.TryCreate(baseUrlForPid, UriKind.Absolute, out var uriForPid); int portForPid = uriForPid?.Port ?? 0; string instanceToken = Guid.NewGuid().ToString("N"); @@ -350,7 +350,7 @@ namespace MCPForUnity.Editor.Services int port = 0; if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0) { - string baseUrl = HttpEndpointUtility.GetBaseUrl(); + string baseUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (IsLocalUrl(baseUrl) && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) && uri.Port > 0) @@ -371,7 +371,7 @@ namespace MCPForUnity.Editor.Services { try { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; @@ -433,7 +433,7 @@ namespace MCPForUnity.Editor.Services { try { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; @@ -500,7 +500,7 @@ namespace MCPForUnity.Editor.Services private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, bool allowNonLocalUrl = false) { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!allowNonLocalUrl && !IsLocalUrl(httpUrl)) { if (!quiet) @@ -665,14 +665,14 @@ namespace MCPForUnity.Editor.Services // fall back to a looser check to avoid leaving orphaned servers after domain reload. if (TryGetUnixProcessArgs(storedPid, out var storedArgsLowerNow)) { - // Never kill Unity/Hub. - // Note: "mcp-for-unity" includes "unity", so detect MCP indicators first. - bool storedMentionsMcp = storedArgsLowerNow.Contains("mcp-for-unity") - || storedArgsLowerNow.Contains("mcp_for_unity") - || storedArgsLowerNow.Contains("mcpforunity"); - if (storedArgsLowerNow.Contains("unityhub") - || storedArgsLowerNow.Contains("unity hub") - || (storedArgsLowerNow.Contains("unity") && !storedMentionsMcp)) + // Never kill Unity/Hub. + // Note: "mcp-for-unity" includes "unity", so detect MCP indicators first. + bool storedMentionsMcp = storedArgsLowerNow.Contains("mcp-for-unity") + || storedArgsLowerNow.Contains("mcp_for_unity") + || storedArgsLowerNow.Contains("mcpforunity"); + if (storedArgsLowerNow.Contains("unityhub") + || storedArgsLowerNow.Contains("unity hub") + || (storedArgsLowerNow.Contains("unity") && !storedMentionsMcp)) { if (!quiet) { @@ -836,7 +836,7 @@ namespace MCPForUnity.Editor.Services /// public bool IsLocalUrl() { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); return IsLocalUrl(httpUrl); } diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs b/MCPForUnity/Editor/Services/Transport/TransportManager.cs index 44f53ce..1204e70 100644 --- a/MCPForUnity/Editor/Services/Transport/TransportManager.cs +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -67,7 +67,7 @@ namespace MCPForUnity.Editor.Services.Transport { McpLog.Warn($"Error while stopping transport {client.TransportName}: {ex.Message}"); } - UpdateState(mode, TransportState.Disconnected(client.TransportName, "Failed to start")); + UpdateState(mode, TransportState.Disconnected(client.TransportName, client.State?.Error ?? "Failed to start")); return false; } diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index 0b6c4aa..856577c 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -6,6 +6,7 @@ using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services.Transport; @@ -56,6 +57,7 @@ namespace MCPForUnity.Editor.Services.Transport.Transports private volatile bool _isConnected; private int _isReconnectingFlag; private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started"); + private string _apiKey; private bool _disposed; public WebSocketTransportClient(IToolDiscoveryService toolDiscoveryService = null) @@ -80,6 +82,9 @@ namespace MCPForUnity.Editor.Services.Transport.Transports _projectName = ProjectIdentityUtility.GetProjectName(); _projectHash = ProjectIdentityUtility.GetProjectHash(); _unityVersion = Application.unityVersion; + _apiKey = HttpEndpointUtility.IsRemoteScope() + ? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty) + : string.Empty; // Get project root path (strip /Assets from dataPath) for focus nudging string dataPath = Application.dataPath; @@ -214,13 +219,21 @@ namespace MCPForUnity.Editor.Services.Transport.Transports _socket = new ClientWebSocket(); _socket.Options.KeepAliveInterval = _socketKeepAliveInterval; + // Add API key header if configured (for remote-hosted mode) + if (!string.IsNullOrEmpty(_apiKey)) + { + _socket.Options.SetRequestHeader(AuthConstants.ApiKeyHeader, _apiKey); + } + try { await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false); } catch (Exception ex) { - McpLog.Error($"[WebSocket] Connection failed: {ex.Message}"); + string errorMsg = "Connection failed. Check that the server URL is correct, the server is running, and your API key (if required) is valid."; + McpLog.Error($"[WebSocket] {errorMsg} (Detail: {ex.Message})"); + _state = TransportState.Disconnected(TransportDisplayName, errorMsg); return false; } @@ -232,7 +245,9 @@ namespace MCPForUnity.Editor.Services.Transport.Transports } catch (Exception ex) { - McpLog.Error($"[WebSocket] Registration failed: {ex.Message}"); + string regMsg = $"Registration with server failed: {ex.Message}"; + McpLog.Error($"[WebSocket] {regMsg}"); + _state = TransportState.Disconnected(TransportDisplayName, regMsg); return false; } diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 91fb26a..0433a82 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -275,6 +275,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh(); + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); // Compute pathPrepend on main thread string pathPrepend = null; @@ -296,10 +297,12 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig { if (client is ClaudeCliMcpConfigurator cliConfigurator) { + var serverTransport = HttpEndpointUtility.GetCurrentServerTransport(); cliConfigurator.ConfigureWithCapturedValues( projectDir, claudePath, pathPrepend, useHttpTransport, httpUrl, - uvxPath, gitUrl, packageName, shouldForceRefresh); + uvxPath, gitUrl, packageName, shouldForceRefresh, + apiKey, serverTransport); } return (success: true, error: (string)null); } @@ -525,12 +528,11 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig return; } - // Check for transport mismatch + // Check for transport mismatch (3-way: Stdio, Http, HttpRemote) bool hasTransportMismatch = false; if (client.ConfiguredTransport != ConfiguredTransport.Unknown) { - bool serverUsesHttp = EditorConfigurationCache.Instance.UseHttpTransport; - ConfiguredTransport serverTransport = serverUsesHttp ? ConfiguredTransport.Http : ConfiguredTransport.Stdio; + ConfiguredTransport serverTransport = HttpEndpointUtility.GetCurrentServerTransport(); hasTransportMismatch = client.ConfiguredTransport != serverTransport; } diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 23e35f1..36f5647 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -45,6 +45,13 @@ namespace MCPForUnity.Editor.Windows.Components.Connection private Label connectionStatusLabel; private Button connectionToggleButton; + // API Key UI Elements (for remote-hosted mode) + private VisualElement apiKeyRow; + private TextField apiKeyField; + private Button getApiKeyButton; + private Button clearApiKeyButton; + private string cachedLoginUrl; + private bool connectionToggleInProgress; private bool httpServerToggleInProgress; private Task verificationTask; @@ -93,6 +100,12 @@ namespace MCPForUnity.Editor.Windows.Components.Connection statusIndicator = Root.Q("status-indicator"); connectionStatusLabel = Root.Q