Skip to content

Commit a9a5005

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 modelcontextprotocol#349.
1 parent d6b5392 commit a9a5005

6 files changed

Lines changed: 285 additions & 2 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,16 +342,32 @@ def handle_post(request)
342342
body = parse_request_body(body_string)
343343
return body if parse_error_tuple?(body)
344344

345+
# Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
346+
# Batched/array bodies are not supported; reject with `-32600` instead of falling through to
347+
# a malformed Rack response.
348+
unless body.is_a?(Hash)
349+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
350+
end
351+
345352
unless initialize_request?(body)
346353
return missing_session_id_response if !@stateless && !session_id
347354

348355
protocol_version_error = validate_protocol_version_header(request)
349356
return protocol_version_error if protocol_version_error
350357
end
351358

352-
return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.
353-
354359
if initialize_request?(body)
360+
if !@stateless && session_id
361+
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
362+
# initialization attempt against a live session, or a retry against an unknown/expired
363+
# one. In the live case, reject with `-32600` so the original session is not abandoned.
364+
# In the unknown/expired case, return 404 so the client retries from scratch instead
365+
# of silently inheriting a fresh session under the old ID.
366+
return already_initialized_response(body[:id]) if session_active?(session_id)
367+
368+
return session_not_found_response
369+
end
370+
355371
handle_initialization(body_string, body)
356372
elsif notification?(body)
357373
dispatch_notification(body_string, session_id)
@@ -600,6 +616,15 @@ def handle_initialization(body_string, body)
600616
@server.handle_json(body_string)
601617
end
602618

619+
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
620+
# `mark_initialized!` was never called. Discard the orphaned session and omit
621+
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
622+
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
623+
if server_session && !server_session.initialized?
624+
cleanup_session(session_id)
625+
session_id = nil
626+
end
627+
603628
headers = {
604629
"Content-Type" => "application/json",
605630
}
@@ -734,6 +759,31 @@ def session_exists?(session_id)
734759
@mutex.synchronize { @sessions.key?(session_id) }
735760
end
736761

762+
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
763+
# are evicted as a side effect so a live request never observes a zombie session that
764+
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
765+
# rejecting a request must not extend the session's lifetime.
766+
def session_active?(session_id)
767+
removed = nil
768+
active = @mutex.synchronize do
769+
next false unless (session = @sessions[session_id])
770+
771+
if session_expired?(session)
772+
removed = cleanup_session_unsafe(session_id)
773+
next false
774+
end
775+
776+
true
777+
end
778+
779+
if removed
780+
close_stream_safely(removed[:get_sse_stream])
781+
close_post_request_streams(removed)
782+
end
783+
784+
active
785+
end
786+
737787
def method_not_allowed_response
738788
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
739789
end
@@ -746,6 +796,22 @@ def session_not_found_response
746796
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
747797
end
748798

799+
def already_initialized_response(request_id)
800+
invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
801+
end
802+
803+
def invalid_request_response(message, request_id: nil)
804+
body = {
805+
jsonrpc: "2.0",
806+
id: request_id,
807+
error: {
808+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
809+
message: message,
810+
},
811+
}
812+
[400, { "Content-Type" => "application/json" }, [body.to_json]]
813+
end
814+
749815
def session_already_connected_response
750816
[
751817
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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,127 @@ def string
115115
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
116116
end
117117

118+
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
119+
init_request = create_rack_request(
120+
"POST",
121+
"/",
122+
{ "CONTENT_TYPE" => "application/json" },
123+
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
124+
)
125+
init_response = @transport.handle_request(init_request)
126+
session_id = init_response[1]["Mcp-Session-Id"]
127+
assert session_id
128+
129+
duplicate_request = create_rack_request(
130+
"POST",
131+
"/",
132+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
133+
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
134+
)
135+
duplicate_response = @transport.handle_request(duplicate_request)
136+
137+
assert_equal 400, duplicate_response[0]
138+
body = JSON.parse(duplicate_response[2][0])
139+
assert_equal "2.0", body["jsonrpc"]
140+
assert_equal "second", body["id"]
141+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
142+
assert_match(/already initialized/i, body["error"]["message"])
143+
144+
# Original session should still be usable.
145+
ping_request = create_rack_request(
146+
"POST",
147+
"/",
148+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
149+
{ jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json,
150+
)
151+
ping_response = @transport.handle_request(ping_request)
152+
assert_equal 200, ping_response[0]
153+
end
154+
155+
test "rejects initialize with stale Mcp-Session-Id with 404" do
156+
request = create_rack_request(
157+
"POST",
158+
"/",
159+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" },
160+
{ jsonrpc: "2.0", method: "initialize", id: "1" }.to_json,
161+
)
162+
163+
response = @transport.handle_request(request)
164+
assert_equal 404, response[0]
165+
body = JSON.parse(response[2][0])
166+
assert_equal "Session not found", body["error"]
167+
end
168+
169+
test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do
170+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.05)
171+
begin
172+
init_request = create_rack_request(
173+
"POST",
174+
"/",
175+
{ "CONTENT_TYPE" => "application/json" },
176+
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
177+
)
178+
init_response = transport.handle_request(init_request)
179+
session_id = init_response[1]["Mcp-Session-Id"]
180+
assert(session_id)
181+
182+
sleep(0.1)
183+
184+
duplicate_request = create_rack_request(
185+
"POST",
186+
"/",
187+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
188+
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
189+
)
190+
duplicate_response = transport.handle_request(duplicate_request)
191+
192+
assert_equal(404, duplicate_response[0])
193+
body = JSON.parse(duplicate_response[2][0])
194+
assert_equal("Session not found", body["error"])
195+
196+
refute(transport.send(:session_exists?, session_id), "expired session must be evicted")
197+
ensure
198+
transport.close
199+
end
200+
end
201+
202+
test "evicts session and omits Mcp-Session-Id when initialize fails" do
203+
# An `initialize` whose JSON-RPC envelope is rejected (e.g. wrong `jsonrpc` version)
204+
# never reaches `Server#init`, so `mark_initialized!` is never called. The transport
205+
# must drop the registered-but-uninitialized session to keep retries clean.
206+
request = create_rack_request(
207+
"POST",
208+
"/",
209+
{ "CONTENT_TYPE" => "application/json" },
210+
{ jsonrpc: "1.0", method: "initialize", id: "broken" }.to_json,
211+
)
212+
213+
response = @transport.handle_request(request)
214+
assert_equal 200, response[0]
215+
refute response[1].key?("Mcp-Session-Id"), "no session id should leak from a failed init"
216+
217+
body = JSON.parse(response[2][0])
218+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
219+
assert_equal({}, @transport.instance_variable_get(:@sessions))
220+
end
221+
222+
test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do
223+
request = create_rack_request(
224+
"POST",
225+
"/",
226+
{ "CONTENT_TYPE" => "application/json" },
227+
[{ jsonrpc: "2.0", method: "initialize", id: "batched" }].to_json,
228+
)
229+
230+
response = @transport.handle_request(request)
231+
assert_equal 400, response[0]
232+
body = JSON.parse(response[2][0])
233+
assert_equal "2.0", body["jsonrpc"]
234+
assert_nil body["id"]
235+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
236+
assert_match(/single request object/i, body["error"]["message"])
237+
end
238+
118239
test "handles GET request with valid session ID" do
119240
# First create a session with initialize
120241
init_request = create_rack_request(

test/mcp/server_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,31 @@ class ServerTest < ActiveSupport::TestCase
195195
assert_instrumentation_data({ method: "ping", client: client_info })
196196
end
197197

198+
test "#handle rejects duplicate initialize on an already-initialized session with -32600" do
199+
session = ServerSession.new(server: @server, transport: mock)
200+
201+
first_request = {
202+
jsonrpc: "2.0",
203+
method: "initialize",
204+
id: 1,
205+
params: { clientInfo: { name: "original", version: "1.0" } },
206+
}
207+
first_response = @server.handle(first_request, session: session)
208+
refute_nil first_response[:result]
209+
210+
second_request = {
211+
jsonrpc: "2.0",
212+
method: "initialize",
213+
id: 2,
214+
params: { clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05" },
215+
}
216+
second_response = @server.handle(second_request, session: session)
217+
218+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, second_response[:error][:code]
219+
assert_equal "Invalid Request", second_response[:error][:message]
220+
assert_equal({ name: "original", version: "1.0" }, session.client)
221+
end
222+
198223
test "instrumentation data does not include client key when no clientInfo provided" do
199224
request = {
200225
jsonrpc: "2.0",

0 commit comments

Comments
 (0)