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..0f7433e969 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -531,6 +531,58 @@ 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; + }); + // 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 => { + signalToolStarted(); + 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 } + ) + ); + + 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. 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 => { + hangTimer = setTimeout(() => resolve({ hung: true, status: 0 }), 2000); + }) + ]).finally(() => { + if (hangTimer !== undefined) clearTimeout(hangTimer); + }); + releaseTool(); + + expect(outcome.hung).toBe(false); + expect(outcome.status).toBe(503); + }); + it('should handle tool calls in JSON response mode', async () => { sessionId = await initializeServer();