Skip to content

Commit 817b8f4

Browse files
authored
Merge pull request #425 from koic/feature_cancellation_for_client
Support client-side `notifications/cancelled` per MCP specification
2 parents b1a31ee + a4cd7c8 commit 817b8f4

8 files changed

Lines changed: 779 additions & 42 deletions

File tree

README.md

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
4141
- Supports roots (server-to-client filesystem boundary queries)
4242
- Supports sampling (server-to-client LLM completion requests)
4343
- Supports cursor-based pagination for list operations
44-
- Supports server-side cancellation of in-flight requests (notifications/cancelled)
44+
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
4545

4646
### Supported Methods
4747

@@ -1205,12 +1205,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
12051205
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
12061206
is never cancellable per the spec.
12071207

1208-
> [!NOTE]
1209-
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1210-
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1211-
> from the client side can be done by constructing the notification payload and writing it
1212-
> directly through the transport, but the calling thread does not yet unwind automatically.
1213-
> This is tracked as a follow-up.
1208+
Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
12141209

12151210
#### Server-Side: Handlers that Check for Cancellation
12161211

@@ -1319,6 +1314,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
13191314
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
13201315
via `server_context.cancelled?` between nested calls.
13211316

1317+
#### Client-Side: Cancelling an In-Flight Request
1318+
1319+
`MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass
1320+
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
1321+
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
1322+
the server, and the calling thread is woken up with `MCP::CancelledError`:
1323+
1324+
```ruby
1325+
client = MCP::Client.new(transport: transport)
1326+
cancellation = MCP::Cancellation.new
1327+
1328+
Thread.new do
1329+
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
1330+
rescue MCP::CancelledError
1331+
# cleanup
1332+
end
1333+
1334+
# Later, from another thread:
1335+
cancellation.cancel(reason: "user pressed cancel")
1336+
```
1337+
1338+
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
1339+
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
1340+
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
1341+
1342+
> [!NOTE]
1343+
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
1344+
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
1345+
> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
1346+
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
1347+
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
1348+
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
1349+
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
1350+
1351+
##### Wire-order guarantees
1352+
1353+
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
1354+
so the server is guaranteed to read the request line before the cancel line.
1355+
1356+
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
1357+
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
1358+
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
1359+
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
1360+
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
1361+
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
1362+
1363+
##### Custom transports
1364+
1365+
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
1366+
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
1367+
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
1368+
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
1369+
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
1370+
13221371
### Ping
13231372

13241373
The MCP Ruby SDK supports the

0 commit comments

Comments
 (0)