fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport (#2541)

This commit is contained in:
Matt 2026-07-27 14:21:49 +01:00 • committed by GitHub
commit 470678d56b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 480 additions and 36 deletions

View file

@ -820,3 +820,57 @@ describe('createMcpHandler — close()', () => {
// Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged.
const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' });
void createMcpHandler(zeroArgFactory);
describe('createMcpHandler — keepAliveMs', () => {
function gatedFactory(): { factory: () => McpServer; release: () => void } {
let release!: () => void;
const gate = new Promise<void>(resolve => {
release = resolve;
});
const factory = (): McpServer => {
const s = new McpServer({ name: 'ka', version: '1.0.0' });
s.registerTool('gated', { inputSchema: z.object({}) }, async () => {
await gate;
return { content: [{ type: 'text', text: 'done' }] };
});
return s;
};
return { factory, release };
}
it('threads keepAliveMs into the modern per-request exchange stream', async () => {
vi.useFakeTimers();
try {
const { factory, release } = gatedFactory();
const handler = createMcpHandler(factory, { responseMode: 'sse', keepAliveMs: 1_000 });
const responsePromise = handler.fetch(postRequest(modernToolsCall('gated', {})));
await vi.advanceTimersByTimeAsync(1_000);
release();
const response = await responsePromise;
expect(response.headers.get('content-type')).toContain('text/event-stream');
const text = await response.text();
expect(text).toContain(': keepalive');
} finally {
vi.useRealTimers();
}
});
it('threads keepAliveMs into the legacy stateless fallback per-request transport', async () => {
vi.useFakeTimers();
try {
const { factory, release } = gatedFactory();
const handler = createMcpHandler(factory, { keepAliveMs: 1_000 });
const responsePromise = handler.fetch(
postRequest({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'gated', arguments: {} } })
);
await vi.advanceTimersByTimeAsync(1_000);
release();
const response = await responsePromise;
expect(response.headers.get('content-type')).toContain('text/event-stream');
const text = await response.text();
expect(text).toContain(': keepalive');
} finally {
vi.useRealTimers();
}
});
});

View file

@ -13,10 +13,11 @@ import {
PROTOCOL_VERSION_META_KEY,
SUBSCRIPTION_ID_META_KEY
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
import type { ServerEventBus } from '../../src/server/serverEventBus';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: '2026-07-28',
@ -105,6 +106,7 @@ describe('createMcpHandler — subscriptions/listen', () => {
);
const response = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-cache, no-transform');
const [ack] = await readMessages(response, 1);
// The factory is consulted exactly once (capabilities probe only); the
// instance is never connected and is closed immediately after the
@ -116,6 +118,44 @@ describe('createMcpHandler — subscriptions/listen', () => {
await handler.close();
});
it.each([0.5, Number.NaN, Number.POSITIVE_INFINITY])('disables invalid keepAliveMs %s', async keepAliveMs => {
vi.useFakeTimers();
try {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs });
const response = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
const reader = response.body!.getReader();
await reader.read();
expect(vi.getTimerCount()).toBe(0);
await reader.cancel();
await handler.close();
} finally {
vi.useRealTimers();
}
});
it('cleans up when a custom bus unsubscribe throws', async () => {
vi.useFakeTimers();
try {
const onerror = vi.fn();
const bus: ServerEventBus = {
publish() {},
subscribe: () => () => {
throw new Error('unsubscribe failed');
}
};
const handler = createMcpHandler(trivialFactory(), { bus, keepAliveMs: 1_000, onerror });
const response = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
const reader = response.body!.getReader();
await reader.read();
await reader.cancel();
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'unsubscribe failed' }));
expect(vi.getTimerCount()).toBe(0);
await handler.close();
} finally {
vi.useRealTimers();
}
});
it('ack is the first frame, stamped with the listen id verbatim, carrying the honored subset', async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
const response = await handler.fetch(listenRequest('sub-42', { toolsListChanged: true, promptsListChanged: false }));

View file

@ -11,7 +11,7 @@ import {
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { PerRequestResponseMode } from '../../src/server/perRequestTransport';
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
@ -46,14 +46,16 @@ interface StreamingSetup {
async function setup(
handler: (ctx: ServerContext) => Promise<CallToolResult>,
responseMode?: PerRequestResponseMode
responseMode?: PerRequestResponseMode,
keepAliveMs?: number
): Promise<StreamingSetup> {
const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx));
setNegotiatedProtocolVersion(server, MODERN_REVISION);
const transport = new PerRequestHTTPServerTransport({
classification: MODERN,
...(responseMode !== undefined && { responseMode })
...(responseMode !== undefined && { responseMode }),
...(keepAliveMs !== undefined && { keepAliveMs })
});
await server.connect(transport);
return { server, transport };
@ -92,7 +94,7 @@ describe('lazy upgrade matrix', () => {
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(response.headers.get('cache-control')).toBe('no-cache, no-transform');
expect(response.headers.get('x-accel-buffering')).toBe('no');
const frames = await sseFrames(response);
@ -249,3 +251,58 @@ describe('disconnect is cancellation', () => {
expect(observedSignal?.aborted).toBe(true);
});
});
describe('keep-alive', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('writes keep-alive comment frames while a forced-sse exchange is streaming', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => {
release = resolve;
});
const { transport } = await setup(async () => {
await gate;
return { content: [] };
}, 'sse');
const responsePromise = transport.handleMessage(toolsCall());
// The stream opened at dispatch end; the handler now idles past the
// default interval with no mid-call output.
await vi.advanceTimersByTimeAsync(15_000);
release();
const response = await responsePromise;
const frames = await sseFrames(response);
expect(frames[0]).toBe(': keepalive');
// The exchange completed and closed the transport: no timer survives.
expect(vi.getTimerCount()).toBe(0);
});
it('does not write keep-alive frames when keepAliveMs is 0', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => {
release = resolve;
});
const { transport } = await setup(
async () => {
await gate;
return { content: [] };
},
'sse',
0
);
const responsePromise = transport.handleMessage(toolsCall());
await vi.advanceTimersByTimeAsync(60_000);
release();
const response = await responsePromise;
const frames = await sseFrames(response);
expect(frames.some(frame => frame.startsWith(': keepalive'))).toBe(false);
});
});

View file

@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { armSseKeepAlive } from '../../src/server/sseKeepAlive';
describe('armSseKeepAlive', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])('disables invalid delay %s', delay => {
expect(armSseKeepAlive(delay, () => {})).toBeUndefined();
expect(vi.getTimerCount()).toBe(0);
});
it('ticks at the configured interval', async () => {
const tick = vi.fn();
const timer = armSseKeepAlive(1_000, tick)!;
await vi.advanceTimersByTimeAsync(3_000);
expect(tick).toHaveBeenCalledTimes(3);
clearInterval(timer);
});
it('clamps overflowing delays instead of creating a 1ms timer', async () => {
const tick = vi.fn();
const timer = armSseKeepAlive(2 ** 31, tick)!;
await vi.advanceTimersByTimeAsync(60_000);
expect(tick).not.toHaveBeenCalled();
clearInterval(timer);
});
});

View file

@ -1407,3 +1407,143 @@ describe('Zod v4', () => {
});
});
});
describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
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(createRequest('POST', 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(createRequest('GET', undefined, { sessionId }));
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-cache, no-transform');
expect(response.headers.get('x-accel-buffering')).toBe('no');
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();
expect(vi.getTimerCount()).toBe(0);
});
it('should not write keep-alive frames when keepAliveMs is 0', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: 0 });
const response = await transport.handleRequest(createRequest('GET', undefined, { sessionId }));
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(60000);
const raced = await Promise.race([reader.read(), Promise.resolve('pending')]);
expect(raced).toBe('pending');
await transport.close();
});
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.registerTool('slow', { description: 'never resolves until released' }, async (): Promise<CallToolResult> => {
await new Promise<void>(resolve => {
resolveTool = resolve;
});
return { content: [{ type: 'text', text: 'done' }] };
});
await mcpServer.connect(transport);
const initResponse = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize));
const sessionId = initResponse.headers.get('mcp-session-id') as string;
const response = await transport.handleRequest(
createRequest(
'POST',
{ jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'call-1' } as JSONRPCMessage,
{
sessionId
}
)
);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-cache, no-transform');
expect(response.headers.get('x-accel-buffering')).toBe('no');
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 not initialize after close races request body parsing', async () => {
const onsessioninitialized = vi.fn();
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized
});
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
let releaseBody!: () => void;
const body = new ReadableStream<Uint8Array>({
start(controller) {
releaseBody = () => {
controller.enqueue(new TextEncoder().encode(JSON.stringify(TEST_MESSAGES.initialize)));
controller.close();
};
}
});
const pending = transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json' },
body,
duplex: 'half'
})
);
await transport.close();
releaseBody();
expect((await pending).status).toBe(404);
expect(onsessioninitialized).not.toHaveBeenCalled();
});
it('should not register a stream after close races session initialization', async () => {
let releaseInitialization!: () => void;
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: () =>
new Promise<void>(resolve => {
releaseInitialization = resolve;
})
});
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
const pending = transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize));
await vi.advanceTimersByTimeAsync(0);
await transport.close();
releaseInitialization();
expect((await pending).status).toBe(404);
expect(vi.getTimerCount()).toBe(0);
});
});