From 4d460f619924dccf1361af23cec983cf0d4e2f12 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Wed, 27 May 2026 06:54:54 +0000 Subject: [PATCH 1/2] feat: add session-lifecycle conformance scenario for streamable HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commits a `server-session-lifecycle` scenario that verifies the server honors the two RFC 2119 statements the Streamable HTTP transport spec places on session termination: 1. After receiving an HTTP DELETE bearing the issued Mcp-Session-Id, the server accepts it (2xx) or signals that explicit termination is not supported (405). 2. Subsequent requests bearing the terminated session ID MUST get HTTP 404 Not Found. The scenario inlines a raw `fetch` initialize/terminate/probe flow so the DELETE is the test action (not background cleanup). Stateless servers that never issue a session ID are reported as INFO, and the lifecycle checks are SKIPPED. Servers that return 405 on DELETE skip both checks without flagging a failure — the spec allows servers to refuse explicit termination. Refs #79 Signed-off-by: Satoshi Ito --- .../servers/typescript/everything-server.ts | 20 +- src/scenarios/index.ts | 2 + .../server/session-lifecycle.test.ts | 121 +++++++++ src/scenarios/server/session-lifecycle.ts | 249 ++++++++++++++++++ 4 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/scenarios/server/session-lifecycle.test.ts create mode 100644 src/scenarios/server/session-lifecycle.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 258fe3d6..391867c3 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -2129,6 +2129,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', @@ -2188,11 +2200,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 1422c90e..ffe30fdb 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 { @@ -129,6 +130,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..0d33f684 --- /dev/null +++ b/src/scenarios/server/session-lifecycle.test.ts @@ -0,0 +1,121 @@ +import { SessionLifecycleScenario } from './session-lifecycle'; + +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(serverUrl); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'server-session-lifecycle-skipped', + status: 'INFO' + }); + }); + + it('reports SUCCESS for both 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(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(2); + expect(checks[0]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS', + details: { statusCode: 200 } + }); + expect(checks[1]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SUCCESS', + details: { statusCode: 404 } + }); + }); + + it('marks both 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(serverUrl); + + // Should NOT POST again after a 405 — the 404 check is meaningless then. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(checks).toHaveLength(2); + expect(checks[0]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SKIPPED', + details: { statusCode: 405 } + }); + expect(checks[1]).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(serverUrl); + + expect(checks[0]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS' + }); + expect(checks[1]).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..5b2b3c6e --- /dev/null +++ b/src/scenarios/server/session-lifecycle.ts @@ -0,0 +1,249 @@ +/** + * 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 } from '../../types'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Session-Management', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management' + } +]; + +const PROTOCOL_VERSION = '2025-11-25'; + +const INITIALIZE_BODY = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-session-lifecycle-test', + version: '1.0.0' + } + } +}; + +const TOOLS_LIST_BODY = { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} +}; + +export class SessionLifecycleScenario implements ClientScenario { + name = 'server-session-lifecycle'; + readonly source = { introducedIn: '2025-03-26' } 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, responding with a 2xx status (or 405 if the + server does not support explicit termination). +- 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(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + let sessionId: string | null = null; + 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(INITIALIZE_BODY) + }); + + sessionId = initResponse.headers.get('mcp-session-id'); + + if (!sessionId) { + checks.push({ + id: 'server-session-lifecycle-skipped', + name: 'SessionLifecycleSkipped', + description: + 'Server is stateless (no MCP-Session-Id) — lifecycle checks not applicable', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { + message: + 'Server did not return an MCP-Session-Id header; session lifecycle does not apply.' + } + }); + return checks; + } + + // Complete the handshake so the server treats the session as live. + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': PROTOCOL_VERSION, + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }) + }); + + // Step 1: DELETE the session. + const deleteResponse = await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'MCP-Protocol-Version': PROTOCOL_VERSION + } + }); + + const deleteAccepted = + deleteResponse.status >= 200 && deleteResponse.status < 300; + const deleteNotSupported = deleteResponse.status === 405; + + if (deleteAccepted) { + checks.push({ + id: 'server-session-delete-accepted', + name: 'SessionDeleteAccepted', + description: + 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { statusCode: deleteResponse.status } + }); + } else if (deleteNotSupported) { + checks.push({ + id: 'server-session-delete-accepted', + name: 'SessionDeleteAccepted', + description: + 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { + statusCode: 405, + message: + 'Server returned 405 Method Not Allowed; spec permits servers to refuse explicit DELETE.' + } + }); + // If the server refused DELETE, the terminated-returns-404 check has + // nothing to assert against. + checks.push({ + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { + message: + 'Skipped because the server does not support explicit session termination (405 on DELETE).' + } + }); + return checks; + } else { + checks.push({ + id: 'server-session-delete-accepted', + name: 'SessionDeleteAccepted', + description: + 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Expected 2xx (or 405), got ${deleteResponse.status}`, + specReferences: SPEC_REFERENCES, + details: { statusCode: deleteResponse.status } + }); + // The terminated-returns-404 check would be misleading without a + // successful DELETE; skip it. + checks.push({ + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { + message: + 'Skipped because the preceding DELETE did not succeed; cannot verify 404 behaviour on a terminated session.' + } + }); + 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': PROTOCOL_VERSION, + 'mcp-session-id': sessionId + }, + body: JSON.stringify(TOOLS_LIST_BODY) + }); + + if (afterTerminationResponse.status === 404) { + checks.push({ + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + details: { statusCode: 404 } + }); + } else { + checks.push({ + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Expected 404 on terminated session ID, got ${afterTerminationResponse.status}`, + specReferences: SPEC_REFERENCES, + details: { statusCode: afterTerminationResponse.status } + }); + } + } catch (error) { + checks.push({ + id: 'server-session-lifecycle-error', + name: 'SessionLifecycleError', + description: 'Session lifecycle test execution', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to exercise session lifecycle: ${ + error instanceof Error ? error.message : String(error) + }`, + specReferences: SPEC_REFERENCES + }); + } + + return checks; + } +} From b146faf7ad5489916a0065fa0808fa0e21b97cf5 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 31 Jul 2026 17:17:40 +0100 Subject: [PATCH 2/2] fix: address review feedback on session-lifecycle scenario - Adapt run() to the RunContext signature introduced in #318. - Send the protocol version the server negotiated in the initialize result on follow-up requests instead of hardcoding 2025-11-25, falling back to the requested spec version (the sse-multiple-streams pattern from #415/#416). Parsing the initialize body is bounded by a 5s timeout, and every fetch shares one AbortController aborted when the scenario ends, so a server answering with a never-ending SSE stream can neither hang the run nor leak a held-open connection. - Fail the scenario when initialize itself fails; previously a broken server (401/500) was reported as stateless and passed. - Check the initialized notification response and fail when it is rejected, emitting SKIPPED placeholders for the lifecycle checks so per-check aggregation keeps its rows. - Report unexpected DELETE statuses as WARNING instead of FAILURE (the spec never pins a success status for session termination). - Treat 404 on DELETE as an already-terminated session only when the follow-up request confirms the session is gone; a 404 from a server whose session still responds (likely no DELETE route) is a WARNING with the 404 check skipped, rather than a misattributed failure. - Exclude the scenario from the draft spec version via removedIn: sessions were removed entirely by SEP-2575. --- .../server/session-lifecycle.test.ts | 217 ++++++++- src/scenarios/server/session-lifecycle.ts | 448 ++++++++++++------ 2 files changed, 512 insertions(+), 153 deletions(-) diff --git a/src/scenarios/server/session-lifecycle.test.ts b/src/scenarios/server/session-lifecycle.test.ts index 0d33f684..2f90da34 100644 --- a/src/scenarios/server/session-lifecycle.test.ts +++ b/src/scenarios/server/session-lifecycle.test.ts @@ -1,4 +1,5 @@ import { SessionLifecycleScenario } from './session-lifecycle'; +import { testContext } from '../../connection/testing'; describe('SessionLifecycleScenario', () => { const serverUrl = 'http://localhost:3000/mcp'; @@ -16,7 +17,9 @@ describe('SessionLifecycleScenario', () => { 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(serverUrl); + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); expect(fetchMock).toHaveBeenCalledTimes(1); expect(checks).toHaveLength(1); @@ -26,7 +29,53 @@ describe('SessionLifecycleScenario', () => { }); }); - it('reports SUCCESS for both checks on the happy path (DELETE 200, then POST 404)', async () => { + 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( @@ -41,7 +90,9 @@ describe('SessionLifecycleScenario', () => { // POST after termination .mockResolvedValueOnce(new Response(null, { status: 404 })); - const checks = await new SessionLifecycleScenario().run(serverUrl); + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); const deleteCall = fetchMock.mock.calls[2]; expect(deleteCall?.[0]).toBe(serverUrl); @@ -56,20 +107,71 @@ describe('SessionLifecycleScenario', () => { 'mcp-session-id': 'session-abc' }); - expect(checks).toHaveLength(2); + 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[1]).toMatchObject({ + expect(checks[2]).toMatchObject({ id: 'server-session-terminated-returns-404', status: 'SUCCESS', details: { statusCode: 404 } }); }); - it('marks both checks as SKIPPED when the server returns 405 on DELETE', async () => { + 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, { @@ -79,17 +181,106 @@ describe('SessionLifecycleScenario', () => { .mockResolvedValueOnce(new Response(null, { status: 202 })) .mockResolvedValueOnce(new Response(null, { status: 405 })); - const checks = await new SessionLifecycleScenario().run(serverUrl); + 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(2); - expect(checks[0]).toMatchObject({ + 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' }); @@ -106,13 +297,15 @@ describe('SessionLifecycleScenario', () => { .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })); - const checks = await new SessionLifecycleScenario().run(serverUrl); + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); - expect(checks[0]).toMatchObject({ + expect(checks[1]).toMatchObject({ id: 'server-session-delete-accepted', status: 'SUCCESS' }); - expect(checks[1]).toMatchObject({ + 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 index 5b2b3c6e..b5facbd4 100644 --- a/src/scenarios/server/session-lifecycle.ts +++ b/src/scenarios/server/session-lifecycle.ts @@ -11,7 +11,12 @@ * See https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { readSseJsonRpcResponse, type RunContext } from '../../connection'; const SPEC_REFERENCES = [ { @@ -20,21 +25,16 @@ const SPEC_REFERENCES = [ } ]; -const PROTOCOL_VERSION = '2025-11-25'; +const INITIALIZE_REQUEST_ID = 1; -const INITIALIZE_BODY = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: 'conformance-session-lifecycle-test', - version: '1.0.0' - } - } -}; +/** + * 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', @@ -43,26 +43,127 @@ const TOOLS_LIST_BODY = { 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'; - readonly source = { introducedIn: '2025-03-26' } as const; + // 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, responding with a 2xx status (or 405 if the - server does not support explicit termination). + \`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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; - let sessionId: string | null = null; + // 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). @@ -72,125 +173,162 @@ Servers without session management (stateless) are reported as SKIPPED.`; 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, - body: JSON.stringify(INITIALIZE_BODY) + 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 }); - sessionId = initResponse.headers.get('mcp-session-id'); + 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({ - id: 'server-session-lifecycle-skipped', - name: 'SessionLifecycleSkipped', - description: - 'Server is stateless (no MCP-Session-Id) — lifecycle checks not applicable', - status: 'INFO', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { - message: - 'Server did not return an MCP-Session-Id header; session lifecycle does not apply.' - } - }); + 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. - await fetch(serverUrl, { + const initializedResponse = await fetch(serverUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': PROTOCOL_VERSION, + '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': PROTOCOL_VERSION - } + '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 (deleteAccepted) { - checks.push({ - id: 'server-session-delete-accepted', - name: 'SessionDeleteAccepted', - description: - 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { statusCode: deleteResponse.status } - }); - } else if (deleteNotSupported) { - checks.push({ - id: 'server-session-delete-accepted', - name: 'SessionDeleteAccepted', - description: - 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { - statusCode: 405, - message: - 'Server returned 405 Method Not Allowed; spec permits servers to refuse explicit DELETE.' - } - }); + if (deleteNotSupported) { // If the server refused DELETE, the terminated-returns-404 check has // nothing to assert against. - checks.push({ - id: 'server-session-terminated-returns-404', - name: 'SessionTerminatedReturns404', - description: - 'Server returns HTTP 404 for requests bearing a terminated session ID', - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { - message: - 'Skipped because the server does not support explicit session termination (405 on DELETE).' - } - }); + 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; - } else { - checks.push({ - id: 'server-session-delete-accepted', - name: 'SessionDeleteAccepted', - description: - 'Server accepts HTTP DELETE on the issued session ID with a 2xx response', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Expected 2xx (or 405), got ${deleteResponse.status}`, - specReferences: SPEC_REFERENCES, - details: { statusCode: deleteResponse.status } - }); - // The terminated-returns-404 check would be misleading without a - // successful DELETE; skip it. - checks.push({ - id: 'server-session-terminated-returns-404', - name: 'SessionTerminatedReturns404', - description: - 'Server returns HTTP 404 for requests bearing a terminated session ID', - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { - message: - 'Skipped because the preceding DELETE did not succeed; cannot verify 404 behaviour on a terminated session.' - } - }); + } + + 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; } @@ -200,50 +338,78 @@ Servers without session management (stateless) are reported as SKIPPED.`; headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': PROTOCOL_VERSION, + 'MCP-Protocol-Version': protocolVersion, 'mcp-session-id': sessionId }, - body: JSON.stringify(TOOLS_LIST_BODY) + 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 (afterTerminationResponse.status === 404) { - checks.push({ - id: 'server-session-terminated-returns-404', - name: 'SessionTerminatedReturns404', - description: - 'Server returns HTTP 404 for requests bearing a terminated session ID', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: SPEC_REFERENCES, - details: { statusCode: 404 } - }); + if (terminated404) { + checks.push( + check(TERMINATED_RETURNS_404, 'SUCCESS', { + details: { statusCode: 404 } + }) + ); } else { - checks.push({ - id: 'server-session-terminated-returns-404', - name: 'SessionTerminatedReturns404', - description: - 'Server returns HTTP 404 for requests bearing a terminated session ID', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Expected 404 on terminated session ID, got ${afterTerminationResponse.status}`, - specReferences: SPEC_REFERENCES, - details: { statusCode: afterTerminationResponse.status } - }); + 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({ - id: 'server-session-lifecycle-error', - name: 'SessionLifecycleError', - description: 'Session lifecycle test execution', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Failed to exercise session lifecycle: ${ - error instanceof Error ? error.message : String(error) - }`, - specReferences: SPEC_REFERENCES - }); + 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 } }); +}