Skip to content
Draft
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-close-settles-request.md
Original file line number Diff line number Diff line change
@@ -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<Response>` 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.
13 changes: 13 additions & 0 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
);
}
});

Expand Down
52 changes: 52 additions & 0 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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<void>(resolve => {
signalToolStarted = resolve;
});
mcpServer.registerTool('slow', { description: 'Parks', inputSchema: z.object({}) }, async (): Promise<CallToolResult> => {
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<typeof setTimeout> | 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();

Expand Down
Loading