Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions examples/clients/typescript/sse-retry-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node

/**
* SSE Retry Test Client
*
* Tests that the MCP client respects the SSE retry field when reconnecting.
* This client connects to a test server that sends retry: field and closes
* the connection, then validates that the client waits the appropriate time.
*/

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

async function main(): Promise<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
}
});

// Track reconnection events
transport.onerror = (error) => {
console.log(`Transport error: ${error.message}`);
};

transport.onclose = () => {
console.log('Transport closed');
};

console.log('Initiating connection...');
await client.connect(transport);
console.log('Connected to MCP server');

// Keep connection alive to observe reconnection behavior
// The server will close the POST SSE stream and the client should reconnect via GET
console.log('Waiting for reconnection cycle...');
console.log(
'Server will send priming event with retry field, then close POST SSE stream'
);
console.log(
'Client should wait for retry period (2000ms) then reconnect via GET with Last-Event-ID'
);

// Wait long enough for:
// 1. Server to send priming event with retry field on POST SSE stream (100ms)
// 2. Server closes POST stream to trigger reconnection
// 3. Client waits for retry period (2000ms expected)
// 4. Client reconnects via GET with Last-Event-ID header
await new Promise((resolve) => setTimeout(resolve, 6000));

console.log('Test duration complete');

await transport.close();
console.log('Connection closed successfully');

process.exit(0);
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}
}

main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});
44 changes: 43 additions & 1 deletion examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
McpServer,
ResourceTemplate
} from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
StreamableHTTPServerTransport,
EventStore,
EventId,
StreamId
} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import express from 'express';
import cors from 'cors';
Expand All @@ -26,6 +31,41 @@ const watchedResourceContent = 'Watched resource content';
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};

// In-memory event store for SEP-1699 resumability
const eventStoreData = new Map<
string,
{ eventId: string; message: any; streamId: string }
>();

function createEventStore(): EventStore {
return {
async storeEvent(streamId: StreamId, message: any): Promise<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
const TEST_IMAGE_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
Expand Down Expand Up @@ -872,6 +912,8 @@ app.post('/mcp', async (req, res) => {

transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore: createEventStore(),
retryInterval: 5000, // 5 second retry interval for SEP-1699
onsessioninitialized: (newSessionId) => {
transports[newSessionId] = transport;
servers[newSessionId] = mcpServer;
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.22.0",
"commander": "^14.0.2",
"eventsource-parser": "^3.0.6",
"express": "^5.1.0",
"lefthook": "^2.0.2",
"zod": "^3.25.76"
Expand Down
Loading
Loading