|
16 | 16 | from mcp.shared.memory import MessageStream, create_client_server_memory_streams |
17 | 17 | from mcp.shared.message import SessionMessage |
18 | 18 | from mcp.types import ( |
| 19 | + REQUEST_TIMEOUT, |
19 | 20 | CallToolResult, |
20 | 21 | EmptyResult, |
21 | 22 | ErrorData, |
@@ -196,7 +197,8 @@ async def sample() -> None: |
196 | 197 | raise NotImplementedError # unreachable: the scope is cancelled |
197 | 198 |
|
198 | 199 | abandon_scope.start_soon(sample) |
199 | | - await callback_started.wait() |
| 200 | + with anyio.fail_after(5): |
| 201 | + await callback_started.wait() |
200 | 202 | abandon_scope.cancel_scope.cancel() |
201 | 203 | with anyio.fail_after(5): |
202 | 204 | await callback_cancelled.wait() |
@@ -284,3 +286,60 @@ async def message_handler(message: IncomingMessage) -> None: |
284 | 286 | assert pong == snapshot(EmptyResult()) |
285 | 287 | # The fabricated response was dropped silently: the ping after it still |
286 | 288 | # round-tripped, and the message handler (a tripwire) was never invoked. |
| 289 | + |
| 290 | + |
| 291 | +@requirement("protocol:cancel:initialize-not-cancellable") |
| 292 | +async def test_timed_out_initialize_sends_no_cancellation() -> None: |
| 293 | + """An abandoned initialize is not followed by notifications/cancelled on the wire. |
| 294 | +
|
| 295 | + Spec-mandated: the initialize request MUST NOT be cancelled. Abandoning any other request |
| 296 | + sends a courtesy notifications/cancelled (see protocol:timeout:sends-cancellation); this |
| 297 | + test pins that initialize opts out. A real Server always answers initialize, so the test |
| 298 | + plays a stalling server by hand: it never answers initialize, the client's read timeout |
| 299 | + fires, and the ping the test sends next is the marker — the in-memory stream is ordered and |
| 300 | + a courtesy cancel goes out before the timeout error reaches the caller, so a regression |
| 301 | + would put notifications/cancelled ahead of the ping in the recorded sequence. |
| 302 | + """ |
| 303 | + received_methods: list[str] = [] |
| 304 | + |
| 305 | + async def scripted_server(streams: MessageStream) -> None: |
| 306 | + server_read, server_write = streams |
| 307 | + |
| 308 | + # Hold the initialize request unanswered until the client's read timeout fires. |
| 309 | + init = await server_read.receive() |
| 310 | + assert isinstance(init, SessionMessage) |
| 311 | + assert isinstance(init.message, JSONRPCRequest) |
| 312 | + received_methods.append(init.message.method) |
| 313 | + |
| 314 | + follow_up = await server_read.receive() |
| 315 | + assert isinstance(follow_up, SessionMessage) |
| 316 | + assert isinstance(follow_up.message, JSONRPCRequest) |
| 317 | + received_methods.append(follow_up.message.method) |
| 318 | + await server_write.send( |
| 319 | + SessionMessage( |
| 320 | + JSONRPCResponse( |
| 321 | + jsonrpc="2.0", |
| 322 | + id=follow_up.message.id, |
| 323 | + # Serialized exactly as a real server serializes results onto the wire. |
| 324 | + result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True), |
| 325 | + ) |
| 326 | + ) |
| 327 | + ) |
| 328 | + |
| 329 | + async with ( |
| 330 | + create_client_server_memory_streams() as ((client_read, client_write), server_streams), |
| 331 | + anyio.create_task_group() as task_group, |
| 332 | + # The session-level read timeout is the only public pathway that abandons initialize; |
| 333 | + # the response never arrives, so any positive value fires on the next event-loop pass. |
| 334 | + ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session, |
| 335 | + ): |
| 336 | + task_group.start_soon(scripted_server, server_streams) |
| 337 | + with anyio.fail_after(5): |
| 338 | + with pytest.raises(MCPError) as exc_info: |
| 339 | + await session.initialize() |
| 340 | + assert exc_info.value.error.code == REQUEST_TIMEOUT |
| 341 | + # Override the session-level timeout: this ping must round-trip normally. |
| 342 | + pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5) |
| 343 | + |
| 344 | + assert pong == snapshot(EmptyResult()) |
| 345 | + assert received_methods == snapshot(["initialize", "ping"]) |
0 commit comments