Isolate Stateless Requests in Ephemeral Sessions per SEP-2567
## Motivation and Context SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release) makes MCP sessionless: every Streamable HTTP POST must be fully self-contained, with no protocol-level session state shared between requests. `StreamableHTTPTransport` already implements the SEP's transport surface via `stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405, DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak: stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and `@client_capabilities` onto the shared `Server` instance. Concurrent stateless requests could therefore observe another client's identity, and the data persisted across requests, which is exactly what SEP-2567 forbids. The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack) solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture: - Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`), so client info, logging level, and initialized state live only for the duration of that POST. Repeated `initialize` requests are naturally permitted because each POST gets a fresh, never-initialized session. - `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising. With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message` would otherwise route every call into the exception reporter; non-delivery matches how these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises, as those are genuinely unsupported without a stream. Resolves #388. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: - a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched (the leak regression) - repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header - a tool calling `server_context.report_progress` under stateless mode returns its result normally and the exception reporter is never invoked - the existing "stateless mode does not support server-sent events" test is updated to assert the new `false` return instead of the removed raise All other existing stateless-mode tests pass unchanged. ## Breaking Changes `StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`. The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation, and the boolean return matches the method's documented semantics in every other non-delivery case. Default (session-oriented) mode is unchanged.
This commit is contained in:
parent
a0cd66287d
commit
eb6ff5d805
1 changed files with 123 additions and 25 deletions
|
|
@ -85,8 +85,10 @@ module MCP
|
|||
end
|
||||
|
||||
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
||||
# Stateless mode doesn't support notifications
|
||||
raise "Stateless mode does not support notifications" if @stateless
|
||||
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
||||
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
||||
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
||||
return false if @stateless
|
||||
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -575,7 +577,9 @@ module MCP
|
|||
# `notifications/initialized`) through the server so it can update session state.
|
||||
def dispatch_notification(body_string, session_id)
|
||||
server_session = nil
|
||||
if session_id && !@stateless
|
||||
if @stateless
|
||||
server_session = ephemeral_session
|
||||
elsif session_id
|
||||
@mutex.synchronize do
|
||||
session = @sessions[session_id]
|
||||
server_session = session[:server_session] if session
|
||||
|
|
@ -611,9 +615,10 @@ module MCP
|
|||
|
||||
def handle_initialization(body_string, body)
|
||||
session_id = nil
|
||||
server_session = nil
|
||||
|
||||
unless @stateless
|
||||
if @stateless
|
||||
server_session = ephemeral_session
|
||||
else
|
||||
session_id = SecureRandom.uuid
|
||||
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
||||
|
||||
|
|
@ -626,17 +631,13 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
response = if server_session
|
||||
server_session.handle_json(body_string)
|
||||
else
|
||||
@server.handle_json(body_string)
|
||||
end
|
||||
response = server_session.handle_json(body_string)
|
||||
|
||||
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
||||
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
||||
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
||||
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
||||
if server_session && !server_session.initialized?
|
||||
if session_id && !server_session.initialized?
|
||||
cleanup_session(session_id)
|
||||
session_id = nil
|
||||
end
|
||||
|
|
@ -657,15 +658,15 @@ module MCP
|
|||
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
||||
server_session = nil
|
||||
|
||||
unless @stateless
|
||||
if session_id
|
||||
error_response = validate_and_touch_session(session_id)
|
||||
return error_response if error_response
|
||||
if @stateless
|
||||
server_session = ephemeral_session
|
||||
elsif session_id
|
||||
error_response = validate_and_touch_session(session_id)
|
||||
return error_response if error_response
|
||||
|
||||
@mutex.synchronize do
|
||||
session = @sessions[session_id]
|
||||
server_session = session[:server_session] if session
|
||||
end
|
||||
@mutex.synchronize do
|
||||
session = @sessions[session_id]
|
||||
server_session = session[:server_session] if session
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -775,6 +776,13 @@ module MCP
|
|||
@mutex.synchronize { @sessions.key?(session_id) }
|
||||
end
|
||||
|
||||
# Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession`
|
||||
# so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests.
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
|
||||
def ephemeral_session
|
||||
ServerSession.new(server: @server, transport: self, session_id: nil)
|
||||
end
|
||||
|
||||
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
||||
# are evicted as a side effect so a live request never observes a zombie session that
|
||||
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
||||
|
|
|
|||
Loading…
Reference in a new issue