Reject duplicate initialize requests (#350)
## 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.
This commit is contained in:
parent
9caba3085d
commit
7db22f4d5b
3 changed files with 289 additions and 13 deletions
|
|
@ -133,6 +133,55 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
test "rejects duplicate initialize on the same stdio session with -32600" do
|
||||
first = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: "first",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
clientInfo: { name: "original", version: "1.0" },
|
||||
},
|
||||
}
|
||||
second = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: "second",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
clientInfo: { name: "intruder", version: "9.9" },
|
||||
},
|
||||
}
|
||||
input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n")
|
||||
output = StringIO.new
|
||||
original_stdin = $stdin
|
||||
original_stdout = $stdout
|
||||
|
||||
begin
|
||||
$stdin = input
|
||||
$stdout = output
|
||||
@transport.open
|
||||
|
||||
lines = output.string.lines
|
||||
assert_equal(2, lines.length)
|
||||
first_response = JSON.parse(lines[0], symbolize_names: true)
|
||||
second_response = JSON.parse(lines[1], symbolize_names: true)
|
||||
|
||||
assert_equal("first", first_response[:id])
|
||||
refute_nil(first_response[:result])
|
||||
|
||||
assert_equal("second", second_response[:id])
|
||||
assert_equal(-32600, second_response[:error][:code])
|
||||
assert_equal("Invalid Request", second_response[:error][:message])
|
||||
|
||||
session = @transport.instance_variable_get(:@session)
|
||||
assert_equal({ name: "original", version: "1.0" }, session.client)
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
$stdout = original_stdout
|
||||
end
|
||||
end
|
||||
|
||||
test "handles invalid JSON requests" do
|
||||
invalid_json = "invalid json"
|
||||
output = StringIO.new
|
||||
|
|
|
|||
|
|
@ -120,7 +120,8 @@ module MCP
|
|||
assert_equal 400, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_includes body["error"], "single JSON-RPC message object"
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_match(/single request object/i, body["error"]["message"])
|
||||
end
|
||||
|
||||
test "POST request with non-object JSON body returns 400" do
|
||||
|
|
@ -147,7 +148,8 @@ module MCP
|
|||
assert_equal 400, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_includes body["error"], "single JSON-RPC message object"
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_match(/single request object/i, body["error"]["message"])
|
||||
end
|
||||
|
||||
test "handles POST request with initialize method" do
|
||||
|
|
@ -169,6 +171,127 @@ module MCP
|
|||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
|
||||
end
|
||||
|
||||
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
|
||||
init_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
|
||||
)
|
||||
init_response = @transport.handle_request(init_request)
|
||||
session_id = init_response[1]["Mcp-Session-Id"]
|
||||
assert session_id
|
||||
|
||||
duplicate_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
|
||||
)
|
||||
duplicate_response = @transport.handle_request(duplicate_request)
|
||||
|
||||
assert_equal 400, duplicate_response[0]
|
||||
body = JSON.parse(duplicate_response[2][0])
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_equal "second", body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_match(/already initialized/i, body["error"]["message"])
|
||||
|
||||
# Original session should still be usable.
|
||||
ping_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json,
|
||||
)
|
||||
ping_response = @transport.handle_request(ping_request)
|
||||
assert_equal 200, ping_response[0]
|
||||
end
|
||||
|
||||
test "rejects initialize with stale Mcp-Session-Id with 404" do
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "1" }.to_json,
|
||||
)
|
||||
|
||||
response = @transport.handle_request(request)
|
||||
assert_equal 404, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
end
|
||||
|
||||
test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do
|
||||
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.05)
|
||||
begin
|
||||
init_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "first" }.to_json,
|
||||
)
|
||||
init_response = transport.handle_request(init_request)
|
||||
session_id = init_response[1]["Mcp-Session-Id"]
|
||||
assert(session_id)
|
||||
|
||||
sleep(0.1)
|
||||
|
||||
duplicate_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "second" }.to_json,
|
||||
)
|
||||
duplicate_response = transport.handle_request(duplicate_request)
|
||||
|
||||
assert_equal(404, duplicate_response[0])
|
||||
body = JSON.parse(duplicate_response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
|
||||
refute(transport.send(:session_exists?, session_id), "expired session must be evicted")
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
end
|
||||
|
||||
test "evicts session and omits Mcp-Session-Id when initialize fails" do
|
||||
# An `initialize` whose JSON-RPC envelope is rejected (e.g. wrong `jsonrpc` version)
|
||||
# never reaches `Server#init`, so `mark_initialized!` is never called. The transport
|
||||
# must drop the registered-but-uninitialized session to keep retries clean.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "1.0", method: "initialize", id: "broken" }.to_json,
|
||||
)
|
||||
|
||||
response = @transport.handle_request(request)
|
||||
assert_equal 200, response[0]
|
||||
refute response[1].key?("Mcp-Session-Id"), "no session id should leak from a failed init"
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_equal({}, @transport.instance_variable_get(:@sessions))
|
||||
end
|
||||
|
||||
test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
[{ jsonrpc: "2.0", method: "initialize", id: "batched" }].to_json,
|
||||
)
|
||||
|
||||
response = @transport.handle_request(request)
|
||||
assert_equal 400, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_match(/single request object/i, body["error"]["message"])
|
||||
end
|
||||
|
||||
test "handles GET request with valid session ID" do
|
||||
# First create a session with initialize
|
||||
init_request = create_rack_request(
|
||||
|
|
|
|||
|
|
@ -195,6 +195,31 @@ module MCP
|
|||
assert_instrumentation_data({ method: "ping", client: client_info })
|
||||
end
|
||||
|
||||
test "#handle rejects duplicate initialize on an already-initialized session with -32600" do
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
first_request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: 1,
|
||||
params: { clientInfo: { name: "original", version: "1.0" } },
|
||||
}
|
||||
first_response = @server.handle(first_request, session: session)
|
||||
refute_nil first_response[:result]
|
||||
|
||||
second_request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: 2,
|
||||
params: { clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05" },
|
||||
}
|
||||
second_response = @server.handle(second_request, session: session)
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, second_response[:error][:code]
|
||||
assert_equal "Invalid Request", second_response[:error][:message]
|
||||
assert_equal({ name: "original", version: "1.0" }, session.client)
|
||||
end
|
||||
|
||||
test "instrumentation data does not include client key when no clientInfo provided" do
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue