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; + } +}