diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index e7a6878e..d7de2228 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -2366,6 +2366,18 @@ app.post('/mcp', async (req, res) => { await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); return; + } else if (sessionId) { + // Session ID was provided but no transport matches it — the session + // has been terminated or was never issued. Spec: HTTP 404. + res.status(404).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Session not found' + }, + id: null + }); + return; } else { res.status(400).json({ jsonrpc: '2.0', @@ -2425,11 +2437,17 @@ app.get('/mcp', async (req, res) => { app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports[sessionId]) { + if (!sessionId) { res.status(400).send('Invalid or missing session ID'); return; } + if (!transports[sessionId]) { + // Session has been terminated or was never issued. Spec: HTTP 404. + res.status(404).send('Session not found'); + return; + } + console.log(`Received session termination request for session ${sessionId}`); try { diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..8081b818 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -18,6 +18,7 @@ import { MRTRClientScenario } from './client/mrtr-client'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; +import { SessionLifecycleScenario } from './server/session-lifecycle'; import { ServerStatelessScenario } from './server/stateless'; import { @@ -156,6 +157,7 @@ const pendingClientScenariosList: ClientScenario[] = [ const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios new ServerInitializeScenario(), + new SessionLifecycleScenario(), new ServerStatelessScenario(), // Utilities scenarios diff --git a/src/scenarios/server/session-lifecycle.test.ts b/src/scenarios/server/session-lifecycle.test.ts new file mode 100644 index 00000000..2f90da34 --- /dev/null +++ b/src/scenarios/server/session-lifecycle.test.ts @@ -0,0 +1,314 @@ +import { SessionLifecycleScenario } from './session-lifecycle'; +import { testContext } from '../../connection/testing'; + +describe('SessionLifecycleScenario', () => { + const serverUrl = 'http://localhost:3000/mcp'; + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits INFO and skips the lifecycle checks when the server is stateless', async () => { + fetchMock.mockResolvedValueOnce(new Response(null)); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'server-session-lifecycle-skipped', + status: 'INFO' + }); + }); + + it('reports FAILURE when initialize itself fails', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'server-session-lifecycle-error', + status: 'FAILURE', + details: { statusCode: 500 } + }); + }); + + it('reports FAILURE when the initialized notification is rejected', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-abc' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 400 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(checks).toHaveLength(3); + expect(checks[0]).toMatchObject({ + id: 'server-session-initialized-accepted', + status: 'FAILURE', + details: { statusCode: 400 } + }); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SKIPPED' + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports SUCCESS for all checks on the happy path (DELETE 200, then POST 404)', async () => { + fetchMock + // initialize + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-abc' } + }) + ) + // notifications/initialized + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE + .mockResolvedValueOnce(new Response(null, { status: 200 })) + // POST after termination + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + const deleteCall = fetchMock.mock.calls[2]; + expect(deleteCall?.[0]).toBe(serverUrl); + expect((deleteCall?.[1] as RequestInit).method).toBe('DELETE'); + expect((deleteCall?.[1] as RequestInit).headers).toMatchObject({ + 'mcp-session-id': 'session-abc' + }); + + const postAfterDelete = fetchMock.mock.calls[3]; + expect((postAfterDelete?.[1] as RequestInit).method).toBe('POST'); + expect((postAfterDelete?.[1] as RequestInit).headers).toMatchObject({ + 'mcp-session-id': 'session-abc' + }); + + expect(checks).toHaveLength(3); + expect(checks[0]).toMatchObject({ + id: 'server-session-initialized-accepted', + status: 'SUCCESS', + details: { statusCode: 202 } + }); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS', + details: { statusCode: 200 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SUCCESS', + details: { statusCode: 404 } + }); + }); + + it('sends the negotiated protocol version on follow-up requests', async () => { + fetchMock + // initialize: server negotiates down to 2025-03-26 + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2025-03-26', + capabilities: {}, + serverInfo: { name: 'test', version: '1.0.0' } + } + }), + { + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'session-old' + } + } + ) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl, '2025-06-18') + ); + + // initialize itself must not carry MCP-Protocol-Version. + const initHeaders = (fetchMock.mock.calls[0]?.[1] as RequestInit) + .headers as Record; + expect(initHeaders['MCP-Protocol-Version']).toBeUndefined(); + + // All follow-up requests carry the negotiated version, not a hardcoded one. + for (const callIndex of [1, 2, 3]) { + const headers = (fetchMock.mock.calls[callIndex]?.[1] as RequestInit) + .headers as Record; + expect(headers['MCP-Protocol-Version']).toBe('2025-03-26'); + } + + expect(checks).toHaveLength(3); + expect(checks.every((c) => c.status === 'SUCCESS')).toBe(true); + }); + + it('marks the lifecycle checks as SKIPPED when the server returns 405 on DELETE', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-no-delete' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 405 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + // Should NOT POST again after a 405 — the 404 check is meaningless then. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SKIPPED', + details: { statusCode: 405 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('treats 404 on DELETE as already terminated when the session stops responding', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-expired' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE says the session is already gone + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS', + details: { statusCode: 404 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SUCCESS' + }); + }); + + it('reports WARNING when DELETE returns 404 but the session still responds', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-no-delete-route' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE 404s (no DELETE route)... + .mockResolvedValueOnce(new Response(null, { status: 404 })) + // ...but the session is still alive. + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'WARNING', + details: { statusCode: 404 } + }); + expect(checks[1].errorMessage).toContain('still responds'); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports WARNING (not FAILURE) on an unexpected DELETE status and skips the 404 check', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-odd-delete' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'WARNING', + details: { statusCode: 500 } + }); + expect(checks[1].errorMessage).toContain('Expected 2xx, 404, or 405'); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports FAILURE on the terminated-returns-404 check when the server returns 200 after DELETE', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-buggy' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS' + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'FAILURE', + details: { statusCode: 200 } + }); + }); +}); diff --git a/src/scenarios/server/session-lifecycle.ts b/src/scenarios/server/session-lifecycle.ts new file mode 100644 index 00000000..b5facbd4 --- /dev/null +++ b/src/scenarios/server/session-lifecycle.ts @@ -0,0 +1,415 @@ +/** + * Session lifecycle conformance test scenario for MCP servers. + * + * Verifies the two server-side guarantees the Streamable HTTP transport spec + * places on session termination: + * + * 1. The server accepts an HTTP DELETE that carries the issued session ID. + * 2. After such a DELETE, a subsequent request bearing the terminated + * session ID is rejected with HTTP 404 Not Found. + * + * See https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { readSseJsonRpcResponse, type RunContext } from '../../connection'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Session-Management', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management' + } +]; + +const INITIALIZE_REQUEST_ID = 1; + +/** + * How long to wait for the initialize result body before giving up on + * extracting the negotiated protocol version. A server that answers the + * initialize POST with a long-lived SSE stream and never emits the response + * must not hang the run; on timeout the caller falls back to the requested + * spec version. + */ +const NEGOTIATED_VERSION_TIMEOUT_MS = 5_000; + +const TOOLS_LIST_BODY = { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} +}; + +interface CheckDef { + id: string; + name: string; + description: string; +} + +const LIFECYCLE_ERROR: CheckDef = { + id: 'server-session-lifecycle-error', + name: 'SessionLifecycleError', + description: 'Session lifecycle test execution' +}; + +const INITIALIZED_ACCEPTED: CheckDef = { + id: 'server-session-initialized-accepted', + name: 'SessionInitializedAccepted', + description: + 'Server accepts the initialized notification on the issued session ID with a 2xx response' +}; + +const DELETE_ACCEPTED: CheckDef = { + id: 'server-session-delete-accepted', + name: 'SessionDeleteAccepted', + description: 'Server accepts HTTP DELETE on the issued session ID' +}; + +const TERMINATED_RETURNS_404: CheckDef = { + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID' +}; + +function check( + def: CheckDef, + status: ConformanceCheck['status'], + extras: Pick, 'errorMessage' | 'details'> = {} +): ConformanceCheck { + return { + ...def, + status, + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + ...extras + }; +} + +/** + * Extract the protocol version the server negotiated in its initialize + * result. Returns undefined when the body cannot be parsed or does not + * arrive within the timeout; the caller falls back to the requested spec + * version. + */ +async function negotiatedProtocolVersion( + initResponse: Response +): Promise { + const parse = async (): Promise => { + const contentType = initResponse.headers.get('content-type') ?? ''; + let body: unknown; + if (contentType.includes('text/event-stream')) { + body = (await readSseJsonRpcResponse(initResponse, INITIALIZE_REQUEST_ID)) + .body; + } else { + body = await initResponse.json(); + } + const result = (body as { result?: { protocolVersion?: unknown } } | null) + ?.result; + return typeof result?.protocolVersion === 'string' + ? result.protocolVersion + : undefined; + }; + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + parse(), + new Promise((resolve) => { + timer = setTimeout( + () => resolve(undefined), + NEGOTIATED_VERSION_TIMEOUT_MS + ); + }) + ]); + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +export class SessionLifecycleScenario implements ClientScenario { + name = 'server-session-lifecycle'; + // Sessions were removed entirely from the draft spec (SEP-2575), so the + // lifecycle checks only apply to the dated stateful versions. + readonly source = { + introducedIn: '2025-03-26', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; + description = `Verify the server honours the streamable-HTTP session +termination contract. + +**Server Implementation Requirements:** + +- Accept an HTTP DELETE to the MCP endpoint that carries the issued + \`Mcp-Session-Id\` header (or respond 405 if the server does not support + explicit termination). The spec does not pin a success status, so any + other response is reported as a warning rather than a failure. +- After such a DELETE, return HTTP 404 Not Found for subsequent requests + bearing the terminated session ID. + +Servers without session management (stateless) are reported as SKIPPED.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + + // Every fetch in this scenario shares one AbortController: cancel() on + // a body whose reader is still locked rejects, so aborting the fetch is + // the only reliable way to drop a never-ending SSE stream (initialize + // or any follow-up response) once the scenario finishes. + const abort = new AbortController(); + + try { + // initialize MUST NOT carry MCP-Protocol-Version (the version is being + // negotiated by the initialize handshake itself). + const initResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: INITIALIZE_REQUEST_ID, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: {}, + clientInfo: { + name: 'conformance-session-lifecycle-test', + version: '1.0.0' + } + } + }), + signal: abort.signal + }); + + if (!initResponse.ok) { + checks.push( + check(LIFECYCLE_ERROR, 'FAILURE', { + errorMessage: `initialize failed with HTTP ${initResponse.status}; cannot exercise session lifecycle`, + details: { statusCode: initResponse.status } + }) + ); + return checks; + } + + const sessionId = initResponse.headers.get('mcp-session-id'); + + if (!sessionId) { + checks.push( + check( + { + id: 'server-session-lifecycle-skipped', + name: 'SessionLifecycleSkipped', + description: + 'Server is stateless (no MCP-Session-Id) — lifecycle checks not applicable' + }, + 'INFO', + { + details: { + message: + 'Server did not return an MCP-Session-Id header; session lifecycle does not apply.' + } + } + ) + ); + return checks; + } + + // Follow-up requests carry the version the server actually negotiated + // (2025-06-18+ says the client SHOULD send the negotiated version, and + // servers MAY reject versions they don't support with 400), falling + // back to the requested spec version. Hardcoding a newer version here + // would false-fail servers that negotiated an older one. + const protocolVersion = + (await negotiatedProtocolVersion(initResponse)) ?? specVersion; + + // Complete the handshake so the server treats the session as live. + const initializedResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }), + signal: abort.signal + }); + + if (!initializedResponse.ok) { + // The session never became live, so the termination checks would be + // meaningless. + checks.push( + check(INITIALIZED_ACCEPTED, 'FAILURE', { + errorMessage: `Expected 2xx for notifications/initialized, got ${initializedResponse.status}`, + details: { statusCode: initializedResponse.status } + }), + skipped( + DELETE_ACCEPTED, + 'Skipped because the initialized notification was rejected; the session never became live.' + ), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the initialized notification was rejected; the session never became live.' + ) + ); + return checks; + } + + checks.push( + check(INITIALIZED_ACCEPTED, 'SUCCESS', { + details: { statusCode: initializedResponse.status } + }) + ); + + // Step 1: DELETE the session. + const deleteResponse = await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'MCP-Protocol-Version': protocolVersion + }, + signal: abort.signal + }); + + const deleteAccepted = + deleteResponse.status >= 200 && deleteResponse.status < 300; + const deleteNotSupported = deleteResponse.status === 405; + // A 404 could mean the server already considers the session terminated + // (e.g. it expired between initialize and DELETE) — or that the server + // simply has no DELETE route. The follow-up request below tells the + // two apart. + const deleteReturned404 = deleteResponse.status === 404; + + if (deleteNotSupported) { + // If the server refused DELETE, the terminated-returns-404 check has + // nothing to assert against. + checks.push( + check(DELETE_ACCEPTED, 'SKIPPED', { + details: { + statusCode: 405, + message: + 'Server returned 405 Method Not Allowed; spec permits servers to refuse explicit DELETE.' + } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the server does not support explicit session termination (405 on DELETE).' + ) + ); + return checks; + } + + if (!deleteAccepted && !deleteReturned404) { + // The spec only says the client SHOULD send DELETE and the server + // MAY respond 405; it never pins a success status, so an unexpected + // status is a warning rather than a failure. Without a confirmed + // termination the terminated-returns-404 check would be misleading. + const warningMessage = `Expected 2xx, 404, or 405 for DELETE, got ${deleteResponse.status}. The spec does not pin a success status for session termination, so this is reported as a warning.`; + checks.push( + check(DELETE_ACCEPTED, 'WARNING', { + errorMessage: warningMessage, + details: { + statusCode: deleteResponse.status, + message: warningMessage + } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the preceding DELETE did not clearly terminate the session; cannot verify 404 behaviour.' + ) + ); + return checks; + } + + // Step 2: Re-send a request with the terminated session ID and expect 404. + const afterTerminationResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'mcp-session-id': sessionId + }, + body: JSON.stringify(TOOLS_LIST_BODY), + signal: abort.signal + }); + const terminated404 = afterTerminationResponse.status === 404; + + if (deleteReturned404 && !terminated404) { + // The 404 on DELETE was not a termination: the session still + // answers. Most likely the server has no DELETE route (it should + // return 405 in that case). + const warningMessage = `DELETE returned 404 but the session still responds (HTTP ${afterTerminationResponse.status} on a follow-up request). A server without explicit termination support should return 405.`; + checks.push( + check(DELETE_ACCEPTED, 'WARNING', { + errorMessage: warningMessage, + details: { statusCode: 404, message: warningMessage } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the preceding DELETE did not terminate the session; cannot verify 404 behaviour.' + ) + ); + return checks; + } + + checks.push( + check(DELETE_ACCEPTED, 'SUCCESS', { + details: deleteReturned404 + ? { + statusCode: 404, + message: + 'Server returned 404 for DELETE and the session no longer responds; treating it as already terminated.' + } + : { statusCode: deleteResponse.status } + }) + ); + + if (terminated404) { + checks.push( + check(TERMINATED_RETURNS_404, 'SUCCESS', { + details: { statusCode: 404 } + }) + ); + } else { + checks.push( + check(TERMINATED_RETURNS_404, 'FAILURE', { + errorMessage: `Expected 404 on terminated session ID, got ${afterTerminationResponse.status}`, + details: { statusCode: afterTerminationResponse.status } + }) + ); + } + } catch (error) { + checks.push( + check(LIFECYCLE_ERROR, 'FAILURE', { + errorMessage: `Failed to exercise session lifecycle: ${ + error instanceof Error ? error.message : String(error) + }` + }) + ); + } finally { + // Drop whatever is left of any response (e.g. a still-open SSE + // stream after the negotiation timeout, or an unread follow-up body) + // so it cannot keep the connection alive after the scenario ends. A + // no-op for bodies that were fully consumed. + abort.abort(); + } + + return checks; + } +} + +function skipped(def: CheckDef, message: string): ConformanceCheck { + return check(def, 'SKIPPED', { details: { message } }); +}