Skip to content
Open
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
41 changes: 40 additions & 1 deletion src/scenarios/server/client-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ import {

export interface MCPClientConnection {
client: Client;
transport: StreamableHTTPClientTransport;
close: () => Promise<void>;
}

/**
* 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
Expand All @@ -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<void> {
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)
*/
Expand Down
44 changes: 41 additions & 3 deletions src/scenarios/server/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('./client-helper')>();
return {
...actual,
connectToServer: vi.fn()
};
});

describe('ServerInitializeScenario', () => {
const serverUrl = 'http://localhost:3000/mcp';
Expand All @@ -15,6 +19,7 @@ describe('ServerInitializeScenario', () => {
vi.stubGlobal('fetch', fetchMock);
vi.mocked(connectToServer).mockResolvedValue({
client: {} as any,
transport: {} as any,
close: closeMock
});
});
Expand Down Expand Up @@ -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);
});
});
8 changes: 7 additions & 1 deletion src/scenarios/server/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$/;

Expand Down Expand Up @@ -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',
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion src/scenarios/server/sse-multiple-streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion src/scenarios/server/sse-polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading