diff --git a/.changeset/fix-v1x-sse-close-once.md b/.changeset/fix-v1x-sse-close-once.md new file mode 100644 index 0000000000..fb7c886b47 --- /dev/null +++ b/.changeset/fix-v1x-sse-close-once.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +fix: avoid duplicate SSE close callbacks diff --git a/src/server/sse.ts b/src/server/sse.ts index 4931beae60..1976c2a7d1 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -124,8 +124,10 @@ export class SSEServerTransport implements Transport { this._sseResponse = this.res; this.res.on('close', () => { - this._sseResponse = undefined; - this.onclose?.(); + if (this._sseResponse !== undefined) { + this._sseResponse = undefined; + this.onclose?.(); + } }); } diff --git a/test/server/sse.test.ts b/test/server/sse.test.ts index 0e996d1d64..09b3116188 100644 --- a/test/server/sse.test.ts +++ b/test/server/sse.test.ts @@ -442,6 +442,39 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { await transport.close(); expect(transport.onclose).toHaveBeenCalled(); }); + + it('should only call onclose once when close ends the SSE response', async () => { + const onclose = vi.fn(); + const server = createServer(async (_req, res) => { + const transport = new SSEServerTransport('/messages', res); + transport.onclose = onclose; + + await transport.start(); + await transport.close(); + }); + + const baseUrl = await listenOnRandomPort(server); + + try { + await new Promise((resolve, reject) => { + const req = http.request(baseUrl, { headers: { Accept: 'text/event-stream' } }, response => { + response.resume(); + response.on('end', resolve); + response.on('error', reject); + }); + + req.on('error', reject); + req.end(); + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(onclose).toHaveBeenCalledTimes(1); + } finally { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); + } + }); }); describe('send method', () => {