Description
RequestResponder.cancel in mcp/shared/session.py sends an ErrorData(code=0, message="Request cancelled") JSON-RPC response back to the sender after a notifications/cancelled is received. The cancellation spec explicitly says receivers SHOULD NOT do this:
Receivers of cancellation notifications SHOULD:
- Stop processing the cancelled request
- Free associated resources
- Not send a response for the cancelled request
— https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/utilities/cancellation.mdx#L33-L36
Reproduction
mcp 1.27.0, Python 3.11. Driving the server over stdio:
- Send
initialize + notifications/initialized.
- Send any
tools/call whose handler enters an await (e.g. await asyncio.sleep).
- Send
notifications/cancelled with the request id.
- Observe stdout: an unsolicited response is sent for the cancelled request.
{"jsonrpc": "2.0", "id": 99, "error": {"code": 0, "message": "Request cancelled"}}
Source:
https://github.com/modelcontextprotocol/python-sdk/blob/v1.27.0/src/mcp/shared/session.py#L137-L150
async def cancel(self) -> None:
...
self._cancel_scope.cancel()
self._completed = True
# Send an error response to indicate cancellation
await self._session._send_response(
request_id=self.request_id,
response=ErrorData(code=0, message="Request cancelled", data=None),
)
Impact
Strict clients treat the unexpected response as an unknown message id and drop the transport. Claude Code, for example, logs Received a response for an unknown message ID, closes the stdio transport, and reconnects. With long-running tools (custom polling tools, long DB queries, etc.) every cancellation costs a reconnect cycle.
Suggested fix
Drop the _send_response call from RequestResponder.cancel. The cancel scope is already cancelled and _completed is already set, so the request is correctly cleaned up server-side without violating the spec.
Workaround
Until this lands, downstream servers can monkey-patch RequestResponder.cancel:
from mcp.shared.session import RequestResponder
async def _cancel_without_response(self) -> None:
if not self._entered:
raise RuntimeError("RequestResponder must be used as a context manager")
if not self._cancel_scope:
raise RuntimeError("No active cancel scope")
self._cancel_scope.cancel()
self._completed = True
RequestResponder.cancel = _cancel_without_response
Environment
- mcp 1.27.0
- Python 3.11
- stdio transport
Description
RequestResponder.cancelinmcp/shared/session.pysends anErrorData(code=0, message="Request cancelled")JSON-RPC response back to the sender after anotifications/cancelledis received. The cancellation spec explicitly says receivers SHOULD NOT do this:— https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/utilities/cancellation.mdx#L33-L36
Reproduction
mcp1.27.0, Python 3.11. Driving the server over stdio:initialize+notifications/initialized.tools/callwhose handler enters anawait(e.g.await asyncio.sleep).notifications/cancelledwith the request id.{"jsonrpc": "2.0", "id": 99, "error": {"code": 0, "message": "Request cancelled"}}Source:
https://github.com/modelcontextprotocol/python-sdk/blob/v1.27.0/src/mcp/shared/session.py#L137-L150
Impact
Strict clients treat the unexpected response as an unknown message id and drop the transport. Claude Code, for example, logs
Received a response for an unknown message ID, closes the stdio transport, and reconnects. With long-running tools (custom polling tools, long DB queries, etc.) every cancellation costs a reconnect cycle.Suggested fix
Drop the
_send_responsecall fromRequestResponder.cancel. The cancel scope is already cancelled and_completedis already set, so the request is correctly cleaned up server-side without violating the spec.Workaround
Until this lands, downstream servers can monkey-patch
RequestResponder.cancel:Environment