Skip to content

Commit afb968c

Browse files
committed
Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
## Motivation and Context In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client. The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count, so the insecure behavior was the default. Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python, and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below): - `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from an explicit `nil`. - A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired), and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects: established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS, which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode. The value is validated as a positive Integer or `nil`. ## How Has This Been Tested? New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out, and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to the new secure default. ## Breaking Changes Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
1 parent 8b1a41a commit afb968c

3 files changed

Lines changed: 163 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,14 +1758,23 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl
17581758
broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
17591759
This mode is suitable for simple tool servers that do not need server-initiated requests.
17601760

1761-
By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
1762-
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
1761+
By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted:
1762+
they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
1763+
session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it
1764+
(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
1765+
so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
1766+
and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
17631767

17641768
```ruby
1765-
# Session timeout of 30 minutes
1766-
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
1769+
# Tune the limits
1770+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
1771+
1772+
# Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
1773+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)
17671774
```
17681775

1776+
Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it.
1777+
17691778
### Pagination
17701779

17711780
The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,40 @@ class InvalidJsonError < StandardError; end
2424
"Connection" => "keep-alive",
2525
}.freeze
2626

27-
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
27+
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
28+
# so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted.
29+
# These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that
30+
# hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport
31+
# rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts
32+
# the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on
33+
# an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to
34+
# opt out of expiry and `max_sessions: nil` to opt out of the cap.
35+
DEFAULT_SESSION_IDLE_TIMEOUT = 1800
36+
DEFAULT_MAX_SESSIONS = 10_000
37+
38+
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
39+
UNSET_IDLE_TIMEOUT = Object.new.freeze
40+
private_constant :UNSET_IDLE_TIMEOUT
41+
42+
def initialize(server, stateless: false, enable_json_response: false,
43+
session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS)
2844
super(server)
2945
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
3046
@sessions = {}
3147
@mutex = Mutex.new
3248

3349
@stateless = stateless
3450
@enable_json_response = enable_json_response
35-
@session_idle_timeout = session_idle_timeout
3651
@pending_responses = {}
3752

53+
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
54+
# which does not apply to stateless mode since it retains no sessions.
55+
@session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
56+
stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT
57+
else
58+
session_idle_timeout
59+
end
60+
3861
if @session_idle_timeout
3962
if @stateless
4063
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
@@ -43,6 +66,13 @@ def initialize(server, stateless: false, enable_json_response: false, session_id
4366
end
4467
end
4568

69+
unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0)
70+
raise ArgumentError, "max_sessions must be a positive Integer or nil"
71+
end
72+
73+
# The cap guards the stateful session store; stateless mode keeps none.
74+
@max_sessions = stateless ? nil : max_sessions
75+
4676
start_reaper_thread if @session_idle_timeout
4777
end
4878

@@ -622,13 +652,30 @@ def handle_initialization(body_string, body)
622652
session_id = SecureRandom.uuid
623653
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
624654

625-
@mutex.synchronize do
655+
# Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
656+
# The check and insert share the mutex so concurrent initializes cannot race past the limit.
657+
reclaimed = []
658+
inserted = @mutex.synchronize do
659+
# When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
660+
# so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
661+
if @max_sessions && @sessions.size >= @max_sessions
662+
@sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
663+
cleanup_and_collect_stream(id, reclaimed)
664+
end
665+
end
666+
667+
next false if @max_sessions && @sessions.size >= @max_sessions
668+
626669
@sessions[session_id] = {
627670
get_sse_stream: nil,
628671
server_session: server_session,
629672
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
630673
}
674+
true
631675
end
676+
677+
reclaimed.each { |stream| close_stream_safely(stream) }
678+
return too_many_sessions_response unless inserted
632679
end
633680

634681
response = server_session.handle_json(body_string)
@@ -655,6 +702,14 @@ def handle_accepted
655702
[202, {}, []]
656703
end
657704

705+
def too_many_sessions_response
706+
json_rpc_error_response(
707+
status: 503,
708+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
709+
message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
710+
)
711+
end
712+
658713
def handle_regular_request(body_string, session_id, related_request_id: nil)
659714
server_session = nil
660715

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3808,35 +3808,116 @@ def string
38083808
transport.close
38093809
end
38103810

3811-
test "default session_idle_timeout is nil and sessions do not expire" do
3811+
test "applies a finite session_idle_timeout by default and starts the reaper" do
3812+
# Secure default: stateful mode expires idle sessions out of the box.
38123813
transport = StreamableHTTPTransport.new(@server)
3813-
assert_nil(transport.instance_variable_get(:@reaper_thread))
3814+
assert_equal(
3815+
StreamableHTTPTransport::DEFAULT_SESSION_IDLE_TIMEOUT,
3816+
transport.instance_variable_get(:@session_idle_timeout),
3817+
)
3818+
assert(transport.instance_variable_get(:@reaper_thread).alive?)
38143819

38153820
init_request = create_rack_request(
38163821
"POST",
38173822
"/",
38183823
{ "CONTENT_TYPE" => "application/json" },
38193824
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
38203825
)
3821-
init_response = transport.handle_request(init_request)
3822-
session_id = init_response[1]["Mcp-Session-Id"]
3823-
3826+
# A freshly created session is well within the timeout, so normal use is unaffected.
3827+
session_id = transport.handle_request(init_request)[1]["Mcp-Session-Id"]
38243828
request = create_rack_request(
38253829
"POST",
38263830
"/",
3827-
{
3828-
"CONTENT_TYPE" => "application/json",
3829-
"HTTP_MCP_SESSION_ID" => session_id,
3830-
},
3831+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
38313832
{ jsonrpc: "2.0", method: "ping", id: "456" }.to_json,
38323833
)
38333834

3834-
response = transport.handle_request(request)
3835-
assert_equal(200, response[0])
3835+
assert_equal(200, transport.handle_request(request)[0])
3836+
ensure
3837+
transport.close
3838+
end
3839+
3840+
test "session_idle_timeout: nil opts out of expiry" do
3841+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil)
3842+
assert_nil(transport.instance_variable_get(:@session_idle_timeout))
3843+
assert_nil(transport.instance_variable_get(:@reaper_thread))
38363844
ensure
38373845
transport.close
38383846
end
38393847

3848+
test "stateless mode keeps no idle timeout despite the secure default" do
3849+
transport = StreamableHTTPTransport.new(@server, stateless: true)
3850+
assert_nil(transport.instance_variable_get(:@session_idle_timeout))
3851+
assert_nil(transport.instance_variable_get(:@reaper_thread))
3852+
ensure
3853+
transport.close
3854+
end
3855+
3856+
test "rejects new initialize with 503 once max_sessions is reached" do
3857+
transport = StreamableHTTPTransport.new(@server, max_sessions: 2)
3858+
3859+
2.times do
3860+
request = create_rack_request(
3861+
"POST",
3862+
"/",
3863+
{ "CONTENT_TYPE" => "application/json" },
3864+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
3865+
)
3866+
assert_equal(200, transport.handle_request(request)[0])
3867+
end
3868+
3869+
overflow = create_rack_request(
3870+
"POST",
3871+
"/",
3872+
{ "CONTENT_TYPE" => "application/json" },
3873+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
3874+
)
3875+
response = transport.handle_request(overflow)
3876+
3877+
assert_equal(503, response[0])
3878+
assert_equal(2, transport.instance_variable_get(:@sessions).size)
3879+
ensure
3880+
transport.close
3881+
end
3882+
3883+
test "at the cap, reclaims an already-expired session before rejecting" do
3884+
# A very short idle timeout so the first session is expired by the time the cap would otherwise reject
3885+
# a new initialize; the expired slot is reclaimed.
3886+
transport = StreamableHTTPTransport.new(@server, max_sessions: 1, session_idle_timeout: 0.01)
3887+
init = lambda do
3888+
transport.handle_request(create_rack_request(
3889+
"POST",
3890+
"/",
3891+
{ "CONTENT_TYPE" => "application/json" },
3892+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
3893+
))
3894+
end
3895+
3896+
assert_equal(200, init.call[0])
3897+
sleep(0.05) # let the first session's idle timer expire
3898+
second = init.call
3899+
3900+
# The expired session was reclaimed in-line, so the second initialize succeeds (200) rather than
3901+
# being rejected with 503, and the store stays at the cap.
3902+
assert_equal(200, second[0])
3903+
assert_equal(1, transport.instance_variable_get(:@sessions).size)
3904+
ensure
3905+
transport.close
3906+
end
3907+
3908+
test "max_sessions: nil opts out of the session cap" do
3909+
transport = StreamableHTTPTransport.new(@server, max_sessions: nil)
3910+
assert_nil(transport.instance_variable_get(:@max_sessions))
3911+
ensure
3912+
transport.close
3913+
end
3914+
3915+
test "raises ArgumentError for an invalid max_sessions" do
3916+
[0, -1, 1.5, "10"].each do |invalid|
3917+
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_sessions: invalid) }
3918+
end
3919+
end
3920+
38403921
test "raises ArgumentError when session_idle_timeout is zero" do
38413922
error = assert_raises(ArgumentError) do
38423923
StreamableHTTPTransport.new(@server, session_idle_timeout: 0)

0 commit comments

Comments
 (0)