From 4d460f619924dccf1361af23cec983cf0d4e2f12 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Wed, 27 May 2026 06:54:54 +0000 Subject: [PATCH] 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; + } +}