using System; using System.Net; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; using UnityEditor; 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 LocalPrefKey = EditorPrefKeys.HttpBaseUrl; private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl; private const string DefaultLocalBaseUrl = "http://127.0.0.1:8080"; private const string DefaultRemoteBaseUrl = ""; /// /// 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() { return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl(); } /// /// Saves a user-provided URL to the currently active HTTP scope's pref. /// public static void SaveBaseUrl(string userValue) { 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, remoteScope: false); } /// /// Saves a user-provided URL to the local HTTP pref. /// public static void SaveLocalBaseUrl(string userValue) { string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false); 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, remoteScope: true); } /// /// 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, remoteScope: true); 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. /// 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; } /// /// Returns true when advanced settings allow binding HTTP Local to all interfaces /// (e.g. 0.0.0.0 / ::). Disabled by default. /// public static bool AllowLanHttpBind() { return EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false); } /// /// Returns true when advanced settings allow insecure HTTP/WS for remote endpoints. /// Disabled by default. /// public static bool AllowInsecureRemoteHttp() { return EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false); } /// /// Returns true if the host is loopback-only. /// public static bool IsLoopbackHost(string host) { if (string.IsNullOrWhiteSpace(host)) { return false; } string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); if (normalized == "localhost") { return true; } if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) { return IPAddress.IsLoopback(parsedIp); } return false; } /// /// Returns true if the host is a bind-all-interfaces address. /// public static bool IsBindAllInterfacesHost(string host) { if (string.IsNullOrWhiteSpace(host)) { return false; } string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) { return parsedIp.Equals(IPAddress.Any) || parsedIp.Equals(IPAddress.IPv6Any); } return false; } /// /// Returns true when the URL host is acceptable for HTTP Local launch. /// Loopback is always allowed. Bind-all interfaces requires explicit opt-in. /// public static bool IsHttpLocalUrlAllowedForLaunch(string url, out string error) { error = null; if (string.IsNullOrWhiteSpace(url)) { error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; return false; } if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { error = $"Invalid URL: {url}"; return false; } string host = uri.Host; if (IsLoopbackHost(host)) { return true; } if (IsBindAllInterfacesHost(host)) { if (AllowLanHttpBind()) { return true; } error = "Binding to all interfaces (0.0.0.0/::) is disabled by default. " + "Enable \"Allow LAN bind for HTTP Local\" in Advanced Settings to opt in."; return false; } error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; return false; } /// /// Returns true when remote URL is allowed by current security policy. /// HTTPS is required by default; HTTP needs explicit opt-in. /// public static bool IsRemoteUrlAllowed(string remoteBaseUrl, out string error) { error = null; if (string.IsNullOrWhiteSpace(remoteBaseUrl)) { error = "HTTP Remote requires a configured URL."; return false; } if (!Uri.TryCreate(remoteBaseUrl, UriKind.Absolute, out var uri)) { error = $"Invalid HTTP Remote URL: {remoteBaseUrl}"; return false; } if (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) { return true; } if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) { if (AllowInsecureRemoteHttp()) { return true; } error = "HTTP Remote requires HTTPS by default. Enable \"Allow insecure HTTP for HTTP Remote\" in Advanced Settings to opt in."; return false; } error = $"Unsupported URL scheme '{uri.Scheme}'. Use https:// (or http:// only with explicit insecure opt-in)."; return false; } /// /// Returns true when the currently configured remote URL satisfies security policy. /// public static bool IsCurrentRemoteUrlAllowed(out string error) { return IsRemoteUrlAllowed(GetRemoteBaseUrl(), out error); } /// /// Human-readable host requirement for HTTP Local based on current security settings. /// public static string GetHttpLocalHostRequirementText() { return AllowLanHttpBind() ? "localhost/127.0.0.1/::1/0.0.0.0/::" : "localhost/127.0.0.1/::1"; } /// /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). /// private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope) { if (string.IsNullOrWhiteSpace(value)) { return defaultUrl; } string trimmed = value.Trim(); // Ensure scheme exists. // For HTTP Remote, default to https:// to avoid accidental plaintext transport. // For HTTP Local, default to http:// for zero-friction local setup. if (!trimmed.Contains("://")) { string defaultScheme = remoteScope ? "https" : "http"; trimmed = $"{defaultScheme}://{trimmed}"; } // Remove trailing slash segments. trimmed = trimmed.TrimEnd('/'); // Strip trailing "/mcp" (case-insensitive) if provided. if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase)) { trimmed = trimmed[..^4]; } // For local scope, force 127.0.0.1 over "localhost". Windows getaddrinfo returns ::1 // first; clients without Happy Eyeballs (e.g., Codex/reqwest) hit the v6 socket while // our server binds v4-only, breaking the handshake. The default server bind is // 127.0.0.1, so emitting the literal v4 keeps every client unambiguous. if (!remoteScope && Uri.TryCreate(trimmed, UriKind.Absolute, out Uri parsed) && string.Equals(parsed.Host, "localhost", StringComparison.OrdinalIgnoreCase)) { var builder = new UriBuilder(parsed) { Host = "127.0.0.1" }; trimmed = builder.Uri.GetLeftPart(UriPartial.Authority); } return trimmed; } private static string AppendPathSegment(string baseUrl, string segment) { return $"{baseUrl.TrimEnd('/')}/{segment}"; } } }