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
6 changes: 6 additions & 0 deletions .changeset/v1x-delete-race-onsessionclosed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

Fire `onsessionclosed` at most once when DELETE requests for the same session overlap. The first DELETE awaited the callback before `close()` ran, so `_closed` was still `false` and a second DELETE passed the same guards and invoked the callback again for a session already being
torn down. The notification is now claimed synchronously before the await. DELETE remains idempotent — a concurrent or repeat request still terminates the session and answers 200.
14 changes: 13 additions & 1 deletion src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private _retryInterval?: number;
private _keepAliveMs: number;
private _closed = false;
/**
* Set synchronously before `onsessionclosed` is awaited, so a DELETE that arrives while the
* callback is in flight does not fire it again. `_closed` cannot serve this purpose: it is set
* by `close()`, which only runs once the callback has already settled.
*/
private _sessionClosedNotified = false;

sessionId?: string;
onclose?: () => void;
Expand Down Expand Up @@ -979,7 +985,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}

try {
await Promise.resolve(this._onsessionclosed?.(this.sessionId!));
// Claim the notification before awaiting it — see `_sessionClosedNotified`. DELETE stays
// idempotent: a concurrent or repeat request still terminates the session and answers 200,
// it just does not re-run the callback.
if (!this._sessionClosedNotified) {
this._sessionClosedNotified = true;
await Promise.resolve(this._onsessionclosed?.(this.sessionId!));
}
return new Response(null, { status: 200 });
} finally {
await this.close();
Expand Down
50 changes: 50 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3114,6 +3114,56 @@ async function createTestServerWithDnsProtection(config: {
};
}

describe('WebStandardStreamableHTTPServerTransport - concurrent DELETE', () => {
it('fires onsessionclosed once when two DELETEs overlap', async () => {
// The first DELETE parks on the callback await. `_closed` is only set by close(), which runs
// in the finally *after* that await, and neither validateSession nor validateProtocolVersion
// consults it — so a second DELETE passes the same guards and fires the callback again for a
// session already being torn down.
//
// Driven through handleRequest() rather than a real socket so the second DELETE is known to
// have reached the handler before the callback is released; over HTTP the release wins the
// race and the two requests simply serialize, which hides the bug.
let releaseCallback!: () => void;
const callbackGate = new Promise<void>(resolve => {
releaseCallback = resolve;
});
const onsessionclosed = vi.fn().mockReturnValue(callbackGate);

const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessionclosed
});
await mcpServer.connect(transport);

const initResponse = await transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json' },
body: JSON.stringify(TEST_MESSAGES.initialize)
})
);
const sessionId = initResponse.headers.get('mcp-session-id') as string;
expect(sessionId).toBeDefined();

const sendDelete = (): Promise<Response> =>
transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'DELETE',
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
})
);

const first = sendDelete();
const second = sendDelete();
releaseCallback();
await Promise.all([first, second]);

expect(onsessionclosed).toHaveBeenCalledTimes(1);
});
});

describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => {
let transport: WebStandardStreamableHTTPServerTransport;
let mcpServer: McpServer;
Expand Down
Loading