Skip to content

Commit 0987c2b

Browse files
committed
Reject duplicate initialize requests
## Motivation and Context The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized. On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities` (including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh `Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session. MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase MUST be the first interaction between client and server; re-initialization on an established session is not part of the defined lifecycle. TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600` ("Invalid Request: Server already initialized"), and Python SDK does not mint a new session on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK. - `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and set by `mark_initialized!` after a successful `initialize` response. - `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when the session is already initialized, which the existing error mapping converts to JSON-RPC `-32600 Invalid Request`. - `StreamableHTTPTransport#handle_post` short-circuits at the transport layer: duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC `-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry instead of misreporting it as a duplicate). - `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id` header when the first `initialize` fails before `mark_initialized!` is reached, so retries do not collide with an orphaned ID. - Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`) are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to an unparseable Rack response. ## How Has This Been Tested? - Server tests: a second `initialize` on the same `ServerSession` returns `code: -32600` and the original `clientInfo` is preserved. - Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id` returns HTTP 400 + `-32600` and the original session remains usable for subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired session is evicted on duplicate `initialize` and returns 404; a failed `initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`. - Stdio tests: a second `initialize` on the same stdio session returns `code: -32600` and the original `clientInfo` is preserved. ## Breaking Changes Clients that previously sent `initialize` more than once on the same session now receive a JSON-RPC error with `code: -32600` for the second and later requests instead of silently overwriting session state (stdio) or being re-issued a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification (single `initialize` per session) are unaffected. Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response. The previous behavior produced an unparseable response, so this is unlikely to affect any working client. Closes #349.
1 parent 9caba30 commit 0987c2b

6 files changed

Lines changed: 289 additions & 13 deletions

File tree

lib/mcp/server.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,13 @@ def server_info
497497
end
498498

499499
def init(params, session: nil)
500+
# MCP spec: the initialization phase MUST be the first interaction between client and server.
501+
# Reject duplicate `initialize` on an already-initialized session so the negotiated
502+
# client identity and capabilities cannot be silently overwritten.
503+
if session&.initialized?
504+
raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
505+
end
506+
500507
if params
501508
if session
502509
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
@@ -524,6 +531,8 @@ def init(params, session: nil)
524531
response_instructions = nil
525532
end
526533

534+
session&.mark_initialized!
535+
527536
{
528537
protocolVersion: negotiated_version,
529538
capabilities: capabilities,

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ def handle_post(request)
347347
return invalid_json_response
348348
end
349349

350+
# Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
351+
# Batched/array bodies are not supported; reject with `-32600` instead of falling through to
352+
# a malformed Rack response.
353+
unless body.is_a?(Hash)
354+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
355+
end
356+
350357
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
351358
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
352359
# This matches the TypeScript and Python SDKs.
@@ -357,10 +364,18 @@ def handle_post(request)
357364
return protocol_version_error if protocol_version_error
358365
end
359366

360-
# MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object.
361-
return non_hash_body_response unless body.is_a?(Hash)
362-
363367
if initialize_request?(body)
368+
if !@stateless && session_id
369+
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
370+
# initialization attempt against a live session, or a retry against an unknown/expired
371+
# one. In the live case, reject with `-32600` so the original session is not abandoned.
372+
# In the unknown/expired case, return 404 so the client retries from scratch instead
373+
# of silently inheriting a fresh session under the old ID.
374+
return already_initialized_response(body[:id]) if session_active?(session_id)
375+
376+
return session_not_found_response
377+
end
378+
364379
handle_initialization(body_string, body)
365380
elsif notification?(body)
366381
dispatch_notification(body_string, session_id)
@@ -523,14 +538,6 @@ def invalid_json_response
523538
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
524539
end
525540

526-
def non_hash_body_response
527-
[
528-
400,
529-
{ "Content-Type" => "application/json" },
530-
[{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json],
531-
]
532-
end
533-
534541
def initialize_request?(body)
535542
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
536543
end
@@ -617,6 +624,15 @@ def handle_initialization(body_string, body)
617624
@server.handle_json(body_string)
618625
end
619626

627+
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
628+
# `mark_initialized!` was never called. Discard the orphaned session and omit
629+
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
630+
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
631+
if server_session && !server_session.initialized?
632+
cleanup_session(session_id)
633+
session_id = nil
634+
end
635+
620636
headers = {
621637
"Content-Type" => "application/json",
622638
}
@@ -751,6 +767,31 @@ def session_exists?(session_id)
751767
@mutex.synchronize { @sessions.key?(session_id) }
752768
end
753769

770+
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
771+
# are evicted as a side effect so a live request never observes a zombie session that
772+
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
773+
# rejecting a request must not extend the session's lifetime.
774+
def session_active?(session_id)
775+
removed = nil
776+
active = @mutex.synchronize do
777+
next false unless (session = @sessions[session_id])
778+
779+
if session_expired?(session)
780+
removed = cleanup_session_unsafe(session_id)
781+
next false
782+
end
783+
784+
true
785+
end
786+
787+
if removed
788+
close_stream_safely(removed[:get_sse_stream])
789+
close_post_request_streams(removed)
790+
end
791+
792+
active
793+
end
794+
754795
def method_not_allowed_response
755796
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
756797
end
@@ -763,6 +804,22 @@ def session_not_found_response
763804
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
764805
end
765806

807+
def already_initialized_response(request_id)
808+
invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
809+
end
810+
811+
def invalid_request_response(message, request_id: nil)
812+
body = {
813+
jsonrpc: "2.0",
814+
id: request_id,
815+
error: {
816+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
817+
message: message,
818+
},
819+
}
820+
[400, { "Content-Type" => "application/json" }, [body.to_json]]
821+
end
822+
766823
def session_already_connected_response
767824
[
768825
409,

lib/mcp/server_session.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ def initialize(server:, transport:, session_id: nil)
1818
@logging_message_notification = nil
1919
@in_flight = {}
2020
@in_flight_mutex = Mutex.new
21+
@initialized = false
22+
end
23+
24+
# Whether `initialize` has already completed for this session.
25+
def initialized?
26+
@initialized
27+
end
28+
29+
# Called by `Server#init` after a successful `initialize` response, so subsequent
30+
# `initialize` requests on the same session can be rejected per MCP spec
31+
# (the initialization phase MUST be the first interaction).
32+
def mark_initialized!
33+
@initialized = true
2134
end
2235

2336
# Registers a `Cancellation` token for an in-flight request.

test/mcp/server/transports/stdio_transport_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,55 @@ class StdioTransportTest < ActiveSupport::TestCase
133133
end
134134
end
135135

136+
test "rejects duplicate initialize on the same stdio session with -32600" do
137+
first = {
138+
jsonrpc: "2.0",
139+
method: "initialize",
140+
id: "first",
141+
params: {
142+
protocolVersion: "2025-11-25",
143+
clientInfo: { name: "original", version: "1.0" },
144+
},
145+
}
146+
second = {
147+
jsonrpc: "2.0",
148+
method: "initialize",
149+
id: "second",
150+
params: {
151+
protocolVersion: "2024-11-05",
152+
clientInfo: { name: "intruder", version: "9.9" },
153+
},
154+
}
155+
input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n")
156+
output = StringIO.new
157+
original_stdin = $stdin
158+
original_stdout = $stdout
159+
160+
begin
161+
$stdin = input
162+
$stdout = output
163+
@transport.open
164+
165+
lines = output.string.lines
166+
assert_equal(2, lines.length)
167+
first_response = JSON.parse(lines[0], symbolize_names: true)
168+
second_response = JSON.parse(lines[1], symbolize_names: true)
169+
170+
assert_equal("first", first_response[:id])
171+
refute_nil(first_response[:result])
172+
173+
assert_equal("second", second_response[:id])
174+
assert_equal(-32600, second_response[:error][:code])
175+
assert_equal("Invalid Request", second_response[:error][:message])
176+
177+
session = @transport.instance_variable_get(:@session)
178+
assert_equal({ name: "original", version: "1.0" }, session.client)
179+
ensure
180+
$stdin = original_stdin
181+
$stdout = original_stdout
182+
end
183+
end
184+
136185
test "handles invalid JSON requests" do
137186
invalid_json = "invalid json"
138187
output = StringIO.new

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ def string
120120
assert_equal 400, response[0]
121121

122122
body = JSON.parse(response[2][0])
123-
assert_includes body["error"], "single JSON-RPC message object"
123+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
124+
assert_match(/single request object/i, body["error"]["message"])
124125
end
125126

126127
test "POST request with non-object JSON body returns 400" do
@@ -147,7 +148,8 @@ def string
147148
assert_equal 400, response[0]
148149

149150
body = JSON.parse(response[2][0])
150-
assert_includes body["error"], "single JSON-RPC message object"
151+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
152+
assert_match(/single request object/i, body["error"]["message"])
151153
end
152154

153155
test "handles POST request with initialize method" do
@@ -169,6 +171,127 @@ def string
169171
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
170172
end
171173

174+
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
175+
init_request = create_rack_request(
176+
"POST",
177+
"/",
178+
{ "CONTENT_TYPE" => "application/json" },
179+
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
180+
)
181+
init_response = @transport.handle_request(init_request)
182+
session_id = init_response[1]["Mcp-Session-Id"]
183+
assert session_id
184+
185+
duplicate_request = create_rack_request(
186+
"POST",
187+
"/",
188+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
189+
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
190+
)
191+
duplicate_response = @transport.handle_request(duplicate_request)
192+
193+
assert_equal 400, duplicate_response[0]
194+
body = JSON.parse(duplicate_response[2][0])
195+
assert_equal "2.0", body["jsonrpc"]
196+
assert_equal "second", body["id"]
197+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
198+
assert_match(/already initialized/i, body["error"]["message"])
199+
200+
# Original session should still be usable.
201+
ping_request = create_rack_request(
202+
"POST",
203+
"/",
204+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
205+
{ jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json,
206+
)
207+
ping_response = @transport.handle_request(ping_request)
208+
assert_equal 200, ping_response[0]
209+
end
210+
211+
test "rejects initialize with stale Mcp-Session-Id with 404" do
212+
request = create_rack_request(
213+
"POST",
214+
"/",
215+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" },
216+
{ jsonrpc: "2.0", method: "initialize", id: "1" }.to_json,
217+
)
218+
219+
response = @transport.handle_request(request)
220+
assert_equal 404, response[0]
221+
body = JSON.parse(response[2][0])
222+
assert_equal "Session not found", body["error"]
223+
end
224+
225+
test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do
226+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.05)
227+
begin
228+
init_request = create_rack_request(
229+
"POST",
230+
"/",
231+
{ "CONTENT_TYPE" => "application/json" },
232+
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
233+
)
234+
init_response = transport.handle_request(init_request)
235+
session_id = init_response[1]["Mcp-Session-Id"]
236+
assert(session_id)
237+
238+
sleep(0.1)
239+
240+
duplicate_request = create_rack_request(
241+
"POST",
242+
"/",
243+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
244+
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
245+
)
246+
duplicate_response = transport.handle_request(duplicate_request)
247+
248+
assert_equal(404, duplicate_response[0])
249+
body = JSON.parse(duplicate_response[2][0])
250+
assert_equal("Session not found", body["error"])
251+
252+
refute(transport.send(:session_exists?, session_id), "expired session must be evicted")
253+
ensure
254+
transport.close
255+
end
256+
end
257+
258+
test "evicts session and omits Mcp-Session-Id when initialize fails" do
259+
# An `initialize` whose JSON-RPC envelope is rejected (e.g. wrong `jsonrpc` version)
260+
# never reaches `Server#init`, so `mark_initialized!` is never called. The transport
261+
# must drop the registered-but-uninitialized session to keep retries clean.
262+
request = create_rack_request(
263+
"POST",
264+
"/",
265+
{ "CONTENT_TYPE" => "application/json" },
266+
{ jsonrpc: "1.0", method: "initialize", id: "broken" }.to_json,
267+
)
268+
269+
response = @transport.handle_request(request)
270+
assert_equal 200, response[0]
271+
refute response[1].key?("Mcp-Session-Id"), "no session id should leak from a failed init"
272+
273+
body = JSON.parse(response[2][0])
274+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
275+
assert_equal({}, @transport.instance_variable_get(:@sessions))
276+
end
277+
278+
test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do
279+
request = create_rack_request(
280+
"POST",
281+
"/",
282+
{ "CONTENT_TYPE" => "application/json" },
283+
[{ jsonrpc: "2.0", method: "initialize", id: "batched" }].to_json,
284+
)
285+
286+
response = @transport.handle_request(request)
287+
assert_equal 400, response[0]
288+
body = JSON.parse(response[2][0])
289+
assert_equal "2.0", body["jsonrpc"]
290+
assert_nil body["id"]
291+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
292+
assert_match(/single request object/i, body["error"]["message"])
293+
end
294+
172295
test "handles GET request with valid session ID" do
173296
# First create a session with initialize
174297
init_request = create_rack_request(

0 commit comments

Comments
 (0)