feat(mcp): add health endpoint with public-path auth exemption

This commit is contained in:
CREDO23 2026-07-07 18:05:56 +02:00
parent 2acc1426bf
commit 2b9daead58
2 changed files with 21 additions and 8 deletions

View file

@ -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. task, from which the SDK's per-request handling inherits it.
Requests without a key are rejected here so no tool ever runs unauthenticated. 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 __future__ import annotations
from collections.abc import Iterable
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
@ -21,11 +24,12 @@ from .identity import bind_api_key, unbind_api_key
class ApiKeyIdentityMiddleware: class ApiKeyIdentityMiddleware:
"""Binds the per-request API key into the identity contextvar, or 401s.""" """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._app = app
self._public_paths = frozenset(public_paths)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 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) await self._app(scope, receive, send)
return return

View file

@ -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 is outermost so keyless browser preflight is answered before the identity
CORS sits outermost so browser preflight (which carries no key) is answered middleware. ``/health`` is a public path, exempt from the key check.
before the identity middleware, and clients can read the ``Mcp-Session-Id``
header the streamable-http protocol relies on.
""" """
from __future__ import annotations from __future__ import annotations
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.types import ASGIApp from starlette.types import ASGIApp
from ..auth.middleware import ApiKeyIdentityMiddleware 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: def build_http_app(mcp: FastMCP) -> ASGIApp:
"""Return the MCP streamable-http app wrapped with identity + CORS.""" """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( return CORSMiddleware(
app, app,
allow_origins=["*"], allow_origins=["*"],