test: interaction-model end-to-end suite with a requirements manifest (#2691)
This commit is contained in:
parent
3eb579948a
commit
24725633f1
1 changed files with 14355 additions and 121 deletions
114
tests/interaction/lowlevel/test_timeouts.py
Normal file
114
tests/interaction/lowlevel/test_timeouts.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Request timeouts against the low-level Server, driven through the public Client API.
|
||||
|
||||
The handler blocks on an event that is never set, so the awaited response can never arrive and
|
||||
any positive timeout fires deterministically on the next event-loop pass. The timeout is therefore
|
||||
set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself
|
||||
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
|
||||
"""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, TextContent
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("protocol:timeout:basic")
|
||||
@requirement("protocol:timeout:sends-cancellation")
|
||||
async def test_request_timeout_fails_the_pending_call() -> None:
|
||||
"""A request whose response does not arrive within its read timeout fails with a timeout error.
|
||||
|
||||
No cancellation is sent to the server (see the divergence note on the requirement): the handler
|
||||
starts and is still running after the caller has already given up. The test waits for the
|
||||
handler to have started only after the timeout has fired, so the timeout itself races nothing.
|
||||
"""
|
||||
handler_started = anyio.Event()
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "block"
|
||||
handler_started.set()
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
|
||||
|
||||
# The request was already on the wire: the handler still runs even though the caller gave up.
|
||||
with anyio.fail_after(5):
|
||||
await handler_started.wait()
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=REQUEST_TIMEOUT,
|
||||
message="Timed out while waiting for response to CallToolRequest. Waited 1e-06 seconds.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("protocol:timeout:session-survives")
|
||||
async def test_session_serves_requests_after_timeout() -> None:
|
||||
"""A timed-out request does not poison the session: the next request succeeds."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(name="block", input_schema={"type": "object"}),
|
||||
types.Tool(name="echo", input_schema={"type": "object"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
if params.name == "echo":
|
||||
return CallToolResult(content=[TextContent(text="still alive")])
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
|
||||
|
||||
result = await client.call_tool("echo", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
|
||||
|
||||
|
||||
@requirement("protocol:timeout:session-default")
|
||||
async def test_session_level_timeout_applies_to_every_request() -> None:
|
||||
"""A read timeout configured on the client applies to requests that do not set their own."""
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "block"
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
# The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
|
||||
# per-request timeouts: a session-level timeout also governs the initialize handshake, so the
|
||||
# value must be long enough for the in-process handshake to complete before the blocked tool
|
||||
# call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
|
||||
# latency; lowering it only erodes the margin against CI scheduler jitter without saving
|
||||
# anything perceptible.
|
||||
async with Client(server, read_timeout_seconds=0.05) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("block", {})
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=REQUEST_TIMEOUT,
|
||||
message="Timed out while waiting for response to CallToolRequest. Waited 0.05 seconds.",
|
||||
)
|
||||
)
|
||||
Loading…
Reference in a new issue