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:
Koichi ITO 2026-06-10 17:05:02 +09:00
commit eb6ff5d805

View file

@ -2061,17 +2061,102 @@ module MCP
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",
)
end
result = stateless_transport.send_notification(
"test_notification",
{ message: "Hello" },
session_id: "some_session_id",
)
assert_equal("Stateless mode does not support notifications", e.message)
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)
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