Skip to content

Commit 49fc501

Browse files
committed
Support session expiry controls for StreamableHTTPTransport
## Motivation and Context The MCP specification recommends expiring session IDs to reduce session hijacking risks: https://modelcontextprotocol.io/specification/latest/basic/security_best_practices#session-hijacking Several other SDKs (Go, C#, PHP, Python) already implement session expiry controls, but the Ruby SDK did not. Sessions persisted indefinitely unless explicitly deleted or a stream error occurred, leaving abandoned sessions to accumulate in memory. This adds a `session_idle_timeout:` option to `StreamableHTTPTransport#initialize`. When set, sessions that receive no HTTP requests for the specified duration (in seconds) are automatically expired. Expired sessions return 404 on subsequent requests (GET and POST), matching the MCP specification's behavior for terminated sessions. Each request resets the idle timer, so actively used sessions are not interrupted. A background reaper thread periodically cleans up expired sessions to handle orphaned sessions that receive no further requests. The reaper only starts when `session_idle_timeout` is configured. The default is `nil` (no expiry) for backward compatibility, consistent with the Python SDK's approach. The Python SDK recommends 1800 seconds (30 minutes) for most deployments: modelcontextprotocol/python-sdk#2022 Resolves #265. ## How Has This Been Tested? Added tests for session expiry, idle timeout reset, reaper thread lifecycle, input validation, and default behavior in `streamable_http_transport_test.rb`. All existing tests continue to pass. ## Breaking Change None. The default value of `session_idle_timeout` is `nil`, which preserves the existing behavior of sessions never expiring. The new `last_active_at` field in the internal session hash is not part of the public API. Existing code that instantiates `StreamableHTTPTransport.new(server)` or `StreamableHTTPTransport.new(server, stateless: true)` continues to work without changes.
1 parent 8969edf commit 49fc501

File tree

3 files changed

+445
-9
lines changed

3 files changed

+445
-9
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
302302
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
303303
```
304304

305+
By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
306+
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
307+
308+
```ruby
309+
# Session timeout of 30 minutes
310+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
311+
```
312+
305313
### Unsupported Features (to be implemented in future versions)
306314

307315
- Resource subscriptions

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,30 @@ module MCP
88
class Server
99
module Transports
1010
class StreamableHTTPTransport < Transport
11-
def initialize(server, stateless: false)
11+
def initialize(server, stateless: false, session_idle_timeout: nil)
1212
super(server)
13-
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`.
13+
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
1414
@sessions = {}
1515
@mutex = Mutex.new
1616

1717
@stateless = stateless
18+
@session_idle_timeout = session_idle_timeout
19+
20+
if @session_idle_timeout
21+
if @stateless
22+
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
23+
elsif @session_idle_timeout <= 0
24+
raise ArgumentError, "session_idle_timeout must be a positive number."
25+
end
26+
end
27+
28+
start_reaper_thread if @session_idle_timeout
1829
end
1930

2031
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
2132
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
2233
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
34+
SESSION_REAP_INTERVAL = 60
2335

2436
def handle_request(request)
2537
case request.env["REQUEST_METHOD"]
@@ -35,6 +47,9 @@ def handle_request(request)
3547
end
3648

3749
def close
50+
@reaper_thread&.kill
51+
@reaper_thread = nil
52+
3853
@mutex.synchronize do
3954
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
4055
end
@@ -56,6 +71,11 @@ def send_notification(method, params = nil, session_id: nil)
5671
session = @sessions[session_id]
5772
return false unless session && session[:stream]
5873

74+
if session_expired?(session)
75+
cleanup_session_unsafe(session_id)
76+
return false
77+
end
78+
5979
begin
6080
send_to_stream(session[:stream], notification)
6181
true
@@ -75,6 +95,11 @@ def send_notification(method, params = nil, session_id: nil)
7595
@sessions.each do |sid, session|
7696
next unless session[:stream]
7797

98+
if session_expired?(session)
99+
failed_sessions << sid
100+
next
101+
end
102+
78103
begin
79104
send_to_stream(session[:stream], notification)
80105
sent_count += 1
@@ -97,6 +122,39 @@ def send_notification(method, params = nil, session_id: nil)
97122

98123
private
99124

125+
def start_reaper_thread
126+
@reaper_thread = Thread.new do
127+
loop do
128+
sleep(SESSION_REAP_INTERVAL)
129+
reap_expired_sessions
130+
rescue StandardError => e
131+
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
132+
end
133+
end
134+
end
135+
136+
def reap_expired_sessions
137+
return unless @session_idle_timeout
138+
139+
expired_streams = @mutex.synchronize do
140+
@sessions.each_with_object([]) do |(session_id, session), streams|
141+
next unless session_expired?(session)
142+
143+
streams << session[:stream] if session[:stream]
144+
@sessions.delete(session_id)
145+
end
146+
end
147+
148+
expired_streams.each do |stream|
149+
# Closing outside the mutex is safe because expired sessions are already
150+
# removed from `@sessions` above, so other threads will not find them
151+
# and will not attempt to close the same stream.
152+
stream.close
153+
rescue
154+
nil
155+
end
156+
end
157+
100158
def send_to_stream(stream, data)
101159
message = data.is_a?(String) ? data : data.to_json
102160
stream.write("data: #{message}\n\n")
@@ -145,7 +203,9 @@ def handle_get(request)
145203
session_id = extract_session_id(request)
146204

147205
return missing_session_id_response unless session_id
148-
return session_not_found_response unless session_exists?(session_id)
206+
207+
error_response = validate_and_touch_session(session_id)
208+
return error_response if error_response
149209
return session_already_connected_response if get_session_stream(session_id)
150210

151211
setup_sse_stream(session_id)
@@ -242,6 +302,7 @@ def handle_initialization(body_string, body)
242302
@sessions[session_id] = {
243303
stream: nil,
244304
server_session: server_session,
305+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
245306
}
246307
end
247308
end
@@ -269,13 +330,16 @@ def handle_regular_request(body_string, session_id)
269330
server_session = nil
270331
stream = nil
271332

272-
if session_id && !@stateless
273-
@mutex.synchronize do
274-
session = @sessions[session_id]
275-
return session_not_found_response unless session
333+
unless @stateless
334+
if session_id
335+
error_response = validate_and_touch_session(session_id)
336+
return error_response if error_response
276337

277-
server_session = session[:server_session]
278-
stream = session[:stream]
338+
@mutex.synchronize do
339+
session = @sessions[session_id]
340+
server_session = session[:server_session] if session
341+
stream = session[:stream] if session
342+
end
279343
end
280344
end
281345

@@ -292,6 +356,22 @@ def handle_regular_request(body_string, session_id)
292356
end
293357
end
294358

359+
def validate_and_touch_session(session_id)
360+
@mutex.synchronize do
361+
return session_not_found_response unless (session = @sessions[session_id])
362+
return unless @session_idle_timeout
363+
364+
if session_expired?(session)
365+
cleanup_session_unsafe(session_id)
366+
return session_not_found_response
367+
end
368+
369+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
370+
end
371+
372+
nil
373+
end
374+
295375
def get_session_stream(session_id)
296376
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
297377
end
@@ -397,6 +477,12 @@ def send_keepalive_ping(session_id)
397477
)
398478
raise # Re-raise to exit the keepalive loop
399479
end
480+
481+
def session_expired?(session)
482+
return false unless @session_idle_timeout
483+
484+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
485+
end
400486
end
401487
end
402488
end

0 commit comments

Comments
 (0)