server: skip duplicate response on CancelledError (#1153)
Co-authored-by: ihrpr <inna@anthropic.com>
This commit is contained in:
parent
35777b9811
commit
b34e7207ad
2 changed files with 117 additions and 0 deletions
|
|
@ -647,6 +647,12 @@ class Server(Generic[LifespanResultT, RequestT]):
|
||||||
response = await handler(req)
|
response = await handler(req)
|
||||||
except McpError as err:
|
except McpError as err:
|
||||||
response = err.error
|
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:
|
except Exception as err:
|
||||||
if raise_exceptions:
|
if raise_exceptions:
|
||||||
raise err
|
raise err
|
||||||
|
|
|
||||||
111
tests/server/test_cancel_handling.py
Normal file
111
tests/server/test_cancel_handling.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue