* feat: add specVersions classification to conformance scenarios
Each scenario declares which spec versions it applies to as a list.
Scenarios that carry forward (e.g. initialize) list all applicable versions
['2025-06-18', '2025-11-25']. Scenarios removed from newer specs (e.g.
backcompat auth) only list their original version ['2025-03-26'].
- specVersions list on Scenario and ClientScenario interfaces
- --spec-version CLI filter uses simple .includes()
- Tier-check conformance matrix (Server / Client: Core / Client: Auth)
with per-version columns and unique All* count
- 7 unit tests for specVersions helpers
- Updated tier-audit skill docs with matrix format
* fix: fix console output template for tier audit skill
The template had an orphan table header (Check | Value | T2 | T1)
with no rows above the conformance matrix, causing an empty table
to render. The scorecard rows below the matrix also lacked their
own header.
Fix: two self-contained tables with clear labels — 'Conformance:'
for the per-version matrix, 'Scorecard:' for the check rows.
* fix: align skill console template with tier-check script output
Add asterisk footnote ('unique scenarios — a scenario may apply to
multiple spec versions') and rename 'Scorecard' to 'Repository Health'
to match the labels used by the tier-check CLI output.
* fix: add specVersions to CrossAppAccessCompleteFlowScenario
Added in 83c446d on main after this branch diverged.
561 lines
17 KiB
TypeScript
561 lines
17 KiB
TypeScript
/**
|
|
* 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,
|
|
SpecVersion
|
|
} from '../../types.js';
|
|
|
|
export class SSERetryScenario implements Scenario {
|
|
name = 'sse-retry';
|
|
specVersions: SpecVersion[] = ['2025-11-25'];
|
|
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 toolStreamCloseTime: number | null = null;
|
|
private getReconnectionTime: number | null = null;
|
|
private getConnectionCount: number = 0;
|
|
private lastEventIds: (string | undefined)[] = [];
|
|
private retryValue: number = 500; // 500ms
|
|
private eventIdCounter: number = 0;
|
|
private sessionId: string = `session-${Date.now()}`;
|
|
|
|
// Pending tool call to respond to after reconnection
|
|
private pendingToolCallId: number | string | null = null;
|
|
private getResponseStream: http.ServerResponse | 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<ScenarioUrls> {
|
|
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<void> {
|
|
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;
|
|
const description = lastEventId
|
|
? `Received GET request for ${req.url} (Last-Event-ID: ${lastEventId})`
|
|
: `Received GET request for ${req.url}`;
|
|
this.checks.push({
|
|
id: 'incoming-request',
|
|
name: 'IncomingRequest',
|
|
description,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
method: 'GET',
|
|
url: req.url,
|
|
headers: req.headers,
|
|
connectionCount: this.getConnectionCount
|
|
}
|
|
});
|
|
|
|
if (lastEventId) {
|
|
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
|
|
const primingContent = `id: ${eventId}\nretry: ${this.retryValue}\ndata: \n\n`;
|
|
res.write(primingContent);
|
|
|
|
this.checks.push({
|
|
id: 'outgoing-sse-event',
|
|
name: 'OutgoingSseEvent',
|
|
description: `Sent SSE priming event on GET stream (id: ${eventId}, retry: ${this.retryValue}ms)`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
eventId,
|
|
retryMs: this.retryValue,
|
|
eventType: 'priming',
|
|
raw: primingContent
|
|
}
|
|
});
|
|
|
|
// Store the GET stream to send pending tool response
|
|
this.getResponseStream = res;
|
|
|
|
// If we have a pending tool call, send the response now
|
|
if (this.pendingToolCallId !== null) {
|
|
const toolResponse = {
|
|
jsonrpc: '2.0',
|
|
id: this.pendingToolCallId,
|
|
result: {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Reconnection test completed successfully'
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
const responseEventId = `event-${++this.eventIdCounter}`;
|
|
const responseContent = `event: message\nid: ${responseEventId}\ndata: ${JSON.stringify(toolResponse)}\n\n`;
|
|
res.write(responseContent);
|
|
|
|
this.checks.push({
|
|
id: 'outgoing-sse-event',
|
|
name: 'OutgoingSseEvent',
|
|
description: `Sent tool response on GET stream after reconnection (id: ${responseEventId})`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
eventId: responseEventId,
|
|
eventType: 'message',
|
|
jsonrpcId: this.pendingToolCallId,
|
|
body: toolResponse,
|
|
raw: responseContent
|
|
}
|
|
});
|
|
|
|
this.pendingToolCallId = null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
this.checks.push({
|
|
id: 'incoming-request',
|
|
name: 'IncomingRequest',
|
|
description: `Received POST request for ${req.url} (method: ${request.method})`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
method: 'POST',
|
|
url: req.url,
|
|
jsonrpcMethod: request.method,
|
|
jsonrpcId: request.id
|
|
}
|
|
});
|
|
|
|
if (request.method === 'initialize') {
|
|
this.handleInitialize(req, res, request);
|
|
} else if (request.method === 'tools/list') {
|
|
this.handleToolsList(res, request);
|
|
} else if (request.method === 'tools/call') {
|
|
this.handleToolsCall(res, request);
|
|
} 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 handleInitialize(
|
|
_req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
request: any
|
|
): void {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'mcp-session-id': this.sessionId
|
|
});
|
|
|
|
const response = {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: '2025-03-26',
|
|
serverInfo: {
|
|
name: 'sse-retry-test-server',
|
|
version: '1.0.0'
|
|
},
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
};
|
|
|
|
res.end(JSON.stringify(response));
|
|
|
|
this.checks.push({
|
|
id: 'outgoing-response',
|
|
name: 'OutgoingResponse',
|
|
description: `Sent initialize response`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
jsonrpcId: request.id,
|
|
body: response
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleToolsList(res: http.ServerResponse, request: any): void {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'mcp-session-id': this.sessionId
|
|
});
|
|
|
|
const response = {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
tools: [
|
|
{
|
|
name: 'test_reconnection',
|
|
description:
|
|
'A tool that triggers SSE stream closure to test client reconnection behavior',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: []
|
|
}
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
res.end(JSON.stringify(response));
|
|
|
|
this.checks.push({
|
|
id: 'outgoing-response',
|
|
name: 'OutgoingResponse',
|
|
description: `Sent tools/list response`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
jsonrpcId: request.id,
|
|
body: response
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleToolsCall(res: http.ServerResponse, request: any): void {
|
|
// Store the request ID so we can respond after reconnection
|
|
this.pendingToolCallId = request.id;
|
|
|
|
// Start SSE stream
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
'mcp-session-id': this.sessionId
|
|
});
|
|
|
|
// Send priming event with retry field
|
|
this.eventIdCounter++;
|
|
const primingEventId = `event-${this.eventIdCounter}`;
|
|
const primingContent = `id: ${primingEventId}\nretry: ${this.retryValue}\ndata: \n\n`;
|
|
res.write(primingContent);
|
|
|
|
this.checks.push({
|
|
id: 'outgoing-sse-event',
|
|
name: 'OutgoingSseEvent',
|
|
description: `Sent SSE priming event for tools/call (id: ${primingEventId}, retry: ${this.retryValue}ms)`,
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
eventId: primingEventId,
|
|
retryMs: this.retryValue,
|
|
eventType: 'priming',
|
|
raw: primingContent
|
|
}
|
|
});
|
|
|
|
// Close the stream after a short delay to trigger reconnection
|
|
setTimeout(() => {
|
|
this.toolStreamCloseTime = performance.now();
|
|
this.checks.push({
|
|
id: 'outgoing-stream-close',
|
|
name: 'OutgoingStreamClose',
|
|
description:
|
|
'Closed tools/call SSE stream to trigger client reconnection',
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
details: {
|
|
retryMs: this.retryValue,
|
|
pendingToolCallId: this.pendingToolCallId
|
|
}
|
|
});
|
|
res.end();
|
|
}, 50);
|
|
}
|
|
|
|
private generateChecks(): void {
|
|
// Check 1: Client should have reconnected via GET after tool call stream close
|
|
if (this.getConnectionCount < 1) {
|
|
this.checks.push({
|
|
id: 'client-sse-graceful-reconnect',
|
|
name: 'ClientGracefulReconnect',
|
|
description:
|
|
'Client reconnects via GET after SSE stream is closed gracefully',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Client did not attempt GET reconnection after 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,
|
|
toolStreamCloseTime: this.toolStreamCloseTime,
|
|
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 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.toolStreamCloseTime !== null &&
|
|
this.getReconnectionTime !== null
|
|
) {
|
|
const actualDelay = this.getReconnectionTime - this.toolStreamCloseTime;
|
|
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 - tool stream close time or GET reconnection time not recorded',
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1699',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
|
}
|
|
],
|
|
details: {
|
|
toolStreamCloseTime: this.toolStreamCloseTime,
|
|
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,
|
|
getConnectionCount: this.getConnectionCount
|
|
},
|
|
errorMessage: !hasLastEventId
|
|
? 'Client did not send Last-Event-ID header on reconnection. This is a SHOULD requirement for resumability.'
|
|
: undefined
|
|
});
|
|
}
|
|
}
|