diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 848604dc98..63acc695a9 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route, request_response # type: ignore -from starlette.types import ASGIApp +from starlette.types import ASGIApp, Receive, Scope, Send from mcp.server.auth.handlers.authorize import AuthorizationHandler from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler @@ -61,6 +61,40 @@ def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp: ) +def _reject_non_preflight_options(app: ASGIApp) -> ASGIApp: + """Reject OPTIONS requests that are not CORS preflights. + + CORSMiddleware only answers *preflight* OPTIONS requests (those carrying + Access-Control-Request-Method). Any other OPTIONS request is forwarded to + the wrapped handler, which would then try to read a body and fail (400/500). + These routes only accept POST, so a plain OPTIONS should get a 405 instead + of being routed into the body-reading handler. + """ + + async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and scope["method"] == "OPTIONS": + headers = scope.get("headers") or [] + is_preflight = any( + k == b"access-control-request-method" for k, _v in headers + ) + if not is_preflight: + await send( + { + "type": "http.response.start", + "status": 405, + "headers": [ + (b"content-length", b"0"), + (b"allow", b"POST, OPTIONS"), + ], + } + ) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + return + await app(scope, receive, send) + + return wrapped + + def _body_limited(app: ASGIApp) -> ASGIApp: return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE) @@ -117,7 +151,7 @@ def create_auth_routes( ), Route( TOKEN_PATH, - endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]), + endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(token_handler.handle))), ["POST", "OPTIONS"]), methods=["POST", "OPTIONS"], ), ] @@ -130,7 +164,7 @@ def create_auth_routes( routes.append( Route( REGISTRATION_PATH, - endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]), + endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(registration_handler.handle))), ["POST", "OPTIONS"]), methods=["POST", "OPTIONS"], ) ) @@ -140,7 +174,7 @@ def create_auth_routes( routes.append( Route( REVOCATION_PATH, - endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]), + endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(revocation_handler.handle))), ["POST", "OPTIONS"]), methods=["POST", "OPTIONS"], ) ) diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index f13f23ea33..b8509d9a96 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -303,9 +303,6 @@ async def test_token_error_handling_refresh_token( ("POST", "/register", "application/json"), ("POST", "/authorize", _FORM), # The other methods these routes accept reach the same body-reading handlers. - ("OPTIONS", "/token", _FORM), - ("OPTIONS", "/revoke", _FORM), - ("OPTIONS", "/register", "application/json"), ("HEAD", "/authorize", _FORM), ], ) @@ -347,3 +344,35 @@ async def test_oversized_cross_origin_request_gets_413_with_cors_headers(client: ) assert response.status_code == 413 assert response.headers["access-control-allow-origin"] == "*" + + +@pytest.mark.anyio +async def test_plain_options_on_token_is_405_not_500(client: httpx2.AsyncClient): + """Non-preflight OPTIONS on /token must not be routed into the body reader.""" + resp = await client.request("OPTIONS", "/token") + assert resp.status_code == 405 + assert resp.headers.get("allow") == "POST, OPTIONS" + + +@pytest.mark.anyio +async def test_preflight_options_on_token_still_gets_cors_204( + client: httpx2.AsyncClient, +): + """CORS preflight on /token keeps working.""" + resp = await client.request( + "OPTIONS", + "/token", + headers={ + "Origin": "https://inspector.example.com", + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status_code in (200, 204) + assert "access-control-allow-origin" in {k.lower() for k in resp.headers.keys()} + + +@pytest.mark.anyio +async def test_plain_options_on_register_is_405_not_500(client: httpx2.AsyncClient): + """Non-preflight OPTIONS on /register must not be routed into the body reader.""" + resp = await client.request("OPTIONS", "/register") + assert resp.status_code == 405