From afb89715695faf2280c786804fdbc8d650aabbd9 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:44:35 +0100 Subject: [PATCH 1/3] Add ondelete hook to StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional ondelete callback that fires when DELETE request is received, providing the sessionId for cleanup handling. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 022d1a47..bf3a93c9 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -133,6 +133,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string; onclose?: () => void; + ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -538,6 +539,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } + this.ondelete?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 52e22ad7cb4d7fc1197976929f2e305dc6d23579 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:57:41 +0100 Subject: [PATCH 2/3] Refactor ondelete hook to onsessionclosed in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed ondelete callback to onsessionclosed for better clarity - Moved callback from public property to private field following options pattern - Added comprehensive JSDoc documentation explaining the callback's purpose - Updated DELETE request handler to use the new callback structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bf3a93c9..37164c86 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -49,6 +49,18 @@ export interface StreamableHTTPServerTransportOptions { */ onsessioninitialized?: (sessionId: string) => void; + /** + * A callback for session close events + * This is called when the server closes a session due to a DELETE request. + * Useful in cases when you need to clean up resources associated with the session. + * Note that this is different from the transport closing, if you are handling + * HTTP requests from multiple nodes you might want to close each + * StreamableHTTPServerTransport after a request is completed while still keeping the + * session open/running. + * @param sessionId The session ID that was closed + */ + onsessionclosed?: (sessionId: string) => void; + /** * If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. @@ -127,13 +139,13 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _onsessionclosed?: (sessionId: string) => void; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; sessionId?: string; onclose?: () => void; - ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -142,6 +154,7 @@ export class StreamableHTTPServerTransport implements Transport { this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._onsessionclosed = options.onsessionclosed; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; @@ -539,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } - this.ondelete?.(this.sessionId!); + this._onsessionclosed?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 61052b1fa89963c1cf5f038e2972fd85cf19f6a2 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 15:19:40 +0100 Subject: [PATCH 3/3] Add comprehensive tests for onsessionclosed callback in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Tests callback is called when session is closed via DELETE • Tests system works correctly when callback is not provided • Tests callback is not called for invalid session DELETE requests • Tests correct session ID is passed when multiple sessions exist • Follows existing test patterns and includes proper cleanup 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.test.ts | 166 +++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 502435ea..e54bea01 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -29,6 +29,7 @@ interface TestServerConfig { enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; + onsessionclosed?: (sessionId: string) => void; } /** @@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); }); +// Test onsessionclosed callback +describe("StreamableHTTPServerTransport onsessionclosed callback", () => { + it("should call onsessionclosed callback when session is closed via DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback when not provided", async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback for invalid session DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); + + // Clean up + tempServer.close(); + }); + + it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => { + const mockCallback = jest.fn(); + + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server1 = result1.server; + const url1 = result1.baseUrl; + + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server2 = result2.server; + const url2 = result2.baseUrl; + + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get("mcp-session-id"); + + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get("mcp-session-id"); + + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId1 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId2 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); + + // Clean up + server1.close(); + server2.close(); + }); +}); + // Test DNS rebinding protection describe("StreamableHTTPServerTransport DNS rebinding protection", () => { let server: Server;