Handle an initialize request sent without an ID

## Motivation and Context

`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.

Two things then went wrong:

- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
  so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
  and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
  The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
  toward the `max_sessions` cap until the reaper reclaims them.

`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.

## How Has This Been Tested?

A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.

## Breaking Changes

None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
This commit is contained in:
Koichi ITO 2026-07-10 01:08:10 +09:00
commit fc0c6b8f3f

View file

@ -315,6 +315,24 @@ module MCP
assert_equal({}, @transport.instance_variable_get(:@sessions))
end
test "acks an initialize sent without an id and retains no session" do
# `initialize` framed as a notification (no id) produces no JSON-RPC response.
# The transport must not put nil in the Rack body or retain the orphaned session.
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json" },
{ jsonrpc: "2.0", method: "initialize" }.to_json,
)
response = @transport.handle_request(request)
assert_equal 202, response[0]
refute response[1].key?("Mcp-Session-Id"), "no session id should leak from an id-less init"
assert_equal [], response[2]
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",