Apply the request body limit to the SSE and OAuth endpoints (#3336)
This commit is contained in:
parent
0cee6249ba
commit
57394b0548
6 changed files with 420 additions and 189 deletions
|
|
@ -17,6 +17,7 @@ from mcp.server.auth.handlers.token import TokenHandler
|
|||
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
|
||||
|
||||
|
|
@ -51,17 +52,24 @@ REVOCATION_PATH = "/revoke"
|
|||
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(
|
||||
|
|
@ -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",
|
||||
|
|
@ -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"],
|
||||
),
|
||||
]
|
||||
|
|
@ -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"],
|
||||
)
|
||||
)
|
||||
|
|
@ -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"],
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,12 +65,8 @@ from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestCon
|
|||
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
|
||||
|
|
|
|||
|
|
@ -87,9 +87,9 @@ from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
|
|||
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
|
||||
|
||||
|
|
@ -365,6 +365,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
port: int = ...,
|
||||
sse_path: str = ...,
|
||||
message_path: str = ...,
|
||||
max_request_body_size: int = ...,
|
||||
transport_security: TransportSecuritySettings | None = ...,
|
||||
) -> None: ...
|
||||
|
||||
|
|
@ -1031,6 +1032,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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."""
|
||||
|
|
@ -1039,6 +1041,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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,
|
||||
)
|
||||
|
|
@ -1093,6 +1096,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
*,
|
||||
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:
|
||||
|
|
@ -1105,7 +1109,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ from starlette.types import Receive, Scope, Send
|
|||
|
||||
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,
|
||||
)
|
||||
|
|
@ -79,7 +81,12 @@ 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.
|
||||
|
||||
|
|
@ -87,6 +94,9 @@ class SseServerTransport:
|
|||
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:
|
||||
|
|
@ -103,6 +113,9 @@ class SseServerTransport:
|
|||
|
||||
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(
|
||||
|
|
@ -118,6 +131,7 @@ class SseServerTransport:
|
|||
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
|
||||
|
|
@ -203,6 +217,17 @@ class SseServerTransport:
|
|||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@ from __future__ import annotations
|
|||
|
||||
import contextlib
|
||||
import logging
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import anyio
|
||||
from anyio.abc import TaskStatus
|
||||
from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
|
||||
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server._streamable_http_modern import handle_modern_request
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_connection, serve_loop
|
||||
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport
|
||||
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE as DEFAULT_MAX_REQUEST_BODY_SIZE
|
||||
from mcp.server.transport_security import RequestBodyLimitMiddleware as RequestBodyLimitMiddleware
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
|
|
@ -34,9 +34,6 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
|
||||
"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""
|
||||
|
||||
|
||||
class StreamableHTTPSessionManager:
|
||||
"""Manages StreamableHTTP sessions with optional resumability via event store.
|
||||
|
|
@ -70,7 +67,7 @@ class StreamableHTTPSessionManager:
|
|||
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
|
||||
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
|
||||
(30 minutes) is recommended for most deployments.
|
||||
max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
|
||||
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
|
||||
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
|
||||
"""
|
||||
|
||||
|
|
@ -371,66 +368,6 @@ class StreamableHTTPSessionManager:
|
|||
await response(scope, receive, send)
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
"""Reject oversized HTTP request bodies before invoking an ASGI application."""
|
||||
|
||||
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
|
||||
self.app = app
|
||||
self.max_body_size = max_body_size
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["method"] != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = Headers(scope=scope)
|
||||
content_length = headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if declared_size > self.max_body_size:
|
||||
response = Response("Request body too large", status_code=413)
|
||||
return await response(scope, receive, send)
|
||||
|
||||
received_body = bytearray()
|
||||
received_request = False
|
||||
body_complete = False
|
||||
trailing_message: Message | None = None
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
trailing_message = message
|
||||
break
|
||||
|
||||
received_request = True
|
||||
body = message.get("body", b"")
|
||||
if len(received_body) + len(body) > self.max_body_size:
|
||||
response = Response("Request body too large", status_code=413)
|
||||
return await response(scope, receive, send)
|
||||
received_body.extend(body)
|
||||
if not message.get("more_body", False):
|
||||
body_complete = True
|
||||
break
|
||||
|
||||
cached_messages: deque[Message] = deque()
|
||||
if received_request:
|
||||
cached_messages.append(
|
||||
{"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
|
||||
)
|
||||
if trailing_message is not None:
|
||||
cached_messages.append(trailing_message)
|
||||
|
||||
async def replay() -> Message:
|
||||
if cached_messages:
|
||||
return cached_messages.popleft()
|
||||
return await receive()
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
|
||||
class StreamableHTTPASGIApp:
|
||||
"""ASGI application for Streamable HTTP server transport."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
"""DNS rebinding protection for MCP server transports."""
|
||||
"""Request checks shared by the HTTP server transports: Host/Origin header validation and body size limits."""
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
|
||||
"""Default maximum HTTP request body size in bytes (4 MiB)."""
|
||||
|
||||
|
||||
# TODO(Marcelo): We should flatten these settings. To be fair, I don't think we should even have this middleware.
|
||||
class TransportSecuritySettings(BaseModel):
|
||||
|
|
@ -114,3 +121,63 @@ class TransportSecurityMiddleware:
|
|||
return Response("Invalid Origin header", status_code=403)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
"""Reject oversized HTTP request bodies before invoking an ASGI application."""
|
||||
|
||||
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
|
||||
self.app = app
|
||||
self.max_body_size = max_body_size
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = Headers(scope=scope)
|
||||
content_length = headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if declared_size > self.max_body_size:
|
||||
response = Response("Request body too large", status_code=413)
|
||||
return await response(scope, receive, send)
|
||||
|
||||
received_body = bytearray()
|
||||
received_request = False
|
||||
body_complete = False
|
||||
trailing_message: Message | None = None
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
trailing_message = message
|
||||
break
|
||||
|
||||
received_request = True
|
||||
body = message.get("body", b"")
|
||||
if len(received_body) + len(body) > self.max_body_size:
|
||||
response = Response("Request body too large", status_code=413)
|
||||
return await response(scope, receive, send)
|
||||
received_body.extend(body)
|
||||
if not message.get("more_body", False):
|
||||
body_complete = True
|
||||
break
|
||||
|
||||
cached_messages: deque[Message] = deque()
|
||||
if received_request:
|
||||
cached_messages.append(
|
||||
{"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
|
||||
)
|
||||
if trailing_message is not None:
|
||||
cached_messages.append(trailing_message)
|
||||
|
||||
async def replay() -> Message:
|
||||
if cached_messages:
|
||||
return cached_messages.popleft()
|
||||
return await receive()
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
|
|
|||
Loading…
Reference in a new issue