Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
Sending cancellation notification to server based on client anyio.CancelScope status#628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f9598addfb3686abda06724553c6fe49931efd0ffd1364b7a17ae44c06f4b3c07e1a5292f806be46c6932a24e0c87722f8f0782d2a0164f9bf220d545ac52a8b7f1cdac4b822d86b4a56f4ae4411d2e52235df35f96aaa5bd734483817fe22fd27a22e86d321039b99b926970cf1bb2b39f7739598756d4842cee8f2cfca64a2ae0File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| from collections.abc import AsyncGenerator | ||
| from datetime import timedelta | ||
| import anyio | ||
| import pytest | ||
| @@ -8,14 +9,9 @@ | ||
| from mcp.server.lowlevel.server import Server | ||
| from mcp.shared.exceptions import McpError | ||
| from mcp.shared.memory import ( | ||
| create_client_server_memory_streams, | ||
| create_connected_server_and_client_session, | ||
| ) | ||
| from mcp.types import ( | ||
| CancelledNotification, | ||
| CancelledNotificationParams, | ||
| ClientNotification, | ||
| ClientRequest, | ||
| EmptyResult, | ||
| ) | ||
| @@ -49,11 +45,11 @@ async def test_in_flight_requests_cleared_after_completion( | ||
| @pytest.mark.anyio | ||
| async def test_request_cancellation(): | ||
| """Test that requests can be cancelled while in-flight.""" | ||
| # The tool is already registered in the fixture | ||
| ev_tool_called = anyio.Event() | ||
| ev_tool_cancelled = anyio.Event() | ||
| ev_cancelled = anyio.Event() | ||
| request_id = None | ||
| ev_cancel_notified = anyio.Event() | ||
| # Start the request in a separate task so we can cancel it | ||
| def make_server() -> Server: | ||
| @@ -62,14 +58,24 @@ def make_server() -> Server: | ||
| # Register the tool handler | ||
| @server.call_tool() | ||
| async def handle_call_tool(name: str, arguments: dict | None) -> list: | ||
| nonlocal request_id, ev_tool_called | ||
| nonlocal ev_tool_called, ev_tool_cancelled | ||
| if name == "slow_tool": | ||
| request_id = server.request_context.request_id | ||
| ev_tool_called.set() | ||
| await anyio.sleep(10) # Long enough to ensure we can cancel | ||
| return [] | ||
| with anyio.CancelScope(): | ||
| try: | ||
| await anyio.sleep(10) # Long enough to ensure we can cancel | ||
| return [] | ||
| except anyio.get_cancelled_exc_class() as err: | ||
| ev_tool_cancelled.set() | ||
| raise err | ||
| raise ValueError(f"Unknown tool: {name}") | ||
| @server.cancel_notification() | ||
| async def handle_cancel(requestId: str | int, reason: str | None): | ||
| nonlocal ev_cancel_notified | ||
| ev_cancel_notified.set() | ||
| # Register the tool so it shows up in list_tools | ||
| @server.list_tools() | ||
| async def handle_list_tools() -> list[types.Tool]: | ||
| @@ -83,18 +89,10 @@ async def handle_list_tools() -> list[types.Tool]: | ||
| return server | ||
| async def make_request(client_session): | ||
| async def make_request(client_session: ClientSession): | ||
| nonlocal ev_cancelled | ||
| try: | ||
| await client_session.send_request( | ||
| ClientRequest( | ||
| types.CallToolRequest( | ||
| method="tools/call", | ||
| params=types.CallToolRequestParams(name="slow_tool", arguments={}), | ||
| ) | ||
| ), | ||
| types.CallToolResult, | ||
| ) | ||
| await client_session.call_tool("slow_tool") | ||
| pytest.fail("Request should have been cancelled") | ||
| except McpError as e: | ||
| # Expected - request was cancelled | ||
| @@ -109,71 +107,85 @@ async def make_request(client_session): | ||
| with anyio.fail_after(1): # Timeout after 1 second | ||
| await ev_tool_called.wait() | ||
| # Send cancellation notification | ||
| assert request_id is not None | ||
| await client_session.send_notification( | ||
| ClientNotification( | ||
| CancelledNotification( | ||
| method="notifications/cancelled", | ||
| params=CancelledNotificationParams(requestId=request_id), | ||
| ) | ||
| ) | ||
| ) | ||
| # Cancel the task via task group | ||
| tg.cancel_scope.cancel() | ||
| # Give cancellation time to process | ||
| with anyio.fail_after(1): | ||
| await ev_cancelled.wait() | ||
| # Check server cancel notification received | ||
| with anyio.fail_after(1): | ||
| await ev_cancel_notified.wait() | ||
| # Give cancellation time to process on server | ||
| with anyio.fail_after(1): | ||
| await ev_tool_cancelled.wait() | ||
| @pytest.mark.anyio | ||
| async def test_connection_closed(): | ||
| """ | ||
| Test that pending requests are cancelled when the connection is closed remotely. | ||
| """ | ||
| ev_closed = anyio.Event() | ||
| ev_response = anyio.Event() | ||
| async with create_client_server_memory_streams() as ( | ||
| client_streams, | ||
| server_streams, | ||
| ): | ||
| client_read, client_write = client_streams | ||
| server_read, server_write = server_streams | ||
| async def make_request(client_session): | ||
| """Send a request in a separate task""" | ||
| nonlocal ev_response | ||
| try: | ||
| # any request will do | ||
| await client_session.initialize() | ||
| pytest.fail("Request should have errored") | ||
| except McpError as e: | ||
| # Expected - request errored | ||
| assert "Connection closed" in str(e) | ||
| ev_response.set() | ||
| async def mock_server(): | ||
| """Wait for a request, then close the connection""" | ||
| nonlocal ev_closed | ||
| # Wait for a request | ||
| await server_read.receive() | ||
| # Close the connection, as if the server exited | ||
| server_write.close() | ||
| server_read.close() | ||
| ev_closed.set() | ||
| async with ( | ||
| anyio.create_task_group() as tg, | ||
| ClientSession( | ||
| read_stream=client_read, | ||
| write_stream=client_write, | ||
| ) as client_session, | ||
| ): | ||
| async def test_request_cancellation_uncancellable(): | ||
| """Test that asserts a call with cancellable=False is not cancelled on | ||
| server when cancel scope on client is set.""" | ||
| ev_tool_called = anyio.Event() | ||
| ev_tool_commplete = anyio.Event() | ||
| ev_cancelled = anyio.Event() | ||
| # Start the request in a separate task so we can cancel it | ||
| def make_server() -> Server: | ||
| server = Server(name="TestSessionServer") | ||
| # Register the tool handler | ||
| @server.call_tool() | ||
| async def handle_call_tool(name: str, arguments: dict | None) -> list: | ||
| nonlocal ev_tool_called, ev_tool_commplete | ||
| if name == "slow_tool": | ||
| ev_tool_called.set() | ||
| with anyio.CancelScope(): | ||
| with anyio.fail_after(10): # Long enough to ensure we can cancel | ||
| await ev_cancelled.wait() | ||
| ev_tool_commplete.set() | ||
| return [] | ||
| raise ValueError(f"Unknown tool: {name}") | ||
| # Register the tool so it shows up in list_tools | ||
| @server.list_tools() | ||
| async def handle_list_tools() -> list[types.Tool]: | ||
| return [ | ||
| types.Tool( | ||
| name="slow_tool", | ||
| description="A slow tool that takes 10 seconds to complete", | ||
| inputSchema={}, | ||
| ) | ||
| ] | ||
| return server | ||
| async def make_request(client_session: ClientSession): | ||
| nonlocal ev_cancelled | ||
| try: | ||
| await client_session.call_tool( | ||
| "slow_tool", | ||
| cancellable=False, | ||
| read_timeout_seconds=timedelta(seconds=10), | ||
Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A test that validates a short timeout with an uncancelable call would be sensible | ||
| ) | ||
| except McpError: | ||
| pytest.fail("Request should not have been cancelled") | ||
| async with create_connected_server_and_client_session(make_server()) as client_session: | ||
| async with anyio.create_task_group() as tg: | ||
| tg.start_soon(make_request, client_session) | ||
| tg.start_soon(mock_server) | ||
| # Wait for the request to be in-flight | ||
| with anyio.fail_after(1): # Timeout after 1 second | ||
| await ev_tool_called.wait() | ||
| # Cancel the task via task group | ||
| tg.cancel_scope.cancel() | ||
| ev_cancelled.set() | ||
| # Check server completed regardless | ||
| with anyio.fail_after(1): | ||
| await ev_closed.wait() | ||
| with anyio.fail_after(1): | ||
| await ev_response.wait() | ||
| await ev_tool_commplete.wait() | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternative to this might be to separate client cancelation and server cancelation, e.g. client can be cancelled and server cancellation event is only set if a flag such as 'propagate_client_cancelation_to_server' (shorter names available) is set on the request.