fix: send SSE keep-alive comment frames from Streamable HTTP server transport (v1.x) (#2538)

This commit is contained in:
Matt 2026-07-23 17:22:29 +01:00 • committed by GitHub
commit 1dad2634ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3272,3 +3272,179 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => {
await storeTransport.close();
});
});
describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
/** Shorthand to build a Web Standard Request for direct transport testing. */
function req(method: string, opts?: { body?: unknown; headers?: Record<string, string> }): Request {
const headers: Record<string, string> = { ...opts?.headers };
if (method === 'POST') {
headers['Accept'] ??= 'application/json, text/event-stream';
headers['Content-Type'] ??= 'application/json';
} else if (method === 'GET') {
headers['Accept'] ??= 'text/event-stream';
}
return new Request('http://localhost/mcp', {
method,
headers,
body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined
});
}
async function createTransport(options?: { keepAliveMs?: number }): Promise<{
transport: WebStandardStreamableHTTPServerTransport;
sessionId: string;
}> {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), ...options });
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
expect(initResponse.status).toBe(200);
return { transport, sessionId: initResponse.headers.get('mcp-session-id') as string };
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should write keep-alive comment frames to an idle standalone GET stream', async () => {
const { transport, sessionId } = await createTransport();
const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');
await transport.close();
});
it('should honor a custom keepAliveMs interval', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: 1000 });
const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(3000);
let received = '';
for (let i = 0; i < 3; i++) {
const { value } = await reader.read();
received += new TextDecoder().decode(value);
}
expect(received).toBe(': keepalive\n\n'.repeat(3));
await transport.close();
});
it('should not write keep-alive frames when keepAliveMs is 0', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: 0 });
const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(60000);
const read = reader.read();
const raced = await Promise.race([read, Promise.resolve('pending')]);
expect(raced).toBe('pending');
await transport.close();
});
it('should stop keep-alive frames after the stream is closed', async () => {
const { transport, sessionId } = await createTransport();
const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();
await transport.close();
const { done } = await reader.read();
expect(done).toBe(true);
// Advancing time after close must not throw or fire further writes
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60000);
});
it('should write keep-alive frames on a POST SSE stream while a request is pending', async () => {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
let resolveTool: (() => void) | undefined;
mcpServer.tool('slow', async () => {
await new Promise<void>(resolve => {
resolveTool = resolve;
});
return { content: [{ type: 'text', text: 'done' }] };
});
await mcpServer.connect(transport);
const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
const sessionId = initResponse.headers.get('mcp-session-id') as string;
const response = await transport.handleRequest(
req('POST', {
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'call-1' },
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
})
);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');
resolveTool?.();
await transport.close();
});
it('should supersede the previous keep-alive timer when a replayed stream re-registers under the same stream id', async () => {
// Event store WITHOUT the optional getStreamIdForEventId — the replay
// path then skips its 409 conflict check, so a reconnect re-registers
// the same stream id. The predecessor's timer must be replaced, not
// orphaned (an orphaned timer's failing write would clear the live
// stream's keep-alive via stopKeepAlive on the shared stream id).
const eventStore: EventStore = {
async storeEvent(): Promise<EventId> {
return 'evt-1';
},
async replayEventsAfter(): Promise<StreamId> {
return 'stream-1';
}
};
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
const sessionId = initResponse.headers.get('mcp-session-id') as string;
const replayHeaders = { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', 'Last-Event-ID': 'evt-1' };
const first = await transport.handleRequest(req('GET', { headers: replayHeaders }));
expect(first.status).toBe(200);
// Reconnect with the same Last-Event-ID — re-registers 'stream-1'
const second = await transport.handleRequest(req('GET', { headers: replayHeaders }));
expect(second.status).toBe(200);
// Exactly one keep-alive timer must remain armed (plus none orphaned)
expect(vi.getTimerCount()).toBe(1);
// The live (second) stream still receives keep-alive frames
const reader = second.body!.getReader();
await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');
await transport.close();
});
});