Skip to content

Commit ef63429

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 3fc7bcd commit ef63429

File tree

3 files changed

+425
-16
lines changed

3 files changed

+425
-16
lines changed

README.md

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

296+
By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
297+
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
298+
299+
```ruby
300+
# Session timeout of 30 minutes
301+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
302+
```
303+
296304
### Unsupported Features (to be implemented in future versions)
297305

298306
- Resource subscriptions

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 89 additions & 5 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-
# { session_id => { stream: stream_object }
13+
# Session data structure: `{ session_id => { stream: stream_object, 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")
@@ -141,7 +199,9 @@ def handle_get(request)
141199
session_id = extract_session_id(request)
142200

143201
return missing_session_id_response unless session_id
144-
return session_not_found_response unless session_exists?(session_id)
202+
203+
error_response = validate_and_touch_session(session_id)
204+
return error_response if error_response
145205
return session_already_connected_response if get_session_stream(session_id)
146206

147207
setup_sse_stream(session_id)
@@ -235,6 +295,7 @@ def handle_initialization(body_string, body)
235295
@mutex.synchronize do
236296
@sessions[session_id] = {
237297
stream: nil,
298+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
238299
}
239300
end
240301
end
@@ -256,8 +317,9 @@ def handle_accepted
256317

257318
def handle_regular_request(body_string, session_id)
258319
unless @stateless
259-
if session_id && !session_exists?(session_id)
260-
return session_not_found_response
320+
if session_id
321+
error_response = validate_and_touch_session(session_id)
322+
return error_response if error_response
261323
end
262324
end
263325

@@ -273,6 +335,22 @@ def handle_regular_request(body_string, session_id)
273335
end
274336
end
275337

338+
def validate_and_touch_session(session_id)
339+
@mutex.synchronize do
340+
return session_not_found_response unless (session = @sessions[session_id])
341+
return unless @session_idle_timeout
342+
343+
if session_expired?(session)
344+
cleanup_session_unsafe(session_id)
345+
return session_not_found_response
346+
end
347+
348+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
349+
end
350+
351+
nil
352+
end
353+
276354
def get_session_stream(session_id)
277355
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
278356
end
@@ -378,6 +456,12 @@ def send_keepalive_ping(session_id)
378456
)
379457
raise # Re-raise to exit the keepalive loop
380458
end
459+
460+
def session_expired?(session)
461+
return false unless @session_idle_timeout
462+
463+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
464+
end
381465
end
382466
end
383467
end

0 commit comments

Comments
 (0)