Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -1253,7 +1253,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
- `host` and `port` - Server network configuration
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
- `stateless_http` - Whether the server operates in stateless mode
- `max_request_body_size` - Maximum Streamable HTTP POST body size in bytes
- `max_request_body_size` - Maximum HTTP request body size in bytes (Streamable HTTP and SSE)
- And other configuration options

```python
Expand Down Expand Up @@ -1418,9 +1418,9 @@ Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMC

> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability.

Streamable HTTP POST bodies are limited to 4 MiB by default. Larger requests receive HTTP 413
before parsing or session creation. If your server intentionally accepts larger MCP messages,
configure the smallest suitable byte limit:
HTTP request bodies (Streamable HTTP and SSE) are limited to 4 MiB by default. Larger requests
receive HTTP 413 before parsing or session creation. If your server intentionally accepts larger MCP
messages, configure the smallest suitable byte limit:

```python
mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)
Expand Down
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)
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":

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

View check run for this annotation

Claude / Claude Code Review

nit (pre-existing-equivalent): handle_post_message reads scope["method"] without a scope["type"] == "http" guard, so non-HTTP scopes (e.g. a websocket handshake routed through the Mount at the message path) raise KeyError before RequestBodyLimitMiddleware

nit (pre-existing-equivalent): handle_post_message reads scope["method"] without a scope["type"] == "http" guard, so non-HTTP scopes (e.g. a websocket handshake routed through the Mount at the message path) raise KeyError before RequestBodyLimitMiddleware's own non-http passthrough can run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (pre-existing-equivalent): handle_post_message reads scope["method"] without a scope["type"] == "http" guard, so non-HTTP scopes (e.g. a websocket handshake routed through the Mount at the message path) raise KeyError before RequestBodyLimitMiddleware's own non-http passthrough can run

Extended reasoning...

A client opens a websocket connection to the mounted message endpoint (Starlette Mount matches websocket scopes); scope has no "method" key, so handle_post_message raises KeyError and the server returns a 500/unhandled-exception instead of cleanly rejecting the request. Before this change the same request also crashed (AssertionError in Request(scope)), so the user-visible outcome is unchanged — flagged only because the new code sits in front of a middleware that deliberately checks scope type.

Verification: nit. The new code at src/mcp/server/sse.py:237 (if scope["method"] != "POST":) reads scope["method"] with no scope["type"] == "http" guard. ASGI websocket scopes have no "method" key, and the callable is reachable by websocket handshakes: FastMCP mounts it via Mount(self.settings.message_path, app=sse.handle_post_message) (src/mcp/server/fastmcp/server.py:912-915, 931-935), and

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
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@

import contextlib
import logging
from collections import deque
from collections.abc import AsyncIterator
from typing import Any, Final
from typing import Any
from uuid import uuid4

import anyio
from anyio.abc import TaskStatus
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.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.lowlevel.server import Server as MCPServer
Expand All @@ -23,13 +21,16 @@
EventStore,
StreamableHTTPServerTransport,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.transport_security import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
RequestBodyLimitMiddleware,
TransportSecuritySettings,
)
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError

logger = logging.getLogger(__name__)
__all__ = ["DEFAULT_MAX_REQUEST_BODY_SIZE", "RequestBodyLimitMiddleware", "StreamableHTTPSessionManager"]

DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""
logger = logging.getLogger(__name__)


class StreamableHTTPSessionManager:
Expand Down Expand Up @@ -65,7 +66,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.
"""

Expand Down Expand Up @@ -361,63 +362,3 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
body.model_dump_json(by_alias=True, exclude_none=True), status_code=404, media_type="application/json"
)
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)
Loading
Loading