Skip to content

Commit df4b6cc

Browse files
fix: prevent stack overflow in transport close with re-entrancy guard (modelcontextprotocol#1788)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 653c5d0 commit df4b6cc

File tree

3 files changed

+48
-0
lines changed

3 files changed

+48
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
Prevent stack overflow in StreamableHTTPServerTransport.close() with re-entrant guard

packages/server/src/server/streamableHttp.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
225225
// when sessionId is not set (undefined), it means the transport is in stateless mode
226226
private sessionIdGenerator: (() => string) | undefined;
227227
private _started: boolean = false;
228+
private _closed: boolean = false;
228229
private _streamMapping: Map<string, StreamMapping> = new Map();
229230
private _requestToStreamMapping: Map<RequestId, string> = new Map();
230231
private _requestResponseMap: Map<RequestId, JSONRPCMessage> = new Map();
@@ -897,6 +898,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
897898
}
898899

899900
async close(): Promise<void> {
901+
if (this._closed) {
902+
return;
903+
}
904+
this._closed = true;
905+
900906
// Close all SSE connections
901907
for (const { cleanup } of this._streamMapping.values()) {
902908
cleanup();

packages/server/test/server/streamableHttp.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,4 +956,41 @@ describe('Zod v4', () => {
956956
expect(error?.message).toContain('Unsupported protocol version');
957957
});
958958
});
959+
960+
describe('close() re-entrancy guard', () => {
961+
it('should not recurse when onclose triggers a second close()', async () => {
962+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
963+
964+
let closeCallCount = 0;
965+
transport.onclose = () => {
966+
closeCallCount++;
967+
// Simulate the Protocol layer calling close() again from within onclose —
968+
// the re-entrancy guard should prevent infinite recursion / stack overflow.
969+
void transport.close();
970+
};
971+
972+
// Should resolve without throwing RangeError: Maximum call stack size exceeded
973+
await expect(transport.close()).resolves.toBeUndefined();
974+
expect(closeCallCount).toBe(1);
975+
});
976+
977+
it('should clean up all streams exactly once even when close() is called concurrently', async () => {
978+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
979+
980+
const cleanupCalls: string[] = [];
981+
982+
// Inject a fake stream entry to verify cleanup runs exactly once
983+
// @ts-expect-error accessing private map for test purposes
984+
transport._streamMapping.set('stream-1', {
985+
cleanup: () => {
986+
cleanupCalls.push('stream-1');
987+
}
988+
});
989+
990+
// Fire two concurrent close() calls — only the first should proceed
991+
await Promise.all([transport.close(), transport.close()]);
992+
993+
expect(cleanupCalls).toEqual(['stream-1']);
994+
});
995+
});
959996
});

0 commit comments

Comments
 (0)