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
|
|
@ -497,6 +497,13 @@ module MCP
|
|||
end
|
||||
|
||||
def init(params, session: nil)
|
||||
# MCP spec: the initialization phase MUST be the first interaction between client and server.
|
||||
# Reject duplicate `initialize` on an already-initialized session so the negotiated
|
||||
# client identity and capabilities cannot be silently overwritten.
|
||||
if session&.initialized?
|
||||
raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
|
||||
end
|
||||
|
||||
if params
|
||||
if session
|
||||
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
||||
|
|
@ -524,6 +531,8 @@ module MCP
|
|||
response_instructions = nil
|
||||
end
|
||||
|
||||
session&.mark_initialized!
|
||||
|
||||
{
|
||||
protocolVersion: negotiated_version,
|
||||
capabilities: capabilities,
|
||||
|
|
|
|||
|
|
@ -347,6 +347,13 @@ module MCP
|
|||
return invalid_json_response
|
||||
end
|
||||
|
||||
# Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
|
||||
# Batched/array bodies are not supported; reject with `-32600` instead of falling through to
|
||||
# a malformed Rack response.
|
||||
unless body.is_a?(Hash)
|
||||
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
||||
end
|
||||
|
||||
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
||||
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
||||
# This matches the TypeScript and Python SDKs.
|
||||
|
|
@ -357,10 +364,18 @@ module MCP
|
|||
return protocol_version_error if protocol_version_error
|
||||
end
|
||||
|
||||
# MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object.
|
||||
return non_hash_body_response unless body.is_a?(Hash)
|
||||
|
||||
if initialize_request?(body)
|
||||
if !@stateless && session_id
|
||||
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
|
||||
# initialization attempt against a live session, or a retry against an unknown/expired
|
||||
# one. In the live case, reject with `-32600` so the original session is not abandoned.
|
||||
# In the unknown/expired case, return 404 so the client retries from scratch instead
|
||||
# of silently inheriting a fresh session under the old ID.
|
||||
return already_initialized_response(body[:id]) if session_active?(session_id)
|
||||
|
||||
return session_not_found_response
|
||||
end
|
||||
|
||||
handle_initialization(body_string, body)
|
||||
elsif notification?(body)
|
||||
dispatch_notification(body_string, session_id)
|
||||
|
|
@ -523,14 +538,6 @@ module MCP
|
|||
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
||||
end
|
||||
|
||||
def non_hash_body_response
|
||||
[
|
||||
400,
|
||||
{ "Content-Type" => "application/json" },
|
||||
[{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json],
|
||||
]
|
||||
end
|
||||
|
||||
def initialize_request?(body)
|
||||
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
||||
end
|
||||
|
|
@ -617,6 +624,15 @@ module MCP
|
|||
@server.handle_json(body_string)
|
||||
end
|
||||
|
||||
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
||||
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
||||
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
||||
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
||||
if server_session && !server_session.initialized?
|
||||
cleanup_session(session_id)
|
||||
session_id = nil
|
||||
end
|
||||
|
||||
headers = {
|
||||
"Content-Type" => "application/json",
|
||||
}
|
||||
|
|
@ -751,6 +767,31 @@ module MCP
|
|||
@mutex.synchronize { @sessions.key?(session_id) }
|
||||
end
|
||||
|
||||
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
||||
# are evicted as a side effect so a live request never observes a zombie session that
|
||||
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
||||
# rejecting a request must not extend the session's lifetime.
|
||||
def session_active?(session_id)
|
||||
removed = nil
|
||||
active = @mutex.synchronize do
|
||||
next false unless (session = @sessions[session_id])
|
||||
|
||||
if session_expired?(session)
|
||||
removed = cleanup_session_unsafe(session_id)
|
||||
next false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
if removed
|
||||
close_stream_safely(removed[:get_sse_stream])
|
||||
close_post_request_streams(removed)
|
||||
end
|
||||
|
||||
active
|
||||
end
|
||||
|
||||
def method_not_allowed_response
|
||||
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
||||
end
|
||||
|
|
@ -763,6 +804,22 @@ module MCP
|
|||
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
||||
end
|
||||
|
||||
def already_initialized_response(request_id)
|
||||
invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
|
||||
end
|
||||
|
||||
def invalid_request_response(message, request_id: nil)
|
||||
body = {
|
||||
jsonrpc: "2.0",
|
||||
id: request_id,
|
||||
error: {
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: message,
|
||||
},
|
||||
}
|
||||
[400, { "Content-Type" => "application/json" }, [body.to_json]]
|
||||
end
|
||||
|
||||
def session_already_connected_response
|
||||
[
|
||||
409,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,19 @@ module MCP
|
|||
@logging_message_notification = nil
|
||||
@in_flight = {}
|
||||
@in_flight_mutex = Mutex.new
|
||||
@initialized = false
|
||||
end
|
||||
|
||||
# Whether `initialize` has already completed for this session.
|
||||
def initialized?
|
||||
@initialized
|
||||
end
|
||||
|
||||
# Called by `Server#init` after a successful `initialize` response, so subsequent
|
||||
# `initialize` requests on the same session can be rejected per MCP spec
|
||||
# (the initialization phase MUST be the first interaction).
|
||||
def mark_initialized!
|
||||
@initialized = true
|
||||
end
|
||||
|
||||
# Registers a `Cancellation` token for an in-flight request.
|
||||
|
|
|
|||
Loading…
Reference in a new issue