Skip to content

Commit 8a4601d

Browse files
Add conformance tests for SEP-1699 SSE polling
Adds conformance tests for SEP-1699 (SSE Polling via Server-Side Disconnect) which allows servers to close connections while maintaining SSE streams for long-polling behavior. New scenarios: - server-sse-polling: Tests server sends priming event (id + empty data) and retry field before closing connection (SHOULD requirements) - sse-retry: Tests client respects SSE retry field timing when reconnecting (MUST requirement) These tests will initially fail for TypeScript SDK as it doesn't currently respect the retry field from SSE events.
1 parent 253ede0 commit 8a4601d

File tree

4 files changed

+632
-0
lines changed

4 files changed

+632
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* SSE Retry Test Client
5+
*
6+
* Tests that the MCP client respects the SSE retry field when reconnecting.
7+
* This client connects to a test server that sends retry: field and closes
8+
* the connection, then validates that the client waits the appropriate time.
9+
*/
10+
11+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
12+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
13+
14+
async function main(): Promise<void> {
15+
const serverUrl = process.argv[2];
16+
17+
if (!serverUrl) {
18+
console.error('Usage: sse-retry-test <server-url>');
19+
process.exit(1);
20+
}
21+
22+
console.log(`Connecting to MCP server at: ${serverUrl}`);
23+
console.log('This test validates SSE retry field compliance (SEP-1699)');
24+
25+
try {
26+
const client = new Client(
27+
{
28+
name: 'sse-retry-test-client',
29+
version: '1.0.0'
30+
},
31+
{
32+
capabilities: {}
33+
}
34+
);
35+
36+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
37+
reconnectionOptions: {
38+
initialReconnectionDelay: 1000,
39+
maxReconnectionDelay: 10000,
40+
reconnectionDelayGrowFactor: 1.5,
41+
maxRetries: 3
42+
}
43+
});
44+
45+
// Track reconnection events
46+
transport.onerror = (error) => {
47+
console.log(`Transport error: ${error.message}`);
48+
};
49+
50+
transport.onclose = () => {
51+
console.log('Transport closed');
52+
};
53+
54+
console.log('Initiating connection...');
55+
await client.connect(transport);
56+
console.log('Connected to MCP server');
57+
58+
// Keep connection alive to observe reconnection behavior
59+
// The server will disconnect and the client should reconnect
60+
console.log('Waiting for reconnection cycle...');
61+
console.log(
62+
'Server will close connection and client should wait for retry field timing'
63+
);
64+
65+
// Wait long enough for:
66+
// 1. Server to send retry field and close (100ms)
67+
// 2. Client to wait for retry period (2000ms expected)
68+
// 3. Client to reconnect (100ms)
69+
// 4. Second disconnect cycle (optional)
70+
await new Promise((resolve) => setTimeout(resolve, 6000));
71+
72+
console.log('Test duration complete');
73+
74+
await transport.close();
75+
console.log('Connection closed successfully');
76+
77+
process.exit(0);
78+
} catch (error) {
79+
console.error('Test failed:', error);
80+
process.exit(1);
81+
}
82+
}
83+
84+
main().catch((error) => {
85+
console.error('Unhandled error:', error);
86+
process.exit(1);
87+
});

src/scenarios/client/sse-retry.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* SSE Retry conformance test scenarios for MCP clients (SEP-1699)
3+
*
4+
* Tests that clients properly respect the SSE retry field by:
5+
* - Waiting the specified milliseconds before reconnecting
6+
* - Sending Last-Event-ID header on reconnection
7+
*/
8+
9+
import http from 'http';
10+
import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js';
11+
12+
export class SSERetryScenario implements Scenario {
13+
name = 'sse-retry';
14+
description = 'Tests that client respects SSE retry field timing (SEP-1699)';
15+
16+
private server: http.Server | null = null;
17+
private checks: ConformanceCheck[] = [];
18+
private port: number = 0;
19+
20+
// Timing tracking
21+
private connectionTimestamps: number[] = [];
22+
private lastEventIds: (string | undefined)[] = [];
23+
private retryValue: number = 2000; // 2 seconds
24+
private eventIdCounter: number = 0;
25+
26+
// Tolerances for timing validation
27+
private readonly EARLY_TOLERANCE = 50; // Allow 50ms early for scheduler variance
28+
private readonly LATE_TOLERANCE = 200; // Allow 200ms late for network/event loop
29+
30+
async start(): Promise<ScenarioUrls> {
31+
return new Promise((resolve, reject) => {
32+
this.server = http.createServer((req, res) => {
33+
this.handleRequest(req, res);
34+
});
35+
36+
this.server.on('error', reject);
37+
38+
this.server.listen(0, () => {
39+
const address = this.server!.address();
40+
if (address && typeof address === 'object') {
41+
this.port = address.port;
42+
resolve({
43+
serverUrl: `http://localhost:${this.port}`
44+
});
45+
} else {
46+
reject(new Error('Failed to get server address'));
47+
}
48+
});
49+
});
50+
}
51+
52+
async stop(): Promise<void> {
53+
return new Promise((resolve, reject) => {
54+
if (this.server) {
55+
this.server.close((err) => {
56+
if (err) {
57+
reject(err);
58+
} else {
59+
this.server = null;
60+
resolve();
61+
}
62+
});
63+
} else {
64+
resolve();
65+
}
66+
});
67+
}
68+
69+
getChecks(): ConformanceCheck[] {
70+
// Generate checks based on observed behavior
71+
this.generateChecks();
72+
return this.checks;
73+
}
74+
75+
private handleRequest(
76+
req: http.IncomingMessage,
77+
res: http.ServerResponse
78+
): void {
79+
const timestamp = performance.now();
80+
this.connectionTimestamps.push(timestamp);
81+
82+
// Track Last-Event-ID header
83+
const lastEventId = req.headers['last-event-id'] as string | undefined;
84+
this.lastEventIds.push(lastEventId);
85+
86+
if (req.method === 'GET') {
87+
// Handle SSE stream request
88+
this.handleSSEStream(req, res);
89+
} else if (req.method === 'POST') {
90+
// Handle JSON-RPC requests (for initialization)
91+
this.handleJSONRPC(req, res);
92+
} else {
93+
res.writeHead(405);
94+
res.end('Method Not Allowed');
95+
}
96+
}
97+
98+
private handleSSEStream(
99+
req: http.IncomingMessage,
100+
res: http.ServerResponse
101+
): void {
102+
// Set SSE headers
103+
res.writeHead(200, {
104+
'Content-Type': 'text/event-stream',
105+
'Cache-Control': 'no-cache',
106+
Connection: 'keep-alive'
107+
});
108+
109+
// Generate event ID
110+
this.eventIdCounter++;
111+
const eventId = `event-${this.eventIdCounter}`;
112+
113+
// Send priming event with ID and empty data
114+
res.write(`id: ${eventId}\n`);
115+
res.write(`retry: ${this.retryValue}\n`);
116+
res.write(`data: \n\n`);
117+
118+
// Close connection after a short delay to trigger client reconnection
119+
setTimeout(() => {
120+
res.end();
121+
}, 100);
122+
}
123+
124+
private handleJSONRPC(
125+
req: http.IncomingMessage,
126+
res: http.ServerResponse
127+
): void {
128+
let body = '';
129+
130+
req.on('data', (chunk) => {
131+
body += chunk.toString();
132+
});
133+
134+
req.on('end', () => {
135+
try {
136+
const request = JSON.parse(body);
137+
138+
if (request.method === 'initialize') {
139+
// Respond to initialize request with SSE stream
140+
res.writeHead(200, {
141+
'Content-Type': 'text/event-stream',
142+
'Cache-Control': 'no-cache',
143+
Connection: 'keep-alive'
144+
});
145+
146+
// Generate event ID
147+
this.eventIdCounter++;
148+
const eventId = `event-${this.eventIdCounter}`;
149+
150+
// Send priming event
151+
res.write(`id: ${eventId}\n`);
152+
res.write(`retry: ${this.retryValue}\n`);
153+
res.write(`data: \n\n`);
154+
155+
// Send initialize response
156+
const response = {
157+
jsonrpc: '2.0',
158+
id: request.id,
159+
result: {
160+
protocolVersion: '2025-06-18',
161+
serverInfo: {
162+
name: 'sse-retry-test-server',
163+
version: '1.0.0'
164+
},
165+
capabilities: {}
166+
}
167+
};
168+
169+
res.write(`event: message\n`);
170+
res.write(`id: event-${++this.eventIdCounter}\n`);
171+
res.write(`data: ${JSON.stringify(response)}\n\n`);
172+
173+
// Close connection after sending response to trigger reconnection
174+
setTimeout(() => {
175+
res.end();
176+
}, 100);
177+
} else {
178+
// For other requests, send a simple JSON response
179+
res.writeHead(200, { 'Content-Type': 'application/json' });
180+
res.end(
181+
JSON.stringify({
182+
jsonrpc: '2.0',
183+
id: request.id,
184+
result: {}
185+
})
186+
);
187+
}
188+
} catch (error) {
189+
res.writeHead(400, { 'Content-Type': 'application/json' });
190+
res.end(
191+
JSON.stringify({
192+
jsonrpc: '2.0',
193+
error: {
194+
code: -32700,
195+
message: `Parse error: ${error}`
196+
}
197+
})
198+
);
199+
}
200+
});
201+
}
202+
203+
private generateChecks(): void {
204+
// Check 1: Client should have reconnected
205+
if (this.connectionTimestamps.length < 2) {
206+
this.checks.push({
207+
id: 'client-sse-retry-reconnect',
208+
name: 'ClientReconnects',
209+
description: 'Client reconnects after server disconnect',
210+
status: 'FAILURE',
211+
timestamp: new Date().toISOString(),
212+
errorMessage: `Expected at least 2 connections, got ${this.connectionTimestamps.length}. Client may not have attempted to reconnect.`,
213+
specReferences: [
214+
{
215+
id: 'SEP-1699',
216+
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
217+
}
218+
],
219+
details: {
220+
connectionCount: this.connectionTimestamps.length,
221+
retryValue: this.retryValue
222+
}
223+
});
224+
return;
225+
}
226+
227+
// Check 2: Client MUST respect retry field timing
228+
const actualDelay =
229+
this.connectionTimestamps[1] - this.connectionTimestamps[0];
230+
const minExpected = this.retryValue - this.EARLY_TOLERANCE;
231+
const maxExpected = this.retryValue + this.LATE_TOLERANCE;
232+
233+
const tooEarly = actualDelay < minExpected;
234+
const tooLate = actualDelay > maxExpected;
235+
const withinTolerance = !tooEarly && !tooLate;
236+
237+
let status: 'SUCCESS' | 'FAILURE' | 'WARNING' = 'SUCCESS';
238+
let errorMessage: string | undefined;
239+
240+
if (tooEarly) {
241+
// Client reconnected too soon - MUST violation
242+
status = 'FAILURE';
243+
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.`;
244+
} else if (tooLate) {
245+
// Client reconnected too late - not a spec violation but suspicious
246+
status = 'WARNING';
247+
errorMessage = `Client reconnected late (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). This is acceptable but may indicate the client is ignoring the retry field and using its own backoff.`;
248+
}
249+
250+
this.checks.push({
251+
id: 'client-sse-retry-timing',
252+
name: 'ClientRespectsRetryField',
253+
description:
254+
'Client MUST respect the retry field, waiting the given number of milliseconds before attempting to reconnect',
255+
status,
256+
timestamp: new Date().toISOString(),
257+
errorMessage,
258+
specReferences: [
259+
{
260+
id: 'SEP-1699',
261+
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
262+
}
263+
],
264+
details: {
265+
expectedRetryMs: this.retryValue,
266+
actualDelayMs: Math.round(actualDelay),
267+
minAcceptableMs: minExpected,
268+
maxAcceptableMs: maxExpected,
269+
earlyToleranceMs: this.EARLY_TOLERANCE,
270+
lateToleranceMs: this.LATE_TOLERANCE,
271+
withinTolerance,
272+
tooEarly,
273+
tooLate,
274+
connectionCount: this.connectionTimestamps.length
275+
}
276+
});
277+
278+
// Check 3: Client SHOULD send Last-Event-ID header on reconnection
279+
const hasLastEventId =
280+
this.lastEventIds.length > 1 && this.lastEventIds[1] !== undefined;
281+
282+
this.checks.push({
283+
id: 'client-sse-last-event-id',
284+
name: 'ClientSendsLastEventId',
285+
description:
286+
'Client SHOULD send Last-Event-ID header on reconnection for resumability',
287+
status: hasLastEventId ? 'SUCCESS' : 'WARNING',
288+
timestamp: new Date().toISOString(),
289+
specReferences: [
290+
{
291+
id: 'SEP-1699',
292+
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
293+
}
294+
],
295+
details: {
296+
hasLastEventId,
297+
lastEventIds: this.lastEventIds,
298+
connectionCount: this.connectionTimestamps.length
299+
},
300+
errorMessage: !hasLastEventId
301+
? 'Client did not send Last-Event-ID header on reconnection. This is a SHOULD requirement for resumability.'
302+
: undefined
303+
});
304+
}
305+
}

0 commit comments

Comments
 (0)