Skip to content

Conversation

@pcarleton
Copy link
Member

Fixes two related cleanup gaps in Protocol._onclose():

Bug 1: _timeoutInfo not cleared in _onclose()

_onclose() methodically clears _responseHandlers, _progressHandlers, _taskProgressTokens, _pendingDebouncedNotifications, and _requestHandlerAbortControllers, but does not clear _timeoutInfo. Pending setTimeout callbacks from old requests survive close() and can fire cancel() against a new transport after close()+connect(), sending spurious notifications/cancelled for request IDs the new client never sent.

Fix: Iterate _timeoutInfo entries calling clearTimeout() for each, then clear the map in _onclose().

Bug 2: Stale .finally() can delete new connection's abort controller

In _onrequest, the fire-and-forget promise chain's .finally() does this._requestHandlerAbortControllers.delete(request.id). The request.id is captured from the closure, but there is no check that the stored controller matches. After reconnect, if a new client reuses the same request ID (common since IDs start from 0), the old handler's .finally() deletes the new connection's abort controller.

Fix: Capture a reference to the specific AbortController in the .finally() closure and only delete from the map if the stored value matches.

Impact

Both issues are pre-existing on v1.x. Practical impact is low because (1) spurious cancellations use stale request IDs that would be ignored, (2) Protocol reuse across different clients is uncommon. However, the issues become more relevant as close() + connect() becomes a supported reconnect pattern.

Found by bughunter.

Fixes #1459

…roller cleanup

Fixes two related cleanup gaps in Protocol._onclose():

1. _timeoutInfo not cleared: pending setTimeout callbacks from old
   requests survive close() and can fire cancel() against a new
   transport after close()+connect(), sending spurious
   notifications/cancelled for request IDs the new client never sent.
   Fix: iterate _timeoutInfo entries calling clearTimeout(), then clear.

2. Stale .finally() can delete new connection's abort controller: the
   .finally() in _onrequest deletes from _requestHandlerAbortControllers
   by request.id without checking if the stored controller matches.
   After reconnect, if a new client reuses the same request ID, the old
   handler's .finally() deletes the new connection's abort controller.
   Fix: only delete if the stored value matches the captured controller.

Fixes #1459
Tests that _onclose() properly clears _timeoutInfo to prevent spurious
cancellation notifications after reconnect, and that the .finally()
abort controller cleanup is scoped to avoid deleting a new connection's
controller when request IDs overlap.
@changeset-bot
Copy link

changeset-bot bot commented Feb 3, 2026

⚠️ No Changeset found

Latest commit: a20c180

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/sdk@1462

commit: a20c180

@pcarleton pcarleton marked this pull request as ready for review February 3, 2026 13:01
@pcarleton pcarleton requested a review from a team as a code owner February 3, 2026 13:01
Copy link
Contributor

@bhosmer-ant bhosmer-ant left a comment

Choose a reason for hiding this comment

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

LGTM

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.

3 participants