From a79648a0918df193c941433380b728cc8f75d175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Mon, 22 Jun 2026 20:48:19 +0800 Subject: [PATCH] fix duplicate initialize handling --- src/mcp/server/runner.py | 3 + tests/interaction/_connect.py | 4 +- tests/interaction/_requirements.py | 6 -- .../transports/test_hosting_session.py | 79 ++++++++++++++++--- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 6b64ce9c49..a58713cd99 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -40,6 +40,7 @@ from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, + INVALID_REQUEST, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, ErrorData, @@ -251,6 +252,8 @@ async def _inner() -> HandlerResult: # the gate become a per-version legacy path then. Initialize runs inline # (read loop parked), so awaiting the peer anywhere on this path deadlocks. if method == "initialize": + if self.connection.client_params is not None: + raise MCPError(code=INVALID_REQUEST, message="Server is already initialized") return self._handle_initialize(params) # Methods without a handler are METHOD_NOT_FOUND regardless of # initialization state: JSON-RPC 2.0 reserves -32601 for "not diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 575a742632..11293239b7 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -274,12 +274,12 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]: return headers -def initialize_body(request_id: int = 1) -> dict[str, object]: +def initialize_body(request_id: int = 1, *, client_name: str = "raw") -> dict[str, object]: """A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it.""" params = InitializeRequestParams( protocol_version=LATEST_PROTOCOL_VERSION, capabilities=ClientCapabilities(), - client_info=Implementation(name="raw", version="0.0.0"), + client_info=Implementation(name=client_name, version="0.0.0"), ) return JSONRPCRequest( jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9aee73b29b..dd38e5849e 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2508,12 +2508,6 @@ def __post_init__(self) -> None: source="sdk", behavior="A second initialize on an already-initialized session transport is rejected.", transports=("streamable-http",), - divergence=Divergence( - note=( - "The transport forwards a second initialize carrying the existing session ID to the running " - "server, which answers it as a fresh handshake; nothing rejects re-initialization." - ), - ), removed_in="2026-07-28", note=( "removed in 2026-07-28 (SEP-2567); per-session initialize guard retired with Mcp-Session-Id, no " diff --git a/tests/interaction/transports/test_hosting_session.py b/tests/interaction/transports/test_hosting_session.py index a926c3e8a2..14b502a29d 100644 --- a/tests/interaction/transports/test_hosting_session.py +++ b/tests/interaction/transports/test_hosting_session.py @@ -14,7 +14,17 @@ from inline_snapshot import snapshot from mcp.server import Server, ServerRequestContext -from mcp.types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool +from mcp.types import ( + INVALID_REQUEST, + CallToolRequestParams, + CallToolResult, + JSONRPCError, + JSONRPCResponse, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) from tests.interaction._connect import ( base_headers, client_via_http, @@ -32,9 +42,25 @@ def _server() -> Server: """A minimal low-level server with one tool, so subsequent-request routing can be observed.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})]) + return ListToolsResult( + tools=[ + Tool(name="noop", description="Does nothing.", input_schema={"type": "object"}), + Tool( + name="client-name", + description="Reports the initialized client name.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + client_params = ctx.session.client_params + assert client_params is not None + assert params.name == "client-name" + client_name = client_params.client_info.name + return CallToolResult(content=[TextContent(text=client_name)], structured_content={"clientName": client_name}) - return Server("hosted", on_list_tools=list_tools) + return Server("hosted", on_list_tools=list_tools, on_call_tool=call_tool) @requirement("hosting:session:create") @@ -142,20 +168,49 @@ async def test_terminating_one_session_leaves_others_working() -> None: @requirement("hosting:session:reinitialize") -async def test_second_initialize_on_an_existing_session_is_accepted() -> None: - """A second initialize POST carrying an existing session ID is processed rather than rejected. - - See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the - second initialize to the running server, which answers it as a fresh handshake. - """ +async def test_second_initialize_on_an_existing_session_is_rejected() -> None: + """A second initialize POST carrying an existing session ID is rejected without changing client params.""" async with mounted_app(_server()) as (http, manager): session_id = await initialize_via_http(http) - response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id) + call_body: dict[str, object] = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "client-name"}, + } + first_call_response, first_call_messages = await post_jsonrpc(http, call_body, session_id=session_id) + + response, messages = await post_jsonrpc( + http, initialize_body(request_id=3, client_name="reinitializer"), session_id=session_id + ) + second_call_body: dict[str, object] = { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "client-name"}, + } + second_call_response, second_call_messages = await post_jsonrpc( + http, + second_call_body, + session_id=session_id, + ) assert len(manager._server_instances) == 1 + assert first_call_response.status_code == 200 + assert isinstance(first_call_messages[0], JSONRPCResponse) + first_call_result = CallToolResult.model_validate(first_call_messages[0].result) + assert first_call_result.structured_content == {"clientName": "raw"} + assert response.status_code == snapshot(200) - assert isinstance(messages[0], JSONRPCResponse) - assert messages[0].id == 2 + assert isinstance(messages[0], JSONRPCError) + assert messages[0].id == 3 + assert messages[0].error.code == INVALID_REQUEST + assert messages[0].error.message == "Server is already initialized" + + assert second_call_response.status_code == 200 + assert isinstance(second_call_messages[0], JSONRPCResponse) + second_call_result = CallToolResult.model_validate(second_call_messages[0].result) + assert second_call_result.structured_content == {"clientName": "raw"} @requirement("hosting:stateless:no-session-id")