chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Test to reproduce issue #88: Random error thrown on response."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from anyio.abc import TaskStatus
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp_types import (
|
||||
REQUEST_TIMEOUT,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.message import SessionMessage
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_validation_error(tmp_path: Path):
|
||||
"""Test that timeouts are handled gracefully and don't break the server.
|
||||
|
||||
This test verifies that when a client request times out:
|
||||
1. The server task stays alive
|
||||
2. The server can still handle new requests
|
||||
3. The client can make new requests
|
||||
4. No resources are leaked
|
||||
|
||||
Uses per-request timeouts to avoid race conditions:
|
||||
- Fast operations use no timeout (reliable in any environment)
|
||||
- Slow operations use minimal timeout (10ms) for quick test execution
|
||||
"""
|
||||
|
||||
request_count = 0
|
||||
slow_request_lock = anyio.Event()
|
||||
|
||||
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="slow",
|
||||
description="A slow tool",
|
||||
input_schema={"type": "object"},
|
||||
),
|
||||
types.Tool(
|
||||
name="fast",
|
||||
description="A fast tool",
|
||||
input_schema={"type": "object"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
assert params.name in ("slow", "fast"), f"Unknown tool: {params.name}"
|
||||
|
||||
if params.name == "slow":
|
||||
# The client's timeout fires during this wait; the courtesy cancellation then interrupts it.
|
||||
await slow_request_lock.wait()
|
||||
text = f"slow {request_count}"
|
||||
else:
|
||||
text = f"fast {request_count}"
|
||||
return CallToolResult(content=[TextContent(type="text", text=text)])
|
||||
|
||||
server = Server(name="test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
|
||||
|
||||
async def server_handler(
|
||||
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
write_stream: MemoryObjectSendStream[SessionMessage],
|
||||
task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
|
||||
):
|
||||
with anyio.CancelScope() as scope:
|
||||
task_status.started(scope) # type: ignore
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
raise_exceptions=True,
|
||||
)
|
||||
|
||||
async def client(
|
||||
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
write_stream: MemoryObjectSendStream[SessionMessage],
|
||||
scope: anyio.CancelScope,
|
||||
):
|
||||
# No session-level timeout to avoid race conditions with fast operations
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# First call should work (fast operation, no timeout)
|
||||
result = await session.call_tool("fast", read_timeout_seconds=None)
|
||||
assert result.content == [TextContent(type="text", text="fast 1")]
|
||||
assert not slow_request_lock.is_set()
|
||||
|
||||
# Second call should timeout (slow operation with minimal timeout)
|
||||
# Use very small timeout to trigger quickly without waiting
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails
|
||||
assert exc_info.value.error.code == REQUEST_TIMEOUT
|
||||
|
||||
# No-op if the courtesy cancellation already interrupted the handler.
|
||||
slow_request_lock.set()
|
||||
|
||||
# Third call should work (fast operation, no timeout),
|
||||
# proving server is still responsive
|
||||
result = await session.call_tool("fast", read_timeout_seconds=None)
|
||||
assert result.content == [TextContent(type="text", text="fast 3")]
|
||||
scope.cancel() # pragma: lax no cover
|
||||
|
||||
# Run server and client in separate task groups to avoid cancellation
|
||||
server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage](1)
|
||||
client_writer, client_reader = anyio.create_memory_object_stream[SessionMessage](1)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
scope = await tg.start(server_handler, server_reader, client_writer)
|
||||
# Run client in a separate task to avoid cancellation
|
||||
tg.start_soon(client, client_reader, server_writer, scope)
|
||||
Reference in New Issue
Block a user