Skip to content

Support notifications/cancelled per MCP specification#332

Open
koic wants to merge 1 commit intomodelcontextprotocol:mainfrom
koic:feature_cancellation
Open

Support notifications/cancelled per MCP specification#332
koic wants to merge 1 commit intomodelcontextprotocol:mainfrom
koic:feature_cancellation

Conversation

@koic
Copy link
Copy Markdown
Member

@koic koic commented Apr 29, 2026

Motivation and Context

The MCP specification defines notifications/cancelled so either party can stop a previously-issued in-flight request. The Ruby SDK declared the method constant but had no receive logic, no in-flight tracking, and no way for handlers to participate in cancellation; cancellation notifications fell through to the unsupported-method path.

This PR implements the server-side half of the spec. The server stops processing the targeted request cooperatively and suppresses its JSON-RPC response, matching the Python SDK's anyio CancelScope and the TypeScript SDK's RequestHandlerExtra.signal. Cancellation is observable by every user-overridable request handler:

  • Tool.call (tools/call)
  • prompt templates (prompts/get)
  • blocks registered via resources_read_handler / completion_handler / resources_subscribe_handler / resources_unsubscribe_handler / define_custom_method

Each handler opts in by declaring a server_context: keyword and polls server_context.cancelled? or calls server_context.raise_if_cancelled! (raising MCP::CancelledError) inside long-running work. Handlers that keep their existing |params| (or |args|) signature continue to work unchanged.

On StreamableHTTPTransport, cancelling a parent tools/call automatically cancels n ested server-to-client requests (sampling/createMessage, elicitation/create); the nested send_request raises MCP::CancelledError and a cancel notification is routed to the peer on the parent's POST response stream. StdioTransport is single-threaded and blocks on $stdin.gets, so it deliberately does not propagate nested cancellation. Tools running on stdio still observe cancellation between calls via server_context.cancelled?.

The design is cooperative-only (no Thread#raise) because preemptive cancellation is unsafe across Rack adapters and arbitrary handler code. The initialize request is never cancellable, satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications are silently ignored per the spec.

MCP::Client#cancel (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation

How Has This Been Tested?

test/mcp/cancellation_test.rb covers the MCP::Cancellation token contract.

test/mcp/server_cancellation_test.rb covers end-to-end cancellation:

  • A handler spins on cancelled? in a background thread; the test sends notifications/cancelled and asserts the handler observed cancellation and the JSON-RPC response was suppressed. Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method) has its own regression test.
  • initialize is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored.
  • The reason propagates into the cancellation_reason instrumentation field.
  • Custom transports implementing only the abstract (method, params = nil) contract keep working; the new kwargs (session_id:, related_request_id:, parent_cancellation:, server_session:) are silently dropped when the transport's signature does not declare them.

StreamableHTTPTransport tests cover nested cancellation, hook deregistration after normal completion (so a late parent cancel does not emit a stray notifications/cancelled), and the first-writer-wins race when a real response and a cancel arrive concurrently.

Breaking Change

None. MCP::ServerContext#initialize gains an optional cancellation: keyword (defaults to nil), and StreamableHTTPTransport#send_request / #send_notification gain optional cancellation kwargs. The abstract Transport base class and StdioTransport keep their existing (method, params = nil) signatures, and custom transports following those signatures continue to work. StreamableHTTPTransport now dispatches client-originated notifications through ServerSession#handle_json before returning 202; previously these were accepted without dispatch, but the existing handlers for notifications/initialized and notifications/progress were already no-ops, so no user-visible effect is expected. Adding server_context: to a handler block is strictly opt-in.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

The MCP specification defines `notifications/cancelled` so either party can stop
a previously-issued in-flight request. The Ruby SDK declared the method constant
but had no receive logic, no in-flight tracking, and no way for handlers to participate
in cancellation; cancellation notifications fell through to the unsupported-method path.

This PR implements the **server-side** half of the spec. The server stops processing
the targeted request cooperatively and suppresses its JSON-RPC response, matching
the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`.
Cancellation is observable by every user-overridable request handler:

- `Tool.call` (tools/call)
- prompt templates (prompts/get)
- blocks registered via `resources_read_handler` / `completion_handler` /
  `resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method`

Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?`
or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work.
Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged.

On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n
ested server-to-client requests (`sampling/createMessage`, `elicitation/create`);
the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to
the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`,
so it deliberately does not propagate nested cancellation.
Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`.

The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe
across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable,
satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications
are silently ignored per the spec.

`MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation

## How Has This Been Tested?

`test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract.

`test/mcp/server_cancellation_test.rb` covers end-to-end cancellation:

- A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled`
  and asserts the handler observed cancellation and the JSON-RPC response was suppressed.
  Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method)
  has its own regression test.
- `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored.
- The `reason` propagates into the `cancellation_reason` instrumentation field.
- Custom transports implementing only the abstract `(method, params = nil)` contract keep working;
  the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`)
  are silently dropped when the transport's signature does not declare them.

`StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion
(so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race
when a real response and a cancel arrive concurrently.

## Breaking Change

None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`),
and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs.
The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures,
and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches
client-originated notifications through `ServerSession#handle_json` before returning 202;
previously these were accepted without dispatch, but the existing handlers for `notifications/initialized`
and `notifications/progress` were already no-ops, so no user-visible effect is expected.
Adding `server_context:` to a handler block is strictly opt-in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant