Skip to content

Commit cf7b17f

Browse files
committed
Reject a second initialize on an already-initialized session
A server that had already completed the initialize handshake on a connection answered a repeated initialize request on that same connection as a fresh handshake, silently overwriting the session's recorded client_params and negotiated protocol version. A check_capability call made after that point then answered against the second client's declared capabilities. The handshake now commits at most once per connection: a repeated initialize is answered with JSON-RPC error -32600 (INVALID_REQUEST, "Session already initialized") and the established session keeps serving. The check lives at the runner's request-dispatch boundary, right next to its mirror (the request-before-initialize gate), so every transport gets it without any transport-level body sniffing. The discriminator is client_params rather than initialize_accepted: the legacy stateless path builds a per-request connection that is born past the gate but has no peer info yet, and its one initialize must still be accepted. No compliant client is affected. The spec makes initialization the first interaction of a session, and ClientSession.initialize() is already idempotent (a repeat call returns the first result without sending anything). This only applies to the legacy (2025-11-25 and earlier) handshake; the 2026-07-28 protocol removes initialize entirely. Closes #2605
1 parent 6ba4a76 commit cf7b17f

5 files changed

Lines changed: 79 additions & 15 deletions

File tree

docs/migration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ per the spec's SHOULD. v1 answered the cancelled request with an error
4949
with anyio cancellation when its scope is cancelled, so no reply is needed to
5050
unblock it.
5151

52+
### A second `initialize` on an already-initialized session is rejected
53+
54+
A server that has already completed the `initialize` handshake on a connection now answers a
55+
repeated `initialize` request on that same connection with JSON-RPC error `-32600`
56+
(`INVALID_REQUEST`, message `"Session already initialized"`) instead of re-running the handshake.
57+
Previously the repeat was answered as a fresh handshake and silently overwrote the session's
58+
recorded `client_params` and negotiated protocol version, so a `check_capability` call made after
59+
that point answered against the second client's declared capabilities
60+
([#2605](https://github.com/modelcontextprotocol/python-sdk/issues/2605)).
61+
62+
No compliant client is affected: the spec makes initialization the first interaction of a session,
63+
and the SDK's own `ClientSession.initialize()` is idempotent (a repeat call returns the first
64+
result without sending anything). A peer that needs a fresh handshake should open a new
65+
connection — on streamable HTTP, a `POST` without an `Mcp-Session-Id` header. This applies only to
66+
the legacy (2025-11-25 and earlier) handshake; the 2026-07-28 protocol removes `initialize`
67+
entirely.
68+
5269
### `streamablehttp_client` removed
5370

5471
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.

src/mcp/server/runner.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
CLIENT_INFO_META_KEY,
2727
INTERNAL_ERROR,
2828
INVALID_PARAMS,
29+
INVALID_REQUEST,
2930
METHOD_NOT_FOUND,
3031
PROTOCOL_VERSION_META_KEY,
3132
ErrorData,
@@ -177,6 +178,15 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
177178
# the gate become a per-version legacy path then. Initialize runs inline
178179
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
179180
if method == "initialize":
181+
# The handshake commits at most once per connection: a repeat would
182+
# silently overwrite the negotiated `client_params` and
183+
# `protocol_version` at the commit at the bottom of `_on_request`
184+
# (#2605). The discriminator is `client_params`, not
185+
# `initialize_accepted`: a born-ready `from_envelope` connection
186+
# (the legacy stateless path) is already past the gate but has no
187+
# peer info yet, and its one `initialize` must still be accepted.
188+
if self.connection.client_params is not None:
189+
raise MCPError(code=INVALID_REQUEST, message="Session already initialized")
180190
return self._serialize(method, version, self._handle_initialize(params))
181191
# Methods without a handler are METHOD_NOT_FOUND regardless of
182192
# initialization state: JSON-RPC 2.0 reserves -32601 for "not

tests/interaction/_requirements.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2571,12 +2571,6 @@ def __post_init__(self) -> None:
25712571
source="sdk",
25722572
behavior="A second initialize on an already-initialized session transport is rejected.",
25732573
transports=("streamable-http",),
2574-
divergence=Divergence(
2575-
note=(
2576-
"The transport forwards a second initialize carrying the existing session ID to the running "
2577-
"server, which answers it as a fresh handshake; nothing rejects re-initialization."
2578-
),
2579-
),
25802574
removed_in="2026-07-28",
25812575
note=(
25822576
"removed in 2026-07-28 (SEP-2567); per-session initialize guard retired with Mcp-Session-Id, no "

tests/interaction/transports/test_hosting_session.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import httpx
1313
import pytest
1414
from inline_snapshot import snapshot
15-
from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
15+
from mcp_types import INVALID_REQUEST, JSONRPCError, JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
1616

1717
from mcp.server import Server, ServerRequestContext
1818
from tests.interaction._connect import (
@@ -142,20 +142,38 @@ async def test_terminating_one_session_leaves_others_working() -> None:
142142

143143

144144
@requirement("hosting:session:reinitialize")
145-
async def test_second_initialize_on_an_existing_session_is_accepted() -> None:
146-
"""A second initialize POST carrying an existing session ID is processed rather than rejected.
147-
148-
See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the
149-
second initialize to the running server, which answers it as a fresh handshake.
145+
async def test_second_initialize_on_an_existing_session_is_rejected_and_the_session_survives() -> None:
146+
"""A second initialize POST on an existing session is answered with INVALID_REQUEST,
147+
and the established session keeps serving.
148+
149+
SDK-defined, no spec MUST: closes the gap where the server answered a repeated initialize as a
150+
fresh handshake and silently overwrote the session's committed client_params (python-sdk#2605;
151+
the kernel-level proof that the committed state survives is in tests/server/test_runner.py).
152+
Raw HTTP because the SDK Client performs exactly one handshake and cannot be made to repeat it.
153+
154+
Steps:
155+
1. A real handshake establishes the session.
156+
2. A second initialize on that session ID routes to the existing transport (the instance
157+
count stays 1) and is rejected at the JSON-RPC layer. The HTTP status is still 200: in
158+
legacy SSE mode the response stream is committed before dispatch, so the rejection
159+
arrives as a JSONRPCError event on it.
160+
3. tools/list on the same session still answers, proving the rejection tore nothing down.
150161
"""
151162
async with mounted_app(_server()) as (http, manager):
152163
session_id = await initialize_via_http(http)
164+
153165
response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id)
154166
assert len(manager._server_instances) == 1
167+
assert response.status_code == snapshot(200)
168+
assert isinstance(messages[0], JSONRPCError)
169+
assert messages[0].id == 2
170+
assert messages[0].error.code == INVALID_REQUEST
171+
assert messages[0].error.message == snapshot("Session already initialized")
155172

156-
assert response.status_code == snapshot(200)
157-
assert isinstance(messages[0], JSONRPCResponse)
158-
assert messages[0].id == 2
173+
_, after = await post_jsonrpc(http, {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, session_id=session_id)
174+
175+
assert isinstance(after[0], JSONRPCResponse)
176+
assert after[0].result["tools"][0]["name"] == "noop"
159177

160178

161179
@requirement("hosting:stateless:no-session-id")

tests/server/test_runner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mcp_types import (
2020
INTERNAL_ERROR,
2121
INVALID_PARAMS,
22+
INVALID_REQUEST,
2223
LATEST_PROTOCOL_VERSION,
2324
METHOD_NOT_FOUND,
2425
ClientCapabilities,
@@ -175,6 +176,30 @@ async def test_runner_initialize_opens_gate_but_event_fires_only_after_initializ
175176
await runner.connection.initialized.wait()
176177

177178

179+
@pytest.mark.anyio
180+
async def test_runner_rejects_a_second_initialize_and_preserves_the_committed_handshake(server: SrvT):
181+
"""A second `initialize` on an already-initialized connection is rejected with
182+
INVALID_REQUEST and the first handshake's committed `client_params` and
183+
`protocol_version` survive unchanged.
184+
185+
SDK-defined (no spec MUST mandates the rejection). Regression lock for python-sdk#2605,
186+
where the repeat was answered as a fresh handshake and silently overwrote both."""
187+
impostor = InitializeRequestParams(
188+
protocol_version=OLDEST_SUPPORTED_VERSION,
189+
capabilities=ClientCapabilities(),
190+
client_info=Implementation(name="impostor", version="9.9"),
191+
).model_dump(by_alias=True, exclude_none=True)
192+
# `connected_runner(server)` already performed the real initialize (client name
193+
# "test-client", protocol version LATEST_HANDSHAKE_VERSION) before yielding.
194+
async with connected_runner(server) as (client, runner):
195+
with pytest.raises(MCPError) as exc:
196+
await client.send_raw_request("initialize", impostor)
197+
assert exc.value.error == ErrorData(code=INVALID_REQUEST, message="Session already initialized")
198+
assert runner.connection.client_params is not None
199+
assert runner.connection.client_params.client_info.name == "test-client"
200+
assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION
201+
202+
178203
@pytest.mark.anyio
179204
async def test_runner_gates_requests_before_initialize(server: SrvT):
180205
async with connected_runner(server, initialized=False) as (client, _):

0 commit comments

Comments
 (0)