fix(core): await the notification send so a failed send is never briefly unhandled - #2885
Conversation
…fly unhandled Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es
🦋 Changeset detectedLatest commit: 607eb84 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the other outbound paths in protocol.ts for the same one-microtask gap: request() (line 1271) and the related-send closures in _onrequest (line 1034) are not async and hand the funnel's promise back directly, so they have no thenable-job hop and are not affected.
Extended reasoning...
The production change is a single return await in Protocol.notification() in packages/core-internal/src/shared/protocol.ts, plus three new tests and a changeset; it touches no auth, crypto, or data-exposure surface. Inline findings already cover the sibling async send* wrappers in server.ts/client.ts that still return without await and a misleading test comment, so the note here only records the request and related-send paths that were examined and ruled out.
2 optional notes from this repository's REVIEW.md or CLAUDE.md checks were not posted as comments, over this review's limit for such notes; they are on this commit's check card.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es
Requested by Felix Weinberger · Slack thread
Problem
Sending a notification on a closed connection (e.g. the transport closes during the legacy
initializehandshake, so_legacyHandshakesendsnotifications/initializedwith no transport) produces a briefly unhandled promise rejection in addition to the rejection the caller receives. Node's tracker is silent about it; workerd reportsunhandledrejectionfollowed byrejectionhandled, which shows up as noise in Vitest runs on Cloudflare Workers.Cause
Protocol.notification()isasyncand didreturn this._notificationViaCodec(...). The funnel throwsNot connectedsynchronously, so it hands back an already-rejected promise. Returning that promise (rather than awaiting it) resolves the outer promise through the thenable job, which only attaches a handler to the inner promise one microtask later. For that one microtask the inner rejection has no handler.Fix
return await this._notificationViaCodec(...)inpackages/core-internal/src/shared/protocol.ts.awaitattaches its reaction synchronously, so the inner promise is handled in the same microtask it rejects in. One-word source change;connect()still rejects with the same error (SdkError/NotConnected), and_legacyHandshakeis untouched.Verification
packages/core-internal/test/shared/notificationSendRejection.test.ts—when not connected, the inner send rejection is handled in the same microtask (no thenable-job hop): substitutes the funnel with a natively rejected promise whosethenproperty counts reads. With a barereturn, the thenable job readsthen(1 read, i.e. the inner promise sat rejected and unhandled for a microtask); withreturn awaitit is never read. Fails onmain(expected 1 to be +0), passes with the fix.packages/server/test/server/notificationSendRejection.test.ts— same observation for the server-side send viaServer.sendLoggingMessage(); fails onmainthe same way, passes with the fix.packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts— end-to-end trigger: a transport that answersinitializethen closes in the same tick. Pins thatconnect()rejects withSdkErrorcodeNotConnected, onlyinitializewent out, and nounhandledRejectionreaches the process (passes on bothmainand this branch, since Node does not report the gap).core-internal/client/serversuites compared against unmodifiedmain: 1457→1459, 895→896, 521→523 passing; no existing test changed outcome.pnpm typecheck:allandpnpm lint:allclean.Fixes #2864
🤖 Generated with Claude Code
https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es
Generated by Claude Code