From ef868cd2dd4dc56b0d116d9e98c42b9c15fb41e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=B1=E5=B7=9D=E5=BF=97?= <3671276527@qq.com> Date: Fri, 25 Sep 2026 21:39:59 +0800 Subject: [PATCH] fix(client): handle transport closure during legacy initialize --- .changeset/calm-workers-close.md | 5 +++ packages/client/src/client/client.ts | 8 ++++ .../test/server/cloudflareWorkers.test.ts | 41 ++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-workers-close.md diff --git a/.changeset/calm-workers-close.md b/.changeset/calm-workers-close.md new file mode 100644 index 0000000000..1aa29aa6fa --- /dev/null +++ b/.changeset/calm-workers-close.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Handle transport closure during legacy initialization without an unhandled rejection. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0b386a63e8..2397167e5c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1075,6 +1075,14 @@ export class Client extends Protocol { this._instructions = result.instructions; + // The transport can close after the initialize response settles but + // before this async continuation sends the initialized notification. + // Fail the handshake explicitly instead of sending through a detached + // connection (which can surface as an unhandled rejection in workerd). + if (this.transport === undefined) { + throw new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed during initialize handshake'); + } + await this.notification({ method: 'notifications/initialized' }); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 02add64201..01a1d4fbdb 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -275,6 +275,7 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { // `@modelcontextprotocol/core/internal` at runtime, and the registry copy of core may // not carry that subpath yet — the test must exercise the workspace pair together. const tarballName = packWorkspacePackage(tempDir, 'server'); + const clientTarballName = packWorkspacePackage(tempDir, 'client'); const coreTarballName = packWorkspacePackage(tempDir, 'core'); // Write package.json @@ -284,6 +285,7 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { type: 'module', dependencies: { '@modelcontextprotocol/core': `file:./${coreTarballName}`, + '@modelcontextprotocol/client': `file:./${clientTarballName}`, '@modelcontextprotocol/server': `file:./${tarballName}` } }; @@ -299,9 +301,16 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { // Write server source const serverSource = ` +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { z } from 'zod'; +const unhandledRejections = []; +addEventListener('unhandledrejection', event => { + unhandledRejections.push(event.reason?.stack ?? String(event.reason)); + event.preventDefault(); +}); + const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); server.registerTool("greet", { @@ -315,7 +324,30 @@ const transport = new WebStandardStreamableHTTPServerTransport(); await server.connect(transport); export default { - fetch: (request) => transport.handleRequest(request) + async fetch(request) { + if (new URL(request.url).pathname === '/legacy-handshake-close') { + const firstRejection = unhandledRejections.length; + for (let ticks = 0; ticks < 30; ticks++) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const testServer = new McpServer({ name: 'downstream', version: '1.0.0' }); + await testServer.connect(serverTransport); + const client = new Client( + { name: 'probe', version: '1.0.0' }, + { versionNegotiation: { mode: 'legacy' } } + ); + const connecting = client.connect(clientTransport).catch(() => {}); + for (let tick = 0; tick < ticks; tick++) await Promise.resolve(); + await clientTransport.close(); + await connecting; + await testServer.close(); + } + // workerd reports unhandled rejections at the end of a turn. Yield + // once so the listener above can observe any detached rejection. + await new Promise(resolve => setTimeout(resolve, 0)); + return Response.json({ unhandledRejections: unhandledRejections.slice(firstRejection) }); + } + return transport.handleRequest(request); + } }; `; fs.writeFileSync(path.join(tempDir, 'server.ts'), serverSource); @@ -371,4 +403,11 @@ export default { await client.close(); }, 30_000); + + it('should not leave an unhandled rejection when the client closes during legacy initialize', async () => { + const response = await fetch(`http://127.0.0.1:${port}/legacy-handshake-close`); + expect(response.ok).toBe(true); + const result = (await response.json()) as { unhandledRejections: string[] }; + expect(result.unhandledRejections).toEqual([]); + }, 30_000); });