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
This PR primarily fixes#572 by enabling graceful shutdown without consuming self. While implementing this, I noticed delete_session() is spawned as a background task, which means close() may return before HTTP session cleanup completes. Since this is part of the same shutdown lifecycle and can cause resource leaks/races, I'm including a small, localized fix to ensure cleanup is completed before close() returns. If maintainers prefer, I can split the cleanup timing change into a follow-up PR.
close_with_timeout(&mut self, Duration) - Same as close() but with a bounded wait time. Useful for apps that need deterministic shutdown.
is_closed(&self) - Check if the service has already been closed or cancelled.
Drop impl - Logs a debug message if dropped without explicit close() call. The DropGuard still handles async cleanup, but this helps developers catch missing cleanup calls.
HTTP Transport (streamable_http_client.rs)
Moved delete_session() from a fire-and-forget tokio::spawn to inline cleanup at the end of the worker's run() method
Added a 5-second timeout on the cleanup HTTP request to prevent close() from hanging indefinitely if the server is unresponsive
This ensures the HTTP DELETE request completes (or times out) beforeclose() returns, preventing orphaned sessions on authenticated MCP servers
Why both changes together?
The original issue mentions that "for authenticated MCP connections, the sessions on the remote would still be active." The service-layer close() method alone doesn't fully solve this because the HTTP transport's session deletion was spawned as a background task. By moving it inline with a timeout, close() now guarantees bounded, deterministic cleanup.
Design Decisions
Q: Can close() hang forever?
No. The HTTP session cleanup has a 5-second timeout. If the server doesn't respond, we log a warning and continue. close_with_timeout() provides an additional layer of control at the service level.
Q: What happens if cancelled mid-cleanup?
The cleanup runs after the main loop exits, so the cancellation token has already fired. The timeout ensures we don't block indefinitely regardless of server behavior.
Manual Verification
# Build
cargo check -p rmcp --all-features
# Run all tests
cargo test -p rmcp --all-features
# Run the new close() tests specifically
cargo test --test test_close_connection --features "client server"# Clippy
cargo clippy -p rmcp --all-features
All tests pass, clippy is clean.
Test Plan
test_close_method - Verifies close() works and can be called twice safely
This PR primarily fixesmodelcontextprotocol#572 by enabling graceful shutdown without consuming self. While implementing this, I noticed delete_session() is spawned as a background task, which means close() may return before HTTP session cleanup completes. Since this is part of the same shutdown lifecycle and can cause resource leaks/races, I'm including a small, localized fix to ensure cleanup is completed before close() returns. If maintainers prefer, I can split the cleanup timing change into a follow-up PR.
Changes:
- Add close(&mut self) for graceful shutdown without consuming
- Add close_with_timeout() for bounded shutdown operations
- Add is_closed() to check connection state
- Move HTTP delete_session from background spawn to inline cleanup
- Add 5-second timeout on session cleanup to prevent indefinite hangs
- Add Drop impl with debug log if dropped without explicit close
Fixesmodelcontextprotocol#572
This seems to be using "cancel" to implement close, should these be two separate things? ie. closing the underlying transport, instead of just always cancelling?
Good point; conceptually, 'close' and 'cancel' represent different intents. close() is meant to be a graceful, deterministic shutdown that ensures the underlying transport is closed and remote session cleanup completes (with timeouts so it can’t hang). cancel() is more of an abort/interrupt.
In this PR, cancel() delegates to close() mainly to make the existing API safe by default and ensure authenticated sessions aren’t leaked (the core issue in #572). I’m happy to split the semantics more explicitly (e.g., graceful vs abort paths) if maintainers prefer keeping those behaviors distinct.
Does this submission seem to address the issue of graceless exit due to the server not closing invalid connections when the client aborts unexpectedly?
It will cause the server can't exited graceful.
The solution given here technically does address the issue in a workable way yes, maybe it should be left to later / a different issue to nail down the details?
…odelcontextprotocol#588)
This PR primarily fixesmodelcontextprotocol#572 by enabling graceful shutdown without consuming self. While implementing this, I noticed delete_session() is spawned as a background task, which means close() may return before HTTP session cleanup completes. Since this is part of the same shutdown lifecycle and can cause resource leaks/races, I'm including a small, localized fix to ensure cleanup is completed before close() returns. If maintainers prefer, I can split the cleanup timing change into a follow-up PR.
Changes:
- Add close(&mut self) for graceful shutdown without consuming
- Add close_with_timeout() for bounded shutdown operations
- Add is_closed() to check connection state
- Move HTTP delete_session from background spawn to inline cleanup
- Add 5-second timeout on session cleanup to prevent indefinite hangs
- Add Drop impl with debug log if dropped without explicit close
Fixesmodelcontextprotocol#572
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.
Summary
This PR primarily fixes#572 by enabling graceful shutdown without consuming self. While implementing this, I noticed
delete_session()is spawned as a background task, which meansclose()may return before HTTP session cleanup completes. Since this is part of the same shutdown lifecycle and can cause resource leaks/races, I'm including a small, localized fix to ensure cleanup is completed beforeclose()returns. If maintainers prefer, I can split the cleanup timing change into a follow-up PR.Changes
Service Layer (
service.rs)close(&mut self)- Gracefully shuts down the connection and waits for cleanup to complete without consuming theRunningService. This was the main ask in Client connection is not closed on drop, and no way to gracefully stop #572.close_with_timeout(&mut self, Duration)- Same asclose()but with a bounded wait time. Useful for apps that need deterministic shutdown.is_closed(&self)- Check if the service has already been closed or cancelled.Dropimpl - Logs a debug message if dropped without explicitclose()call. TheDropGuardstill handles async cleanup, but this helps developers catch missing cleanup calls.HTTP Transport (
streamable_http_client.rs)delete_session()from a fire-and-forgettokio::spawnto inline cleanup at the end of the worker'srun()methodclose()from hanging indefinitely if the server is unresponsiveclose()returns, preventing orphaned sessions on authenticated MCP serversWhy both changes together?
The original issue mentions that "for authenticated MCP connections, the sessions on the remote would still be active." The service-layer
close()method alone doesn't fully solve this because the HTTP transport's session deletion was spawned as a background task. By moving it inline with a timeout,close()now guarantees bounded, deterministic cleanup.Design Decisions
Q: Can
close()hang forever?No. The HTTP session cleanup has a 5-second timeout. If the server doesn't respond, we log a warning and continue.
close_with_timeout()provides an additional layer of control at the service level.Q: What happens if cancelled mid-cleanup?
The cleanup runs after the main loop exits, so the cancellation token has already fired. The timeout ensures we don't block indefinitely regardless of server behavior.
Manual Verification
All tests pass, clippy is clean.
Test Plan
test_close_method- Verifiesclose()works and can be called twice safelytest_close_with_timeout- Verifies timeout-bounded shutdowntest_cancel_method- Confirms existingcancel()API still works (backward compat)test_drop_without_close- Verifies drop behavior doesn't panic and async cleanup still happensBackward Compatibility
cancel()method still works (now delegates toclose()internally)waiting()method still worksclose()still triggers cleanup viaDropGuard- just with a debug log nowFixes#572