Skip to content

Commit c8e0cd9

Browse files
authored
Merge pull request #415 from koic/stateless_session_isolation
Isolate Stateless Requests in Ephemeral Sessions per SEP-2567
2 parents aac41cd + eb6ff5d commit c8e0cd9

3 files changed

Lines changed: 123 additions & 25 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
16641664
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
16651665
```
16661666

1667+
In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
1668+
handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
1669+
and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
1670+
(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
1671+
16671672
You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
16681673
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
16691674

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ def close
8585
end
8686

8787
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
88-
# Stateless mode doesn't support notifications
89-
raise "Stateless mode does not support notifications" if @stateless
88+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
89+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
90+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
91+
return false if @stateless
9092

9193
notification = {
9294
jsonrpc: "2.0",
@@ -575,7 +577,9 @@ def notification?(body)
575577
# `notifications/initialized`) through the server so it can update session state.
576578
def dispatch_notification(body_string, session_id)
577579
server_session = nil
578-
if session_id && !@stateless
580+
if @stateless
581+
server_session = ephemeral_session
582+
elsif session_id
579583
@mutex.synchronize do
580584
session = @sessions[session_id]
581585
server_session = session[:server_session] if session
@@ -611,9 +615,10 @@ def handle_response(body, session_id:)
611615

612616
def handle_initialization(body_string, body)
613617
session_id = nil
614-
server_session = nil
615618

616-
unless @stateless
619+
if @stateless
620+
server_session = ephemeral_session
621+
else
617622
session_id = SecureRandom.uuid
618623
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
619624

@@ -626,17 +631,13 @@ def handle_initialization(body_string, body)
626631
end
627632
end
628633

629-
response = if server_session
630-
server_session.handle_json(body_string)
631-
else
632-
@server.handle_json(body_string)
633-
end
634+
response = server_session.handle_json(body_string)
634635

635636
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
636637
# `mark_initialized!` was never called. Discard the orphaned session and omit
637638
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
638639
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
639-
if server_session && !server_session.initialized?
640+
if session_id && !server_session.initialized?
640641
cleanup_session(session_id)
641642
session_id = nil
642643
end
@@ -657,15 +658,15 @@ def handle_accepted
657658
def handle_regular_request(body_string, session_id, related_request_id: nil)
658659
server_session = nil
659660

660-
unless @stateless
661-
if session_id
662-
error_response = validate_and_touch_session(session_id)
663-
return error_response if error_response
661+
if @stateless
662+
server_session = ephemeral_session
663+
elsif session_id
664+
error_response = validate_and_touch_session(session_id)
665+
return error_response if error_response
664666

665-
@mutex.synchronize do
666-
session = @sessions[session_id]
667-
server_session = session[:server_session] if session
668-
end
667+
@mutex.synchronize do
668+
session = @sessions[session_id]
669+
server_session = session[:server_session] if session
669670
end
670671
end
671672

@@ -775,6 +776,13 @@ def session_exists?(session_id)
775776
@mutex.synchronize { @sessions.key?(session_id) }
776777
end
777778

779+
# Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession`
780+
# so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests.
781+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
782+
def ephemeral_session
783+
ServerSession.new(server: @server, transport: self, session_id: nil)
784+
end
785+
778786
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
779787
# are evicted as a side effect so a live request never observes a zombie session that
780788
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,17 +2061,102 @@ def string
20612061
end
20622062

20632063
test "stateless mode does not support server-sent events" do
2064+
# Notifications have no stream to ride in stateless mode; the transport reports non-delivery
2065+
# instead of raising so per-request session notify_* helpers degrade gracefully (SEP-2567).
20642066
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
20652067

2066-
e = assert_raises(RuntimeError) do
2067-
stateless_transport.send_notification(
2068-
"test_notification",
2069-
{ message: "Hello" },
2070-
session_id: "some_session_id",
2068+
result = stateless_transport.send_notification(
2069+
"test_notification",
2070+
{ message: "Hello" },
2071+
session_id: "some_session_id",
2072+
)
2073+
2074+
refute result
2075+
end
2076+
2077+
test "stateless mode does not leak client info onto the shared server" do
2078+
# Each stateless POST runs against an ephemeral per-request session (SEP-2567); concurrent requests
2079+
# must never observe another client's identity through the shared Server instance.
2080+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
2081+
2082+
request = create_rack_request(
2083+
"POST",
2084+
"/",
2085+
{ "CONTENT_TYPE" => "application/json" },
2086+
{
2087+
jsonrpc: "2.0",
2088+
method: "initialize",
2089+
id: 1,
2090+
params: {
2091+
protocolVersion: "2025-11-25",
2092+
capabilities: { roots: {} },
2093+
clientInfo: { name: "client-a", version: "1.0" },
2094+
},
2095+
}.to_json,
2096+
)
2097+
response = stateless_transport.handle_request(request)
2098+
2099+
assert_equal 200, response[0]
2100+
assert_nil @server.client_capabilities
2101+
assert_nil @server.instance_variable_get(:@client)
2102+
end
2103+
2104+
test "stateless mode allows repeated initialize requests" do
2105+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
2106+
2107+
2.times do |i|
2108+
request = create_rack_request(
2109+
"POST",
2110+
"/",
2111+
{ "CONTENT_TYPE" => "application/json" },
2112+
{
2113+
jsonrpc: "2.0",
2114+
method: "initialize",
2115+
id: i + 1,
2116+
params: {
2117+
protocolVersion: "2025-11-25",
2118+
clientInfo: { name: "client-#{i}", version: "1.0" },
2119+
},
2120+
}.to_json,
20712121
)
2122+
response = stateless_transport.handle_request(request)
2123+
2124+
assert_equal 200, response[0]
2125+
body = JSON.parse(response[2][0])
2126+
assert body.key?("result"), "initialize ##{i + 1} should succeed, got #{body.inspect}"
2127+
refute response[1].key?("Mcp-Session-Id")
2128+
end
2129+
end
2130+
2131+
test "stateless mode skips progress notifications without raising" do
2132+
reported = []
2133+
configuration = MCP::Configuration.new
2134+
configuration.exception_reporter = ->(exception, _context) { reported << exception }
2135+
2136+
server = Server.new(name: "stateless_progress_test", configuration: configuration)
2137+
server.define_tool(name: "progress_tool") do |server_context:|
2138+
server_context.report_progress(50, total: 100)
2139+
Tool::Response.new([{ type: "text", text: "ok" }])
20722140
end
2141+
stateless_transport = StreamableHTTPTransport.new(server, stateless: true)
20732142

2074-
assert_equal("Stateless mode does not support notifications", e.message)
2143+
request = create_rack_request(
2144+
"POST",
2145+
"/",
2146+
{ "CONTENT_TYPE" => "application/json" },
2147+
{
2148+
jsonrpc: "2.0",
2149+
method: "tools/call",
2150+
id: 1,
2151+
params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "tok" } },
2152+
}.to_json,
2153+
)
2154+
response = stateless_transport.handle_request(request)
2155+
2156+
assert_equal 200, response[0]
2157+
body = JSON.parse(response[2][0])
2158+
assert_equal "ok", body.dig("result", "content", 0, "text")
2159+
assert_empty reported
20752160
end
20762161

20772162
test "stateless mode responds with 202 when client sends a notification/initialized request" do

0 commit comments

Comments
 (0)