Skip to content

Commit 7d7896f

Browse files
authored
Merge pull request #743 from modelcontextprotocol/jerome/feature/ondelete-shttp-hook
Add onsessionclosed hook to StreamableHTTPServerTransport
2 parents 1bd56ee + 61052b1 commit 7d7896f

File tree

2 files changed

+179
-2
lines changed

2 files changed

+179
-2
lines changed

src/server/streamableHttp.test.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface TestServerConfig {
2929
enableJsonResponse?: boolean;
3030
customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
3131
eventStore?: EventStore;
32+
onsessionclosed?: (sessionId: string) => void;
3233
}
3334

3435
/**
@@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator:
5758
const transport = new StreamableHTTPServerTransport({
5859
sessionIdGenerator: config.sessionIdGenerator,
5960
enableJsonResponse: config.enableJsonResponse ?? false,
60-
eventStore: config.eventStore
61+
eventStore: config.eventStore,
62+
onsessionclosed: config.onsessionclosed
6163
});
6264

6365
await mcpServer.connect(transport);
@@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera
111113
const transport = new StreamableHTTPServerTransport({
112114
sessionIdGenerator: config.sessionIdGenerator,
113115
enableJsonResponse: config.enableJsonResponse ?? false,
114-
eventStore: config.eventStore
116+
eventStore: config.eventStore,
117+
onsessionclosed: config.onsessionclosed
115118
});
116119

117120
await mcpServer.connect(transport);
@@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
15041507
});
15051508
});
15061509

1510+
// Test onsessionclosed callback
1511+
describe("StreamableHTTPServerTransport onsessionclosed callback", () => {
1512+
it("should call onsessionclosed callback when session is closed via DELETE", async () => {
1513+
const mockCallback = jest.fn();
1514+
1515+
// Create server with onsessionclosed callback
1516+
const result = await createTestServer({
1517+
sessionIdGenerator: () => randomUUID(),
1518+
onsessionclosed: mockCallback,
1519+
});
1520+
1521+
const tempServer = result.server;
1522+
const tempUrl = result.baseUrl;
1523+
1524+
// Initialize to get a session ID
1525+
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1526+
const tempSessionId = initResponse.headers.get("mcp-session-id");
1527+
expect(tempSessionId).toBeDefined();
1528+
1529+
// DELETE the session
1530+
const deleteResponse = await fetch(tempUrl, {
1531+
method: "DELETE",
1532+
headers: {
1533+
"mcp-session-id": tempSessionId || "",
1534+
"mcp-protocol-version": "2025-03-26",
1535+
},
1536+
});
1537+
1538+
expect(deleteResponse.status).toBe(200);
1539+
expect(mockCallback).toHaveBeenCalledWith(tempSessionId);
1540+
expect(mockCallback).toHaveBeenCalledTimes(1);
1541+
1542+
// Clean up
1543+
tempServer.close();
1544+
});
1545+
1546+
it("should not call onsessionclosed callback when not provided", async () => {
1547+
// Create server without onsessionclosed callback
1548+
const result = await createTestServer({
1549+
sessionIdGenerator: () => randomUUID(),
1550+
});
1551+
1552+
const tempServer = result.server;
1553+
const tempUrl = result.baseUrl;
1554+
1555+
// Initialize to get a session ID
1556+
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1557+
const tempSessionId = initResponse.headers.get("mcp-session-id");
1558+
1559+
// DELETE the session - should not throw error
1560+
const deleteResponse = await fetch(tempUrl, {
1561+
method: "DELETE",
1562+
headers: {
1563+
"mcp-session-id": tempSessionId || "",
1564+
"mcp-protocol-version": "2025-03-26",
1565+
},
1566+
});
1567+
1568+
expect(deleteResponse.status).toBe(200);
1569+
1570+
// Clean up
1571+
tempServer.close();
1572+
});
1573+
1574+
it("should not call onsessionclosed callback for invalid session DELETE", async () => {
1575+
const mockCallback = jest.fn();
1576+
1577+
// Create server with onsessionclosed callback
1578+
const result = await createTestServer({
1579+
sessionIdGenerator: () => randomUUID(),
1580+
onsessionclosed: mockCallback,
1581+
});
1582+
1583+
const tempServer = result.server;
1584+
const tempUrl = result.baseUrl;
1585+
1586+
// Initialize to get a valid session
1587+
await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1588+
1589+
// Try to DELETE with invalid session ID
1590+
const deleteResponse = await fetch(tempUrl, {
1591+
method: "DELETE",
1592+
headers: {
1593+
"mcp-session-id": "invalid-session-id",
1594+
"mcp-protocol-version": "2025-03-26",
1595+
},
1596+
});
1597+
1598+
expect(deleteResponse.status).toBe(404);
1599+
expect(mockCallback).not.toHaveBeenCalled();
1600+
1601+
// Clean up
1602+
tempServer.close();
1603+
});
1604+
1605+
it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => {
1606+
const mockCallback = jest.fn();
1607+
1608+
// Create first server
1609+
const result1 = await createTestServer({
1610+
sessionIdGenerator: () => randomUUID(),
1611+
onsessionclosed: mockCallback,
1612+
});
1613+
1614+
const server1 = result1.server;
1615+
const url1 = result1.baseUrl;
1616+
1617+
// Create second server
1618+
const result2 = await createTestServer({
1619+
sessionIdGenerator: () => randomUUID(),
1620+
onsessionclosed: mockCallback,
1621+
});
1622+
1623+
const server2 = result2.server;
1624+
const url2 = result2.baseUrl;
1625+
1626+
// Initialize both servers
1627+
const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize);
1628+
const sessionId1 = initResponse1.headers.get("mcp-session-id");
1629+
1630+
const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize);
1631+
const sessionId2 = initResponse2.headers.get("mcp-session-id");
1632+
1633+
expect(sessionId1).toBeDefined();
1634+
expect(sessionId2).toBeDefined();
1635+
expect(sessionId1).not.toBe(sessionId2);
1636+
1637+
// DELETE first session
1638+
const deleteResponse1 = await fetch(url1, {
1639+
method: "DELETE",
1640+
headers: {
1641+
"mcp-session-id": sessionId1 || "",
1642+
"mcp-protocol-version": "2025-03-26",
1643+
},
1644+
});
1645+
1646+
expect(deleteResponse1.status).toBe(200);
1647+
expect(mockCallback).toHaveBeenCalledWith(sessionId1);
1648+
expect(mockCallback).toHaveBeenCalledTimes(1);
1649+
1650+
// DELETE second session
1651+
const deleteResponse2 = await fetch(url2, {
1652+
method: "DELETE",
1653+
headers: {
1654+
"mcp-session-id": sessionId2 || "",
1655+
"mcp-protocol-version": "2025-03-26",
1656+
},
1657+
});
1658+
1659+
expect(deleteResponse2.status).toBe(200);
1660+
expect(mockCallback).toHaveBeenCalledWith(sessionId2);
1661+
expect(mockCallback).toHaveBeenCalledTimes(2);
1662+
1663+
// Clean up
1664+
server1.close();
1665+
server2.close();
1666+
});
1667+
});
1668+
15071669
// Test DNS rebinding protection
15081670
describe("StreamableHTTPServerTransport DNS rebinding protection", () => {
15091671
let server: Server;

src/server/streamableHttp.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ export interface StreamableHTTPServerTransportOptions {
4949
*/
5050
onsessioninitialized?: (sessionId: string) => void;
5151

52+
/**
53+
* A callback for session close events
54+
* This is called when the server closes a session due to a DELETE request.
55+
* Useful in cases when you need to clean up resources associated with the session.
56+
* Note that this is different from the transport closing, if you are handling
57+
* HTTP requests from multiple nodes you might want to close each
58+
* StreamableHTTPServerTransport after a request is completed while still keeping the
59+
* session open/running.
60+
* @param sessionId The session ID that was closed
61+
*/
62+
onsessionclosed?: (sessionId: string) => void;
63+
5264
/**
5365
* If true, the server will return JSON responses instead of starting an SSE stream.
5466
* This can be useful for simple request/response scenarios without streaming.
@@ -127,6 +139,7 @@ export class StreamableHTTPServerTransport implements Transport {
127139
private _standaloneSseStreamId: string = '_GET_stream';
128140
private _eventStore?: EventStore;
129141
private _onsessioninitialized?: (sessionId: string) => void;
142+
private _onsessionclosed?: (sessionId: string) => void;
130143
private _allowedHosts?: string[];
131144
private _allowedOrigins?: string[];
132145
private _enableDnsRebindingProtection: boolean;
@@ -141,6 +154,7 @@ export class StreamableHTTPServerTransport implements Transport {
141154
this._enableJsonResponse = options.enableJsonResponse ?? false;
142155
this._eventStore = options.eventStore;
143156
this._onsessioninitialized = options.onsessioninitialized;
157+
this._onsessionclosed = options.onsessionclosed;
144158
this._allowedHosts = options.allowedHosts;
145159
this._allowedOrigins = options.allowedOrigins;
146160
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
@@ -538,6 +552,7 @@ export class StreamableHTTPServerTransport implements Transport {
538552
if (!this.validateProtocolVersion(req, res)) {
539553
return;
540554
}
555+
this._onsessionclosed?.(this.sessionId!);
541556
await this.close();
542557
res.writeHead(200).end();
543558
}

0 commit comments

Comments
 (0)