Skip to content

Commit 029e04d

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 3b1fc72 commit 029e04d

File tree

3 files changed

+421
-16
lines changed

3 files changed

+421
-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: 86 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,36 @@ 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+
stream.close
150+
rescue
151+
nil
152+
end
153+
end
154+
100155
def send_to_stream(stream, data)
101156
message = data.is_a?(String) ? data : data.to_json
102157
stream.write("data: #{message}\n\n")
@@ -141,7 +196,9 @@ def handle_get(request)
141196
session_id = extract_session_id(request)
142197

143198
return missing_session_id_response unless session_id
144-
return session_not_found_response unless session_exists?(session_id)
199+
200+
error_response = validate_and_touch_session(session_id)
201+
return error_response if error_response
145202

146203
setup_sse_stream(session_id)
147204
end
@@ -234,6 +291,7 @@ def handle_initialization(body_string, body)
234291
@mutex.synchronize do
235292
@sessions[session_id] = {
236293
stream: nil,
294+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
237295
}
238296
end
239297
end
@@ -255,8 +313,9 @@ def handle_accepted
255313

256314
def handle_regular_request(body_string, session_id)
257315
unless @stateless
258-
if session_id && !session_exists?(session_id)
259-
return session_not_found_response
316+
if session_id
317+
error_response = validate_and_touch_session(session_id)
318+
return error_response if error_response
260319
end
261320
end
262321

@@ -272,6 +331,22 @@ def handle_regular_request(body_string, session_id)
272331
end
273332
end
274333

334+
def validate_and_touch_session(session_id)
335+
@mutex.synchronize do
336+
return session_not_found_response unless (session = @sessions[session_id])
337+
return unless @session_idle_timeout
338+
339+
if session_expired?(session)
340+
cleanup_session_unsafe(session_id)
341+
return session_not_found_response
342+
end
343+
344+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
345+
end
346+
347+
nil
348+
end
349+
275350
def get_session_stream(session_id)
276351
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
277352
end
@@ -364,6 +439,12 @@ def send_keepalive_ping(session_id)
364439
)
365440
raise # Re-raise to exit the keepalive loop
366441
end
442+
443+
def session_expired?(session)
444+
return false unless @session_idle_timeout
445+
446+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
447+
end
367448
end
368449
end
369450
end

0 commit comments

Comments
 (0)