Skip to content

Commit 5f26e0d

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 e189d78 commit 5f26e0d

File tree

3 files changed

+422
-16
lines changed

3 files changed

+422
-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
return session_already_connected_response if get_session_stream(session_id)
146203

147204
setup_sse_stream(session_id)
@@ -235,6 +292,7 @@ def handle_initialization(body_string, body)
235292
@mutex.synchronize do
236293
@sessions[session_id] = {
237294
stream: nil,
295+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
238296
}
239297
end
240298
end
@@ -256,8 +314,9 @@ def handle_accepted
256314

257315
def handle_regular_request(body_string, session_id)
258316
unless @stateless
259-
if session_id && !session_exists?(session_id)
260-
return session_not_found_response
317+
if session_id
318+
error_response = validate_and_touch_session(session_id)
319+
return error_response if error_response
261320
end
262321
end
263322

@@ -273,6 +332,22 @@ def handle_regular_request(body_string, session_id)
273332
end
274333
end
275334

335+
def validate_and_touch_session(session_id)
336+
@mutex.synchronize do
337+
return session_not_found_response unless (session = @sessions[session_id])
338+
return unless @session_idle_timeout
339+
340+
if session_expired?(session)
341+
cleanup_session_unsafe(session_id)
342+
return session_not_found_response
343+
end
344+
345+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
346+
end
347+
348+
nil
349+
end
350+
276351
def get_session_stream(session_id)
277352
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
278353
end
@@ -378,6 +453,12 @@ def send_keepalive_ping(session_id)
378453
)
379454
raise # Re-raise to exit the keepalive loop
380455
end
456+
457+
def session_expired?(session)
458+
return false unless @session_idle_timeout
459+
460+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
461+
end
381462
end
382463
end
383464
end

0 commit comments

Comments
 (0)