Skip to content

Commit dbd9632

Browse files
author
冯基魁
committed
fix duplicate initialize handling
1 parent 2397319 commit dbd9632

3 files changed

Lines changed: 64 additions & 18 deletions

File tree

src/mcp/server/runner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from mcp.types import (
4141
INTERNAL_ERROR,
4242
INVALID_PARAMS,
43+
INVALID_REQUEST,
4344
LATEST_PROTOCOL_VERSION,
4445
METHOD_NOT_FOUND,
4546
ErrorData,
@@ -251,6 +252,8 @@ async def _inner() -> HandlerResult:
251252
# the gate become a per-version legacy path then. Initialize runs inline
252253
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
253254
if method == "initialize":
255+
if self.connection.client_params is not None:
256+
raise MCPError(code=INVALID_REQUEST, message="Server is already initialized")
254257
return self._handle_initialize(params)
255258
# Methods without a handler are METHOD_NOT_FOUND regardless of
256259
# 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
@@ -2508,12 +2508,6 @@ def __post_init__(self) -> None:
25082508
source="sdk",
25092509
behavior="A second initialize on an already-initialized session transport is rejected.",
25102510
transports=("streamable-http",),
2511-
divergence=Divergence(
2512-
note=(
2513-
"The transport forwards a second initialize carrying the existing session ID to the running "
2514-
"server, which answers it as a fresh handshake; nothing rejects re-initialization."
2515-
),
2516-
),
25172511
removed_in="2026-07-28",
25182512
note=(
25192513
"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: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,17 @@
1414
from inline_snapshot import snapshot
1515

1616
from mcp.server import Server, ServerRequestContext
17-
from mcp.types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
17+
from mcp.types import (
18+
INVALID_REQUEST,
19+
CallToolRequestParams,
20+
CallToolResult,
21+
JSONRPCError,
22+
JSONRPCResponse,
23+
ListToolsResult,
24+
PaginatedRequestParams,
25+
TextContent,
26+
Tool,
27+
)
1828
from tests.interaction._connect import (
1929
base_headers,
2030
client_via_http,
@@ -32,9 +42,25 @@ def _server() -> Server:
3242
"""A minimal low-level server with one tool, so subsequent-request routing can be observed."""
3343

3444
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
35-
return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})])
45+
return ListToolsResult(
46+
tools=[
47+
Tool(name="noop", description="Does nothing.", input_schema={"type": "object"}),
48+
Tool(
49+
name="client-name",
50+
description="Reports the initialized client name.",
51+
input_schema={"type": "object"},
52+
),
53+
]
54+
)
3655

37-
return Server("hosted", on_list_tools=list_tools)
56+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
57+
client_params = ctx.session.client_params
58+
assert client_params is not None
59+
assert params.name == "client-name"
60+
client_name = client_params.client_info.name
61+
return CallToolResult(content=[TextContent(text=client_name)], structured_content={"clientName": client_name})
62+
63+
return Server("hosted", on_list_tools=list_tools, on_call_tool=call_tool)
3864

3965

4066
@requirement("hosting:session:create")
@@ -142,20 +168,43 @@ async def test_terminating_one_session_leaves_others_working() -> None:
142168

143169

144170
@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.
150-
"""
171+
async def test_second_initialize_on_an_existing_session_is_rejected() -> None:
172+
"""A second initialize POST carrying an existing session ID is rejected without changing client params."""
151173
async with mounted_app(_server()) as (http, manager):
152174
session_id = await initialize_via_http(http)
153-
response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id)
175+
call_body = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "client-name"}}
176+
first_call_response, first_call_messages = await post_jsonrpc(http, call_body, session_id=session_id)
177+
178+
reinitialize_body = initialize_body(request_id=3)
179+
reinitialize_params = reinitialize_body["params"]
180+
assert isinstance(reinitialize_params, dict)
181+
reinitialize_client_info = reinitialize_params["clientInfo"]
182+
assert isinstance(reinitialize_client_info, dict)
183+
reinitialize_client_info["name"] = "reinitializer"
184+
185+
response, messages = await post_jsonrpc(http, reinitialize_body, session_id=session_id)
186+
second_call_response, second_call_messages = await post_jsonrpc(
187+
http,
188+
{"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "client-name"}},
189+
session_id=session_id,
190+
)
154191
assert len(manager._server_instances) == 1
155192

193+
assert first_call_response.status_code == 200
194+
assert isinstance(first_call_messages[0], JSONRPCResponse)
195+
first_call_result = CallToolResult.model_validate(first_call_messages[0].result)
196+
assert first_call_result.structured_content == {"clientName": "raw"}
197+
156198
assert response.status_code == snapshot(200)
157-
assert isinstance(messages[0], JSONRPCResponse)
158-
assert messages[0].id == 2
199+
assert isinstance(messages[0], JSONRPCError)
200+
assert messages[0].id == 3
201+
assert messages[0].error.code == INVALID_REQUEST
202+
assert messages[0].error.message == "Server is already initialized"
203+
204+
assert second_call_response.status_code == 200
205+
assert isinstance(second_call_messages[0], JSONRPCResponse)
206+
second_call_result = CallToolResult.model_validate(second_call_messages[0].result)
207+
assert second_call_result.structured_content == {"clientName": "raw"}
159208

160209

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

0 commit comments

Comments
 (0)