Skip to content

Commit a79648a

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

4 files changed

Lines changed: 72 additions & 20 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/_connect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,12 +274,12 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
274274
return headers
275275

276276

277-
def initialize_body(request_id: int = 1) -> dict[str, object]:
277+
def initialize_body(request_id: int = 1, *, client_name: str = "raw") -> dict[str, object]:
278278
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
279279
params = InitializeRequestParams(
280280
protocol_version=LATEST_PROTOCOL_VERSION,
281281
capabilities=ClientCapabilities(),
282-
client_info=Implementation(name="raw", version="0.0.0"),
282+
client_info=Implementation(name=client_name, version="0.0.0"),
283283
)
284284
return JSONRPCRequest(
285285
jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)

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: 67 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+
)
55+
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})
3662

37-
return Server("hosted", on_list_tools=list_tools)
63+
return Server("hosted", on_list_tools=list_tools, on_call_tool=call_tool)
3864

3965

4066
@requirement("hosting:session:create")
@@ -142,20 +168,49 @@ 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: dict[str, object] = {
176+
"jsonrpc": "2.0",
177+
"id": 2,
178+
"method": "tools/call",
179+
"params": {"name": "client-name"},
180+
}
181+
first_call_response, first_call_messages = await post_jsonrpc(http, call_body, session_id=session_id)
182+
183+
response, messages = await post_jsonrpc(
184+
http, initialize_body(request_id=3, client_name="reinitializer"), session_id=session_id
185+
)
186+
second_call_body: dict[str, object] = {
187+
"jsonrpc": "2.0",
188+
"id": 4,
189+
"method": "tools/call",
190+
"params": {"name": "client-name"},
191+
}
192+
second_call_response, second_call_messages = await post_jsonrpc(
193+
http,
194+
second_call_body,
195+
session_id=session_id,
196+
)
154197
assert len(manager._server_instances) == 1
155198

199+
assert first_call_response.status_code == 200
200+
assert isinstance(first_call_messages[0], JSONRPCResponse)
201+
first_call_result = CallToolResult.model_validate(first_call_messages[0].result)
202+
assert first_call_result.structured_content == {"clientName": "raw"}
203+
156204
assert response.status_code == snapshot(200)
157-
assert isinstance(messages[0], JSONRPCResponse)
158-
assert messages[0].id == 2
205+
assert isinstance(messages[0], JSONRPCError)
206+
assert messages[0].id == 3
207+
assert messages[0].error.code == INVALID_REQUEST
208+
assert messages[0].error.message == "Server is already initialized"
209+
210+
assert second_call_response.status_code == 200
211+
assert isinstance(second_call_messages[0], JSONRPCResponse)
212+
second_call_result = CallToolResult.model_validate(second_call_messages[0].result)
213+
assert second_call_result.structured_content == {"clientName": "raw"}
159214

160215

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

0 commit comments

Comments
 (0)