Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
46 changes: 27 additions & 19 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
97 changes: 91 additions & 6 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down