diff --git a/src/scenarios/client/stateless_server.ts b/src/scenarios/client/stateless_server.ts new file mode 100644 index 0000000..004c860 --- /dev/null +++ b/src/scenarios/client/stateless_server.ts @@ -0,0 +1,334 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import type { Scenario, ConformanceCheck, SpecVersion } from '../../types'; +import express, { Request, Response } from 'express'; +import { ScenarioUrls } from '../../types'; +import { createRequestLogger } from '../request-logger'; + +function createServer(checks: ConformanceCheck[]): express.Application { + // Factory: new Server per request (stateless = no shared state) + function getServer(): Server { + const server = new Server( + { + name: 'stateless-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'add_numbers', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { + type: 'number', + description: 'First number' + }, + b: { + type: 'number', + description: 'Second number' + } + }, + required: ['a', 'b'] + } + } + ] + }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'add_numbers') { + const { a, b } = request.params.arguments as { + a: number; + b: number; + }; + const result = a + b; + + checks.push({ + id: 'stateless-tools-call', + name: 'StatelessToolsCall', + description: + 'Validates that the client can call a tool on a stateless server', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + } + ], + details: { + a, + b, + result + } + }); + + return { + content: [ + { + type: 'text', + text: `The sum of ${a} and ${b} is ${result}` + } + ] + }; + } + + throw new Error(`Unknown tool: ${request.params.name}`); + }); + + return server; + } + + const app = express(); + app.use(express.json()); + + app.use( + createRequestLogger(checks, { + incomingId: 'incoming-request', + outgoingId: 'outgoing-response', + mcpRoute: '/mcp' + }) + ); + + let isFirstPost = true; + + app.post('/mcp', async (req: Request, res: Response) => { + if (!isFirstPost) { + const clientSessionHeader = req.headers['mcp-session-id']; + if (clientSessionHeader) { + checks.push({ + id: 'stateless-no-session-header-sent', + name: 'StatelessNoSessionHeaderSent', + description: + 'Client omits mcp-session-id when server did not provide one', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Client sent mcp-session-id: ${clientSessionHeader}`, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } else if ( + !checks.find((c) => c.id === 'stateless-no-session-header-sent') + ) { + checks.push({ + id: 'stateless-no-session-header-sent', + name: 'StatelessNoSessionHeaderSent', + description: + 'Client omits mcp-session-id when server did not provide one', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + } + isFirstPost = false; + + const server = getServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + transport.close(); + server.close(); + }); + }); + + app.get('/mcp', async (_req: Request, res: Response) => { + checks.push({ + id: 'stateless-get-405', + name: 'StatelessGet405', + description: + 'Stateless server returns 405 for GET (no SSE stream without sessions)', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); + }); + + app.delete('/mcp', async (_req: Request, res: Response) => { + checks.push({ + id: 'stateless-delete-405', + name: 'StatelessDelete405', + description: + 'Stateless server returns 405 for DELETE (no session to terminate)', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); + }); + + return app; +} + +export class StatelessServerScenario implements Scenario { + name = 'stateless_server'; + specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25']; + description = 'Tests that clients handle a stateless server (no session ID)'; + private app: express.Application | null = null; + private httpServer: any = null; + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + this.app = createServer(this.checks); + this.httpServer = this.app.listen(0); + const port = this.httpServer.address().port; + return { serverUrl: `http://localhost:${port}/mcp` }; + } + + async stop() { + if (this.httpServer) { + await new Promise((resolve) => this.httpServer.close(resolve)); + this.httpServer = null; + } + this.app = null; + } + + getChecks(): ConformanceCheck[] { + // Server never sends mcp-session-id with sessionIdGenerator: undefined + if (!this.checks.find((c) => c.id === 'stateless-init-no-session')) { + this.checks.push({ + id: 'stateless-init-no-session', + name: 'StatelessInitNoSession', + description: + 'Server response contains no mcp-session-id header (stateless)', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + if (!this.checks.find((c) => c.id === 'stateless-no-session-header-sent')) { + this.checks.push({ + id: 'stateless-no-session-header-sent', + name: 'StatelessNoSessionHeaderSent', + description: + 'Client omits mcp-session-id when server did not provide one', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + if (!this.checks.find((c) => c.id === 'stateless-get-405')) { + this.checks.push({ + id: 'stateless-get-405', + name: 'StatelessGet405', + description: + 'Stateless server returns 405 for GET (client did not attempt GET)', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + if (!this.checks.find((c) => c.id === 'stateless-delete-405')) { + this.checks.push({ + id: 'stateless-delete-405', + name: 'StatelessDelete405', + description: + 'Stateless server returns 405 for DELETE (client did not attempt DELETE)', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + if (!this.checks.find((c) => c.id === 'stateless-tools-call')) { + this.checks.push({ + id: 'stateless-tools-call', + name: 'StatelessToolsCall', + description: + 'Validates that the client can call a tool on a stateless server', + status: 'FAILURE', + timestamp: new Date().toISOString(), + details: { message: 'Tool was not called by client' }, + specReferences: [ + { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + } + ] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 665a7a9..2675f78 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -58,6 +58,10 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { StatelessServerScenario } from './client/stateless_server'; + +import { StatelessServerCheckScenario } from './server/stateless'; + import { authScenariosList, backcompatScenariosList, @@ -76,7 +80,10 @@ const pendingClientScenariosList: ClientScenario[] = [ // On hold until server-side SSE improvements are made // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 - new ServerSSEPollingScenario() + new ServerSSEPollingScenario(), + + // Only for stateless servers - not testable against everything-server + new StatelessServerCheckScenario() ]; // All client scenarios @@ -115,6 +122,8 @@ const allClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1330) - pending new ElicitationEnumsScenario(), + new StatelessServerCheckScenario(), + // Resources scenarios new ResourcesListScenario(), new ResourcesReadTextScenario(), @@ -171,6 +180,7 @@ const scenariosList: Scenario[] = [ new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), new SSERetryScenario(), + new StatelessServerScenario(), ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts new file mode 100644 index 0000000..dede262 --- /dev/null +++ b/src/scenarios/server/stateless.ts @@ -0,0 +1,264 @@ +/** + * Stateless server test scenario + */ + +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { connectToServer } from './client-helper'; + +export class StatelessServerCheckScenario implements ClientScenario { + name = 'stateless-server'; + specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25']; + description = `Test that a stateless server correctly omits session IDs and rejects GET/DELETE. + +**Server Implementation Requirements:** + +**Transport**: Streamable HTTP with \`sessionIdGenerator: undefined\` + +**Requirements**: +- MUST NOT include \`Mcp-Session-Id\` in any response headers +- MUST accept POST requests without \`Mcp-Session-Id\` header +- MUST return 405 for GET requests +- MUST return 405 for DELETE requests +- MUST handle tool calls without session state + +This test verifies servers that intentionally omit session management.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + // Initialize via raw fetch and verify no session header + try { + const initResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { + name: 'conformance-stateless-test', + version: '1.0.0' + } + }, + id: 1 + }) + }); + + const sessionHeader = initResponse.headers.get('mcp-session-id'); + + checks.push({ + id: 'stateless-server-no-session-header', + name: 'StatelessServerNoSessionHeader', + description: + 'Stateless server omits Mcp-Session-Id from initialize response', + status: sessionHeader ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: sessionHeader + ? `Server sent Mcp-Session-Id: ${sessionHeader}` + : undefined, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ], + details: { serverUrl } + }); + } catch (error) { + checks.push({ + id: 'stateless-server-no-session-header', + name: 'StatelessServerNoSessionHeader', + description: + 'Stateless server omits Mcp-Session-Id from initialize response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + // Connect via SDK (POST without session header) and call a tool + let connection; + try { + connection = await connectToServer(serverUrl); + + checks.push({ + id: 'stateless-server-post-without-session', + name: 'StatelessServerPostWithoutSession', + description: 'Server accepts requests without Mcp-Session-Id header', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ], + details: { serverUrl } + }); + } catch (error) { + checks.push({ + id: 'stateless-server-post-without-session', + name: 'StatelessServerPostWithoutSession', + description: 'Server accepts requests without Mcp-Session-Id header', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + // GET returns 405 + try { + const getResponse = await fetch(serverUrl, { method: 'GET' }); + + checks.push({ + id: 'stateless-server-get-405', + name: 'StatelessServerGet405', + description: 'Stateless server returns 405 for GET requests', + status: getResponse.status === 405 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + getResponse.status !== 405 + ? `Expected 405, got ${getResponse.status}` + : undefined, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ], + details: { status: getResponse.status } + }); + } catch (error) { + checks.push({ + id: 'stateless-server-get-405', + name: 'StatelessServerGet405', + description: 'Stateless server returns 405 for GET requests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + // DELETE returns 405 + try { + const deleteResponse = await fetch(serverUrl, { method: 'DELETE' }); + + checks.push({ + id: 'stateless-server-delete-405', + name: 'StatelessServerDelete405', + description: 'Stateless server returns 405 for DELETE requests', + status: deleteResponse.status === 405 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + deleteResponse.status !== 405 + ? `Expected 405, got ${deleteResponse.status}` + : undefined, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ], + details: { status: deleteResponse.status } + }); + } catch (error) { + checks.push({ + id: 'stateless-server-delete-405', + name: 'StatelessServerDelete405', + description: 'Stateless server returns 405 for DELETE requests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Session', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management' + } + ] + }); + } + + // Call a tool via SDK + if (connection) { + try { + const result = await connection.client.callTool({ + name: 'start-notification-stream', + arguments: { interval: 100, count: 1 } + }); + + checks.push({ + id: 'stateless-server-tools-call', + name: 'StatelessServerToolsCall', + description: 'Tool call completes successfully on a stateless server', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + } + ], + details: { result } + }); + } catch (error) { + checks.push({ + id: 'stateless-server-tools-call', + name: 'StatelessServerToolsCall', + description: 'Tool call completes successfully on a stateless server', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + } + ] + }); + } + + await connection.close(); + } else { + checks.push({ + id: 'stateless-server-tools-call', + name: 'StatelessServerToolsCall', + description: 'Tool call completes successfully on a stateless server', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Failed: connection failed earlier, could not test tool call', + specReferences: [ + { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + } + ] + }); + } + + return checks; + } +}