Skip to content

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

Open
ameyypawar wants to merge 3 commits into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel
Open

fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967
ameyypawar wants to merge 3 commits into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel

Conversation

@ameyypawar

Copy link
Copy Markdown
Contributor

Fixes #857.

Motivation and Context

When a client connected over transport-streamable-http-server closes its TCP connection while a tool handler is still running, the server-side future was not cancelled — it ran to completion, and the per-request RequestContext::ct never fired. Explicit notifications/cancelled already worked; only the raw TCP-disconnect case was affected.

Root cause: the per-request cancellation token is only fired on a natural response (Event::ToSink) or an explicit notifications/cancelled. The request-wise SSE response stream is what observes the disconnect (the runtime drops it), but nothing wired that drop back to the token.

What this does

Wraps the request-wise SSE response stream returned by LocalSessionManager::create_stream in a small private drop-guard (CancelOnDisconnect):

  • It marks itself completed when the inner stream ends normally (the worker closes the request-wise sender once the response is delivered, or the stream is handed off for reconnection).
  • On drop-before-completion (client disconnect), it injects a synthetic notifications/cancelled for the request through the session's existing event channel. The service's existing cancellation path then fires the handler's RequestContext::ct — exactly as an explicit client cancellation would.

This reuses the existing cancellation machinery and adds no public API (the guard is private, and create_stream still returns impl Stream<Item = ServerSseMessage>).

How Has This Been Tested?

Added crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs (2 tests):

  • client_disconnect_cancels_in_flight_request: starts a long-running tool over streamable HTTP, drops the client connection mid-flight, and asserts the handler's cancellation token fires (it does, promptly).
  • normal_tool_response_is_delivered: a normal, fully-read tool call still returns unchanged (the guard does not spuriously cancel or corrupt the response).

Both pass locally. Also verified cargo +nightly fmt --all -- --check and cargo clippy are clean on the changed code.

Breaking Changes

None. Additive; no wire or public-API change.

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

Note

#857 has a comment from @loocor (2026-06-05, "I'd like to take this one"), but no PR followed in over a month, so I went ahead. Happy to defer or collaborate if they are still working on it.

…sconnect (modelcontextprotocol#857)

When a streamable-HTTP client closes its TCP connection while a tool
handler is still running, the request-wise SSE response stream is
dropped, but nothing fired the per-request cancellation token. Only a
natural response completion or an explicit notifications/cancelled
cancelled it, so a long-running handler ran to completion after the
client had already disconnected.

Wrap the request-wise response stream in a drop-guard. When the stream
is dropped before completing (client disconnect), it injects a synthetic
notifications/cancelled for the request, reusing the existing
cancellation path so the service fires the handler's RequestContext::ct.
Streams that end normally (response delivered, or a reconnection handoff)
do not cancel.

Adds a regression test covering both the disconnect-cancels and the
normal-completion paths. No public API change.
…elcontextprotocol#857)

Add the `[[test]]` entry so the new integration test is only built when
its required features (server, transport-streamable-http-server, reqwest)
are enabled, matching the other streamable-http test targets.
… ::new (modelcontextprotocol#857)

- Wrap the synthetic-cancellation construction to match `cargo +nightly
  fmt`.
- `ServerInfo` is `#[non_exhaustive]`; construct it in the test with
  `ServerInfo::new(..)` instead of a struct literal.

Verified locally: `cargo +nightly fmt --all -- --check` clean and the
regression test passes (disconnect cancels the in-flight request).
@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
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)

1 participant