fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport (#2541)
This commit is contained in:
parent
1e1392e3f9
commit
470678d56b
5 changed files with 480 additions and 36 deletions
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
29
packages/server/test/server/sseKeepAlive.test.ts
Normal file
29
packages/server/test/server/sseKeepAlive.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue