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/json-mode-lifecycle-v1x.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
);
}
});

Expand Down Expand Up @@ -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();
Expand Down
72 changes: 72 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(resolve => {
signalToolStarted = resolve;
});
mcpServer.tool('slow', async () => {
signalToolStarted!();
await new Promise<void>(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(),
Expand Down
Loading