[v1.x] Apply the request body limit to the SSE and OAuth endpoints by maxisbey · Pull Request #3344 · modelcontextprotocol/python-sdk · GitHub
Skip to content
Merged
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
8 changes: 4 additions & 4 deletions docs/server.md
43 changes: 22 additions & 21 deletions src/mcp/server/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
from mcp.shared.auth import OAuthMetadata


Expand Down Expand Up @@ -53,17 +54,24 @@ def validate_issuer_url(url: AnyHttpUrl):
REVOCATION_PATH = "/revoke"


def cors_middleware(
handler: Callable[[Request], Response | Awaitable[Response]],
allow_methods: list[str],
) -> ASGIApp:
cors_app = CORSMiddleware(
app=request_response(handler),
def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
return CORSMiddleware(
app=app,
allow_origins="*",
allow_methods=allow_methods,
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
)
return cors_app


def _body_limited(app: ASGIApp) -> ASGIApp:
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)


def cors_middleware(
handler: Callable[[Request], Response | Awaitable[Response]],
allow_methods: list[str],
) -> ASGIApp:
return _cors(request_response(handler), allow_methods)


def create_auth_routes(
Expand All @@ -84,11 +92,13 @@ def create_auth_routes(
revocation_options,
)
client_authenticator = ClientAuthenticator(provider)
token_handler = TokenHandler(provider, client_authenticator)

# Create routes
# Allow CORS requests for endpoints meant to be hit by the OAuth client
# (with the client secret). This is intended to support things like MCP Inspector,
# where the client runs in a web browser.
# where the client runs in a web browser. CORS is the outermost wrapper so that
# responses produced by inner layers (such as a 413) still carry CORS headers.
routes = [
Route(
"/.well-known/oauth-authorization-server",
Expand All @@ -102,15 +112,12 @@ def create_auth_routes(
AUTHORIZATION_PATH,
# do not allow CORS for authorization endpoint;
# clients should just redirect to this
endpoint=AuthorizationHandler(provider).handle,
endpoint=_body_limited(request_response(AuthorizationHandler(provider).handle)),
methods=["GET", "POST"],
),
Route(
TOKEN_PATH,
endpoint=cors_middleware(
TokenHandler(provider, client_authenticator).handle,
["POST", "OPTIONS"],
),
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
),
]
Expand All @@ -123,10 +130,7 @@ def create_auth_routes(
routes.append(
Route(
REGISTRATION_PATH,
endpoint=cors_middleware(
registration_handler.handle,
["POST", "OPTIONS"],
),
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
)
)
Expand All @@ -136,10 +140,7 @@ def create_auth_routes(
routes.append(
Route(
REVOCATION_PATH,
endpoint=cors_middleware(
revocation_handler.handle,
["POST", "OPTIONS"],
),
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
)
)
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
from mcp.types import Prompt as MCPPrompt
Expand Down Expand Up @@ -107,6 +107,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
stateless_http: bool
"""Define if the server should create a new transport per request."""
max_request_body_size: int
"""Maximum request body size in bytes for the Streamable HTTP endpoint and the SSE message endpoint."""

# resource settings
warn_on_duplicate_resources: bool
Expand Down Expand Up @@ -835,6 +836,7 @@ def sse_app(self, mount_path: str | None = None) -> Starlette:
sse = SseServerTransport(
normalized_message_endpoint,
security_settings=self.settings.transport_security,
max_request_body_size=self.settings.max_request_body_size,
)

async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
Expand Down
29 changes: 27 additions & 2 deletions src/mcp/server/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
import mcp.types as types
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.transport_security import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
RequestBodyLimitMiddleware,
TransportSecurityMiddleware,
TransportSecuritySettings,
)
Expand Down Expand Up @@ -81,7 +83,12 @@
_session_owners: dict[UUID, AuthorizationContext]
_security: TransportSecurityMiddleware

def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
def __init__(
self,
endpoint: str,
security_settings: TransportSecuritySettings | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
) -> None:
"""
Creates a new SSE server transport, which will direct the client to POST
messages to the relative path given.
Expand All @@ -90,6 +97,9 @@
endpoint: A relative path where messages should be posted
(e.g., "/messages/").
security_settings: Optional security settings for DNS rebinding protection.
max_request_body_size: Maximum size in bytes for POSTed message bodies. Requests that
declare or stream a larger body receive HTTP 413. Defaults to 4 MiB, matching
`StreamableHTTPSessionManager`.

Note:
We use relative paths instead of full URLs for several reasons:
Expand All @@ -106,6 +116,9 @@

super().__init__()

if max_request_body_size <= 0:
raise ValueError("max_request_body_size must be a positive number of bytes")

# Validate that endpoint is a relative path and not a full URL
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
raise ValueError(
Expand All @@ -121,6 +134,7 @@
self._read_stream_writers = {}
self._session_owners = {}
self._security = TransportSecurityMiddleware(security_settings)
self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size)

Check warning on line 137 in src/mcp/server/sse.py

View check run for this annotation

Claude / Claude Code Review

Minor ordering regression: wrapping _handle_post_message in RequestBodyLimitMiddleware means the SSE message endpoint now reads and buffers the full request body (up to max_request_body_size, 4 MiB default) BEFORE the DNS-rebinding Host/Origin validation,

Minor ordering regression: wrapping _handle_post_message in RequestBodyLimitMiddleware means the SSE message endpoint now reads and buffers the full request body (up to max_request_body_size, 4 MiB default) BEFORE the DNS-rebinding Host/Origin validation, Content-Type check, session_id lookup, and session-ownership check in _handle_post_message run. Before this change, handle_post_message called self._security.validate_request() and the session checks first and sent 400/403/404/421 without ever
Comment thread
maxisbey marked this conversation as resolved.
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")

@asynccontextmanager
Expand Down Expand Up @@ -214,7 +228,18 @@
self._read_stream_writers.pop(session_id, None)
self._session_owners.pop(session_id, None)

async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
"""ASGI application for the message endpoint.

Only POST is accepted (other methods get 405), and bodies larger than
`max_request_body_size` are answered with 413 before the message is handled.
"""
if scope["method"] != "POST":
Comment thread
maxisbey marked this conversation as resolved.
response = Response(status_code=405, headers={"Allow": "POST"})
return await response(scope, receive, send)
await self._post_message_app(scope, receive, send)

async def _handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
logger.debug("Handling POST message")
request = Request(scope, receive)

Expand Down
79 changes: 10 additions & 69 deletions src/mcp/server/streamable_http_manager.py
Loading
Loading