Merge pull request #415 from koic/stateless_session_isolation
Isolate Stateless Requests in Ephemeral Sessions per SEP-2567
This commit is contained in:
commit
c8e0cd9562
3 changed files with 123 additions and 25 deletions
|
|
@ -1664,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
|
||||||
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
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`.
|
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`:
|
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,10 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
||||||
# Stateless mode doesn't support notifications
|
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
||||||
raise "Stateless mode does not support notifications" if @stateless
|
# 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 = {
|
notification = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -575,7 +577,9 @@ module MCP
|
||||||
# `notifications/initialized`) through the server so it can update session state.
|
# `notifications/initialized`) through the server so it can update session state.
|
||||||
def dispatch_notification(body_string, session_id)
|
def dispatch_notification(body_string, session_id)
|
||||||
server_session = nil
|
server_session = nil
|
||||||
if session_id && !@stateless
|
if @stateless
|
||||||
|
server_session = ephemeral_session
|
||||||
|
elsif session_id
|
||||||
@mutex.synchronize do
|
@mutex.synchronize do
|
||||||
session = @sessions[session_id]
|
session = @sessions[session_id]
|
||||||
server_session = session[:server_session] if session
|
server_session = session[:server_session] if session
|
||||||
|
|
@ -611,9 +615,10 @@ module MCP
|
||||||
|
|
||||||
def handle_initialization(body_string, body)
|
def handle_initialization(body_string, body)
|
||||||
session_id = nil
|
session_id = nil
|
||||||
server_session = nil
|
|
||||||
|
|
||||||
unless @stateless
|
if @stateless
|
||||||
|
server_session = ephemeral_session
|
||||||
|
else
|
||||||
session_id = SecureRandom.uuid
|
session_id = SecureRandom.uuid
|
||||||
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
||||||
|
|
||||||
|
|
@ -626,17 +631,13 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
response = if server_session
|
response = server_session.handle_json(body_string)
|
||||||
server_session.handle_json(body_string)
|
|
||||||
else
|
|
||||||
@server.handle_json(body_string)
|
|
||||||
end
|
|
||||||
|
|
||||||
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
||||||
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
# `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
|
# 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`.
|
# 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)
|
cleanup_session(session_id)
|
||||||
session_id = nil
|
session_id = nil
|
||||||
end
|
end
|
||||||
|
|
@ -657,15 +658,15 @@ module MCP
|
||||||
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
||||||
server_session = nil
|
server_session = nil
|
||||||
|
|
||||||
unless @stateless
|
if @stateless
|
||||||
if session_id
|
server_session = ephemeral_session
|
||||||
error_response = validate_and_touch_session(session_id)
|
elsif session_id
|
||||||
return error_response if error_response
|
error_response = validate_and_touch_session(session_id)
|
||||||
|
return error_response if error_response
|
||||||
|
|
||||||
@mutex.synchronize do
|
@mutex.synchronize do
|
||||||
session = @sessions[session_id]
|
session = @sessions[session_id]
|
||||||
server_session = session[:server_session] if session
|
server_session = session[:server_session] if session
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -775,6 +776,13 @@ module MCP
|
||||||
@mutex.synchronize { @sessions.key?(session_id) }
|
@mutex.synchronize { @sessions.key?(session_id) }
|
||||||
end
|
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
|
# 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
|
# 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
|
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
||||||
|
|
|
||||||
|
|
@ -2061,17 +2061,102 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
test "stateless mode does not support server-sent events" do
|
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)
|
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
|
||||||
|
|
||||||
e = assert_raises(RuntimeError) do
|
result = stateless_transport.send_notification(
|
||||||
stateless_transport.send_notification(
|
"test_notification",
|
||||||
"test_notification",
|
{ message: "Hello" },
|
||||||
{ message: "Hello" },
|
session_id: "some_session_id",
|
||||||
session_id: "some_session_id",
|
)
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
test "stateless mode responds with 202 when client sends a notification/initialized request" do
|
test "stateless mode responds with 202 when client sends a notification/initialized request" do
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue