diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
index 71a5be703..c5dab7c41 100644
--- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
+++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
@@ -30,6 +30,8 @@ internal static class EditorPrefKeys
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp";
+ internal const string AllowLanHttpBind = "MCPForUnity.Security.AllowLanHttpBind";
+ internal const string AllowInsecureRemoteHttp = "MCPForUnity.Security.AllowInsecureRemoteHttp";
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath";
diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
index 98fdb2d5a..4f8064612 100644
--- a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
+++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
@@ -1,4 +1,5 @@
using System;
+using System.Net;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
@@ -51,7 +52,7 @@ public static void SaveBaseUrl(string userValue)
public static string GetLocalBaseUrl()
{
string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl);
- return NormalizeBaseUrl(stored, DefaultLocalBaseUrl);
+ return NormalizeBaseUrl(stored, DefaultLocalBaseUrl, remoteScope: false);
}
///
@@ -59,7 +60,7 @@ public static string GetLocalBaseUrl()
///
public static void SaveLocalBaseUrl(string userValue)
{
- string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl);
+ string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false);
EditorPrefs.SetString(LocalPrefKey, normalized);
}
@@ -74,7 +75,7 @@ public static string GetRemoteBaseUrl()
{
return DefaultRemoteBaseUrl;
}
- return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl);
+ return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true);
}
///
@@ -87,7 +88,7 @@ public static void SaveRemoteBaseUrl(string userValue)
EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl);
return;
}
- string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl);
+ string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl, remoteScope: true);
EditorPrefs.SetString(RemotePrefKey, normalized);
}
@@ -146,10 +147,169 @@ public static ConfiguredTransport GetCurrentServerTransport()
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)
+ private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -158,10 +318,13 @@ private static string NormalizeBaseUrl(string value, string defaultUrl)
string trimmed = value.Trim();
- // Ensure scheme exists; default to http:// if user omitted it.
+ // 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("://"))
{
- trimmed = $"http://{trimmed}";
+ string defaultScheme = remoteScope ? "https" : "http";
+ trimmed = $"{defaultScheme}://{trimmed}";
}
// Remove trailing slash segments.
diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs
index 299fad511..2da66738f 100644
--- a/MCPForUnity/Editor/Services/IServerManagementService.cs
+++ b/MCPForUnity/Editor/Services/IServerManagementService.cs
@@ -58,7 +58,7 @@ public interface IServerManagementService
///
/// Check if the local HTTP server can be started
///
- /// True if HTTP transport is enabled and URL is local
+ /// True if HTTP transport is enabled and URL satisfies local launch security policy
bool CanStartLocalServer();
}
}
diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
index 47b46755b..47ba7186d 100644
--- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
+++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
@@ -31,9 +31,11 @@ public bool TryBuildCommand(out string fileName, out string arguments, out strin
}
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
- if (!IsLocalUrl(httpUrl))
+ if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out string localUrlError))
{
- error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost.";
+ error = string.IsNullOrEmpty(localUrlError)
+ ? $"The configured URL ({httpUrl}) is not allowed for HTTP Local launch."
+ : $"{localUrlError} (configured URL: {httpUrl})";
return false;
}
@@ -129,23 +131,5 @@ public string QuoteIfNeeded(string input)
return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input;
}
- ///
- /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1)
- ///
- private static bool IsLocalUrl(string url)
- {
- if (string.IsNullOrEmpty(url)) return false;
-
- try
- {
- var uri = new Uri(url);
- string host = uri.Host.ToLower();
- return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1";
- }
- catch
- {
- return false;
- }
- }
}
}
diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs
index ecd248da5..0b7799fad 100644
--- a/MCPForUnity/Editor/Services/ServerManagementService.cs
+++ b/MCPForUnity/Editor/Services/ServerManagementService.cs
@@ -654,13 +654,32 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
}
return false;
}
- if (!quiet)
+
+ // If the pidfile PID is no longer the active listener, treat handshake state as stale
+ // and continue with guarded port-based heuristics below.
+ if (!pidIsListener)
{
- McpLog.Warn(
- $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " +
- $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}).");
+ if (!quiet)
+ {
+ McpLog.Warn(
+ $"Stale pidfile for port {port}: pidfile PID {pidFromFile} is not the current listener " +
+ $"(tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}). Falling back to guarded port heuristics.");
+ }
+ try { DeletePidFile(pidFilePath); } catch { }
+ ClearLocalServerPidTracking();
+ }
+ else
+ {
+ // PID still owns the listener, but identity validation failed.
+ // Fail closed to avoid terminating unrelated processes.
+ if (!quiet)
+ {
+ McpLog.Warn(
+ $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " +
+ $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}).");
+ }
+ return false;
}
- return false;
}
}
}
@@ -875,7 +894,8 @@ public bool IsLocalUrl()
}
///
- /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0)
+ /// Check if a URL is local or bind-all (localhost/loopback and 0.0.0.0/::).
+ /// This helper is intentionally broader than local-launch policy checks.
///
private static bool IsLocalUrl(string url)
{
@@ -884,8 +904,8 @@ private static bool IsLocalUrl(string url)
try
{
var uri = new Uri(url);
- string host = uri.Host.ToLower();
- return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1";
+ string host = uri.Host;
+ return HttpEndpointUtility.IsLoopbackHost(host) || HttpEndpointUtility.IsBindAllInterfacesHost(host);
}
catch
{
@@ -899,7 +919,13 @@ private static bool IsLocalUrl(string url)
public bool CanStartLocalServer()
{
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
- return useHttpTransport && IsLocalUrl();
+ if (!useHttpTransport)
+ {
+ return false;
+ }
+
+ string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
+ return HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out _);
}
private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
index 668c20ad2..8832b3227 100644
--- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
+++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
@@ -86,6 +86,15 @@ public async Task StartAsync()
? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty)
: string.Empty;
+ if (HttpEndpointUtility.IsRemoteScope()
+ && !HttpEndpointUtility.IsCurrentRemoteUrlAllowed(out string remoteUrlError))
+ {
+ string message = remoteUrlError ?? "HTTP Remote URL is not allowed by current security settings.";
+ _state = TransportState.Disconnected(TransportDisplayName, message);
+ McpLog.Error($"[WebSocket] {message}");
+ return false;
+ }
+
// Get project root path (strip /Assets from dataPath) for focus nudging
string dataPath = Application.dataPath;
if (!string.IsNullOrEmpty(dataPath))
diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
index 386f8ac59..218e95e17 100644
--- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
+++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs
@@ -26,6 +26,8 @@ public class McpAdvancedSection
private Button clearGitUrlButton;
private Toggle debugLogsToggle;
private Toggle devModeForceRefreshToggle;
+ private Toggle allowLanHttpBindToggle;
+ private Toggle allowInsecureRemoteHttpToggle;
private TextField deploySourcePath;
private Button browseDeploySourceButton;
private Button clearDeploySourceButton;
@@ -64,6 +66,8 @@ private void CacheUIElements()
clearGitUrlButton = Root.Q