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
asyncdefcancel(self) ->None:
...
self._cancel_scope.cancel()
self._completed=True# Send an error response to indicate cancellationawaitself._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:
frommcp.shared.sessionimportRequestResponderasyncdef_cancel_without_response(self) ->None:
ifnotself._entered:
raiseRuntimeError("RequestResponder must be used as a context manager")
ifnotself._cancel_scope:
raiseRuntimeError("No active cancel scope")
self._cancel_scope.cancel()
self._completed=TrueRequestResponder.cancel=_cancel_without_responseEnvironment
- 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