From c68902626b6a09bbb9e4761cb26740caacf5351e Mon Sep 17 00:00:00 2001 From: Axit Date: Thu, 30 Jul 2026 19:20:17 +0530 Subject: [PATCH 1/3] fix(server): settle in-flight JSON-mode requests on transport close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `close()` runs every stream mapping's `cleanup`. JSON response mode's `cleanup` only deleted the map entry, so the `Promise` returned by `handleRequest()` was never settled and a POST whose handler was still running hung until the client gave up. Resolve it with a 503 JSON-RPC error instead. The success path is unaffected: `send()` resolves the response before calling `cleanup()`, and re-resolving an already-settled promise is a no-op, so this only fires when nothing was sent. Note the companion half of #2559 — the `_streamMapping` leak on completed JSON-mode POSTs — no longer reproduces on main; `send()` has called `stream.cleanup()` since #2286. Verified: `_streamMapping.size` is 0 after a completed JSON-mode POST. Refs #2559 Co-Authored-By: Claude Opus 5 --- .changeset/json-mode-close-settles-request.md | 5 +++ packages/server/src/server/streamableHttp.ts | 13 ++++++ .../server/test/server/streamableHttp.test.ts | 44 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 .changeset/json-mode-close-settles-request.md diff --git a/.changeset/json-mode-close-settles-request.md b/.changeset/json-mode-close-settles-request.md new file mode 100644 index 0000000000..f551c0cd14 --- /dev/null +++ b/.changeset/json-mode-close-settles-request.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Settle in-flight JSON-mode requests when the transport closes. `close()` runs every stream mapping's `cleanup`, but JSON response mode's `cleanup` only deleted the map entry — the `Promise` returned by `handleRequest()` was never settled, so a POST whose handler was still running hung until the client timed out. It now resolves with a `503` JSON-RPC error. The success path is unchanged: `send()` resolves before calling `cleanup()`, and re-resolving a settled promise is a no-op. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c0f48560a2..d8457119cd 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -864,6 +864,19 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { resolveJson: resolve, cleanup: () => { this._streamMapping.delete(streamId); + // Settle the pending Response as well as dropping the mapping. close() + // runs every mapping's cleanup, so without this a JSON-mode POST whose + // handler is still in flight never settles and the HTTP request hangs + // until the client gives up. On the success path send() has already + // resolved by the time it calls cleanup(), and a second resolve on a + // settled promise is a no-op, so this only fires when nothing was sent. + resolve( + this.createJsonErrorResponse( + 503, + -32_000, + 'Service Unavailable: transport closed before a response was produced' + ) + ); } }); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 9ec6baf46c..b371c6c19d 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -531,6 +531,50 @@ describe('Zod v4', () => { }); }); + it('settles an in-flight request when the transport closes mid-handler', async () => { + // close() runs every stream mapping's cleanup. JSON mode's cleanup only dropped the + // map entry, leaving the Promise returned by handleRequest() unsettled — so the HTTP + // request hung until the client gave up rather than failing. + let releaseTool!: () => void; + const toolGate = new Promise(resolve => { + releaseTool = resolve; + }); + let toolStarted = false; + mcpServer.registerTool('slow', { description: 'Parks', inputSchema: z.object({}) }, async (): Promise => { + toolStarted = true; + await toolGate; + return { content: [] }; + }); + + sessionId = await initializeServer(); + + const inFlight = transport.handleRequest( + createRequest( + 'POST', + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'slow-1' } as JSONRPCMessage, + { sessionId } + ) + ); + + // Only close once the handler is genuinely parked, or close() lands before the POST + // is even validated and the test proves nothing. + for (let i = 0; i < 200 && !toolStarted; i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(toolStarted).toBe(true); + + await transport.close(); + + const outcome = await Promise.race([ + inFlight.then(response => ({ hung: false, status: response.status })), + new Promise<{ hung: true; status: number }>(resolve => setTimeout(() => resolve({ hung: true, status: 0 }), 1000)) + ]); + releaseTool(); + + expect(outcome.hung).toBe(false); + expect(outcome.status).toBe(503); + }); + it('should handle tool calls in JSON response mode', async () => { sessionId = await initializeServer(); From 2c9b33c7d5d9fa12e8d3b3628758d82b6be2c963 Mon Sep 17 00:00:00 2001 From: Axit Date: Thu, 30 Jul 2026 19:40:51 +0530 Subject: [PATCH 2/3] test(server): make the JSON-mode close test timing-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler now signals that it has parked via a promise the test awaits, instead of the test polling a boolean on a 5ms tick. close() still has to land while the handler is parked — landing earlier means the POST is not yet validated and the test would pass for the wrong reason — but that no longer depends on how fast the runner is. The single remaining timer is the hang detector, which only fires on a regression: with the fix the response is already resolved by the time close() returns. Co-Authored-By: Claude Opus 5 --- .../server/test/server/streamableHttp.test.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index b371c6c19d..de1436183e 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -539,9 +539,15 @@ describe('Zod v4', () => { const toolGate = new Promise(resolve => { releaseTool = resolve; }); - let toolStarted = false; + // Signalled by the handler rather than polled: close() has to land while the handler is + // genuinely parked. Landing earlier means the POST is not yet validated and the test + // passes for the wrong reason, and a poll loop would make that depend on runner speed. + let signalToolStarted!: () => void; + const toolStarted = new Promise(resolve => { + signalToolStarted = resolve; + }); mcpServer.registerTool('slow', { description: 'Parks', inputSchema: z.object({}) }, async (): Promise => { - toolStarted = true; + signalToolStarted(); await toolGate; return { content: [] }; }); @@ -556,18 +562,14 @@ describe('Zod v4', () => { ) ); - // Only close once the handler is genuinely parked, or close() lands before the POST - // is even validated and the test proves nothing. - for (let i = 0; i < 200 && !toolStarted; i++) { - await new Promise(resolve => setTimeout(resolve, 5)); - } - expect(toolStarted).toBe(true); - + await toolStarted; await transport.close(); + // The only remaining timer, and it fires solely on a regression: with the fix the + // response is already resolved by the time close() returns. const outcome = await Promise.race([ inFlight.then(response => ({ hung: false, status: response.status })), - new Promise<{ hung: true; status: number }>(resolve => setTimeout(() => resolve({ hung: true, status: 0 }), 1000)) + new Promise<{ hung: true; status: number }>(resolve => setTimeout(() => resolve({ hung: true, status: 0 }), 2000)) ]); releaseTool(); From 14729420154c6c6bc93f3d4208de64e38e5d8745 Mon Sep 17 00:00:00 2001 From: Axit Date: Thu, 30 Jul 2026 19:57:38 +0530 Subject: [PATCH 3/3] test(server): clear the hang-detector timer on the passing path Promise.race left the 2s timeout pending whenever the request won, so a passing run handed a live timer to the rest of the suite. Co-Authored-By: Claude Opus 5 --- packages/server/test/server/streamableHttp.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index de1436183e..0f7433e969 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -566,11 +566,17 @@ describe('Zod v4', () => { await transport.close(); // The only remaining timer, and it fires solely on a regression: with the fix the - // response is already resolved by the time close() returns. + // response is already resolved by the time close() returns. Cleared either way so a + // passing run leaves no pending timer behind for the rest of the suite. + let hangTimer: ReturnType | undefined; const outcome = await Promise.race([ inFlight.then(response => ({ hung: false, status: response.status })), - new Promise<{ hung: true; status: number }>(resolve => setTimeout(() => resolve({ hung: true, status: 0 }), 2000)) - ]); + new Promise<{ hung: true; status: number }>(resolve => { + hangTimer = setTimeout(() => resolve({ hung: true, status: 0 }), 2000); + }) + ]).finally(() => { + if (hangTimer !== undefined) clearTimeout(hangTimer); + }); releaseTool(); expect(outcome.hung).toBe(false);