Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-client-connect-cleanup-rejection.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
24 changes: 24 additions & 0 deletions test/client/connect-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
}
44 changes: 44 additions & 0 deletions test/client/fixtures/connect-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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');
Loading