Apply the request body limit to the SSE and OAuth endpoints by maxisbey · Pull Request #3336 · 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
2 changes: 1 addition & 1 deletion docs/migration.md
2 changes: 1 addition & 1 deletion docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`:
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.
* `stateless_http=True`: a fresh transport per request, no session tracking.
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
* `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
exceed that size.
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.
Expand Down
45 changes: 22 additions & 23 deletions src/mcp/server/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER

Expand Down Expand Up @@ -51,17 +52,24 @@ def validate_issuer_url(url: AnyHttpUrl):
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"


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(
supports_identity_assertion=identity_assertion_enabled,
)
client_authenticator = ClientAuthenticator(provider)
token_handler = TokenHandler(provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled)

# 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,17 +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, identity_assertion_enabled=identity_assertion_enabled
).handle,
["POST", "OPTIONS"],
),
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
),
]
Expand All @@ -125,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 @@ -138,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
8 changes: 2 additions & 6 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,8 @@ async def main():
from mcp.server.models import InitializationOptions
from mcp.server.runner import serve_dual_era_loop
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
StreamableHTTPASGIApp,
StreamableHTTPSessionManager,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import SessionMessage
Expand Down
12 changes: 9 additions & 3 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@
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.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.shared.uri_template import UriTemplate

Expand Down Expand Up @@ -365,6 +365,7 @@ def run(
port: int = ...,
sse_path: str = ...,
message_path: str = ...,
max_request_body_size: int = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...

Expand Down Expand Up @@ -1031,6 +1032,7 @@ async def run_sse_async( # pragma: no cover
port: int = 8000,
sse_path: str = "/sse",
message_path: str = "/messages/",
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using SSE transport."""
Expand All @@ -1039,6 +1041,7 @@ async def run_sse_async( # pragma: no cover
starlette_app = self.sse_app(
sse_path=sse_path,
message_path=message_path,
max_request_body_size=max_request_body_size,
transport_security=transport_security,
host=host,
)
Expand Down Expand Up @@ -1093,6 +1096,7 @@ def sse_app(
*,
sse_path: str = "/sse",
message_path: str = "/messages/",
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
Expand All @@ -1105,7 +1109,9 @@ def sse_app(
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)

sse = SseServerTransport(message_path, security_settings=transport_security)
sse = SseServerTransport(
message_path, security_settings=transport_security, max_request_body_size=max_request_body_size
)

async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
# Add client ID from auth context into request context if available
Expand Down
27 changes: 26 additions & 1 deletion src/mcp/server/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ async def handle_sse(request):

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 @@ -79,14 +81,22 @@ class SseServerTransport:
_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.

Args:
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 @@ -103,6 +113,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |

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 @@ -118,6 +131,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
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)
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")

@asynccontextmanager
Expand Down Expand Up @@ -203,6 +217,17 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
self._session_owners.pop(session_id, None)

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":
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:
logger.debug("Handling POST message")
request = Request(scope, receive)

Expand Down
73 changes: 5 additions & 68 deletions src/mcp/server/streamable_http_manager.py
Loading
Loading