Skip to content

Support session expiry controls for StreamableHTTPTransport#268

Merged
koic merged 1 commit intomodelcontextprotocol:mainfrom
koic:security__missing_session_expiry_controls
Mar 29, 2026
Merged

Support session expiry controls for StreamableHTTPTransport#268
koic merged 1 commit intomodelcontextprotocol:mainfrom
koic:security__missing_session_expiry_controls

Conversation

@koic
Copy link
Copy Markdown
Member

@koic koic commented Mar 23, 2026

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic koic force-pushed the security__missing_session_expiry_controls branch 5 times, most recently from 25830a2 to 5f26e0d Compare March 27, 2026 02:36
@koic koic force-pushed the security__missing_session_expiry_controls branch from 5f26e0d to ef63429 Compare March 27, 2026 07:33
end

def close
@reaper_thread&.kill
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to the mutex if the reaper thread had a lock on it and was busy reaping?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I could verify, In MRI Ruby, this should be safe. Ruby runs ensure clauses even when a thread is terminated via Thread#kill, and Mutex#synchronize releases the lock in its ensure path.

So if the reaper is killed while holding the mutex, the thread will unwind and the lock should be released as part of that process. As a result, close's @mutex.synchronize is not expected to deadlock due to the mutex being left locked by Thread#kill.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! LGTM!

atesgoral
atesgoral previously approved these changes Mar 29, 2026
Copy link
Copy Markdown
Contributor

@atesgoral atesgoral left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Left a question because I don't know how mutexes held by threads behave when the thread is killed while holding a mutex. If this is already safe, no further comments!

## 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 modelcontextprotocol#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.
@koic koic force-pushed the security__missing_session_expiry_controls branch from ef63429 to 49fc501 Compare March 29, 2026 04:15
@koic
Copy link
Copy Markdown
Member Author

koic commented Mar 29, 2026

@atesgoral Thanks for the review. It looks like the approval was dismissed when I resolved a previous conflict. Could you take another look when you have a moment?

@koic koic merged commit f0186d0 into modelcontextprotocol:main Mar 29, 2026
11 checks passed
@koic koic deleted the security__missing_session_expiry_controls branch March 29, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add session timeouts

2 participants