|
| 1 | +--- |
| 2 | +layout: default |
| 3 | +title: Cancellation |
| 4 | +nav_order: 5 |
| 5 | +--- |
| 6 | + |
| 7 | +# Cancellation |
| 8 | + |
| 9 | +`MCP::Client` lets the caller cancel a request it has already issued, |
| 10 | +per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). |
| 11 | +The recommended pattern is to pass |
| 12 | +an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call |
| 13 | +`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to |
| 14 | +the server, and the calling thread is woken up with `MCP::CancelledError`: |
| 15 | + |
| 16 | +```ruby |
| 17 | +client = MCP::Client.new(transport: transport) |
| 18 | +cancellation = MCP::Cancellation.new |
| 19 | + |
| 20 | +Thread.new do |
| 21 | + client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) |
| 22 | +rescue MCP::CancelledError |
| 23 | + # cleanup |
| 24 | +end |
| 25 | + |
| 26 | +# Later, from another thread: |
| 27 | +cancellation.cancel(reason: "user pressed cancel") |
| 28 | +``` |
| 29 | + |
| 30 | +All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, |
| 31 | +`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword. |
| 32 | +Request ids are managed internally, so the token is the only thing a caller needs to cancel a request. |
| 33 | + |
| 34 | +{: .note } |
| 35 | +> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed; |
| 36 | +> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side |
| 37 | +> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` |
| 38 | +> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` |
| 39 | +> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal |
| 40 | +> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at |
| 41 | +> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. |
| 42 | +
|
| 43 | +{: .note } |
| 44 | +> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight |
| 45 | +> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests |
| 46 | +> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still |
| 47 | +> raises `MCP::CancelledError` - but the server runs the request to completion. |
| 48 | +
|
| 49 | +## Wire-order guarantees |
| 50 | + |
| 51 | +`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, |
| 52 | +so the server is guaranteed to read the request line before the cancel line. |
| 53 | + |
| 54 | +`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, |
| 55 | +so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST |
| 56 | +on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and |
| 57 | +still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)), |
| 58 | +and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST |
| 59 | +happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. |
| 60 | + |
| 61 | +## Custom transports |
| 62 | + |
| 63 | +Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. |
| 64 | +They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire |
| 65 | +(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). |
| 66 | +The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for |
| 67 | +the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. |
| 68 | + |
| 69 | +## Server Side |
| 70 | + |
| 71 | +How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page. |
0 commit comments