Skip to content

Comments

Support cancellation#803

Open
yzaparto wants to merge 1 commit intomodelcontextprotocol:mainfrom
yzaparto:feature/support-cancellation
Open

Support cancellation#803
yzaparto wants to merge 1 commit intomodelcontextprotocol:mainfrom
yzaparto:feature/support-cancellation

Conversation

@yzaparto
Copy link

@yzaparto yzaparto commented Feb 20, 2026

Summary

Implements the notifications/cancelled notification from the MCP specification, enabling bidirectional request cancellation between clients and servers. Both sides can cancel in-flight requests they previously issued, with the receiver disposing the in-progress reactive pipeline.

Motivation and Context

The MCP spec defines a cancellation mechanism where either party can send a notifications/cancelled notification to signal that a previously-issued request is no longer needed. Without this, long-running operations like tool calls have no way to be interrupted — the caller must wait for the full timeout or the operation to complete naturally.

This was a missing feature in the Java SDK that is required for full spec compliance.

How Has This Been Tested?

  • Unit tests for McpClientSession cancellation (McpClientSessionCancellationTests) — verifies outbound cancellation errors the local pending response with -32800, sends the notification over the wire, and that inbound cancellation disposes the in-progress request pipeline
  • Unit tests for McpServerSession cancellation (McpServerSessionCancellationTests) — verifies the same behavior on the server side
  • Unit tests for McpRequestHandle (McpRequestHandleTests) — verifies the lazy ID resolution pattern, the cancel closure, and the guard against cancelling before subscription
  • Updated LifecycleInitializerTests and LifecycleInitializerPostInitializationHookTests — updated to work with sendRequestWithId (used for tracking the initialize request ID)
  • Updated McpAsyncServerExchangeTests and McpSyncServerExchangeTests — cover the new cancelRequest method on the exchange
  • Tested against the conformance test server to verify no regressions in the initialization and tool-call flows

Breaking Changes

No breaking changes for existing users. All new APIs are additive:

  • McpAsyncClient.cancelRequest() and callToolWithHandle() are new public methods
  • McpSyncClient.cancelRequest() and callTool(request, timeout) are new public methods
  • McpAsyncServerExchange.cancelRequest() / McpSyncServerExchange.cancelRequest() are new
  • McpServer builder's .cancellationConsumer() is optional
  • McpServerFeatures.Async record has a backwards-compatible constructor without cancellationConsumer
  • McpSession.sendCancellation() is a default method on the interface

The only internal change is that LifecycleInitializer now uses sendRequestWithId instead of sendRequest for the initialize handshake, but this is an internal class not exposed to users.

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

Additional context

Architecture

Cancellation touches three layers, each with a distinct responsibility:

Schema layer (McpSchema) — pure data types:

  • CancelledNotification record with requestId, reason, _meta
  • ErrorCodes.REQUEST_CANCELLED = -32800
  • METHOD_NOTIFICATION_CANCELLED = "notifications/cancelled"

Session layer (McpClientSession / McpServerSession / McpStreamableServerSession) — protocol mechanics:

  • Outbound cancellation via sendCancellation(id, reason): errors the local pendingResponses sink with -32800, then sends notifications/cancelled over the wire
  • Inbound cancellation: tracks each in-progress inbound request's Subscription in an inProgressInbound map using doOnSubscribe/doFinally lifecycle hooks. When a cancellation notification arrives, the Subscription is disposed, cancelling the reactive pipeline mid-flight

Public API layer (McpAsyncClient / McpSyncClient / Exchange classes) — user-facing:

  • cancelRequest(requestId, reason) — cancel by ID with initialize-request protection
  • callToolWithHandle()McpRequestHandle — returns a handle with requestId(), response(), and cancel(reason) methods
  • Server-side exchange.cancelRequest() for the server→client direction
  • Optional cancellationConsumer on the server builder for app-level side-effects (logging, metrics)

Cancellation flow (client → server)

CLIENT                                         SERVER
  │  ── JSONRPCRequest {id:"abc-1"} ──────────►  │
  │                                               │  handler processing...
  │  cancelRequest("abc-1", "timeout")            │
  │                                               │
  │  LOCAL:                                        │
  │    pendingResponses.remove("abc-1")            │
  │    sink.error(McpError -32800)                 │
  │                                               │
  │  ── notifications/cancelled ───────────────►  │
  │     {requestId:"abc-1", reason:"timeout"}     │
  │                                               │
  │                                               │  inProgressInbound.remove("abc-1")
  │                                               │  disposable.dispose()
  │                                               │  → reactive pipeline cancelled

Spec compliance notes

  • Cancellation is a notification (fire-and-forget), not a request
  • Cancellation MUST only reference requests in the same direction — enforced by API design
  • The initialize request MUST NOT be cancelled — enforced in McpAsyncClient.cancelRequest() by checking against the tracked initialize request ID
  • Receivers SHOULD continue processing if already started — our dispose is best-effort
  • DefaultMcpStatelessServerHandler registers a no-op handler to avoid "unknown notification" errors

[Demo]

Screen.Recording.2026-02-19.at.9.01.59.PM.mov

@yzaparto
Copy link
Author

@chemicL @tzolov This PR adds cancellation support for in-progress requests (via notifications/cancelled) and ensures safe termination + cleanup.

Looking forward to your review on the approach and code!

@yzaparto yzaparto mentioned this pull request Feb 20, 2026
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