Skip to content

Commit e953733

Browse files
committed
Introduce stateless mode to allow for HA
- Create failing tests for `stateless` mode - Implement `stateless` mode by turning of SSE in Streamable HTTP, making the interaction a standard HTTP Req/Resp Implement `stateless` mode
1 parent 122bbd0 commit e953733

File tree

2 files changed

+144
-13
lines changed

2 files changed

+144
-13
lines changed

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ module MCP
88
class Server
99
module Transports
1010
class StreamableHTTPTransport < Transport
11-
def initialize(server)
12-
super
11+
def initialize(server, stateless: false)
12+
super(server)
1313
# { session_id => { stream: stream_object }
1414
@sessions = {}
1515
@mutex = Mutex.new
16+
17+
@stateless = stateless
1618
end
1719

1820
def handle_request(request)
@@ -24,7 +26,7 @@ def handle_request(request)
2426
when "DELETE"
2527
handle_delete(request)
2628
else
27-
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
29+
method_not_allowed_response
2830
end
2931
end
3032

@@ -35,6 +37,9 @@ def close
3537
end
3638

3739
def send_notification(method, params = nil, session_id: nil)
40+
# Stateless mode doesn't support notifications
41+
raise "Stateless mode does not support notifications" if @stateless
42+
3843
notification = {
3944
jsonrpc: "2.0",
4045
method:,
@@ -117,6 +122,10 @@ def handle_post(request)
117122
end
118123

119124
def handle_get(request)
125+
if @stateless
126+
return method_not_allowed_response
127+
end
128+
120129
session_id = extract_session_id(request)
121130

122131
return missing_session_id_response unless session_id
@@ -126,6 +135,13 @@ def handle_get(request)
126135
end
127136

128137
def handle_delete(request)
138+
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
139+
140+
if @stateless
141+
# Stateless mode doesn't support sessions, so we can just return a success response
142+
return success_response
143+
end
144+
129145
session_id = request.env["HTTP_MCP_SESSION_ID"]
130146

131147
return [
@@ -135,7 +151,7 @@ def handle_delete(request)
135151
] unless session_id
136152

137153
cleanup_session(session_id)
138-
[200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
154+
success_response
139155
end
140156

141157
def cleanup_session(session_id)
@@ -167,31 +183,40 @@ def parse_request_body(body_string)
167183
end
168184

169185
def handle_initialization(body_string, body)
170-
session_id = SecureRandom.uuid
186+
session_id = nil
171187

172-
@mutex.synchronize do
173-
@sessions[session_id] = {
174-
stream: nil,
175-
}
188+
unless @stateless
189+
session_id = SecureRandom.uuid
190+
191+
@mutex.synchronize do
192+
@sessions[session_id] = {
193+
stream: nil,
194+
}
195+
end
176196
end
177197

178198
response = @server.handle_json(body_string)
179199

180200
headers = {
181201
"Content-Type" => "application/json",
182-
"Mcp-Session-Id" => session_id,
183202
}
184203

204+
headers["Mcp-Session-Id"] = session_id if session_id
205+
185206
[200, headers, [response]]
186207
end
187208

188209
def handle_regular_request(body_string, session_id)
189-
# If session ID is provided, but not in the sessions hash, return an error
190-
if session_id && !@sessions.key?(session_id)
191-
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
210+
unless @stateless
211+
# If session ID is provided, but not in the sessions hash, return an error
212+
if session_id && !@sessions.key?(session_id)
213+
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
214+
end
192215
end
193216

194217
response = @server.handle_json(body_string)
218+
219+
# Stream can be nil since stateless mode doesn't retain streams
195220
stream = get_session_stream(session_id) if session_id
196221

197222
if stream
@@ -222,6 +247,10 @@ def session_exists?(session_id)
222247
@mutex.synchronize { @sessions.key?(session_id) }
223248
end
224249

250+
def method_not_allowed_response
251+
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
252+
end
253+
225254
def missing_session_id_response
226255
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
227256
end

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,108 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase
575575
assert_equal "Method not allowed", body["error"]
576576
end
577577

578+
test "stateless mode allows requests without session IDs, responding with no session ID" do
579+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
580+
581+
init_request = create_rack_request(
582+
"POST",
583+
"/",
584+
{ "CONTENT_TYPE" => "application/json" },
585+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
586+
)
587+
init_response = stateless_transport.handle_request(init_request)
588+
assert_nil init_response[1]["Mcp-Session-Id"]
589+
end
590+
591+
test "stateless mode responds without any session ID when session ID is present" do
592+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
593+
594+
request = create_rack_request(
595+
"POST",
596+
"/",
597+
{
598+
"CONTENT_TYPE" => "application/json",
599+
"HTTP_MCP_SESSION_ID" => "unseen_session_id",
600+
},
601+
{ jsonrpc: "2.0", method: "ping", id: "123" }.to_json,
602+
)
603+
604+
response = stateless_transport.handle_request(request)
605+
assert_equal 200, response[0]
606+
assert_equal(
607+
{
608+
"Content-Type" => "application/json",
609+
},
610+
response[1],
611+
)
612+
613+
body = JSON.parse(response[2][0])
614+
assert_equal "2.0", body["jsonrpc"]
615+
assert_equal "123", body["id"]
616+
end
617+
618+
test "stateless mode responds with 405 when SSE is requested" do
619+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
620+
621+
get_request = create_rack_request(
622+
"GET",
623+
"/",
624+
{
625+
"CONTENT_TYPE" => "application/json,text/event-stream",
626+
},
627+
)
628+
response = stateless_transport.handle_request(get_request)
629+
assert_equal 405, response[0]
630+
assert_equal({ "Content-Type" => "application/json" }, response[1])
631+
632+
body = JSON.parse(response[2][0])
633+
assert_equal "Method not allowed", body["error"]
634+
end
635+
636+
test "stateless mode silently responds with success to session DELETE when session ID is not present" do
637+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
638+
639+
delete_request = create_rack_request(
640+
"DELETE",
641+
"/",
642+
{},
643+
)
644+
response = stateless_transport.handle_request(delete_request)
645+
assert_equal 200, response[0]
646+
assert_equal({ "Content-Type" => "application/json" }, response[1])
647+
648+
body = JSON.parse(response[2][0])
649+
assert body["success"]
650+
end
651+
652+
test "stateless mode silently responds with success to session DELETE when session ID is provided" do
653+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
654+
655+
delete_request = create_rack_request(
656+
"DELETE",
657+
"/",
658+
{ "HTTP_MCP_SESSION_ID" => "session_id" },
659+
)
660+
response = stateless_transport.handle_request(delete_request)
661+
assert_equal 200, response[0]
662+
assert_equal({ "Content-Type" => "application/json" }, response[1])
663+
664+
body = JSON.parse(response[2][0])
665+
assert body["success"]
666+
end
667+
668+
test "stateless mode does not support notifications" do
669+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
670+
671+
assert_raises(RuntimeError, "Stateless mode does not support notifications") do
672+
stateless_transport.send_notification(
673+
"test_notification",
674+
{ message: "Hello" },
675+
session_id: "some_session_id",
676+
)
677+
end
678+
end
679+
578680
test "handle post request with a standard error" do
579681
request = create_rack_request(
580682
"POST",

0 commit comments

Comments
 (0)