|
12 | 12 | import httpx |
13 | 13 | import pytest |
14 | 14 | 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 |
16 | 16 |
|
17 | 17 | from mcp.server import Server, ServerRequestContext |
18 | 18 | from tests.interaction._connect import ( |
@@ -142,20 +142,38 @@ async def test_terminating_one_session_leaves_others_working() -> None: |
142 | 142 |
|
143 | 143 |
|
144 | 144 | @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. |
150 | 161 | """ |
151 | 162 | async with mounted_app(_server()) as (http, manager): |
152 | 163 | session_id = await initialize_via_http(http) |
| 164 | + |
153 | 165 | response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id) |
154 | 166 | 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") |
155 | 172 |
|
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" |
159 | 177 |
|
160 | 178 |
|
161 | 179 | @requirement("hosting:stateless:no-session-id") |
|
0 commit comments