From ac93422a02c27b3c492c3b944f3afa4cfcf8be6c Mon Sep 17 00:00:00 2001 From: Jordon Harrison Date: Fri, 5 Dec 2025 11:14:52 +0000 Subject: [PATCH 01/38] feat: implement optional IP/token authentication for HTTP/WebSocket connections --- Server/README.md | 14 ++ Server/src/core/auth.py | 162 +++++++++++++++++++++ Server/src/main.py | 64 +++++++- Server/src/services/custom_tool_service.py | 10 +- Server/src/transport/plugin_hub.py | 13 ++ 5 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 Server/src/core/auth.py diff --git a/Server/README.md b/Server/README.md index a18554be..faab9016 100644 --- a/Server/README.md +++ b/Server/README.md @@ -147,6 +147,20 @@ The server connects to Unity Editor automatically when both are running. No addi - `DISABLE_TELEMETRY=true` - Opt out of anonymous usage analytics - `LOG_LEVEL=DEBUG` - Enable detailed logging (default: INFO) +- `UNITY_MCP_AUTH_ENABLED=1` - Enable optional IP/token auth for HTTP/WebSocket +- `UNITY_MCP_ALLOWED_IPS=127.0.0.1,10.0.0.0/8` - Comma-separated allowlist (default `*`) +- `UNITY_MCP_AUTH_TOKEN=supersecret` - Bearer token required when auth is enabled (omit to allow IP-only) + +**CLI examples (HTTP auth):** + +```bash +python -m src.main --transport http \ + --auth-enabled \ + --auth-token supersecret \ + --allowed-ip 127.0.0.1 --allowed-ip 10.0.0.0/8 +``` + +When auth is enabled, clients must send `Authorization: Bearer ` and connect from an allowed IP. If no token is configured, only the IP allowlist is enforced. --- diff --git a/Server/src/core/auth.py b/Server/src/core/auth.py new file mode 100644 index 00000000..5063b42a --- /dev/null +++ b/Server/src/core/auth.py @@ -0,0 +1,162 @@ +"""Authentication and network access controls for the MCP server.""" + +from __future__ import annotations + +import ipaddress +import os +from dataclasses import dataclass +from typing import Iterable, Sequence + +from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.dependencies import get_http_request +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.websockets import WebSocket + + +@dataclass +class AuthSettings: + enabled: bool = False + allowed_ips: list[str] | None = None + token: str | None = None + + @property + def normalized_allowed_ips(self) -> list[str]: + if self.allowed_ips: + return [ip.strip() for ip in self.allowed_ips if ip and ip.strip()] + return ["*"] + + @classmethod + def from_env_and_args( + cls, + *, + args_enabled: bool | None = None, + args_allowed_ips: Sequence[str] | None = None, + args_token: str | None = None, + ) -> "AuthSettings": + env_enabled = os.environ.get("UNITY_MCP_AUTH_ENABLED", "").lower() in ( + "1", + "true", + "yes", + "on", + ) + enabled = bool(args_enabled) or env_enabled + + env_allowed = os.environ.get("UNITY_MCP_ALLOWED_IPS") + allowed_ips: list[str] | None = None + if args_allowed_ips: + allowed_ips = list(args_allowed_ips) + elif env_allowed: + allowed_ips = [p.strip() for p in env_allowed.split(",") if p.strip()] + + token = args_token or os.environ.get("UNITY_MCP_AUTH_TOKEN") or None + return cls(enabled=enabled, allowed_ips=allowed_ips, token=token or None) + + +def _ip_in_allowlist(client_ip: str | None, allowed: Iterable[str]) -> bool: + if client_ip is None: + return False + try: + ip = ipaddress.ip_address(client_ip) + except ValueError: + return False + + for pattern in allowed: + pat = pattern.strip() + if pat == "*": + return True + try: + net = ipaddress.ip_network(pat, strict=False) + if ip in net: + return True + except ValueError: + # Exact IP fall back + try: + if ip == ipaddress.ip_address(pat): + return True + except ValueError: + continue + return False + + +def _extract_bearer_token(headers: dict[str, str]) -> str | None: + auth_header = headers.get("authorization") or headers.get("Authorization") + if not auth_header: + return None + if not auth_header.lower().startswith("bearer "): + return None + return auth_header[7:].strip() or None + + +def _unauthorized_response(message: str, status_code: int = 401) -> JSONResponse: + return JSONResponse({"success": False, "error": "unauthorized", "message": message}, status_code=status_code) + + +def verify_http_request(request: Request, settings: AuthSettings) -> JSONResponse | None: + if not settings.enabled: + return None + + client_ip = request.client.host if request.client else None + if not _ip_in_allowlist(client_ip, settings.normalized_allowed_ips): + return _unauthorized_response("IP not allowed", status_code=403) + + if settings.token: + bearer = _extract_bearer_token(request.headers) + if bearer != settings.token: + return _unauthorized_response("Missing or invalid bearer token", status_code=401) + + return None + + +async def verify_websocket(websocket: WebSocket, settings: AuthSettings) -> JSONResponse | None: + if not settings.enabled: + return None + + client_ip = websocket.client.host if websocket.client else None + if not _ip_in_allowlist(client_ip, settings.normalized_allowed_ips): + return _unauthorized_response("IP not allowed", status_code=403) + + if settings.token: + bearer = _extract_bearer_token(dict(websocket.headers)) + if bearer != settings.token: + return _unauthorized_response("Missing or invalid bearer token", status_code=401) + + return None + + +class AuthMiddleware(Middleware): + """Enforces optional IP allowlist and bearer token on MCP requests.""" + + def __init__(self, settings: AuthSettings): + super().__init__() + self.settings = settings + + def _check_request_if_present(self) -> JSONResponse | None: + try: + request = get_http_request() + except Exception: + return None + if request is None: + return None + return verify_http_request(request, self.settings) + + async def on_request(self, context: MiddlewareContext, call_next): + failure = self._check_request_if_present() + if failure is not None: + return failure + return await call_next(context) + + async def on_call_tool(self, context: MiddlewareContext, call_next): + # Defense-in-depth: enforce token again at tool dispatch + if self.settings.enabled and self.settings.token: + failure = self._check_request_if_present() + if failure is not None: + return failure + return await call_next(context) + + async def on_read_resource(self, context: MiddlewareContext, call_next): + if self.settings.enabled and self.settings.token: + failure = self._check_request_if_present() + if failure is not None: + return failure + return await call_next(context) diff --git a/Server/src/main.py b/Server/src/main.py index ee04d535..057ec367 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -26,6 +26,7 @@ UnityInstanceMiddleware, get_unity_instance_middleware ) +from core.auth import AuthSettings, AuthMiddleware, verify_http_request # Configure logging using settings from config logging.basicConfig( @@ -83,6 +84,9 @@ _unity_connection_pool: UnityConnectionPool | None = None _plugin_registry: PluginRegistry | None = None +# Authentication settings (populated during argument parsing) +AUTH_SETTINGS: AuthSettings = AuthSettings() + # In-memory custom tool service initialized after MCP construction custom_tool_service: CustomToolService | None = None @@ -110,7 +114,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: if _plugin_registry is None: _plugin_registry = PluginRegistry() loop = asyncio.get_running_loop() - PluginHub.configure(_plugin_registry, loop) + PluginHub.configure(_plugin_registry, loop, auth_settings=AUTH_SETTINGS) # Record server startup telemetry start_time = time.time() @@ -249,7 +253,10 @@ def _emit_startup(): @mcp.custom_route("/health", methods=["GET"]) -async def health_http(_: Request) -> JSONResponse: +async def health_http(request: Request) -> JSONResponse: + failure = verify_http_request(request, AUTH_SETTINGS) + if failure: + return failure return JSONResponse({ "status": "healthy", "timestamp": time.time(), @@ -258,16 +265,16 @@ async def health_http(_: Request) -> JSONResponse: @mcp.custom_route("/plugin/sessions", methods=["GET"]) -async def plugin_sessions_route(_: Request) -> JSONResponse: +async def plugin_sessions_route(request: Request) -> JSONResponse: + failure = verify_http_request(request, AUTH_SETTINGS) + if failure: + return failure data = await PluginHub.get_sessions() return JSONResponse(data.model_dump()) # Initialize and register middleware for session-based Unity instance routing # Using the singleton getter ensures we use the same instance everywhere -unity_middleware = get_unity_instance_middleware() -mcp.add_middleware(unity_middleware) -logger.info("Registered Unity instance middleware for session-based routing") # Mount plugin websocket hub at /hub/plugin when HTTP transport is active existing_routes = [ @@ -354,8 +361,53 @@ def main(): "Overrides UNITY_MCP_HTTP_PORT environment variable." ) + parser.add_argument( + "--auth-enabled", + action="store_true", + help="Enable MCP HTTP/WebSocket auth (can also set UNITY_MCP_AUTH_ENABLED)." + ) + parser.add_argument( + "--auth-token", + type=str, + default=None, + help="Bearer token required when auth is enabled (UNITY_MCP_AUTH_TOKEN)." + ) + parser.add_argument( + "--allowed-ip", + dest="allowed_ips", + action="append", + default=None, + help="Allowed IP/CIDR (repeatable). Defaults to '*'. Overrides UNITY_MCP_ALLOWED_IPS when set." + ) + args = parser.parse_args() + global AUTH_SETTINGS + AUTH_SETTINGS = AuthSettings.from_env_and_args( + args_enabled=args.auth_enabled, + args_allowed_ips=args.allowed_ips, + args_token=args.auth_token, + ) + + if AUTH_SETTINGS.enabled: + logger.info( + "Auth enabled; allowed IPs=%s; token %s", + AUTH_SETTINGS.normalized_allowed_ips, + "configured" if AUTH_SETTINGS.token else "not set (token not required)", + ) + else: + logger.info("Auth disabled (HTTP exposed without token/IP restrictions)") + + # Register auth middleware first so it short-circuits unauthorized requests + mcp.add_middleware(AuthMiddleware(AUTH_SETTINGS)) + # Session-based Unity instance routing + unity_middleware = get_unity_instance_middleware() + mcp.add_middleware(unity_middleware) + logger.info("Registered middleware: auth -> unity instance routing") + + if custom_tool_service: + custom_tool_service.set_auth_settings(AUTH_SETTINGS) + # Set environment variables from command line args if args.default_instance: os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance diff --git a/Server/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index d1f37036..51f1f479 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -16,6 +16,7 @@ get_unity_connection_pool, ) from transport.plugin_hub import PluginHub +from core.auth import AuthSettings, verify_http_request logger = logging.getLogger("mcp-for-unity-server") @@ -39,13 +40,17 @@ class ToolRegistrationResponse(BaseModel): class CustomToolService: _instance: "CustomToolService | None" = None - def __init__(self, mcp: FastMCP): + def __init__(self, mcp: FastMCP, auth_settings: AuthSettings | None = None): CustomToolService._instance = self self._mcp = mcp self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {} self._hash_to_project: dict[str, str] = {} + self._auth_settings: AuthSettings = auth_settings or AuthSettings() self._register_http_routes() + def set_auth_settings(self, auth_settings: AuthSettings) -> None: + self._auth_settings = auth_settings + @classmethod def get_instance(cls) -> "CustomToolService": if cls._instance is None: @@ -56,6 +61,9 @@ def get_instance(cls) -> "CustomToolService": def _register_http_routes(self) -> None: @self._mcp.custom_route("/register-tools", methods=["POST"]) async def register_tools(request: Request) -> JSONResponse: + failure = verify_http_request(request, self._auth_settings) + if failure: + return failure try: payload = RegisterToolsPayload.model_validate(await request.json()) except ValidationError as exc: diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index fed65b22..a0a9ce6d 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -12,6 +12,7 @@ from starlette.websockets import WebSocket from core.config import config +from core.auth import AuthSettings, verify_websocket from transport.plugin_registry import PluginRegistry from transport.models import ( WelcomeMessage, @@ -37,6 +38,7 @@ class PluginHub(WebSocketEndpoint): COMMAND_TIMEOUT = 30 _registry: PluginRegistry | None = None + _auth_settings: AuthSettings | None = None _connections: dict[str, WebSocket] = {} _pending: dict[str, asyncio.Future] = {} _lock: asyncio.Lock | None = None @@ -47,17 +49,28 @@ def configure( cls, registry: PluginRegistry, loop: asyncio.AbstractEventLoop | None = None, + auth_settings: AuthSettings | None = None, ) -> None: cls._registry = registry cls._loop = loop or asyncio.get_running_loop() # Ensure coordination primitives are bound to the configured loop cls._lock = asyncio.Lock() + cls._auth_settings = auth_settings + + @classmethod + def set_auth_settings(cls, settings: AuthSettings | None) -> None: + cls._auth_settings = settings @classmethod def is_configured(cls) -> bool: return cls._registry is not None and cls._lock is not None async def on_connect(self, websocket: WebSocket) -> None: + failure = await verify_websocket(websocket, self._auth_settings or AuthSettings()) + if failure is not None: + await websocket.close(code=4401) + return + await websocket.accept() msg = WelcomeMessage( serverTimeout=self.SERVER_TIMEOUT, From bfa9cffa12d2d1d670fc0bb1fb8105d5b78e5b96 Mon Sep 17 00:00:00 2001 From: Jordon Harrison Date: Fri, 5 Dec 2025 11:34:26 +0000 Subject: [PATCH 02/38] feat: add optional authentication settings and UI integration --- .../Editor/Constants/EditorPrefKeys.cs | 4 + .../Editor/Helpers/AuthPreferencesUtility.cs | 64 +++++++++++++++ .../Components/Settings/McpSettingsSection.cs | 79 +++++++++++++++++++ .../Settings/McpSettingsSection.uxml | 13 +++ MCPForUnity/README.md | 8 ++ Server/src/main.py | 11 ++- Server/src/utils/network.py | 23 ++++++ Server/tests/integration/conftest.py | 4 + .../tests/integration/test_auth_controls.py | 77 ++++++++++++++++++ .../tests/integration/test_host_resolution.py | 40 ++++++++++ 10 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs create mode 100644 Server/src/utils/network.py create mode 100644 Server/tests/integration/test_auth_controls.py create mode 100644 Server/tests/integration/test_host_resolution.py diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 30dcd2bb..56a7109a 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -21,6 +21,10 @@ internal static class EditorPrefKeys internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + internal const string AuthEnabled = "MCPForUnity.AuthEnabled"; + internal const string AuthToken = "MCPForUnity.AuthToken"; + internal const string AllowedIps = "MCPForUnity.AllowedIps"; + internal const string ServerSrc = "MCPForUnity.ServerSrc"; internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; diff --git a/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs new file mode 100644 index 00000000..de3942b0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class AuthPreferencesUtility + { + internal static bool GetAuthEnabled() + { + return EditorPrefs.GetBool(EditorPrefKeys.AuthEnabled, false); + } + + internal static void SetAuthEnabled(bool enabled) + { + EditorPrefs.SetBool(EditorPrefKeys.AuthEnabled, enabled); + } + + internal static string GetAllowedIpsRaw() + { + return EditorPrefs.GetString(EditorPrefKeys.AllowedIps, "*"); + } + + internal static string[] GetAllowedIps() + { + var raw = GetAllowedIpsRaw(); + if (string.IsNullOrWhiteSpace(raw)) + { + return new[] { "*" }; + } + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .DefaultIfEmpty("*") + .ToArray(); + } + + internal static void SetAllowedIps(string csv) + { + string value = string.IsNullOrWhiteSpace(csv) ? "*" : csv.Trim(); + EditorPrefs.SetString(EditorPrefKeys.AllowedIps, value); + } + + internal static string GetAuthToken() + { + return EditorPrefs.GetString(EditorPrefKeys.AuthToken, string.Empty); + } + + internal static void SetAuthToken(string token) + { + if (string.IsNullOrEmpty(token)) + { + EditorPrefs.DeleteKey(EditorPrefKeys.AuthToken); + } + else + { + EditorPrefs.SetString(EditorPrefKeys.AuthToken, token); + } + } + } +} diff --git a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs index 55a4c665..61710cc5 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs @@ -29,6 +29,10 @@ public class McpSettingsSection private VisualElement uvxPathStatus; private TextField gitUrlOverride; private Button clearGitUrlButton; + private Foldout authSettingsFoldout; + private Toggle authEnabledToggle; + private TextField allowedIpsField; + private TextField authTokenField; // Data private ValidationLevel currentValidationLevel = ValidationLevel.Standard; @@ -69,6 +73,10 @@ private void CacheUIElements() uvxPathStatus = Root.Q("uv-path-status"); gitUrlOverride = Root.Q("git-url-override"); clearGitUrlButton = Root.Q