11"""Request timeouts against the low-level Server, driven through the public client API.
22
33The handler blocks on an event that is never set, so the awaited response can never arrive and
4- any positive timeout fires deterministically on the next event-loop pass. The timeout is therefore
5- set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself
4+ any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
5+ set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
6+ (see the comment there). Either way the tests add no wall-clock time to the suite. (Zero itself
67cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
78"""
89
1213import anyio
1314import pytest
1415from inline_snapshot import snapshot
16+ from trio .testing import MockClock
1517
1618from mcp import McpError , types
1719from mcp .server .lowlevel import Server
@@ -82,7 +84,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
8284 assert result == snapshot (CallToolResult (content = [TextContent (type = "text" , text = "still alive" )]))
8385
8486
87+ # A session-level timeout cannot use the effectively-zero pattern above: it also governs the
88+ # initialize handshake, which must complete before the blocked tool call can wait the timeout
89+ # out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
90+ # that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
91+ # this test runs on trio's virtual clock instead. With autojump, time advances only when every
92+ # task is blocked: the handshake always has a runnable task and therefore cannot time out no
93+ # matter how slow the runner, and once the tool call blocks on the never-answered request the
94+ # run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
8595@requirement ("protocol:timeout:session-default" )
96+ @pytest .mark .parametrize (
97+ "anyio_backend" ,
98+ [pytest .param (("trio" , {"clock" : MockClock (autojump_threshold = 0 )}), id = "trio-mockclock" )],
99+ )
86100async def test_session_level_timeout_applies_to_every_request (connect : Connect ) -> None :
87101 """A read timeout configured on the client applies to requests that do not set their own."""
88102 server : Server [Any ] = Server ("blocker" )
@@ -93,12 +107,6 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
93107 await anyio .Event ().wait () # blocks until the session is torn down
94108 raise NotImplementedError # unreachable
95109
96- # The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
97- # per-request timeouts: a session-level timeout also governs the initialize handshake, so the
98- # value must be long enough for the in-process handshake to complete before the blocked tool
99- # call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
100- # latency; lowering it only erodes the margin against CI scheduler jitter without saving
101- # anything perceptible.
102110 async with connect (server , read_timeout_seconds = timedelta (seconds = 0.05 )) as client :
103111 with pytest .raises (McpError ) as exc_info :
104112 await client .call_tool ("block" , {})
0 commit comments