Skip to content

fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967

Merged
DaleSeo merged 1 commit into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel
Jul 17, 2026
Merged

fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967
DaleSeo merged 1 commit into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel

Conversation

@ameyypawar

@ameyypawar ameyypawar commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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::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.

@ameyypawar
ameyypawar requested a review from a team as a code owner July 9, 2026 19:11
@github-actions github-actions Bot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Jul 9, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, @ameyypawar! I left a couple of comments.

});
Ok(futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner)))
let stream = futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner));
Ok(CancelOnDisconnect::new(stream, handle.clone(), request_id))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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.

// blocking. If the channel is momentarily full the request is not
// cancelled early, but the session keep-alive/idle timeout still reaps
// it — no worse than before this fix.
let _ = self.handle.event_tx.try_send(SessionEvent::ClientMessage {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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.

@ameyypawar
ameyypawar force-pushed the fix/857-streamable-http-disconnect-cancel branch from 10c947e to aaec423 Compare July 13, 2026 03:26
@alexhancock

Copy link
Copy Markdown
Contributor

@DaleSeo Can you take another look after the revisions? And @ameyypawar It looks like there is a test failure to look into.

@ameyypawar
ameyypawar force-pushed the fix/857-streamable-http-disconnect-cancel branch from aaec423 to 74c65c4 Compare July 17, 2026 08:57
… 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).
@ameyypawar
ameyypawar force-pushed the fix/857-streamable-http-disconnect-cancel branch from 74c65c4 to 9092067 Compare July 17, 2026 09:12
@DaleSeo
DaleSeo merged commit bc2c5f3 into modelcontextprotocol:main Jul 17, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

streamable-http-server: client TCP disconnect does not cancel in-flight tool futures (RequestContext::ct never fires)

3 participants