From a0d3b865b0d1418d42928adb9214570680acb174 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Thu, 21 May 2026 10:37:08 +0000 Subject: [PATCH] fix: send HTTP DELETE to terminate server sessions after each scenario The conformance test harness opened sessions against servers under test but never issued an HTTP DELETE to terminate them, leaving a dangling session on the server after each scenario. This violates the "hermetic" expectation called out in the Streamable HTTP transport spec. - `client-helper.connectToServer` now calls `transport.terminateSession()` before `client.close()`. A 405 response (DELETE not supported) is tolerated because the spec allows the server to refuse. - A new `terminateSessionRaw` helper handles scenarios that bypass the SDK client and open sessions via raw `fetch` (`lifecycle.ts`, `http-standard-headers.ts`). - `sse-polling.ts` and `sse-multiple-streams.ts`, which manage their own transport, now terminate the session in their existing `finally` block before closing the client. - `lifecycle.test.ts` gains two cases asserting that DELETE is sent with the issued session ID and that no DELETE is sent when the server did not assign one. Verified manually against `examples/servers/typescript/everything-server.ts` that running a server scenario now causes the server to log `Received session termination request for session `. Fixes #79 Signed-off-by: Satoshi Ito --- src/scenarios/server/client-helper.ts | 41 +++++++++++++++++- src/scenarios/server/lifecycle.test.ts | 44 ++++++++++++++++++-- src/scenarios/server/lifecycle.ts | 8 +++- src/scenarios/server/sse-multiple-streams.ts | 10 ++++- src/scenarios/server/sse-polling.ts | 10 ++++- 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts index eebbd9b5..255f1ba4 100644 --- a/src/scenarios/server/client-helper.ts +++ b/src/scenarios/server/client-helper.ts @@ -11,11 +11,17 @@ import { export interface MCPClientConnection { client: Client; + transport: StreamableHTTPClientTransport; close: () => Promise; } /** - * Create and connect an MCP client to a server + * Create and connect an MCP client to a server. + * + * The returned `close()` sends an HTTP DELETE to terminate the server-side + * session (per the Streamable HTTP transport spec) before closing the client, + * so each scenario leaves the server hermetic. A server that responds 405 + * (DELETE not supported) is tolerated, since the spec allows that. */ export async function connectToServer( serverUrl: string @@ -40,12 +46,45 @@ export async function connectToServer( return { client, + transport, close: async () => { + if (transport.sessionId) { + try { + await transport.terminateSession(); + } catch { + // The spec allows the server to respond 405 to a DELETE; best-effort. + } + } await client.close(); } }; } +/** + * Best-effort HTTP DELETE to terminate a raw (non-SDK) session. + * + * Used by scenarios that open sessions via raw `fetch` instead of going through + * the SDK's StreamableHTTPClientTransport. Failures are swallowed because the + * spec allows servers to respond 405, and cleanup must not derail the scenario. + */ +export async function terminateSessionRaw( + serverUrl: string, + sessionId: string, + protocolVersion: string +): Promise { + try { + await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'MCP-Protocol-Version': protocolVersion + } + }); + } catch { + // best-effort cleanup + } +} + /** * Helper to collect notifications (logging and progress) */ diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts index 15ee6d80..4baa9940 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -1,9 +1,13 @@ import { ServerInitializeScenario } from './lifecycle'; import { connectToServer } from './client-helper'; -vi.mock('./client-helper', () => ({ - connectToServer: vi.fn() -})); +vi.mock('./client-helper', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + connectToServer: vi.fn() + }; +}); describe('ServerInitializeScenario', () => { const serverUrl = 'http://localhost:3000/mcp'; @@ -15,6 +19,7 @@ describe('ServerInitializeScenario', () => { vi.stubGlobal('fetch', fetchMock); vi.mocked(connectToServer).mockResolvedValue({ client: {} as any, + transport: {} as any, close: closeMock }); }); @@ -91,4 +96,37 @@ describe('ServerInitializeScenario', () => { } }); }); + + it('sends an HTTP DELETE with the issued session ID to terminate the raw session', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123_ABC' + } + }) + ); + + await new ServerInitializeScenario().run(serverUrl); + + expect(fetchMock).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'mcp-session-id': 'session-123_ABC' + }) + }) + ); + }); + + it('does not send DELETE when the server did not assign a session ID', async () => { + fetchMock.mockResolvedValue(new Response(null)); + + await new ServerInitializeScenario().run(serverUrl); + + const deleteCalls = fetchMock.mock.calls.filter( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ); + expect(deleteCalls).toHaveLength(0); + }); }); diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index bb55869e..35e1b045 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -3,7 +3,7 @@ */ import { ClientScenario, ConformanceCheck } from '../../types'; -import { connectToServer } from './client-helper'; +import { connectToServer, terminateSessionRaw } from './client-helper'; const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; @@ -82,6 +82,7 @@ and validates session ID format if one is assigned.`; // Check: Session ID visible ASCII validation // Use a raw fetch to inspect the MCP-Session-Id response header, // since the SDK client transport does not expose it. + let rawSessionId: string | null = null; try { const response = await fetch(serverUrl, { method: 'POST', @@ -106,6 +107,7 @@ and validates session ID format if one is assigned.`; }); const sessionId = response.headers.get('mcp-session-id'); + rawSessionId = sessionId; if (!sessionId) { checks.push({ @@ -161,6 +163,10 @@ and validates session ID format if one is assigned.`; errorMessage: `Failed to send initialize request for session ID check: ${error instanceof Error ? error.message : String(error)}`, specReferences: SESSION_SPEC_REFERENCES }); + } finally { + if (rawSessionId) { + await terminateSessionRaw(serverUrl, rawSessionId, '2025-11-25'); + } } return checks; diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index 7090f81c..c4a7c6c4 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -254,7 +254,15 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { ] }); } finally { - // Clean up + // Clean up: terminate the session before closing the client so the + // server is left hermetic. + if (transport && sessionId) { + try { + await transport.terminateSession(); + } catch { + // Server MAY respond 405 to DELETE; best-effort. + } + } if (client) { try { await client.close(); diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 79e59864..7b34fb39 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -587,7 +587,15 @@ export class ServerSSEPollingScenario implements ClientScenario { ] }); } finally { - // Clean up + // Clean up: terminate the session before closing the client so the + // server is left hermetic. + if (transport && sessionId) { + try { + await transport.terminateSession(); + } catch { + // Server MAY respond 405 to DELETE; best-effort. + } + } if (client) { try { await client.close();