"""Bearer-token gate for the HTTP transport of the MCP server (#231.2). Why this exists --------------- Over ``stdio`` the protection is the pipe itself: only a process that already runs as this user can speak to the server. ``streamable-http`` removes that property entirely — anything that can reach the socket can call any of the 108 registered tools, and the registry includes ``case_delete``, ``precedent_library_delete``, ``document_upload`` and every block-writing tool. An unauthenticated listener is therefore a delete-any-case endpoint. The agent platform already speaks this dialect: it injects each runtime MCP server as ``{type:"http", name, url, headers:[{name:"Authorization", value:"Bearer "}]}``. So a static bearer token is exactly the shape the caller will present — no negotiation, no OAuth dance. Design notes ------------ - We implement the SDK's own ``TokenVerifier`` protocol and let ``BearerAuthBackend`` do the enforcement, rather than adding bespoke middleware. One auth path, the framework's (G2). - The token is read from the environment, which the service unit populates from Infisical. It is never defaulted, never logged, and never embedded here. - Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a caller who can time responses. - ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the protocol's "reject" signal and yields a clean 401 instead of a 500 that would read as a server fault. """ from __future__ import annotations import hmac import logging import os from mcp.server.auth.provider import AccessToken, TokenVerifier logger = logging.getLogger("legal_mcp.http_auth") #: Environment variable carrying the shared bearer token. #: #: Name matches the Infisical key exactly — All Infrastructure / env `main` / #: `/apps/legal-ai` / ``MCP_HTTP_SHARED_SECRET``, tagged ``credentials``. Keeping #: the two identical means nobody has to hold a mapping in their head, and it #: follows the two bridge tokens already in that folder #: (``COURT_FETCH_SHARED_SECRET``, ``LEGAL_CHAT_SHARED_SECRET``). TOKEN_ENV = "MCP_HTTP_SHARED_SECRET" #: Minimum acceptable token length. Short tokens are brute-forceable; refusing #: them at startup is cheaper than discovering it from an access log. MIN_TOKEN_LEN = 32 #: Reported as the authenticated principal. Single shared token today, so this #: is a constant rather than a real identity — kept explicit so that audit rows #: never imply per-agent attribution we cannot actually make. CLIENT_ID = "mcp-http-shared" class MissingTokenError(RuntimeError): """Raised when the HTTP transport is requested without a usable token. Deliberately fatal. The tempting alternative — start anyway and log a warning — produces a listener that looks healthy and answers every destructive tool call. Refusing to boot is the safe failure (§6: never swallow, never degrade silently). """ def load_token() -> str: """Return the configured bearer token, or raise if it is unusable.""" token = (os.environ.get(TOKEN_ENV) or "").strip() if not token: raise MissingTokenError( f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive " f"tools and will not start without a bearer token. Set it from " f"Infisical, or use MCP_TRANSPORT=stdio.", ) if len(token) < MIN_TOKEN_LEN: raise MissingTokenError( f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing " f"to start. Generate a long random token.", ) return token class StaticTokenVerifier(TokenVerifier): """Verifies the single shared bearer token presented by the platform. Not an identity system: it answers "may this caller in at all", not "who is it". Per-agent attribution would need per-agent tokens, which is a later step once profiles exist (#231.4). """ def __init__(self, expected: str) -> None: self._expected = expected async def verify_token(self, token: str) -> AccessToken | None: # compare_digest over bytes; it tolerates unequal lengths without # short-circuiting, which is the whole point. if not hmac.compare_digest(token.encode("utf-8"), self._expected.encode("utf-8")): # No token material in the log line — only the fact of a rejection. logger.warning("Rejected MCP HTTP request: bearer token mismatch") return None return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])