feat: add conformance tests for SEP-1699 SSE polling (#47)
This commit is contained in:
parent
314f0f6d89
commit
f0e948187d
14 changed files with 1938 additions and 337 deletions
89
examples/clients/typescript/sse-retry-test.ts
Normal file
89
examples/clients/typescript/sse-retry-test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/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 closes the SSE stream mid-tool-call,
|
||||||
|
* then waits for the client to reconnect and sends the tool result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const serverUrl = process.argv[2];
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
console.error('Usage: sse-retry-test <server-url>');
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
console.log('Calling test_reconnection tool...');
|
||||||
|
console.log(
|
||||||
|
'Server will close SSE stream mid-call and send result after reconnection'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.request(
|
||||||
|
{
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'test_reconnection',
|
||||||
|
arguments: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CallToolResultSchema
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Tool call completed:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,12 @@ import {
|
||||||
McpServer,
|
McpServer,
|
||||||
ResourceTemplate
|
ResourceTemplate
|
||||||
} from '@modelcontextprotocol/sdk/server/mcp.js';
|
} 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 {
|
import {
|
||||||
ElicitResultSchema,
|
ElicitResultSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
|
@ -33,6 +38,41 @@ const watchedResourceContent = 'Watched resource content';
|
||||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||||
const servers: { [sessionId: string]: McpServer } = {};
|
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<EventId> {
|
||||||
|
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<void> }
|
||||||
|
): Promise<StreamId> {
|
||||||
|
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
|
// Sample base64 encoded 1x1 red PNG pixel for testing
|
||||||
const TEST_IMAGE_BASE64 =
|
const TEST_IMAGE_BASE64 =
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||||
|
|
@ -309,6 +349,46 @@ function createMcpServer() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection
|
||||||
|
mcpServer.registerTool(
|
||||||
|
'test_reconnection',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.',
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async (_args, { sessionId, requestId }) => {
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
console.log(`[${sessionId}] Starting test_reconnection tool...`);
|
||||||
|
|
||||||
|
// Get the transport for this session
|
||||||
|
const transport = sessionId ? transports[sessionId] : undefined;
|
||||||
|
if (transport && requestId) {
|
||||||
|
// Close the SSE stream to trigger client reconnection
|
||||||
|
console.log(
|
||||||
|
`[${sessionId}] Closing SSE stream to trigger client polling...`
|
||||||
|
);
|
||||||
|
transport.closeSSEStream(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for client to reconnect (should respect retry field)
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
console.log(`[${sessionId}] test_reconnection tool complete`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Sampling tool - requests LLM completion from client
|
// Sampling tool - requests LLM completion from client
|
||||||
mcpServer.registerTool(
|
mcpServer.registerTool(
|
||||||
'test_sampling',
|
'test_sampling',
|
||||||
|
|
@ -1003,6 +1083,8 @@ app.post('/mcp', async (req, res) => {
|
||||||
|
|
||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
eventStore: createEventStore(),
|
||||||
|
retryInterval: 5000, // 5 second retry interval for SEP-1699
|
||||||
onsessioninitialized: (newSessionId) => {
|
onsessioninitialized: (newSessionId) => {
|
||||||
transports[newSessionId] = transport;
|
transports[newSessionId] = transport;
|
||||||
servers[newSessionId] = mcpServer;
|
servers[newSessionId] = mcpServer;
|
||||||
|
|
|
||||||
634
examples/servers/typescript/package-lock.json
generated
634
examples/servers/typescript/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,8 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0",
|
||||||
|
"zod-to-json-schema": "^3.25.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,11 +204,14 @@ program
|
||||||
'Suite to run: "active" (default, excludes pending), "all", or "pending"',
|
'Suite to run: "active" (default, excludes pending), "all", or "pending"',
|
||||||
'active'
|
'active'
|
||||||
)
|
)
|
||||||
|
.option('--verbose', 'Show verbose output (JSON instead of pretty print)')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
// Validate options with Zod
|
// Validate options with Zod
|
||||||
const validated = ServerOptionsSchema.parse(options);
|
const validated = ServerOptionsSchema.parse(options);
|
||||||
|
|
||||||
|
const verbose = options.verbose ?? false;
|
||||||
|
|
||||||
// If a single scenario is specified, run just that one
|
// If a single scenario is specified, run just that one
|
||||||
if (validated.scenario) {
|
if (validated.scenario) {
|
||||||
const result = await runServerConformanceTest(
|
const result = await runServerConformanceTest(
|
||||||
|
|
@ -218,7 +221,8 @@ program
|
||||||
|
|
||||||
const { failed } = printServerResults(
|
const { failed } = printServerResults(
|
||||||
result.checks,
|
result.checks,
|
||||||
result.scenarioDescription
|
result.scenarioDescription,
|
||||||
|
verbose
|
||||||
);
|
);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ConformanceCheck } from '../types';
|
import { ConformanceCheck } from '../types';
|
||||||
import { getClientScenario } from '../scenarios';
|
import { getClientScenario } from '../scenarios';
|
||||||
import { ensureResultsDir, createResultDir } from './utils';
|
import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format markdown-style text for terminal output using ANSI codes
|
* Format markdown-style text for terminal output using ANSI codes
|
||||||
|
|
@ -54,7 +54,8 @@ export async function runServerConformanceTest(
|
||||||
|
|
||||||
export function printServerResults(
|
export function printServerResults(
|
||||||
checks: ConformanceCheck[],
|
checks: ConformanceCheck[],
|
||||||
scenarioDescription: string
|
scenarioDescription: string,
|
||||||
|
verbose: boolean = false
|
||||||
): {
|
): {
|
||||||
passed: number;
|
passed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
|
|
@ -68,7 +69,11 @@ export function printServerResults(
|
||||||
const failed = checks.filter((c) => c.status === 'FAILURE').length;
|
const failed = checks.filter((c) => c.status === 'FAILURE').length;
|
||||||
const warnings = checks.filter((c) => c.status === 'WARNING').length;
|
const warnings = checks.filter((c) => c.status === 'WARNING').length;
|
||||||
|
|
||||||
console.log(`Checks:\n${JSON.stringify(checks, null, 2)}`);
|
if (verbose) {
|
||||||
|
console.log(JSON.stringify(checks, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`Checks:\n${formatPrettyChecks(checks)}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\nTest Results:`);
|
console.log(`\nTest Results:`);
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
555
src/scenarios/client/sse-retry.ts
Normal file
555
src/scenarios/client/sse-retry.ts
Normal file
|
|
@ -0,0 +1,555 @@
|
||||||
|
/**
|
||||||
|
* 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 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { Scenario, ClientScenario } from '../types';
|
||||||
import { InitializeScenario } from './client/initialize';
|
import { InitializeScenario } from './client/initialize';
|
||||||
import { ToolsCallScenario } from './client/tools_call';
|
import { ToolsCallScenario } from './client/tools_call';
|
||||||
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults';
|
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults';
|
||||||
|
import { SSERetryScenario } from './client/sse-retry';
|
||||||
|
|
||||||
// Import all new server test scenarios
|
// Import all new server test scenarios
|
||||||
import { ServerInitializeScenario } from './server/lifecycle';
|
import { ServerInitializeScenario } from './server/lifecycle';
|
||||||
|
|
@ -29,6 +30,8 @@ import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12';
|
||||||
|
|
||||||
import { ElicitationDefaultsScenario } from './server/elicitation-defaults';
|
import { ElicitationDefaultsScenario } from './server/elicitation-defaults';
|
||||||
import { ElicitationEnumsScenario } from './server/elicitation-enums';
|
import { ElicitationEnumsScenario } from './server/elicitation-enums';
|
||||||
|
import { ServerSSEPollingScenario } from './server/sse-polling';
|
||||||
|
import { ServerSSEMultipleStreamsScenario } from './server/sse-multiple-streams';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ResourcesListScenario,
|
ResourcesListScenario,
|
||||||
|
|
@ -63,7 +66,11 @@ const pendingClientScenariosList: ClientScenario[] = [
|
||||||
// On hold until elicitation schema types are fixed
|
// On hold until elicitation schema types are fixed
|
||||||
// https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863
|
// https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863
|
||||||
new ToolsCallElicitationScenario(),
|
new ToolsCallElicitationScenario(),
|
||||||
new ElicitationDefaultsScenario()
|
new ElicitationDefaultsScenario(),
|
||||||
|
|
||||||
|
// On hold until server-side SSE improvements are made
|
||||||
|
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1129
|
||||||
|
new ServerSSEPollingScenario()
|
||||||
];
|
];
|
||||||
|
|
||||||
// All client scenarios
|
// All client scenarios
|
||||||
|
|
@ -94,6 +101,10 @@ const allClientScenariosList: ClientScenario[] = [
|
||||||
// Elicitation scenarios (SEP-1034)
|
// Elicitation scenarios (SEP-1034)
|
||||||
new ElicitationDefaultsScenario(),
|
new ElicitationDefaultsScenario(),
|
||||||
|
|
||||||
|
// SSE Polling scenarios (SEP-1699)
|
||||||
|
new ServerSSEPollingScenario(),
|
||||||
|
new ServerSSEMultipleStreamsScenario(),
|
||||||
|
|
||||||
// Elicitation scenarios (SEP-1330) - pending
|
// Elicitation scenarios (SEP-1330) - pending
|
||||||
new ElicitationEnumsScenario(),
|
new ElicitationEnumsScenario(),
|
||||||
|
|
||||||
|
|
@ -132,6 +143,7 @@ const scenariosList: Scenario[] = [
|
||||||
new InitializeScenario(),
|
new InitializeScenario(),
|
||||||
new ToolsCallScenario(),
|
new ToolsCallScenario(),
|
||||||
new ElicitationClientDefaultsScenario(),
|
new ElicitationClientDefaultsScenario(),
|
||||||
|
new SSERetryScenario(),
|
||||||
...authScenariosList
|
...authScenariosList
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,9 @@ describe('Server Scenarios', () => {
|
||||||
throw new Error(`Scenario failed with checks:\n ${failureMessages}`);
|
throw new Error(`Scenario failed with checks:\n ${failureMessages}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All checks should be SUCCESS
|
// All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable)
|
||||||
const successes = checks.filter((c) => c.status === 'SUCCESS');
|
const nonFailures = checks.filter((c) => c.status !== 'FAILURE');
|
||||||
expect(successes.length).toBe(checks.length);
|
expect(nonFailures.length).toBe(checks.length);
|
||||||
}, 10000); // 10 second timeout per scenario
|
}, 10000); // 10 second timeout per scenario
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
268
src/scenarios/server/sse-multiple-streams.ts
Normal file
268
src/scenarios/server/sse-multiple-streams.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
/**
|
||||||
|
* SSE Multiple Streams conformance test scenarios for MCP servers (SEP-1699)
|
||||||
|
*
|
||||||
|
* Tests that servers properly support multiple concurrent SSE streams:
|
||||||
|
* - Accepting multiple POST requests that return SSE streams simultaneously
|
||||||
|
* - Each POST request gets its own stream with unique stream ID
|
||||||
|
*
|
||||||
|
* Note: The standalone GET stream (without Last-Event-ID) is limited to one per session.
|
||||||
|
* Multiple concurrent streams are achieved via POST requests, each getting their own stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 ServerSSEMultipleStreamsScenario implements ClientScenario {
|
||||||
|
name = 'server-sse-multiple-streams';
|
||||||
|
description =
|
||||||
|
'Test server supports multiple concurrent POST SSE streams (SEP-1699)';
|
||||||
|
|
||||||
|
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||||
|
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
|
||||||
|
sessionId = (transport as unknown as { sessionId?: string }).sessionId;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-multiple-streams-session',
|
||||||
|
name: 'ServerSSEMultipleStreamsSession',
|
||||||
|
description: 'Server provides session ID for multiple streams test',
|
||||||
|
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 - multiple streams test may not work correctly'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Open multiple POST SSE streams concurrently
|
||||||
|
// Each POST request gets its own stream with unique streamId
|
||||||
|
// Spec says: "The client MAY remain connected to multiple SSE streams simultaneously"
|
||||||
|
const streamResponses: Response[] = [];
|
||||||
|
const numStreams = 3;
|
||||||
|
|
||||||
|
// Launch all POST requests concurrently
|
||||||
|
const postPromises = [];
|
||||||
|
for (let i = 0; i < numStreams; i++) {
|
||||||
|
const promise = fetch(serverUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'text/event-stream, application/json',
|
||||||
|
'mcp-session-id': sessionId,
|
||||||
|
'mcp-protocol-version': '2025-03-26'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1000 + i, // Different request IDs for each stream
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
postPromises.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all responses
|
||||||
|
const responses = await Promise.all(postPromises);
|
||||||
|
streamResponses.push(...responses);
|
||||||
|
|
||||||
|
// Check that all streams were accepted (HTTP 200)
|
||||||
|
const allAccepted = streamResponses.every((r) => r.ok);
|
||||||
|
const statuses = streamResponses.map((r) => r.status);
|
||||||
|
const contentTypes = streamResponses.map((r) =>
|
||||||
|
r.headers.get('content-type')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count how many returned SSE streams vs JSON
|
||||||
|
const sseStreams = contentTypes.filter((ct) =>
|
||||||
|
ct?.includes('text/event-stream')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: 'server-accepts-multiple-post-streams',
|
||||||
|
name: 'ServerAcceptsMultiplePostStreams',
|
||||||
|
description:
|
||||||
|
'Server allows multiple concurrent POST requests (each may return SSE or JSON)',
|
||||||
|
status: allAccepted ? 'SUCCESS' : 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
numStreamsAttempted: numStreams,
|
||||||
|
numStreamsAccepted: statuses.filter((s) => s === 200).length,
|
||||||
|
numSseStreams: sseStreams,
|
||||||
|
statuses,
|
||||||
|
contentTypes
|
||||||
|
},
|
||||||
|
errorMessage: !allAccepted
|
||||||
|
? `Server rejected some requests. Statuses: ${statuses.join(', ')}`
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Verify SSE streams are functional by reading events
|
||||||
|
// Only test streams that returned SSE content-type
|
||||||
|
const eventResults = await Promise.all(
|
||||||
|
streamResponses.map(async (response, index) => {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
// Skip non-SSE responses (JSON responses are also valid)
|
||||||
|
if (!contentType?.includes('text/event-stream')) {
|
||||||
|
return { index, type: 'json', skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
return { index, type: 'sse', error: 'Stream not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = response.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.pipeThrough(new EventSourceParserStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
|
// Wait for one event with timeout
|
||||||
|
const timeoutPromise = new Promise<null>((resolve) =>
|
||||||
|
setTimeout(() => resolve(null), 2000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventPromise = reader.read().then(({ value }) => value);
|
||||||
|
|
||||||
|
const event = await Promise.race([eventPromise, timeoutPromise]);
|
||||||
|
|
||||||
|
// Cancel reader
|
||||||
|
await reader.cancel();
|
||||||
|
|
||||||
|
return { index, type: 'sse', event };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
type: 'sse',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count functional SSE streams (received event or timed out waiting - both are valid)
|
||||||
|
const sseResults = eventResults.filter((r) => r.type === 'sse');
|
||||||
|
const functionalSseStreams = sseResults.filter(
|
||||||
|
(r) => !('error' in r)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// If server returned SSE streams, they should be functional
|
||||||
|
if (sseStreams > 0) {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-streams-functional',
|
||||||
|
name: 'ServerSSEStreamsFunctional',
|
||||||
|
description: 'Multiple POST SSE streams should be functional',
|
||||||
|
status:
|
||||||
|
functionalSseStreams === sseStreams
|
||||||
|
? 'SUCCESS'
|
||||||
|
: functionalSseStreams > 0
|
||||||
|
? 'WARNING'
|
||||||
|
: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
numSseStreams: sseStreams,
|
||||||
|
functionalSseStreams,
|
||||||
|
results: eventResults
|
||||||
|
},
|
||||||
|
errorMessage:
|
||||||
|
functionalSseStreams < sseStreams
|
||||||
|
? `Only ${functionalSseStreams}/${sseStreams} SSE streams were functional`
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Server returned JSON for all requests - this is valid but worth noting
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-streams-functional',
|
||||||
|
name: 'ServerSSEStreamsFunctional',
|
||||||
|
description: 'Server returned JSON responses (SSE streams optional)',
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
numSseStreams: 0,
|
||||||
|
message:
|
||||||
|
'Server returned JSON for all requests - SSE streaming is optional',
|
||||||
|
results: eventResults
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-multiple-streams-error',
|
||||||
|
name: 'ServerSSEMultipleStreamsTest',
|
||||||
|
description: 'Test server multiple SSE streams 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
601
src/scenarios/server/sse-polling.ts
Normal file
601
src/scenarios/server/sse-polling.ts
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* - Closing SSE stream mid-operation and resuming after client reconnects
|
||||||
|
* - 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';
|
||||||
|
|
||||||
|
function createLoggingFetch(checks: ConformanceCheck[]) {
|
||||||
|
return async (url: string, options: RequestInit): Promise<Response> => {
|
||||||
|
const method = options.method || 'GET';
|
||||||
|
let description = `Sending ${method} request`;
|
||||||
|
if (options.body) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(options.body as string);
|
||||||
|
if (body.method) {
|
||||||
|
description = `Sending ${method} ${body.method}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: 'outgoing-request',
|
||||||
|
name: 'OutgoingRequest',
|
||||||
|
description,
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: options.headers,
|
||||||
|
body: options.body ? JSON.parse(options.body as string) : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
responseHeaders[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: 'incoming-response',
|
||||||
|
name: 'IncomingResponse',
|
||||||
|
description: `Received ${response.status} response for ${method}`,
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: responseHeaders
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerSSEPollingScenario implements ClientScenario {
|
||||||
|
name = 'server-sse-polling';
|
||||||
|
description =
|
||||||
|
'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)';
|
||||||
|
|
||||||
|
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||||
|
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: Call test_reconnection tool via raw fetch to observe SSE behavior
|
||||||
|
// This tool should close the stream mid-call, requiring reconnection
|
||||||
|
const loggingFetch = createLoggingFetch(checks);
|
||||||
|
|
||||||
|
const postResponse = await loggingFetch(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/call',
|
||||||
|
params: {
|
||||||
|
name: 'test_reconnection',
|
||||||
|
arguments: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!postResponse.ok) {
|
||||||
|
// Check if tool doesn't exist (method not found or similar)
|
||||||
|
if (postResponse.status === 400 || postResponse.status === 404) {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-test-reconnection-tool',
|
||||||
|
name: 'ServerTestReconnectionTool',
|
||||||
|
description:
|
||||||
|
'Server implements test_reconnection tool for SSE polling tests',
|
||||||
|
status: 'WARNING',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errorMessage: `Server does not implement test_reconnection tool (HTTP ${postResponse.status}). This tool is recommended for testing SSE polling behavior.`,
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 and tool response
|
||||||
|
let hasEventId = false;
|
||||||
|
let hasPrimingEvent = false;
|
||||||
|
let primingEventIsFirst = false;
|
||||||
|
let hasRetryField = false;
|
||||||
|
let retryValue: number | undefined;
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
let eventCount = 0;
|
||||||
|
let receivedToolResponse = false;
|
||||||
|
|
||||||
|
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 - expect stream to close before we get the response
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reader.cancel();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value: event, done } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventCount++;
|
||||||
|
|
||||||
|
// Track the last event ID for reconnection
|
||||||
|
if (event.id) {
|
||||||
|
hasEventId = true;
|
||||||
|
lastEventId = event.id;
|
||||||
|
|
||||||
|
// Check if this is a priming event (empty or minimal data)
|
||||||
|
const isPriming =
|
||||||
|
event.data === '' ||
|
||||||
|
event.data === '{}' ||
|
||||||
|
event.data.trim() === '';
|
||||||
|
if (isPriming) {
|
||||||
|
hasPrimingEvent = true;
|
||||||
|
// Check if priming event is the first event
|
||||||
|
if (eventCount === 1) {
|
||||||
|
primingEventIsFirst = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the SSE event
|
||||||
|
checks.push({
|
||||||
|
id: 'incoming-sse-event',
|
||||||
|
name: 'IncomingSseEvent',
|
||||||
|
description: isPriming
|
||||||
|
? `Received SSE priming event (id: ${event.id})`
|
||||||
|
: `Received SSE event (id: ${event.id})`,
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: event.event || 'message',
|
||||||
|
isPriming,
|
||||||
|
hasRetryField,
|
||||||
|
retryValue,
|
||||||
|
data: event.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the tool response
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.id === 1 && parsed.result) {
|
||||||
|
receivedToolResponse = true;
|
||||||
|
const isError = parsed.result?.isError === true;
|
||||||
|
checks.push({
|
||||||
|
id: 'incoming-sse-event',
|
||||||
|
name: 'IncomingSseEvent',
|
||||||
|
description: `Received tool response on POST stream`,
|
||||||
|
status: isError ? 'FAILURE' : 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
eventId: event.id,
|
||||||
|
body: parsed
|
||||||
|
},
|
||||||
|
...(isError && { errorMessage: `Tool call failed` })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log stream closure
|
||||||
|
checks.push({
|
||||||
|
id: 'stream-closed',
|
||||||
|
name: 'StreamClosed',
|
||||||
|
description: `POST SSE stream closed after ${eventCount} event(s)`,
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
eventCount,
|
||||||
|
lastEventId,
|
||||||
|
receivedToolResponse
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
lastEventId,
|
||||||
|
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: If tool response wasn't received, reconnect with Last-Event-ID
|
||||||
|
if (!receivedToolResponse && lastEventId && sessionId) {
|
||||||
|
// Make a GET request with Last-Event-ID to get the tool response
|
||||||
|
const getResponse = await loggingFetch(serverUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'mcp-session-id': sessionId,
|
||||||
|
'mcp-protocol-version': '2025-03-26',
|
||||||
|
'last-event-id': lastEventId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (getResponse.ok && getResponse.body) {
|
||||||
|
const reconnectReader = getResponse.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.pipeThrough(new EventSourceParserStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
|
const reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectReader.cancel();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value: event, done } = await reconnectReader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
// Log each event received on GET stream
|
||||||
|
checks.push({
|
||||||
|
id: 'incoming-sse-event',
|
||||||
|
name: 'IncomingSseEvent',
|
||||||
|
description: `Received SSE event on GET reconnection stream (id: ${event.id || 'none'})`,
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: event.event || 'message',
|
||||||
|
data: event.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this is the tool response
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.id === 1 && parsed.result) {
|
||||||
|
receivedToolResponse = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-disconnect-resume',
|
||||||
|
name: 'ServerDisconnectResume',
|
||||||
|
description:
|
||||||
|
'Server closes SSE stream mid-call and resumes after client reconnects with Last-Event-ID',
|
||||||
|
status: receivedToolResponse ? 'SUCCESS' : 'WARNING',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
lastEventIdUsed: lastEventId,
|
||||||
|
receivedToolResponse,
|
||||||
|
message: receivedToolResponse
|
||||||
|
? 'Successfully received tool response after reconnection'
|
||||||
|
: 'Tool response not received after reconnection'
|
||||||
|
},
|
||||||
|
errorMessage: !receivedToolResponse
|
||||||
|
? 'Server did not send tool response after client reconnected with Last-Event-ID'
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if server doesn't support standalone GET streams
|
||||||
|
if (getResponse.status === 405) {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-disconnect-resume',
|
||||||
|
name: 'ServerDisconnectResume',
|
||||||
|
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-disconnect-resume',
|
||||||
|
name: 'ServerDisconnectResume',
|
||||||
|
description:
|
||||||
|
'Server supports GET reconnection with Last-Event-ID',
|
||||||
|
status: 'WARNING',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
statusCode: getResponse.status,
|
||||||
|
lastEventIdUsed: lastEventId,
|
||||||
|
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 if (receivedToolResponse) {
|
||||||
|
// Tool response was received on the initial POST stream (server didn't disconnect)
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-disconnect-resume',
|
||||||
|
name: 'ServerDisconnectResume',
|
||||||
|
description:
|
||||||
|
'Server closes SSE stream mid-call and resumes after reconnection',
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
receivedToolResponse: true,
|
||||||
|
message:
|
||||||
|
'Tool response received on initial POST stream - server did not disconnect mid-call. The test_reconnection tool should close the stream before sending the result.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
id: 'server-sse-disconnect-resume',
|
||||||
|
name: 'ServerDisconnectResume',
|
||||||
|
description:
|
||||||
|
'Server closes SSE stream mid-call and resumes after reconnection',
|
||||||
|
status: 'INFO',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
{
|
||||||
|
id: 'SEP-1699',
|
||||||
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
lastEventId,
|
||||||
|
sessionId,
|
||||||
|
message:
|
||||||
|
'Could not test disconnect/resume - no last 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['**/*.test.ts'],
|
include: ['**/*.test.ts'],
|
||||||
exclude: ['node_modules', 'dist'],
|
exclude: ['**/node_modules/**', 'dist'],
|
||||||
// Run test files sequentially to avoid port conflicts
|
// Run test files sequentially to avoid port conflicts
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
// Increase timeout for server tests in CI
|
// Increase timeout for server tests in CI
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue