You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the streamable-HTTP server transport, when a client disconnects while a tool handler is still running, the server-side future is not cancelled — RequestContext::ct never fires. Long-running or destructive tools keep running after the client is gone (Ctrl-C, read timeout, network drop). Only an explicit notifications/cancelled fires the token today.
Scope: stateless mode
This fixes the stateless path (the issue's reproduction). A stateless streamable-HTTP request is one-shot — no session, no resumption — so a dropped response is terminal and safe to cancel.
Stateful (resumable) mode is intentionally left unchanged. There an in-flight SSE stream can be resumed via Last-Event-ID (the transport keeps the request channel alive and advertises retry), so treating a disconnect as a cancellation would break resumption — disconnection is not cancellation for a resumable transport. Cancelling in-flight requests on the resumable path needs a reconnect grace-period design and is out of scope here.
Fix
Give each stateless request its own CancellationToken via serve_directly_with_ct, and cancel it when the client disconnects:
SSE mode: a CancelOnDisconnect guard on the response stream fires the token if the stream is dropped before it ends naturally. It disarms on natural completion, so a normal response is never cancelled.
JSON mode: a drop-guard fires the token if the request future is dropped before the handler produces its first message. It disarms once the handler emits anything, so a normal response is never cancelled.
Cancelling the token ends the dedicated serve_directly loop, which cancels the handler's RequestContext::ct.
Notes:
Detection latency is bounded by when hyper observes the client is gone — prompt on a clean close, and at the next SSE keep-alive for a silent drop.
Server shutdown now also cancels in-flight stateless SSE handlers rather than leaking them.
Tests
Adds tests/test_streamable_http_disconnect_cancel.rs covering both stateless sub-modes (SSE and JSON). Each test fails without the fix (the handler runs to its timeout) and passes with it. The full non-local test suite passes, and clippy --all-features -D warnings and cargo fmt are clean.
The reason will be displayed to describe this comment to others. Learn more.
Could we also handle client disconnects after a request-wise SSE stream has been resumed? resume() currently returns a plain ReceiverStream without the disconnect guard.
The reason will be displayed to describe this comment to others. Learn more.
Could we make sure the disconnect cancellation isn’t silently dropped when the session event channel is full? Since the result of try_send is ignored, a busy session could still leave the handler running until cleanup.
… client disconnect (modelcontextprotocol#857)
A stateless streamable-HTTP request is one-shot (no session, no resumption),
so if the client drops the response before the handler finishes, the request
is terminal and should be cancelled. Previously the handler kept running with
its `RequestContext::ct` never firing, so long-running or destructive tools
could not observe a client disconnect.
Give each stateless request its own cancellation token via
`serve_directly_with_ct` and cancel it when the client disconnects. This covers
both stateless paths: `serve_negotiated_request_directly` (per-request version
negotiation) and the non-negotiated path. In each:
- A disconnect while the handler is still producing its first message cancels
it. The guard is disarmed once the handler emits anything, so a normal
response is never cancelled.
- The SSE response stream is wrapped in a guard that cancels the handler if the
stream is dropped before it ends naturally.
- When a negotiated request replies directly (JSON mode, or a non-OK status),
the receiver is dropped, so a still-running handler is cancelled rather than
left emitting into a closed channel. Without this its terminal send fails
before adding the termination permit and the serve loop parks forever.
Stateful (resumable) mode is intentionally left unchanged: there a disconnect
may be recovered via `Last-Event-ID`, so cancelling on disconnect would break
resumption.
Adds a regression test covering both stateless sub-modes (SSE and JSON).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#857.
Problem
On the streamable-HTTP server transport, when a client disconnects while a tool handler is still running, the server-side future is not cancelled —
RequestContext::ctnever fires. Long-running or destructive tools keep running after the client is gone (Ctrl-C, read timeout, network drop). Only an explicitnotifications/cancelledfires the token today.Scope: stateless mode
This fixes the stateless path (the issue's reproduction). A stateless streamable-HTTP request is one-shot — no session, no resumption — so a dropped response is terminal and safe to cancel.
Stateful (resumable) mode is intentionally left unchanged. There an in-flight SSE stream can be resumed via
Last-Event-ID(the transport keeps the request channel alive and advertisesretry), so treating a disconnect as a cancellation would break resumption — disconnection is not cancellation for a resumable transport. Cancelling in-flight requests on the resumable path needs a reconnect grace-period design and is out of scope here.Fix
Give each stateless request its own
CancellationTokenviaserve_directly_with_ct, and cancel it when the client disconnects:CancelOnDisconnectguard on the response stream fires the token if the stream is dropped before it ends naturally. It disarms on natural completion, so a normal response is never cancelled.Cancelling the token ends the dedicated
serve_directlyloop, which cancels the handler'sRequestContext::ct.Notes:
Tests
Adds
tests/test_streamable_http_disconnect_cancel.rscovering both stateless sub-modes (SSE and JSON). Each test fails without the fix (the handler runs to its timeout) and passes with it. The full non-local test suite passes, andclippy --all-features -D warningsandcargo fmtare clean.