diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 562de31b7..0493feeff 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -647,6 +647,12 @@ async def _handle_request( response = await handler(req) except McpError as err: response = err.error + except anyio.get_cancelled_exc_class(): + logger.info( + "Request %s cancelled - duplicate response suppressed", + message.request_id, + ) + return except Exception as err: if raise_exceptions: raise err diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py new file mode 100644 index 000000000..44b9a924d --- /dev/null +++ b/tests/server/test_cancel_handling.py @@ -0,0 +1,111 @@ +"""Test that cancelled requests don't cause double responses.""" + +import anyio +import pytest + +import mcp.types as types +from mcp.server.lowlevel.server import Server +from mcp.shared.exceptions import McpError +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelledNotification, + CancelledNotificationParams, + ClientNotification, + ClientRequest, + Tool, +) + + +@pytest.mark.anyio +async def test_server_remains_functional_after_cancel(): + """Verify server can handle new requests after a cancellation.""" + + server = Server("test-server") + + # Track tool calls + call_count = 0 + ev_first_call = anyio.Event() + first_request_id = None + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="test_tool", + description="Tool for testing", + inputSchema={}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict | None) -> list: + nonlocal call_count, first_request_id + if name == "test_tool": + call_count += 1 + if call_count == 1: + first_request_id = server.request_context.request_id + ev_first_call.set() + await anyio.sleep(5) # First call is slow + return [types.TextContent(type="text", text=f"Call number: {call_count}")] + raise ValueError(f"Unknown tool: {name}") + + async with create_connected_server_and_client_session(server) as client: + # First request (will be cancelled) + async def first_request(): + try: + await client.send_request( + ClientRequest( + CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + CallToolResult, + ) + pytest.fail("First request should have been cancelled") + except McpError: + pass # Expected + + # Start first request + async with anyio.create_task_group() as tg: + tg.start_soon(first_request) + + # Wait for it to start + await ev_first_call.wait() + + # Cancel it + assert first_request_id is not None + await client.send_notification( + ClientNotification( + CancelledNotification( + method="notifications/cancelled", + params=CancelledNotificationParams( + requestId=first_request_id, + reason="Testing server recovery", + ), + ) + ) + ) + + # Second request (should work normally) + result = await client.send_request( + ClientRequest( + CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + CallToolResult, + ) + + # Verify second request completed successfully + assert len(result.content) == 1 + # Type narrowing for pyright + content = result.content[0] + assert content.type == "text" + assert isinstance(content, types.TextContent) + assert content.text == "Call number: 2" + assert call_count == 2