diff --git a/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta new file mode 100644 index 000000000..d6c740f35 --- /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 20d97a2f0..0d69cafbc 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -156,7 +156,17 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) } 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 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) } else if (!string.IsNullOrEmpty(configuredUrl)) { + // Match against the active scope's URL string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); matches = UrlsEqual(configuredUrl, expectedUrl); } @@ -189,9 +200,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) 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 @@ public override void Configure() 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 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) // 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 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) 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 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) 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 @@ public override void Configure() 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 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran 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 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTran 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 @@ public override void Configure() 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 @@ public void ConfigureWithCapturedValues( else { RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, - useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh); + useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh, + apiKey, serverTransport); } } @@ -594,7 +614,9 @@ public void ConfigureWithCapturedValues( 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 @@ private void RegisterWithCapturedValues( 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 @@ private void RegisterWithCapturedValues( 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 @@ private void Register() 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 @@ private void Register() // 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 @@ public override string GetManualSnippet() 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 @@ public override string GetManualSnippet() "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 000000000..76579e642 --- /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 000000000..55af6d27f --- /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 60f81c1be..a08649e3f 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -24,6 +24,7 @@ internal static class EditorPrefKeys 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 @@ internal static class EditorPrefKeys 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 1e40ba971..938d33c26 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -71,6 +71,26 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl 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 2fa881bf6..76b7aef11 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); + } + } + + /// + /// 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 used by FastMCP clients (base + /mcp). + /// 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 @@ public static string GetRegisterToolsUrl() 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 4fb84265e..c23bc819b 100644 --- a/MCPForUnity/Editor/Models/McpStatus.cs +++ b/MCPForUnity/Editor/Models/McpStatus.cs @@ -21,9 +21,10 @@ public enum McpStatus /// 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 86b5df95f..40dedd58a 100644 --- a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -53,6 +53,7 @@ public static EditorConfigurationCache Instance 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 @@ public static EditorConfigurationCache Instance 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 @@ public void Refresh() _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 @@ public void SetHttpBaseUrl(string value) } } + /// + /// 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 @@ public void InvalidateKey(string keyName) 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 47791aa99..47b46755b 100644 --- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -30,7 +30,7 @@ public bool TryBuildCommand(out string fileName, out string arguments, out strin 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 d67dfada6..1df3384fb 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 @@ public bool ClearUvxCache() 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 @@ public bool StartLocalHttpServer() // 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 @@ public bool StartLocalHttpServer() // 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 @@ public bool StopManagedLocalHttpServer() 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 @@ public bool IsLocalHttpServerRunning() { try { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; @@ -433,7 +433,7 @@ public bool IsLocalHttpServerReachable() { try { - string httpUrl = HttpEndpointUtility.GetBaseUrl(); + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; @@ -500,7 +500,7 @@ private static bool TryConnectToLocalPort(string host, int port, int timeoutMs) 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 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b // 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 @@ private bool TryGetLocalHttpServerCommandParts(out string fileName, out string a /// 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 44f53ce0d..1204e7014 100644 --- a/MCPForUnity/Editor/Services/Transport/TransportManager.cs +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -67,7 +67,7 @@ public async Task StartAsync(TransportMode mode) { 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 0b6c4aafc..856577c06 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -6,6 +6,7 @@ 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 @@ public class WebSocketTransportClient : IMcpTransportClient, IDisposable 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 @@ public async Task StartAsync() _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 @@ private async Task EstablishConnectionAsync(CancellationToken token) _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 @@ private async Task EstablishConnectionAsync(CancellationToken token) } 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 91fb26a65..0433a824a 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -275,6 +275,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client) 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 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client) { 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 @@ private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = 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 23e35f15c..36f564749 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -45,6 +45,13 @@ private enum TransportProtocol 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 @@ private void CacheUIElements() statusIndicator = Root.Q("status-indicator"); connectionStatusLabel = Root.Q