Skip to content

Commit be37d17

Browse files
committed
Bound the initialize request a session retains
## Motivation and Context A stateful session keeps the `clientInfo` and `capabilities` of the `initialize` request that created it, for as long as the session lives, and `initialize` is validated for shape only. The session count and the idle timeout bound how many sessions exist, and `max_request_bytes` bounds one request, but nothing bounded what each session retained, so the sessions together could retain up to `max_sessions` times `max_request_bytes` of client-supplied data. The documentation described the session limits as bounding what sessions retain, which held for their number and not for their data. The TypeScript and Python SDKs keep the initialize data whole and bound neither. The body of an `initialize` request in stateful mode is now bounded by a new `max_initialize_request_bytes:` keyword, 64 KiB by default, which leaves room for large `experimental` capabilities while an ordinary `initialize` is under 2 KiB. A larger body is rejected with HTTP 413 before any session is created, so nothing over the bound is ever retained; `nil` removes the bound, and stateless mode, which retains nothing, ignores it. A byte bound alone does not tightly bound the memory a session keeps, because the parsed objects, not the bytes, are what it keeps: a body made of many small values parses into far more objects than its size suggests. The `params` of an `initialize` are therefore also limited to 1024 JSON values, counted in a walk that stops at the limit, and a request over it is rejected the same way; an ordinary `initialize` holds a few dozen. Like `MAX_JSON_NESTING`, this structural limit applies in stateful mode whatever `max_initialize_request_bytes` is, including `nil`, so removing the byte bound does not reopen the object count; a keyword can follow if a real client ever needs more. Together the two bound both the serialized input and the number of parsed values a session can retain, and the documentation now states that budget instead of the earlier claim. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` send an `initialize` over the bound, one exactly at it and one a byte over, one under `nil`, and one in stateless mode, and check the 413, that no session exists afterwards, and that a regular request larger than the bound is still governed by `max_request_bytes` alone. Further tests send `params` over the value bound while under the byte bound, exactly at it and one value over, a large number of values within it, the same over-bound `params` under `nil`, and in stateless mode, and check that object keys are counted and that nested shapes are refused. Against the previous library an `initialize` just under `max_request_bytes` creates a session that keeps it. One more sends a representative `initialize` with several icons, every capability, and an `experimental` object of many members, and checks that it is accepted well inside both bounds. ## Breaking Changes Requests that earlier releases accepted are now refused at the new bounds: an `initialize` request larger than 64 KiB is refused in stateful mode unless `max_initialize_request_bytes:` is raised or set to `nil`, and one whose `params` hold more than 1024 JSON values is refused in stateful mode.
1 parent 08e20ed commit be37d17

3 files changed

Lines changed: 344 additions & 16 deletions

File tree

‎docs/_server/transports.md‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,22 +222,36 @@ This mode is suitable for simple tool servers that do not need server-initiated
222222
223223
### Session Limits
224224

225-
By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted:
226-
they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
227-
session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it
228-
(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
229-
so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
230-
and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
225+
By default, stateful sessions are bounded in three ways: they expire after `session_idle_timeout` seconds of inactivity
226+
(default 1800, i.e. 30 minutes), the concurrent session count is capped at `max_sessions` (default 10000),
227+
and the `initialize` request whose `clientInfo` and `capabilities` a session keeps may be at most `max_initialize_request_bytes` (default 64 KiB),
228+
with at most 1024 JSON values in its `params`; a larger one is rejected with HTTP 413 before any session is created.
229+
The byte bound can be raised or removed with `nil`, in which case `max_request_bytes` still caps the body; the value bound always applies,
230+
since a session keeps the parsed data and many small values parse into far more objects than their size suggests.
231+
Together they bound both the serialized input and the number of parsed values each of the `max_sessions` sessions can retain.
232+
A session's idle timer is reset by activity that touches it (a GET, or a regular-request POST), and expired sessions are collected by
233+
a background reaper roughly once a minute, so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims
234+
any already-expired slots and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
231235

232236
```ruby
233237
# Tune the limits
234-
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
238+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
239+
server,
240+
session_idle_timeout: 900,
241+
max_sessions: 5000,
242+
max_initialize_request_bytes: 16 * 1024,
243+
)
235244

236-
# Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
237-
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)
245+
# Opt out of expiry and/or the caps (not recommended on internet-facing deployments)
246+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
247+
server,
248+
session_idle_timeout: nil,
249+
max_sessions: nil,
250+
max_initialize_request_bytes: nil,
251+
)
238252
```
239253

240-
Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. The same holds
254+
Stateless mode (`stateless: true`) retains no sessions, so none of these limits applies to it. The same holds
241255
for requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), which never create a session;
242256
their long-lived [`subscriptions/listen`](/server/subscriptions/) streams are bounded separately
243257
by `max_listen_subscriptions:`.

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,27 @@ class InvalidJsonError < StandardError; end
8282
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
8383
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
8484

85-
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
86-
# body into memory and parses it, so without a cap a single unauthenticated POST
87-
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
88-
# fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
89-
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
90-
# TypeScript SDK's 4 MB default.
85+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole body into memory and parses it,
86+
# so without a cap a single unauthenticated POST can allocate gigabytes and OOM the worker. 4 MiB comfortably fits
87+
# a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 payload); raise `max_request_bytes:`
88+
# for unusually large payloads. Matches the TypeScript SDK's 4 MB default.
9189
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
9290

91+
# Bound on the body of an `initialize` request in stateful mode. Its `clientInfo` and `capabilities` are
92+
# kept for the life of the session, so without a bound of its own each session could retain a whole
93+
# `max_request_bytes` body and the retained total would be `max_sessions` times that. An ordinary
94+
# `initialize` is under 2 KiB; 64 KiB leaves room for large `experimental` capabilities. Pass
95+
# `max_initialize_request_bytes: nil` to opt out. The TypeScript and Python SDKs keep the initialize
96+
# data whole with no bound of their own; this is the same kind of local safety limit as `max_request_bytes`.
97+
DEFAULT_MAX_INITIALIZE_REQUEST_BYTES = 64 * 1024
98+
99+
# Bound on the number of JSON values (objects, arrays, keys, and scalars) in the `params` of an `initialize`
100+
# request in stateful mode. A byte bound alone does not tightly bound the memory a session keeps: a body of
101+
# many small values parses into far more objects than its size suggests, so the parsed structure is bounded
102+
# as well. Like `MAX_JSON_NESTING`, it is a structural limit applied whatever `max_initialize_request_bytes`
103+
# is, including `nil`. An ordinary `initialize` holds a few dozen values.
104+
MAX_INITIALIZE_PARAMS_VALUES = 1_024
105+
93106
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
94107
# the stack or amplify parse cost (complements the byte cap).
95108
MAX_JSON_NESTING = 64
@@ -137,6 +150,12 @@ class InvalidJsonError < StandardError; end
137150
# ownership is not enforced.
138151
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
139152
# requests are rejected with HTTP 413. Defaults to 4 MiB.
153+
# @param max_initialize_request_bytes [Integer, nil] upper bound in bytes on the body of an `initialize`
154+
# request in stateful mode, whose `clientInfo` and `capabilities` the session keeps; a larger one is
155+
# rejected with HTTP 413 before any session is created. Defaults to `DEFAULT_MAX_INITIALIZE_REQUEST_BYTES`
156+
# (64 KiB), and `nil` disables this bound, leaving `max_request_bytes` as the only cap on the body; `params`
157+
# holding more than `MAX_INITIALIZE_PARAMS_VALUES` JSON values are rejected the same way either way.
158+
# Neither applies in stateless mode, which retains nothing.
140159
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
141160
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
142161
# the cap.
@@ -168,6 +187,7 @@ def initialize(
168187
dns_rebinding_protection: true,
169188
session_request_validator: nil,
170189
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
190+
max_initialize_request_bytes: DEFAULT_MAX_INITIALIZE_REQUEST_BYTES,
171191
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
172192
max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
173193
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
@@ -233,6 +253,13 @@ def initialize(
233253

234254
@max_request_bytes = max_request_bytes
235255

256+
if !max_initialize_request_bytes.nil? && !(max_initialize_request_bytes.is_a?(Integer) && max_initialize_request_bytes > 0)
257+
raise ArgumentError, "max_initialize_request_bytes must be a positive Integer or nil"
258+
end
259+
260+
# The bound guards what stateful sessions retain; stateless mode keeps none.
261+
@max_initialize_request_bytes = stateless ? nil : max_initialize_request_bytes
262+
236263
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
237264
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
238265
end
@@ -1356,6 +1383,16 @@ def handle_post(request, body_string: nil)
13561383
end
13571384

13581385
if initialize_request?(body)
1386+
# Checked before a session exists: the body's `clientInfo` and `capabilities` are what the session would keep,
1387+
# so an oversized `initialize` is refused outright instead of being stored and capped later.
1388+
if @max_initialize_request_bytes && body_string.bytesize > @max_initialize_request_bytes
1389+
return initialize_too_large_response(body[:id], "body exceeds #{@max_initialize_request_bytes} bytes")
1390+
end
1391+
1392+
if !@stateless && json_values_exceed?(body[:params], MAX_INITIALIZE_PARAMS_VALUES)
1393+
return initialize_too_large_response(body[:id], "params exceed #{MAX_INITIALIZE_PARAMS_VALUES} JSON values")
1394+
end
1395+
13591396
if !@stateless && session_id
13601397
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
13611398
# initialization attempt against a live session, or a retry against an unknown/expired
@@ -1584,6 +1621,45 @@ def payload_too_large_response
15841621
)
15851622
end
15861623

1624+
def initialize_too_large_response(request_id, reason)
1625+
json_rpc_error_response(
1626+
status: 413,
1627+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1628+
message: "Payload too large: initialize request #{reason}",
1629+
id: request_id,
1630+
)
1631+
end
1632+
1633+
# Counts the JSON values in `value` (every object, array, object key, and scalar) and reports whether
1634+
# they exceed `limit`. The walk is iterative. Every value still queued is at least one more value,
1635+
# so `count + pending.size` never overstates the total, and a container is refused before its members are
1636+
# queued if they would take that sum past the limit; the walk therefore stops as soon as the limit is
1637+
# certain to be passed and never queues more than `limit` values, whatever the shape of the input.
1638+
def json_values_exceed?(value, limit)
1639+
count = 0
1640+
pending = [value]
1641+
1642+
until pending.empty?
1643+
current = pending.pop
1644+
count += 1
1645+
1646+
case current
1647+
when Hash
1648+
count += current.size
1649+
return true if count + pending.size + current.size > limit
1650+
1651+
pending.concat(current.values)
1652+
when Array
1653+
return true if count + pending.size + current.size > limit
1654+
1655+
pending.concat(current)
1656+
end
1657+
return true if count + pending.size > limit
1658+
end
1659+
1660+
false
1661+
end
1662+
15871663
def parse_request_body(body_string)
15881664
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
15891665
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.

0 commit comments

Comments
 (0)