diff --git a/examples/clients/typescript/sse-retry-test.ts b/examples/clients/typescript/sse-retry-test.ts new file mode 100644 index 0000000..8595668 --- /dev/null +++ b/examples/clients/typescript/sse-retry-test.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * SSE Retry Test Client + * + * Tests that the MCP client respects the SSE retry field when reconnecting. + * This client connects to a test server that sends retry: field and closes + * the connection, then validates that the client waits the appropriate time. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +async function main(): Promise { + const serverUrl = process.argv[2]; + + if (!serverUrl) { + console.error('Usage: sse-retry-test '); + process.exit(1); + } + + console.log(`Connecting to MCP server at: ${serverUrl}`); + console.log('This test validates SSE retry field compliance (SEP-1699)'); + + try { + const client = new Client( + { + name: 'sse-retry-test-client', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + reconnectionOptions: { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 3 + } + }); + + // Track reconnection events + transport.onerror = (error) => { + console.log(`Transport error: ${error.message}`); + }; + + transport.onclose = () => { + console.log('Transport closed'); + }; + + console.log('Initiating connection...'); + await client.connect(transport); + console.log('Connected to MCP server'); + + // Keep connection alive to observe reconnection behavior + // The server will close the POST SSE stream and the client should reconnect via GET + console.log('Waiting for reconnection cycle...'); + console.log( + 'Server will send priming event with retry field, then close POST SSE stream' + ); + console.log( + 'Client should wait for retry period (2000ms) then reconnect via GET with Last-Event-ID' + ); + + // Wait long enough for: + // 1. Server to send priming event with retry field on POST SSE stream (100ms) + // 2. Server closes POST stream to trigger reconnection + // 3. Client waits for retry period (2000ms expected) + // 4. Client reconnects via GET with Last-Event-ID header + await new Promise((resolve) => setTimeout(resolve, 6000)); + + console.log('Test duration complete'); + + await transport.close(); + console.log('Connection closed successfully'); + + process.exit(0); + } catch (error) { + console.error('Test failed:', error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); +}); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index be90caf..0888732 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -12,7 +12,12 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + StreamableHTTPServerTransport, + EventStore, + EventId, + StreamId +} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import express from 'express'; import cors from 'cors'; @@ -26,6 +31,41 @@ const watchedResourceContent = 'Watched resource content'; const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map< + string, + { eventId: string; message: any; streamId: string } +>(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: any): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: any) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0]; + const eventsToReplay: Array<[string, { message: any }]> = []; + for (const [eventId, data] of eventStoreData.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + // Sample base64 encoded 1x1 red PNG pixel for testing const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; @@ -872,6 +912,8 @@ app.post('/mcp', async (req, res) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, // 5 second retry interval for SEP-1699 onsessioninitialized: (newSessionId) => { transports[newSessionId] = transport; servers[newSessionId] = mcpServer; diff --git a/package-lock.json b/package-lock.json index 2232591..12cdd24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", "express": "^5.1.0", "lefthook": "^2.0.2", "zod": "^3.25.76" diff --git a/package.json b/package.json index cedad3c..b996a7c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", "express": "^5.1.0", "lefthook": "^2.0.2", "zod": "^3.25.76" diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts new file mode 100644 index 0000000..a982678 --- /dev/null +++ b/src/scenarios/client/sse-retry.ts @@ -0,0 +1,375 @@ +/** + * SSE Retry conformance test scenarios for MCP clients (SEP-1699) + * + * Tests that clients properly respect the SSE retry field by: + * - Waiting the specified milliseconds before reconnecting + * - Sending Last-Event-ID header on reconnection + * - Treating graceful stream closure as reconnectable + */ + +import http from 'http'; +import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; + +export class SSERetryScenario implements Scenario { + name = 'sse-retry'; + description = + 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; + + private server: http.Server | null = null; + private checks: ConformanceCheck[] = []; + private port: number = 0; + + // Timing tracking + private postStreamCloseTime: number | null = null; + private getReconnectionTime: number | null = null; + private getConnectionCount: number = 0; + private lastEventIds: (string | undefined)[] = []; + private retryValue: number = 2000; // 2 seconds + private eventIdCounter: number = 0; + private sessionId: string = `session-${Date.now()}`; + private primingEventId: string | null = null; + + // Tolerances for timing validation + private readonly EARLY_TOLERANCE = 50; // Allow 50ms early for scheduler variance + private readonly LATE_TOLERANCE = 200; // Allow 200ms late for network/event loop + private readonly VERY_LATE_MULTIPLIER = 2; // If >2x retry value, client is likely ignoring it + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.on('error', reject); + + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + this.port = address.port; + resolve({ + serverUrl: `http://localhost:${this.port}` + }); + } else { + reject(new Error('Failed to get server address')); + } + }); + }); + } + + async stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + this.server.close((err) => { + if (err) { + reject(err); + } else { + this.server = null; + resolve(); + } + }); + } else { + resolve(); + } + }); + } + + getChecks(): ConformanceCheck[] { + // Generate checks based on observed behavior + this.generateChecks(); + return this.checks; + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method === 'GET') { + // Track GET reconnection timing and Last-Event-ID + this.getConnectionCount++; + this.getReconnectionTime = performance.now(); + + const lastEventId = req.headers['last-event-id'] as string | undefined; + this.lastEventIds.push(lastEventId); + + // Handle GET SSE stream request (reconnection) + this.handleGetSSEStream(req, res); + } else if (req.method === 'POST') { + // Handle POST JSON-RPC requests + this.handlePostRequest(req, res); + } else { + res.writeHead(405); + res.end('Method Not Allowed'); + } + } + + private handleGetSSEStream( + _req: http.IncomingMessage, + res: http.ServerResponse + ): void { + // Set SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + + // Generate event ID + this.eventIdCounter++; + const eventId = `event-${this.eventIdCounter}`; + + // Send priming event with ID and retry field + res.write(`id: ${eventId}\n`); + res.write(`retry: ${this.retryValue}\n`); + res.write(`data: \n\n`); + + // Keep connection open for now (don't close immediately to avoid infinite reconnection loop) + // The test will stop the server when done + } + + private handlePostRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + let body = ''; + + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const request = JSON.parse(body); + + if (request.method === 'initialize') { + // Respond to initialize request with SSE stream containing priming event + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + + // Generate priming event ID + this.eventIdCounter++; + this.primingEventId = `event-${this.eventIdCounter}`; + + // Send priming event with retry field + res.write(`id: ${this.primingEventId}\n`); + res.write(`retry: ${this.retryValue}\n`); + res.write(`data: \n\n`); + + // Send initialize response + const response = { + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2025-03-26', + serverInfo: { + name: 'sse-retry-test-server', + version: '1.0.0' + }, + capabilities: {} + } + }; + + res.write(`event: message\n`); + res.write(`id: event-${++this.eventIdCounter}\n`); + res.write(`data: ${JSON.stringify(response)}\n\n`); + + // Close connection after sending response to trigger reconnection + // Record the time when we close the stream + setTimeout(() => { + this.postStreamCloseTime = performance.now(); + res.end(); + }, 100); + } else if (request.id === undefined) { + // Notifications (no id) - return 202 Accepted + res.writeHead(202); + res.end(); + } else { + // For other requests, send a simple JSON response + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: {} + }) + ); + } + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32700, + message: `Parse error: ${error}` + } + }) + ); + } + }); + } + + private generateChecks(): void { + // Check 1: Client should have reconnected via GET after POST stream close + if (this.getConnectionCount < 1) { + this.checks.push({ + id: 'client-sse-graceful-reconnect', + name: 'ClientGracefulReconnect', + description: + 'Client reconnects via GET after POST SSE stream is closed gracefully', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Client did not attempt GET reconnection after POST stream closure. Client should treat graceful stream close as reconnectable.`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + getConnectionCount: this.getConnectionCount, + postStreamCloseTime: this.postStreamCloseTime, + retryValue: this.retryValue + } + }); + return; + } + + // Client did reconnect - SUCCESS for graceful reconnection + this.checks.push({ + id: 'client-sse-graceful-reconnect', + name: 'ClientGracefulReconnect', + description: + 'Client reconnects via GET after POST SSE stream is closed gracefully', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + getConnectionCount: this.getConnectionCount + } + }); + + // Check 2: Client MUST respect retry field timing + if ( + this.postStreamCloseTime !== null && + this.getReconnectionTime !== null + ) { + const actualDelay = this.getReconnectionTime - this.postStreamCloseTime; + const minExpected = this.retryValue - this.EARLY_TOLERANCE; + const maxExpected = this.retryValue + this.LATE_TOLERANCE; + + const tooEarly = actualDelay < minExpected; + const slightlyLate = actualDelay > maxExpected; + const veryLate = + actualDelay > this.retryValue * this.VERY_LATE_MULTIPLIER; + const withinTolerance = !tooEarly && !slightlyLate; + + let status: 'SUCCESS' | 'FAILURE' | 'WARNING' = 'SUCCESS'; + let errorMessage: string | undefined; + + if (tooEarly) { + // Client reconnected too soon - MUST violation + status = 'FAILURE'; + errorMessage = `Client reconnected too early (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). Client MUST respect the retry field and wait the specified time.`; + } else if (veryLate) { + // Client reconnected way too late - likely ignoring retry field entirely + status = 'FAILURE'; + errorMessage = `Client reconnected very late (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). Client appears to be ignoring the retry field and using its own backoff strategy.`; + } else if (slightlyLate) { + // Client reconnected slightly late - not a spec violation but suspicious + status = 'WARNING'; + errorMessage = `Client reconnected slightly late (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). This is acceptable but may indicate network delays.`; + } + + this.checks.push({ + id: 'client-sse-retry-timing', + name: 'ClientRespectsRetryField', + description: + 'Client MUST respect the retry field, waiting the given number of milliseconds before attempting to reconnect', + status, + timestamp: new Date().toISOString(), + errorMessage, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + expectedRetryMs: this.retryValue, + actualDelayMs: Math.round(actualDelay), + minAcceptableMs: minExpected, + maxAcceptableMs: maxExpected, + veryLateThresholdMs: this.retryValue * this.VERY_LATE_MULTIPLIER, + earlyToleranceMs: this.EARLY_TOLERANCE, + lateToleranceMs: this.LATE_TOLERANCE, + withinTolerance, + tooEarly, + slightlyLate, + veryLate, + getConnectionCount: this.getConnectionCount + } + }); + } else { + this.checks.push({ + id: 'client-sse-retry-timing', + name: 'ClientRespectsRetryField', + description: 'Client MUST respect the retry field timing', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + 'Could not measure timing - POST stream close time or GET reconnection time not recorded', + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + postStreamCloseTime: this.postStreamCloseTime, + getReconnectionTime: this.getReconnectionTime + } + }); + } + + // Check 3: Client SHOULD send Last-Event-ID header on reconnection + const hasLastEventId = + this.lastEventIds.length > 0 && this.lastEventIds[0] !== undefined; + + this.checks.push({ + id: 'client-sse-last-event-id', + name: 'ClientSendsLastEventId', + description: + 'Client SHOULD send Last-Event-ID header on reconnection for resumability', + status: hasLastEventId ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasLastEventId, + lastEventIds: this.lastEventIds, + primingEventId: this.primingEventId, + getConnectionCount: this.getConnectionCount + }, + errorMessage: !hasLastEventId + ? 'Client did not send Last-Event-ID header on reconnection. This is a SHOULD requirement for resumability.' + : undefined + }); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 5d50dff..03dcddf 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -2,6 +2,7 @@ import { Scenario, ClientScenario } from '../types.js'; import { InitializeScenario } from './client/initialize.js'; import { ToolsCallScenario } from './client/tools_call.js'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js'; +import { SSERetryScenario } from './client/sse-retry.js'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle.js'; @@ -27,6 +28,7 @@ import { import { ElicitationDefaultsScenario } from './server/elicitation-defaults.js'; import { ElicitationEnumsScenario } from './server/elicitation-enums.js'; +import { ServerSSEPollingScenario } from './server/sse-polling.js'; import { ResourcesListScenario, @@ -78,6 +80,9 @@ const allClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1034) new ElicitationDefaultsScenario(), + // SSE Polling scenarios (SEP-1699) + new ServerSSEPollingScenario(), + // Elicitation scenarios (SEP-1330) - pending ...pendingClientScenariosList, @@ -116,6 +121,7 @@ const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), + new SSERetryScenario(), ...authScenariosList ]; diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts new file mode 100644 index 0000000..0b1194d --- /dev/null +++ b/src/scenarios/server/sse-polling.ts @@ -0,0 +1,419 @@ +/** + * SSE Polling conformance test scenarios for MCP servers (SEP-1699) + * + * Tests that servers properly implement SSE polling behavior including: + * - Sending priming events with event ID and empty data on POST SSE streams + * - Sending retry field in priming events when configured + * - Replaying events when client reconnects with Last-Event-ID + */ + +import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export class ServerSSEPollingScenario implements ClientScenario { + name = 'server-sse-polling'; + description = + 'Test server sends SSE priming events on POST streams and supports event replay (SEP-1699)'; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + let sessionId: string | undefined; + let client: Client | undefined; + let transport: StreamableHTTPClientTransport | undefined; + + try { + // Step 1: Initialize session with the server + client = new Client( + { + name: 'conformance-test-client', + version: '1.0.0' + }, + { + capabilities: { + sampling: {}, + elicitation: {} + } + } + ); + + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + + // Extract session ID from transport (accessing internal state) + sessionId = (transport as unknown as { sessionId?: string }).sessionId; + + if (!sessionId) { + checks.push({ + id: 'server-sse-polling-session', + name: 'ServerSSEPollingSession', + description: 'Server provides session ID for SSE polling tests', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + message: + 'Server did not provide session ID - SSE polling tests may not work correctly' + } + }); + } + + // Step 2: Make a POST request that returns SSE stream + // We need to use raw fetch to observe the priming event + const postResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + ...(sessionId && { 'mcp-session-id': sessionId }), + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }) + }); + + if (!postResponse.ok) { + checks.push({ + id: 'server-sse-post-request', + name: 'ServerSSEPostRequest', + description: 'Server accepts POST request with SSE stream response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Server returned HTTP ${postResponse.status}`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + return checks; + } + + // Check if server returned SSE stream + const contentType = postResponse.headers.get('content-type'); + if (!contentType?.includes('text/event-stream')) { + checks.push({ + id: 'server-sse-content-type', + name: 'ServerSSEContentType', + description: 'Server returns text/event-stream for POST request', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + contentType, + message: + 'Server returned JSON instead of SSE stream - priming event tests not applicable' + } + }); + return checks; + } + + // Step 3: Parse SSE stream for priming event + let hasEventId = false; + let hasPrimingEvent = false; + let primingEventIsFirst = false; + let hasRetryField = false; + let retryValue: number | undefined; + let primingEventId: string | undefined; + let eventCount = 0; + + if (!postResponse.body) { + checks.push({ + id: 'server-sse-polling-stream', + name: 'ServerSSEPollingStream', + description: 'Server provides SSE response body', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Response body is null', + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + return checks; + } + + const reader = postResponse.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough( + new EventSourceParserStream({ + onRetry: (retryMs: number) => { + hasRetryField = true; + retryValue = retryMs; + } + }) + ) + .getReader(); + + // Read events with timeout + const timeout = setTimeout(() => { + reader.cancel(); + }, 5000); + + try { + while (true) { + const { value: event, done } = await reader.read(); + + if (done) { + break; + } + + eventCount++; + + // Check for event ID + if (event.id) { + hasEventId = true; + if (!primingEventId) { + primingEventId = event.id; + } + + // Check if this is a priming event (empty or minimal data) + if ( + event.data === '' || + event.data === '{}' || + event.data.trim() === '' + ) { + hasPrimingEvent = true; + // Check if priming event is the first event + if (eventCount === 1) { + primingEventIsFirst = true; + } + } + } + } + } finally { + clearTimeout(timeout); + } + + // Check 1: Server SHOULD send priming event with ID on POST SSE stream + let primingStatus: 'SUCCESS' | 'WARNING' = 'SUCCESS'; + let primingErrorMessage: string | undefined; + + if (!hasPrimingEvent) { + primingStatus = 'WARNING'; + primingErrorMessage = + 'Server did not send priming event with id and empty data on POST SSE stream. This is recommended for resumability.'; + } else if (!primingEventIsFirst) { + primingStatus = 'WARNING'; + primingErrorMessage = + 'Priming event was not sent first. It should be sent immediately when the SSE stream is established.'; + } + + checks.push({ + id: 'server-sse-priming-event', + name: 'ServerSendsPrimingEvent', + description: + 'Server SHOULD send priming event with id and empty data on POST SSE streams', + status: primingStatus, + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasPrimingEvent, + primingEventIsFirst, + hasEventId, + primingEventId, + eventCount + }, + errorMessage: primingErrorMessage + }); + + // Check 2: Server SHOULD send retry field in priming event + checks.push({ + id: 'server-sse-retry-field', + name: 'ServerSendsRetryField', + description: + 'Server SHOULD send retry field to control client reconnection timing', + status: hasRetryField ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasRetryField, + retryValue + }, + errorMessage: !hasRetryField + ? 'Server did not send retry field. This is recommended for controlling client reconnection timing.' + : undefined + }); + + // Step 4: Test event replay by reconnecting with Last-Event-ID + if (primingEventId && sessionId) { + // Make a GET request with Last-Event-ID to test replay + const getResponse = await fetch(serverUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': primingEventId + } + }); + + if (getResponse.ok) { + // Server accepted reconnection with Last-Event-ID + let replayedEvents = 0; + + if (getResponse.body) { + const replayReader = getResponse.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + const replayTimeout = setTimeout(() => { + replayReader.cancel(); + }, 2000); + + try { + while (true) { + const { done } = await replayReader.read(); + if (done) break; + replayedEvents++; + } + } finally { + clearTimeout(replayTimeout); + } + } + + checks.push({ + id: 'server-sse-event-replay', + name: 'ServerReplaysEvents', + description: + 'Server replays events after Last-Event-ID on reconnection', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + lastEventIdUsed: primingEventId, + replayedEvents, + message: 'Server accepted GET request with Last-Event-ID header' + } + }); + } else { + // Check if server doesn't support standalone GET streams + if (getResponse.status === 405) { + checks.push({ + id: 'server-sse-event-replay', + name: 'ServerReplaysEvents', + description: + 'Server supports GET reconnection with Last-Event-ID', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + statusCode: getResponse.status, + message: + 'Server does not support standalone GET SSE endpoint (405 Method Not Allowed)' + } + }); + } else { + checks.push({ + id: 'server-sse-event-replay', + name: 'ServerReplaysEvents', + description: + 'Server replays events after Last-Event-ID on reconnection', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + statusCode: getResponse.status, + lastEventIdUsed: primingEventId, + message: `Server returned ${getResponse.status} for GET request with Last-Event-ID` + }, + errorMessage: `Server did not accept reconnection with Last-Event-ID (HTTP ${getResponse.status})` + }); + } + } + } else { + checks.push({ + id: 'server-sse-event-replay', + name: 'ServerReplaysEvents', + description: + 'Server replays events after Last-Event-ID on reconnection', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + primingEventId, + sessionId, + message: + 'Could not test event replay - no priming event ID or session ID available' + } + }); + } + } catch (error) { + checks.push({ + id: 'server-sse-polling-error', + name: 'ServerSSEPollingTest', + description: 'Test server SSE polling behavior', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Error: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + } finally { + // Clean up + if (client) { + try { + await client.close(); + } catch { + // Ignore cleanup errors + } + } + } + + return checks; + } +}