From eb6ff5d80559d89c3d59851575cab341b542b54c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 17:05:02 +0900 Subject: [PATCH] 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. --- README.md | 5 + .../transports/streamable_http_transport.rb | 46 +++++---- .../streamable_http_transport_test.rb | 97 +++++++++++++++++-- 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index dee86ca5..59e0d89a 100644 --- a/README.md +++ b/README.md @@ -1664,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) ``` +In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required, +handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server), +and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped +(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. + You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 351d0a96..4b8f8ad7 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -85,8 +85,10 @@ def close 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 @@ def notification?(body) # `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 @@ def handle_response(body, session_id:) 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 @@ def handle_initialization(body_string, body) 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 @@ def handle_accepted 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 @@ def session_exists?(session_id) @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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index bf3d0b2d..550e926a 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2061,17 +2061,102 @@ def string end test "stateless mode does not support server-sent events" do + # Notifications have no stream to ride in stateless mode; the transport reports non-delivery + # instead of raising so per-request session notify_* helpers degrade gracefully (SEP-2567). stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) - e = assert_raises(RuntimeError) do - stateless_transport.send_notification( - "test_notification", - { message: "Hello" }, - session_id: "some_session_id", + result = stateless_transport.send_notification( + "test_notification", + { message: "Hello" }, + session_id: "some_session_id", + ) + + refute result + end + + test "stateless mode does not leak client info onto the shared server" do + # Each stateless POST runs against an ephemeral per-request session (SEP-2567); concurrent requests + # must never observe another client's identity through the shared Server instance. + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "client-a", version: "1.0" }, + }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + assert_nil @server.client_capabilities + assert_nil @server.instance_variable_get(:@client) + end + + test "stateless mode allows repeated initialize requests" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + 2.times do |i| + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: i + 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "client-#{i}", version: "1.0" }, + }, + }.to_json, ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert body.key?("result"), "initialize ##{i + 1} should succeed, got #{body.inspect}" + refute response[1].key?("Mcp-Session-Id") + end + end + + test "stateless mode skips progress notifications without raising" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(exception, _context) { reported << exception } + + server = Server.new(name: "stateless_progress_test", configuration: configuration) + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100) + Tool::Response.new([{ type: "text", text: "ok" }]) end + stateless_transport = StreamableHTTPTransport.new(server, stateless: true) - assert_equal("Stateless mode does not support notifications", e.message) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "tok" } }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal "ok", body.dig("result", "content", 0, "text") + assert_empty reported end test "stateless mode responds with 202 when client sends a notification/initialized request" do