Skip to content

Commit a9cf6eb

Browse files
author
冯基魁
committed
fix: make request responder completion idempotent
1 parent 32d3290 commit a9cf6eb

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

src/mcp/shared/session.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@ async def respond(self, response: SendResultT | ErrorData) -> None:
122122
Must be called within a context manager block.
123123
Raises:
124124
RuntimeError: If not used within a context manager
125-
AssertionError: If request was already responded to
126125
"""
127126
if not self._entered: # pragma: no cover
128127
raise RuntimeError("RequestResponder must be used as a context manager")
129-
assert not self._completed, "Request already responded to"
128+
if self._completed:
129+
return
130130

131131
if not self.cancelled: # pragma: no branch
132132
self._completed = True
@@ -143,6 +143,9 @@ async def cancel(self) -> None:
143143
raise RuntimeError("No active cancel scope")
144144

145145
self._cancel_scope.cancel()
146+
if self._completed:
147+
return
148+
146149
self._completed = True # Mark as completed so it's removed from in_flight
147150
# Send an error response to indicate cancellation
148151
await self._session._send_response( # type: ignore[reportPrivateUsage]

tests/shared/test_session.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import AsyncGenerator
22
from typing import Any
3+
from unittest.mock import AsyncMock, MagicMock
34

45
import anyio
56
import pytest
@@ -10,11 +11,13 @@
1011
from mcp.shared.exceptions import McpError
1112
from mcp.shared.memory import create_client_server_memory_streams, create_connected_server_and_client_session
1213
from mcp.shared.message import SessionMessage
14+
from mcp.shared.session import RequestResponder
1315
from mcp.types import (
1416
CancelledNotification,
1517
CancelledNotificationParams,
1618
ClientNotification,
1719
ClientRequest,
20+
ClientResult,
1821
EmptyResult,
1922
ErrorData,
2023
JSONRPCError,
@@ -30,6 +33,20 @@ def mcp_server() -> Server:
3033
return Server(name="test server")
3134

3235

36+
def make_request_responder() -> tuple[RequestResponder[ClientRequest, ClientResult], MagicMock]:
37+
mock_session = MagicMock()
38+
mock_session._send_response = AsyncMock()
39+
request = ClientRequest(types.PingRequest())
40+
responder: RequestResponder[ClientRequest, ClientResult] = RequestResponder(
41+
request_id=1,
42+
request_meta=None,
43+
request=request,
44+
session=mock_session,
45+
on_complete=lambda responder: None,
46+
)
47+
return responder, mock_session
48+
49+
3350
@pytest.fixture
3451
async def client_connected_to_server(
3552
mcp_server: Server,
@@ -128,6 +145,33 @@ async def make_request(client_session: ClientSession):
128145
await ev_cancelled.wait()
129146

130147

148+
@pytest.mark.anyio
149+
async def test_request_responder_respond_after_cancel_does_not_raise():
150+
responder, mock_session = make_request_responder()
151+
152+
with responder:
153+
await responder.cancel()
154+
await responder.respond(ClientResult(root=EmptyResult()))
155+
156+
mock_session._send_response.assert_awaited_once()
157+
assert mock_session._send_response.await_args.kwargs["response"] == ErrorData(
158+
code=0, message="Request cancelled", data=None
159+
)
160+
161+
162+
@pytest.mark.anyio
163+
async def test_request_responder_cancel_after_respond_does_not_send_error():
164+
responder, mock_session = make_request_responder()
165+
response = ClientResult(root=EmptyResult())
166+
167+
with responder:
168+
await responder.respond(response)
169+
await responder.cancel()
170+
171+
mock_session._send_response.assert_awaited_once()
172+
assert mock_session._send_response.await_args.kwargs["response"] == response
173+
174+
131175
@pytest.mark.anyio
132176
async def test_response_id_type_mismatch_string_to_int():
133177
"""

0 commit comments

Comments
 (0)