diff --git a/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py b/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py index 354ae0a89..8ba6ce939 100644 --- a/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py +++ b/surfsense_mcp/src/surfsense_mcp/core/auth/middleware.py @@ -6,10 +6,13 @@ not reach the tool handler. A pure middleware binds the key in the request's own task, from which the SDK's per-request handling inherits it. Requests without a key are rejected here so no tool ever runs unauthenticated. +Paths in ``public_paths`` (e.g. the health probe) skip the check entirely. """ from __future__ import annotations +from collections.abc import Iterable + from starlette.datastructures import Headers from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -21,11 +24,12 @@ from .identity import bind_api_key, unbind_api_key class ApiKeyIdentityMiddleware: """Binds the per-request API key into the identity contextvar, or 401s.""" - def __init__(self, app: ASGIApp) -> None: + def __init__(self, app: ASGIApp, public_paths: Iterable[str] = ()) -> None: self._app = app + self._public_paths = frozenset(public_paths) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != "http": + if scope["type"] != "http" or scope["path"] in self._public_paths: await self._app(scope, receive, send) return diff --git a/surfsense_mcp/src/surfsense_mcp/core/transport/http.py b/surfsense_mcp/src/surfsense_mcp/core/transport/http.py index 2f2cf8f53..e375e8b98 100644 --- a/surfsense_mcp/src/surfsense_mcp/core/transport/http.py +++ b/surfsense_mcp/src/surfsense_mcp/core/transport/http.py @@ -1,23 +1,32 @@ -"""Assemble the streamable-http ASGI app for the remote transport. +"""Wrap the SDK's MCP endpoint with identity + CORS for the remote transport. -Wraps the SDK's MCP endpoint with the API-key identity middleware and CORS. -CORS sits outermost so browser preflight (which carries no key) is answered -before the identity middleware, and clients can read the ``Mcp-Session-Id`` -header the streamable-http protocol relies on. +CORS is outermost so keyless browser preflight is answered before the identity +middleware. ``/health`` is a public path, exempt from the key check. """ from __future__ import annotations from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse from starlette.middleware.cors import CORSMiddleware from starlette.types import ASGIApp from ..auth.middleware import ApiKeyIdentityMiddleware +HEALTH_PATH = "/health" + + +async def _health(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + def build_http_app(mcp: FastMCP) -> ASGIApp: """Return the MCP streamable-http app wrapped with identity + CORS.""" - app: ASGIApp = ApiKeyIdentityMiddleware(mcp.streamable_http_app()) + mcp.custom_route(HEALTH_PATH, methods=["GET"])(_health) + app: ASGIApp = ApiKeyIdentityMiddleware( + mcp.streamable_http_app(), public_paths={HEALTH_PATH} + ) return CORSMiddleware( app, allow_origins=["*"],