Skip to content

Commit da74971

Browse files
authored
Merge branch 'v1.x' into fix/v1x-nonutf8-post-body-parse-error
2 parents 4b5c576 + 5ebdfed commit da74971

15 files changed

Lines changed: 549 additions & 163 deletions

‎docs/server.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
12531253
- `host` and `port` - Server network configuration
12541254
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
12551255
- `stateless_http` - Whether the server operates in stateless mode
1256-
- `max_request_body_size` - Maximum Streamable HTTP POST body size in bytes
1256+
- `max_request_body_size` - Maximum HTTP request body size in bytes (Streamable HTTP and SSE)
12571257
- And other configuration options
12581258

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

14191419
> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability.
14201420
1421-
Streamable HTTP POST bodies are limited to 4 MiB by default. Larger requests receive HTTP 413
1422-
before parsing or session creation. If your server intentionally accepts larger MCP messages,
1423-
configure the smallest suitable byte limit:
1421+
HTTP request bodies (Streamable HTTP and SSE) are limited to 4 MiB by default. Larger requests
1422+
receive HTTP 413 before parsing or session creation. If your server intentionally accepts larger MCP
1423+
messages, configure the smallest suitable byte limit:
14241424

14251425
```python
14261426
mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)

‎src/mcp/client/session.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,17 +428,24 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
428428

429429
if output_schema is not None:
430430
from jsonschema import SchemaError, ValidationError, validate
431+
from referencing import Registry
432+
from referencing.exceptions import Unresolvable
431433

432434
if result.structuredContent is None:
433435
raise RuntimeError(
434436
f"Tool {name} has an output schema but did not return structured content"
435437
) # pragma: no cover
438+
# An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas.
439+
registry: Registry[Any] = Registry()
436440
try:
437-
validate(result.structuredContent, output_schema)
441+
validate(result.structuredContent, output_schema, registry=registry)
438442
except ValidationError as e:
439443
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
440444
except SchemaError as e: # pragma: no cover
441445
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
446+
except Unresolvable as e:
447+
# A `$ref` did not resolve within the schema document.
448+
raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e
442449

443450
@overload
444451
@deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead")

‎src/mcp/server/auth/routes.py‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
2020
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
21+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
2122
from mcp.shared.auth import OAuthMetadata
2223

2324

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

5556

56-
def cors_middleware(
57-
handler: Callable[[Request], Response | Awaitable[Response]],
58-
allow_methods: list[str],
59-
) -> ASGIApp:
60-
cors_app = CORSMiddleware(
61-
app=request_response(handler),
57+
def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
58+
return CORSMiddleware(
59+
app=app,
6260
allow_origins="*",
6361
allow_methods=allow_methods,
6462
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
6563
)
66-
return cors_app
64+
65+
66+
def _body_limited(app: ASGIApp) -> ASGIApp:
67+
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)
68+
69+
70+
def cors_middleware(
71+
handler: Callable[[Request], Response | Awaitable[Response]],
72+
allow_methods: list[str],
73+
) -> ASGIApp:
74+
return _cors(request_response(handler), allow_methods)
6775

6876

6977
def create_auth_routes(
@@ -84,11 +92,13 @@ def create_auth_routes(
8492
revocation_options,
8593
)
8694
client_authenticator = ClientAuthenticator(provider)
95+
token_handler = TokenHandler(provider, client_authenticator)
8796

8897
# Create routes
8998
# Allow CORS requests for endpoints meant to be hit by the OAuth client
9099
# (with the client secret). This is intended to support things like MCP Inspector,
91-
# where the client runs in a web browser.
100+
# where the client runs in a web browser. CORS is the outermost wrapper so that
101+
# responses produced by inner layers (such as a 413) still carry CORS headers.
92102
routes = [
93103
Route(
94104
"/.well-known/oauth-authorization-server",
@@ -102,15 +112,12 @@ def create_auth_routes(
102112
AUTHORIZATION_PATH,
103113
# do not allow CORS for authorization endpoint;
104114
# clients should just redirect to this
105-
endpoint=AuthorizationHandler(provider).handle,
115+
endpoint=_body_limited(request_response(AuthorizationHandler(provider).handle)),
106116
methods=["GET", "POST"],
107117
),
108118
Route(
109119
TOKEN_PATH,
110-
endpoint=cors_middleware(
111-
TokenHandler(provider, client_authenticator).handle,
112-
["POST", "OPTIONS"],
113-
),
120+
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
114121
methods=["POST", "OPTIONS"],
115122
),
116123
]
@@ -123,10 +130,7 @@ def create_auth_routes(
123130
routes.append(
124131
Route(
125132
REGISTRATION_PATH,
126-
endpoint=cors_middleware(
127-
registration_handler.handle,
128-
["POST", "OPTIONS"],
129-
),
133+
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
130134
methods=["POST", "OPTIONS"],
131135
)
132136
)
@@ -136,10 +140,7 @@ def create_auth_routes(
136140
routes.append(
137141
Route(
138142
REVOCATION_PATH,
139-
endpoint=cors_middleware(
140-
revocation_handler.handle,
141-
["POST", "OPTIONS"],
142-
),
143+
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
143144
methods=["POST", "OPTIONS"],
144145
)
145146
)

‎src/mcp/server/fastmcp/server.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@
6262
from mcp.server.sse import SseServerTransport
6363
from mcp.server.stdio import stdio_server
6464
from mcp.server.streamable_http import EventStore
65-
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
66-
from mcp.server.transport_security import TransportSecuritySettings
65+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
66+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
6767
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
6868
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
6969
from mcp.types import Prompt as MCPPrompt
@@ -107,6 +107,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
107107
stateless_http: bool
108108
"""Define if the server should create a new transport per request."""
109109
max_request_body_size: int
110+
"""Maximum request body size in bytes for the Streamable HTTP endpoint and the SSE message endpoint."""
110111

111112
# resource settings
112113
warn_on_duplicate_resources: bool
@@ -835,6 +836,7 @@ def sse_app(self, mount_path: str | None = None) -> Starlette:
835836
sse = SseServerTransport(
836837
normalized_message_endpoint,
837838
security_settings=self.settings.transport_security,
839+
max_request_body_size=self.settings.max_request_body_size,
838840
)
839841

840842
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
@@ -1087,6 +1089,11 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -
10871089
raise ValueError(str(e))
10881090

10891091

1092+
# `Settings.lifespan` refers to FastMCP, which is only defined above; complete the model now so
1093+
# settings sources never see an unresolved annotation when a FastMCP instance is created.
1094+
Settings.model_rebuild()
1095+
1096+
10901097
class StreamableHTTPASGIApp:
10911098
"""
10921099
ASGI application for Streamable HTTP server transport.

‎src/mcp/server/fastmcp/utilities/func_metadata.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
4545
raise ValueError(f"JSON schema warning: {kind} - {detail}")
4646

4747

48+
_LOCAL_DEFS_PREFIX = "#/$defs/"
49+
50+
51+
def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
52+
"""Give a schema whose root is a bare `$ref` into `$defs` an inline root.
53+
54+
pydantic emits a self-referential model as `{"$defs": {...}, "$ref": "#/$defs/Model"}`, with no
55+
`type` at the root; `Tool.outputSchema` requires `type: object` at the root. The referenced
56+
definition is copied onto the root and `$defs` is kept, since nested references still point into
57+
it. Root siblings of the `$ref` win over the definition's keys.
58+
"""
59+
ref = schema.get("$ref")
60+
if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX):
61+
return schema
62+
definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)])
63+
siblings = {key: value for key, value in schema.items() if key != "$ref"}
64+
return {**definition, **siblings}
65+
66+
4867
class ArgModelBase(BaseModel):
4968
"""A model representing the arguments to a function."""
5069

@@ -428,7 +447,7 @@ def _try_create_model_and_schema(
428447
logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}")
429448
return None, None, False
430449

431-
return model, schema, wrap_output
450+
return model, _inline_root_ref(schema), wrap_output
432451

433452
return None, None, False
434453

‎src/mcp/server/sse.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ async def handle_sse(request):
5454
import mcp.types as types
5555
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
5656
from mcp.server.transport_security import (
57+
DEFAULT_MAX_REQUEST_BODY_SIZE,
58+
RequestBodyLimitMiddleware,
5759
TransportSecurityMiddleware,
5860
TransportSecuritySettings,
5961
)
@@ -81,7 +83,12 @@ class SseServerTransport:
8183
_session_owners: dict[UUID, AuthorizationContext]
8284
_security: TransportSecurityMiddleware
8385

84-
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
86+
def __init__(
87+
self,
88+
endpoint: str,
89+
security_settings: TransportSecuritySettings | None = None,
90+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
91+
) -> None:
8592
"""
8693
Creates a new SSE server transport, which will direct the client to POST
8794
messages to the relative path given.
@@ -90,6 +97,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
9097
endpoint: A relative path where messages should be posted
9198
(e.g., "/messages/").
9299
security_settings: Optional security settings for DNS rebinding protection.
100+
max_request_body_size: Maximum size in bytes for POSTed message bodies. Requests that
101+
declare or stream a larger body receive HTTP 413. Defaults to 4 MiB, matching
102+
`StreamableHTTPSessionManager`.
93103
94104
Note:
95105
We use relative paths instead of full URLs for several reasons:
@@ -106,6 +116,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
106116

107117
super().__init__()
108118

119+
if max_request_body_size <= 0:
120+
raise ValueError("max_request_body_size must be a positive number of bytes")
121+
109122
# Validate that endpoint is a relative path and not a full URL
110123
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
111124
raise ValueError(
@@ -121,6 +134,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
121134
self._read_stream_writers = {}
122135
self._session_owners = {}
123136
self._security = TransportSecurityMiddleware(security_settings)
137+
self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size)
124138
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
125139

126140
@asynccontextmanager
@@ -214,7 +228,18 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
214228
self._read_stream_writers.pop(session_id, None)
215229
self._session_owners.pop(session_id, None)
216230

217-
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
231+
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
232+
"""ASGI application for the message endpoint.
233+
234+
Only POST is accepted (other methods get 405), and bodies larger than
235+
`max_request_body_size` are answered with 413 before the message is handled.
236+
"""
237+
if scope["method"] != "POST":
238+
response = Response(status_code=405, headers={"Allow": "POST"})
239+
return await response(scope, receive, send)
240+
await self._post_message_app(scope, receive, send)
241+
242+
async def _handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
218243
logger.debug("Handling POST message")
219244
request = Request(scope, receive)
220245

0 commit comments

Comments
 (0)