diff --git a/.changeset/json-mode-lifecycle-v1x.md b/.changeset/json-mode-lifecycle-v1x.md new file mode 100644 index 0000000000..756cdafb6e --- /dev/null +++ b/.changeset/json-mode-lifecycle-v1x.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Complete the JSON response mode request lifecycle in Streamable HTTP: a completed POST no longer leaks its stream mapping, and close() during an in-flight JSON-mode request settles the pending HTTP response with a 503 JSON-RPC error instead of leaving it hanging until the client times out. diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index cffca7bc0e..e4d9eb038a 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -861,6 +861,20 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { resolveJson: resolve, cleanup: () => { this._streamMapping.delete(streamId); + // Settle the pending POST as well as dropping the mapping. + // close() runs every mapping's cleanup, so without this a + // JSON-mode request 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 resolving a settled promise is + // a no-op, so this only fires when nothing was sent. + resolve( + this.createJsonErrorResponse( + 503, + -32000, + 'Service Unavailable: transport closed before a response was produced' + ) + ); } }); @@ -1255,6 +1269,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } else { stream.resolveJson(new Response(JSON.stringify(responses), { status: 200, headers })); } + // Release the stream mapping now that the HTTP response is settled; + // leaving it in place leaks one entry per POST for the session's lifetime. + stream.cleanup(); } else { // End the SSE stream stream.cleanup(); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index f402abeff5..8519d50bf5 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -4094,6 +4094,78 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive lifecycle', () await transport.close(); }); + it('should settle an in-flight JSON-mode request when the transport closes mid-handler', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + let releaseTool: (() => void) | undefined; + let signalToolStarted: (() => void) | undefined; + const toolStarted = new Promise(resolve => { + signalToolStarted = resolve; + }); + mcpServer.tool('slow', async () => { + signalToolStarted!(); + await new Promise(resolve => { + releaseTool = resolve; + }); + return { content: [] }; + }); + await mcpServer.connect(transport); + const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + const sessionId = initResponse.headers.get('mcp-session-id') as string; + + // close() runs while the handler is genuinely parked: signalled by the + // handler rather than polled, so the POST is guaranteed registered. + const inFlight = transport.handleRequest( + req('POST', { + body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'call-1' }, + headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } + }) + ); + await toolStarted; + await transport.close(); + + // Without settling in cleanup(), the POST would hang until the client + // gave up instead of failing fast. + const response = await inFlight; + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32000 }, + id: null + }); + + releaseTool?.(); + await vi.advanceTimersByTimeAsync(0); + }); + + it('should release the JSON-mode stream mapping once the response has been sent', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport); + const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + const sessionId = initResponse.headers.get('mcp-session-id') as string; + + const response = await transport.handleRequest( + req('POST', { + body: { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'list-1' }, + headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } + }) + ); + expect(response.status).toBe(200); + await response.arrayBuffer(); + + // A completed POST must not leave its mapping behind until close(): + // long-lived sessions would otherwise accumulate one entry per request. + expect(transport['_streamMapping'].size).toBe(0); + + await transport.close(); + }); + it('should close the transport when the onsessionclosed callback throws on DELETE', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(),