Skip to content

RequestResponder.cancel sends JSON-RPC response, violating cancellation spec #2480

@q-thomasdickson

Description

@q-thomasdickson

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:

  1. Send initialize + notifications/initialized.
  2. Send any tools/call whose handler enters an await (e.g. await asyncio.sleep).
  3. Send notifications/cancelled with the request id.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions