From cd3ed36a719e8bf41a24390b9e9f229ef60f281c Mon Sep 17 00:00:00 2001 From: Koen Van Geert Date: Tue, 8 Sep 2026 21:18:13 +0200 Subject: [PATCH] fix(client): handle cleanup rejection after initialization failure --- .../fix-client-connect-cleanup-rejection.md | 5 +++ src/client/index.ts | 4 +- test/client/connect-cleanup.test.ts | 24 ++++++++++ test/client/fixtures/connect-cleanup.ts | 44 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-client-connect-cleanup-rejection.md create mode 100644 test/client/connect-cleanup.test.ts create mode 100644 test/client/fixtures/connect-cleanup.ts diff --git a/.changeset/fix-client-connect-cleanup-rejection.md b/.changeset/fix-client-connect-cleanup-rejection.md new file mode 100644 index 0000000000..0d11dcb192 --- /dev/null +++ b/.changeset/fix-client-connect-cleanup-rejection.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Handle cleanup rejections when client initialization fails, preserving the original initialization error without an unhandled rejection or waiting for cleanup to finish. diff --git a/src/client/index.ts b/src/client/index.ts index ae0e698ec3..5fddddef6d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -509,8 +509,8 @@ export class Client< this._pendingListChangedConfig = undefined; } } catch (error) { - // Disconnect if initialization fails. - void this.close(); + // Disconnect without delaying or replacing the initialization error if cleanup fails. + void this.close().catch(() => undefined); throw error; } } diff --git a/test/client/connect-cleanup.test.ts b/test/client/connect-cleanup.test.ts new file mode 100644 index 0000000000..a0ae4473f0 --- /dev/null +++ b/test/client/connect-cleanup.test.ts @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +for (const cleanup of ['resolve', 'reject', 'deferred-reject', 'pending']) { + test(`given ${cleanup} cleanup, when initialization fails, then connect preserves the error without an unhandled rejection`, () => { + const child = spawnSync( + process.execPath, + [ + '--unhandled-rejections=strict', + '--import', + 'tsx', + fileURLToPath(new URL('./fixtures/connect-cleanup.ts', import.meta.url)), + cleanup + ], + { encoding: 'utf8', timeout: 10_000 } + ); + + expect(child.error).toBeUndefined(); + expect(child.signal).toBeNull(); + expect(child.status, child.stdout + child.stderr).toBe(0); + expect(child.stderr).toBe(''); + expect(child.stdout).toBe('Original initialization error preserved; cleanup rejection handled.\n'); + }); +} diff --git a/test/client/fixtures/connect-cleanup.ts b/test/client/fixtures/connect-cleanup.ts new file mode 100644 index 0000000000..4d28a286fa --- /dev/null +++ b/test/client/fixtures/connect-cleanup.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import { Client } from '../../../src/client/index.js'; +import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js'; + +const cleanup = process.argv[2]; +assert.ok(['resolve', 'reject', 'deferred-reject', 'pending'].includes(cleanup)); + +const initializationError = new TypeError('Initialization fetch failed'); +const cleanupError = new Error('Connection cleanup failed'); +let initializeRequests = 0; +const transport = new StreamableHTTPClientTransport(new URL('https://example.test/mcp'), { + fetch: async () => { + initializeRequests++; + throw initializationError; + } +}); +const client = new Client({ name: 'cleanup-regression', version: '1.0.0' }); +const originalClose = client.close.bind(client); +let closeCalls = 0; +let finishCleanup: (() => void) | undefined; +client.close = async () => { + closeCalls++; + await originalClose(); + if (cleanup === 'pending') { + await new Promise(resolve => { + finishCleanup = resolve; + }); + } + if (cleanup === 'deferred-reject') await setImmediate(); + if (cleanup === 'reject' || cleanup === 'deferred-reject') throw cleanupError; +}; + +await assert.rejects(client.connect(transport), error => error === initializationError); +assert.equal(initializeRequests, 1); +assert.equal(closeCalls, 1); +if (cleanup === 'pending') { + assert.ok(finishCleanup, 'connect must reject without waiting for cleanup to finish'); + finishCleanup(); +} +// Cross the unhandled-rejection checkpoint, including delayed cleanup failures. +await setImmediate(); +await setImmediate(); +process.stdout.write('Original initialization error preserved; cleanup rejection handled.\n');