Skip to content

Commit c053c2e

Browse files
committed
handle session termination
1 parent a5144dc commit c053c2e

File tree

4 files changed

+211
-1
lines changed

4 files changed

+211
-1
lines changed

src/client/streamableHttp.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,81 @@ describe("StreamableHTTPClientTransport", () => {
101101
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
102102
});
103103

104+
it("should terminate session with DELETE request", async () => {
105+
// First, simulate getting a session ID
106+
const message: JSONRPCMessage = {
107+
jsonrpc: "2.0",
108+
method: "initialize",
109+
params: {
110+
clientInfo: { name: "test-client", version: "1.0" },
111+
protocolVersion: "2025-03-26"
112+
},
113+
id: "init-id"
114+
};
115+
116+
(global.fetch as jest.Mock).mockResolvedValueOnce({
117+
ok: true,
118+
status: 200,
119+
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
120+
});
121+
122+
await transport.send(message);
123+
expect(transport.sessionId).toBe("test-session-id");
124+
125+
// Now terminate the session
126+
(global.fetch as jest.Mock).mockResolvedValueOnce({
127+
ok: true,
128+
status: 200,
129+
headers: new Headers()
130+
});
131+
132+
await transport.terminateSession();
133+
134+
// Verify the DELETE request was sent with the session ID
135+
const calls = (global.fetch as jest.Mock).mock.calls;
136+
const lastCall = calls[calls.length - 1];
137+
expect(lastCall[1].method).toBe("DELETE");
138+
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
139+
140+
// The session ID should be cleared after successful termination
141+
expect(transport.sessionId).toBeUndefined();
142+
});
143+
144+
it("should handle 405 response when server doesn't support session termination", async () => {
145+
// First, simulate getting a session ID
146+
const message: JSONRPCMessage = {
147+
jsonrpc: "2.0",
148+
method: "initialize",
149+
params: {
150+
clientInfo: { name: "test-client", version: "1.0" },
151+
protocolVersion: "2025-03-26"
152+
},
153+
id: "init-id"
154+
};
155+
156+
(global.fetch as jest.Mock).mockResolvedValueOnce({
157+
ok: true,
158+
status: 200,
159+
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
160+
});
161+
162+
await transport.send(message);
163+
164+
// Now terminate the session, but server responds with 405
165+
(global.fetch as jest.Mock).mockResolvedValueOnce({
166+
ok: false,
167+
status: 405,
168+
statusText: "Method Not Allowed",
169+
headers: new Headers()
170+
});
171+
172+
// This should not throw an error
173+
await transport.terminateSession();
174+
175+
// The session ID should still be preserved since termination wasn't accepted
176+
expect(transport.sessionId).toBe("test-session-id");
177+
});
178+
104179
it("should handle 404 response when session expires", async () => {
105180
const message: JSONRPCMessage = {
106181
jsonrpc: "2.0",

src/client/streamableHttp.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,4 +467,51 @@ export class StreamableHTTPClientTransport implements Transport {
467467
get sessionId(): string | undefined {
468468
return this._sessionId;
469469
}
470+
471+
/**
472+
* Terminates the current session by sending a DELETE request to the server.
473+
*
474+
* Clients that no longer need a particular session
475+
* (e.g., because the user is leaving the client application) SHOULD send an
476+
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
477+
* terminate the session.
478+
*
479+
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
480+
* the server does not allow clients to terminate sessions.
481+
*/
482+
async terminateSession(): Promise<void> {
483+
if (!this._sessionId) {
484+
return; // No session to terminate
485+
}
486+
487+
try {
488+
const headers = await this._commonHeaders();
489+
490+
const init = {
491+
...this._requestInit,
492+
method: "DELETE",
493+
headers,
494+
signal: this._abortController?.signal,
495+
};
496+
497+
const response = await fetch(this._url, init);
498+
499+
// We specifically handle 405 as a valid response according to the spec,
500+
// meaning the server does not support explicit session termination
501+
if (!response.ok && response.status !== 405) {
502+
throw new StreamableHTTPError(
503+
response.status,
504+
`Failed to terminate session: ${response.statusText}`
505+
);
506+
}
507+
508+
// If session termination was successful, clear our session ID
509+
if (response.ok) {
510+
this._sessionId = undefined;
511+
}
512+
} catch (error) {
513+
this.onerror?.(error as Error);
514+
throw error;
515+
}
516+
}
470517
}

src/examples/client/simpleStreamableHttp.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function printHelp(): void {
4848
console.log('\nAvailable commands:');
4949
console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)');
5050
console.log(' disconnect - Disconnect from server');
51+
console.log(' terminate-session - Terminate the current session');
5152
console.log(' reconnect - Reconnect to the server');
5253
console.log(' list-tools - List available tools');
5354
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
@@ -76,6 +77,10 @@ function commandLoop(): void {
7677
await disconnect();
7778
break;
7879

80+
case 'terminate-session':
81+
await terminateSession();
82+
break;
83+
7984
case 'reconnect':
8085
await reconnect();
8186
break;
@@ -249,6 +254,36 @@ async function disconnect(): Promise<void> {
249254
}
250255
}
251256

257+
async function terminateSession(): Promise<void> {
258+
if (!client || !transport) {
259+
console.log('Not connected.');
260+
return;
261+
}
262+
263+
try {
264+
console.log('Terminating session with ID:', transport.sessionId);
265+
await transport.terminateSession();
266+
console.log('Session terminated successfully');
267+
268+
// Check if sessionId was cleared after termination
269+
if (!transport.sessionId) {
270+
console.log('Session ID has been cleared');
271+
sessionId = undefined;
272+
273+
// Also close the transport and clear client objects
274+
await transport.close();
275+
console.log('Transport closed after session termination');
276+
client = null;
277+
transport = null;
278+
} else {
279+
console.log('Server responded with 405 Method Not Allowed (session termination not supported)');
280+
console.log('Session ID is still active:', transport.sessionId);
281+
}
282+
} catch (error) {
283+
console.error('Error terminating session:', error);
284+
}
285+
}
286+
252287
async function reconnect(): Promise<void> {
253288
if (client) {
254289
await disconnect();
@@ -411,13 +446,24 @@ async function listResources(): Promise<void> {
411446
async function cleanup(): Promise<void> {
412447
if (client && transport) {
413448
try {
449+
// First try to terminate the session gracefully
450+
if (transport.sessionId) {
451+
try {
452+
console.log('Terminating session before exit...');
453+
await transport.terminateSession();
454+
console.log('Session terminated successfully');
455+
} catch (error) {
456+
console.error('Error terminating session:', error);
457+
}
458+
}
459+
460+
// Then close the transport
414461
await transport.close();
415462
} catch (error) {
416463
console.error('Error closing transport:', error);
417464
}
418465
}
419466

420-
421467
process.stdin.setRawMode(false);
422468
readline.close();
423469
console.log('\nGoodbye!');

src/examples/server/simpleStreamableHttp.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ app.post('/mcp', async (req: Request, res: Response) => {
255255
}
256256
});
257257

258+
// Set up onclose handler to clean up transport when closed
259+
transport.onclose = () => {
260+
const sid = transport.sessionId;
261+
if (sid && transports[sid]) {
262+
console.log(`Transport closed for session ${sid}, removing from transports map`);
263+
delete transports[sid];
264+
}
265+
};
266+
258267
// Connect the transport to the MCP server BEFORE handling the request
259268
// so responses can flow back through the same transport
260269
await server.connect(transport);
@@ -312,6 +321,27 @@ app.get('/mcp', async (req: Request, res: Response) => {
312321
await transport.handleRequest(req, res);
313322
});
314323

324+
// Handle DELETE requests for session termination (according to MCP spec)
325+
app.delete('/mcp', async (req: Request, res: Response) => {
326+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
327+
if (!sessionId || !transports[sessionId]) {
328+
res.status(400).send('Invalid or missing session ID');
329+
return;
330+
}
331+
332+
console.log(`Received session termination request for session ${sessionId}`);
333+
334+
try {
335+
const transport = transports[sessionId];
336+
await transport.handleRequest(req, res);
337+
} catch (error) {
338+
console.error('Error handling session termination:', error);
339+
if (!res.headersSent) {
340+
res.status(500).send('Error processing session termination');
341+
}
342+
}
343+
});
344+
315345
// Start the server
316346
const PORT = 3000;
317347
app.listen(PORT, () => {
@@ -343,6 +373,18 @@ app.listen(PORT, () => {
343373
// Handle server shutdown
344374
process.on('SIGINT', async () => {
345375
console.log('Shutting down server...');
376+
377+
// Close all active transports to properly clean up resources
378+
for (const sessionId in transports) {
379+
try {
380+
console.log(`Closing transport for session ${sessionId}`);
381+
await transports[sessionId].close();
382+
delete transports[sessionId];
383+
} catch (error) {
384+
console.error(`Error closing transport for session ${sessionId}:`, error);
385+
}
386+
}
346387
await server.close();
388+
console.log('Server shutdown complete');
347389
process.exit(0);
348390
});

0 commit comments

Comments
 (0)