Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions src/mcp/server/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"],
),
]
Expand All @@ -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"],
)
)
Expand All @@ -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"],
)
)
Expand Down
35 changes: 32 additions & 3 deletions tests/server/auth/test_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
)
Expand Down Expand Up @@ -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
Loading