Fix SSE keep-alive timer lifecycle in Streamable HTTP server transport (v1.x) (#2547)
This commit is contained in:
parent
bb5a718cbf
commit
e3f3daa12c
2 changed files with 1172 additions and 97 deletions
29
test/server/sseKeepAlive.test.ts
Normal file
29
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.js';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -3412,9 +3412,9 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
|
|||
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).
|
||||
// the same stream id. The predecessor stream must be closed and its
|
||||
// timer cleared, not left orphaned; each timer is owned by its stream,
|
||||
// so a stale timer's failing write only ever clears itself.
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(): Promise<EventId> {
|
||||
return 'evt-1';
|
||||
|
|
@ -3448,3 +3448,879 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
|
|||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebStandardStreamableHTTPServerTransport SSE keep-alive lifecycle', () => {
|
||||
/** 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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal event store WITHOUT the optional getStreamIdForEventId, so the
|
||||
* replay path re-registers a stream id without a 409 conflict check —
|
||||
* the reconnect shape these tests exercise.
|
||||
*/
|
||||
function createSimpleEventStore(): EventStore {
|
||||
const events: { id: EventId; streamId: StreamId; message: JSONRPCMessage }[] = [];
|
||||
let counter = 0;
|
||||
return {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
const id = `${streamId}#${counter++}`;
|
||||
events.push({ id, streamId, message });
|
||||
return id;
|
||||
},
|
||||
async replayEventsAfter(lastEventId: EventId, { send }): Promise<StreamId> {
|
||||
const index = events.findIndex(e => e.id === lastEventId);
|
||||
const streamId = events[index]?.streamId ?? '_GET_stream';
|
||||
for (const event of events.slice(index + 1).filter(e => e.streamId === streamId)) {
|
||||
await send(event.id, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createTransport(options?: {
|
||||
keepAliveMs?: number;
|
||||
eventStore?: EventStore;
|
||||
}): 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 };
|
||||
}
|
||||
|
||||
function get(sessionId: string, lastEventId?: string): Request {
|
||||
const headers: Record<string, string> = { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' };
|
||||
if (lastEventId !== undefined) {
|
||||
headers['Last-Event-ID'] = lastEventId;
|
||||
}
|
||||
return req('GET', { headers });
|
||||
}
|
||||
|
||||
/** Opens the standalone GET stream and returns a real stored event id to resume from. */
|
||||
async function openGetStreamWithEvent(
|
||||
transport: WebStandardStreamableHTTPServerTransport,
|
||||
sessionId: string
|
||||
): Promise<{ reader: ReadableStreamDefaultReader<Uint8Array>; eventId: string }> {
|
||||
const response = await transport.handleRequest(get(sessionId));
|
||||
expect(response.status).toBe(200);
|
||||
const reader = response.body!.getReader();
|
||||
await transport.send({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'x' } });
|
||||
const { value } = await reader.read();
|
||||
const eventId = /^id: (.+)$/m.exec(new TextDecoder().decode(value))?.[1];
|
||||
expect(eventId).toBeDefined();
|
||||
return { reader, eventId: eventId! };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should keep the resumed stream alive when the predecessor connection is cancelled late', async () => {
|
||||
const { transport, sessionId } = await createTransport({ eventStore: createSimpleEventStore() });
|
||||
const { reader: staleReader, eventId } = await openGetStreamWithEvent(transport, sessionId);
|
||||
|
||||
// Client reconnects and resumes while the old connection is still half-open
|
||||
const resumed = await transport.handleRequest(get(sessionId, eventId));
|
||||
expect(resumed.status).toBe(200);
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
|
||||
// The old connection's socket finally dies. This must not tear down the
|
||||
// resumed stream's keep-alive timer or its stream registration.
|
||||
await staleReader.cancel();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
const { value: frame } = await resumedReader.read();
|
||||
expect(new TextDecoder().decode(frame)).toBe(': keepalive\n\n');
|
||||
|
||||
// Server-to-client messages must still reach the resumed stream too
|
||||
await transport.send({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'after' } });
|
||||
const { value: notification } = await resumedReader.read();
|
||||
expect(new TextDecoder().decode(notification)).toContain('notifications/message');
|
||||
|
||||
await transport.close();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should close the predecessor stream when a resume re-registers its stream id', async () => {
|
||||
const { transport, sessionId } = await createTransport({ eventStore: createSimpleEventStore() });
|
||||
const { reader: staleReader, eventId } = await openGetStreamWithEvent(transport, sessionId);
|
||||
|
||||
await transport.handleRequest(get(sessionId, eventId));
|
||||
|
||||
// The superseded stream must end cleanly rather than hang as a zombie
|
||||
const { done } = await staleReader.read();
|
||||
expect(done).toBe(true);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should not arm keep-alive when the transport closes during an event-store replay await', async () => {
|
||||
let releaseReplay: (() => void) | undefined;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(): Promise<EventId> {
|
||||
return 'evt-1';
|
||||
},
|
||||
async replayEventsAfter(): Promise<StreamId> {
|
||||
await new Promise<void>(resolve => {
|
||||
releaseReplay = resolve;
|
||||
});
|
||||
return '_GET_stream';
|
||||
}
|
||||
};
|
||||
const { transport, sessionId } = await createTransport({ eventStore });
|
||||
|
||||
// Enter replayEvents and park on the replayEventsAfter await
|
||||
const pendingGet = transport.handleRequest(get(sessionId, 'evt-1'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseReplay).toBeDefined();
|
||||
|
||||
// Close the transport mid-await, then let the replay continuation run.
|
||||
// The deferred continuation must not arm a timer close() can never clear,
|
||||
// and must not hand out a 200 SSE stream nothing will ever write to.
|
||||
await transport.close();
|
||||
releaseReplay!();
|
||||
const response = await pendingGet;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not leave a keep-alive timer armed when the priming event write fails', async () => {
|
||||
let failStore = false;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId): Promise<EventId> {
|
||||
if (failStore) {
|
||||
throw new Error('store unavailable');
|
||||
}
|
||||
return `${streamId}#0`;
|
||||
},
|
||||
async replayEventsAfter(): Promise<StreamId> {
|
||||
return '_GET_stream';
|
||||
}
|
||||
};
|
||||
const { transport, sessionId } = await createTransport({ eventStore });
|
||||
// Let the init response complete so its own stream cleanup has run
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
failStore = true;
|
||||
const response = await transport.handleRequest(
|
||||
req('POST', {
|
||||
body: { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' },
|
||||
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
|
||||
})
|
||||
);
|
||||
|
||||
// The 400 discards the Response, so nothing could ever cancel the stream —
|
||||
// no timer may be left behind.
|
||||
expect(response.status).toBe(400);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// The failed request's stream and correlation must be released too: a
|
||||
// late response for it has nowhere to go and must say so, rather than
|
||||
// being written to a dead stream.
|
||||
await expect(transport.send({ jsonrpc: '2.0', id: 'req-1', result: { tools: [] } })).rejects.toThrow(
|
||||
'No connection established for request ID'
|
||||
);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it.each([NaN, Infinity])('should disable keep-alive for non-finite keepAliveMs (%s)', async keepAliveMs => {
|
||||
const { transport, sessionId } = await createTransport({ keepAliveMs });
|
||||
|
||||
const response = await transport.handleRequest(get(sessionId));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// A non-finite interval must disable keep-alive, not arm a broken timer
|
||||
// (setInterval would clamp it to ~1ms and flood the stream).
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should clamp keepAliveMs above 2^31-1 instead of flooding the stream', async () => {
|
||||
const { transport, sessionId } = await createTransport({ keepAliveMs: 2 ** 31 });
|
||||
|
||||
const response = await transport.handleRequest(get(sessionId));
|
||||
const reader = response.body!.getReader();
|
||||
|
||||
// Un-clamped, setInterval treats 2^31 as ~1ms and floods; clamped, no
|
||||
// frame is due for a very long time.
|
||||
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 deliver a response to the successor stream when a resume completes during the event-store write', async () => {
|
||||
// storeEvent parks on the RESPONSE write, so a resume can complete and
|
||||
// replace the stream registration while send() is awaiting.
|
||||
const events: { id: string; streamId: string; message: JSONRPCMessage }[] = [];
|
||||
let counter = 0;
|
||||
let parkNext = false;
|
||||
let releaseStore: (() => void) | undefined;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
if (parkNext) {
|
||||
parkNext = false;
|
||||
await new Promise<void>(resolve => {
|
||||
releaseStore = resolve;
|
||||
});
|
||||
}
|
||||
const id = `${streamId}#${counter++}`;
|
||||
events.push({ id, streamId, message });
|
||||
return id;
|
||||
},
|
||||
async replayEventsAfter(lastEventId: EventId, { send }): Promise<StreamId> {
|
||||
const index = events.findIndex(e => e.id === lastEventId);
|
||||
const streamId = events[index]?.streamId ?? '_GET_stream';
|
||||
for (const event of events.slice(index + 1).filter(e => e.streamId === streamId)) {
|
||||
await send(event.id, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
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;
|
||||
|
||||
// Original request stream; capture its priming event id for the resume
|
||||
const original = 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' }
|
||||
})
|
||||
);
|
||||
const originalReader = original.body!.getReader();
|
||||
const { value: priming } = await originalReader.read();
|
||||
const primingEventId = /^id: (.+)$/m.exec(new TextDecoder().decode(priming))?.[1];
|
||||
expect(primingEventId).toBeDefined();
|
||||
|
||||
// Complete the tool; the response's storeEvent parks inside send()
|
||||
parkNext = true;
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseStore).toBeDefined();
|
||||
|
||||
// Client reconnects and resumes the request stream while send() is parked
|
||||
const resumed = await transport.handleRequest(
|
||||
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', 'Last-Event-ID': primingEventId! } })
|
||||
);
|
||||
expect(resumed.status).toBe(200);
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
|
||||
// Release the parked write: the response must reach the live (resumed)
|
||||
// stream, not vanish into the evicted one.
|
||||
releaseStore!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
let resumedData = '';
|
||||
for (let i = 0; i < 3 && !resumedData.includes('call-1'); i++) {
|
||||
const { value, done } = await resumedReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
resumedData += new TextDecoder().decode(value);
|
||||
}
|
||||
expect(resumedData).toContain('"id":"call-1"');
|
||||
expect(resumedData).toContain('done');
|
||||
|
||||
await transport.close();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not register streams when the transport closes during the session initialization callback', async () => {
|
||||
let releaseInit: (() => void) | undefined;
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: () =>
|
||||
new Promise<void>(resolve => {
|
||||
releaseInit = resolve;
|
||||
})
|
||||
});
|
||||
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
|
||||
|
||||
// Initialization parks on the onsessioninitialized await
|
||||
const pendingInit = transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseInit).toBeDefined();
|
||||
|
||||
// Close mid-await, then let the continuation run: it must not hand out
|
||||
// a 200 SSE stream nothing will ever write to, nor arm a timer.
|
||||
await transport.close();
|
||||
releaseInit!();
|
||||
const response = await pendingInit;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not double-deliver a response when the event becomes replayable before the store write resolves', async () => {
|
||||
// storeEvent persists the event (making it replay-visible) and THEN
|
||||
// parks: a resume in that window replays the response to the successor,
|
||||
// and the parked send() continuation must not write it a second time.
|
||||
const events: { id: string; streamId: string; message: JSONRPCMessage }[] = [];
|
||||
let counter = 0;
|
||||
let parkNext = false;
|
||||
let releaseStore: (() => void) | undefined;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
const id = `${streamId}#${counter++}`;
|
||||
events.push({ id, streamId, message });
|
||||
if (parkNext) {
|
||||
parkNext = false;
|
||||
await new Promise<void>(resolve => {
|
||||
releaseStore = resolve;
|
||||
});
|
||||
}
|
||||
return id;
|
||||
},
|
||||
async replayEventsAfter(lastEventId: EventId, { send }): Promise<StreamId> {
|
||||
const index = events.findIndex(e => e.id === lastEventId);
|
||||
const streamId = events[index]?.streamId ?? '_GET_stream';
|
||||
for (const event of events.slice(index + 1).filter(e => e.streamId === streamId)) {
|
||||
await send(event.id, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
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 original = 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' }
|
||||
})
|
||||
);
|
||||
const originalReader = original.body!.getReader();
|
||||
const { value: priming } = await originalReader.read();
|
||||
const primingEventId = /^id: (.+)$/m.exec(new TextDecoder().decode(priming))?.[1];
|
||||
expect(primingEventId).toBeDefined();
|
||||
|
||||
parkNext = true;
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseStore).toBeDefined();
|
||||
|
||||
// The response event is already visible in the store: the resume's
|
||||
// replay delivers it to the successor stream.
|
||||
const resumed = await transport.handleRequest(
|
||||
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', 'Last-Event-ID': primingEventId! } })
|
||||
);
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
|
||||
releaseStore!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
let resumedData = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const { value, done } = await resumedReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
resumedData += new TextDecoder().decode(value);
|
||||
}
|
||||
const deliveries = resumedData.match(/"id":"call-1"/g) ?? [];
|
||||
expect(deliveries).toHaveLength(1);
|
||||
|
||||
await transport.close();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not initialize a session when the transport closes while the request body is being read', async () => {
|
||||
const onsessioninitialized = vi.fn();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized
|
||||
});
|
||||
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
|
||||
|
||||
// Init request whose body stream parks until we release it
|
||||
let releaseBody: (() => void) | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
releaseBody = () => {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(TEST_MESSAGES.initialize)));
|
||||
controller.close();
|
||||
};
|
||||
}
|
||||
});
|
||||
const request = new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json' },
|
||||
body,
|
||||
// @ts-expect-error duplex is required for streaming bodies but not yet in lib types
|
||||
duplex: 'half'
|
||||
});
|
||||
|
||||
const pending = transport.handleRequest(request);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseBody).toBeDefined();
|
||||
|
||||
// Close while req.json() is still reading, then deliver the body: the
|
||||
// continuation must not initialize a session on the closed transport.
|
||||
await transport.close();
|
||||
releaseBody!();
|
||||
const response = await pending;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(onsessioninitialized).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should store and replay a response completed after closeSSEStream switched the client to polling', async () => {
|
||||
const events: { id: string; streamId: string; message: JSONRPCMessage }[] = [];
|
||||
let counter = 0;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
const id = `${streamId}#${counter++}`;
|
||||
events.push({ id, streamId, message });
|
||||
return id;
|
||||
},
|
||||
async replayEventsAfter(lastEventId: EventId, { send }): Promise<StreamId> {
|
||||
const index = events.findIndex(e => e.id === lastEventId);
|
||||
const streamId = events[index]?.streamId ?? '_GET_stream';
|
||||
for (const event of events.slice(index + 1).filter(e => e.streamId === streamId)) {
|
||||
await send(event.id, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
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 original = 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' }
|
||||
})
|
||||
);
|
||||
const { value: priming } = await original.body!.getReader().read();
|
||||
const primingEventId = /^id: (.+)$/m.exec(new TextDecoder().decode(priming))?.[1];
|
||||
expect(primingEventId).toBeDefined();
|
||||
|
||||
// Server switches the client to polling; the request stream is gone
|
||||
transport.closeSSEStream('call-1');
|
||||
|
||||
// Tool completes with no stream attached: the response must be stored
|
||||
// for replay, and the released correlations must not make send() throw.
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Client polls back in with Last-Event-ID and must receive the response
|
||||
const resumed = await transport.handleRequest(
|
||||
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', 'Last-Event-ID': primingEventId! } })
|
||||
);
|
||||
expect(resumed.status).toBe(200);
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
let resumedData = '';
|
||||
for (let i = 0; i < 3 && !resumedData.includes('call-1'); i++) {
|
||||
const { value, done } = await resumedReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
resumedData += new TextDecoder().decode(value);
|
||||
}
|
||||
expect(resumedData).toContain('"id":"call-1"');
|
||||
expect(resumedData).toContain('done');
|
||||
|
||||
await transport.close();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not re-deliver replayed server notifications to a resumed standalone stream', async () => {
|
||||
const events: { id: string; streamId: string; message: JSONRPCMessage }[] = [];
|
||||
let counter = 0;
|
||||
let parkNext = false;
|
||||
let releaseStore: (() => void) | undefined;
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
const id = `${streamId}#${counter++}`;
|
||||
events.push({ id, streamId, message });
|
||||
if (parkNext) {
|
||||
parkNext = false;
|
||||
await new Promise<void>(resolve => {
|
||||
releaseStore = resolve;
|
||||
});
|
||||
}
|
||||
return id;
|
||||
},
|
||||
async replayEventsAfter(lastEventId: EventId, { send }): Promise<StreamId> {
|
||||
const index = events.findIndex(e => e.id === lastEventId);
|
||||
const streamId = events[index]?.streamId ?? '_GET_stream';
|
||||
for (const event of events.slice(index + 1).filter(e => e.streamId === streamId)) {
|
||||
await send(event.id, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
const { transport, sessionId } = await createTransport({ eventStore });
|
||||
|
||||
// First notification anchors the resume point
|
||||
const first = await transport.handleRequest(get(sessionId));
|
||||
const firstReader = first.body!.getReader();
|
||||
await transport.send({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'anchor' } });
|
||||
const { value } = await firstReader.read();
|
||||
const anchorId = /^id: (.+)$/m.exec(new TextDecoder().decode(value))?.[1];
|
||||
expect(anchorId).toBeDefined();
|
||||
|
||||
// Second notification: stored (replay-visible), then the write parks
|
||||
parkNext = true;
|
||||
const parkedSend = transport.send({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'raced' } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseStore).toBeDefined();
|
||||
|
||||
// The old connection drops and the client resumes: replay delivers the
|
||||
// second notification to the successor stream.
|
||||
await firstReader.cancel();
|
||||
const resumed = await transport.handleRequest(get(sessionId, anchorId));
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
|
||||
releaseStore!();
|
||||
await parkedSend;
|
||||
|
||||
let resumedData = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const read = resumedReader.read();
|
||||
const raced = await Promise.race([read, Promise.resolve('pending')]);
|
||||
if (raced === 'pending') {
|
||||
break;
|
||||
}
|
||||
const { value: chunk, done } = raced as ReadableStreamReadResult<Uint8Array>;
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
resumedData += new TextDecoder().decode(chunk);
|
||||
}
|
||||
const deliveries = resumedData.match(/raced/g) ?? [];
|
||||
expect(deliveries).toHaveLength(1);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should fail loudly when a JSON-mode response completes after its stream is gone', async () => {
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
enableJsonResponse: true
|
||||
});
|
||||
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 errors: Error[] = [];
|
||||
mcpServer.server.onerror = error => {
|
||||
errors.push(error);
|
||||
};
|
||||
|
||||
void 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' }
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The stream mapping disappears while the tool is still running
|
||||
transport.closeSSEStream('call-1');
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// In JSON mode nothing can ever replay the response: completing against
|
||||
// a missing stream must surface an error, not vanish silently.
|
||||
expect(errors.map(e => e.message).join('\n')).toContain('No connection established for request ID');
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should close the transport when the onsessionclosed callback throws on DELETE', async () => {
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessionclosed: () => {
|
||||
throw new Error('session registry unavailable');
|
||||
}
|
||||
});
|
||||
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;
|
||||
|
||||
// Open the standalone stream so a keep-alive timer is armed
|
||||
const getResponse = await transport.handleRequest(get(sessionId));
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await expect(
|
||||
transport.handleRequest(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'DELETE',
|
||||
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('session registry unavailable');
|
||||
|
||||
// The callback threw, but the transport must still have been closed:
|
||||
// timers swept and subsequent requests rejected.
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
const after = await transport.handleRequest(get(sessionId));
|
||||
expect(after.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should reject requests with 404 after the transport is closed', async () => {
|
||||
const { transport, sessionId } = await createTransport();
|
||||
await transport.close();
|
||||
|
||||
const response = await transport.handleRequest(get(sessionId));
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should surface an error when a response completes for a disconnected client that cannot resume', async () => {
|
||||
// Pre-2025-11-25 clients never receive a priming event, so they cannot
|
||||
// resume a request stream: a response completing after their disconnect
|
||||
// is undeliverable and must surface, not be silently handed to replay.
|
||||
const eventStore = createSimpleEventStore();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
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: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0' } }
|
||||
}
|
||||
})
|
||||
);
|
||||
const sessionId = initResponse.headers.get('mcp-session-id') as string;
|
||||
|
||||
const errors: Error[] = [];
|
||||
mcpServer.server.onerror = error => {
|
||||
errors.push(error);
|
||||
};
|
||||
|
||||
const response = await transport.handleRequest(
|
||||
req('POST', {
|
||||
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'legacy-1' },
|
||||
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-03-26' }
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Client disconnects mid-call; no priming event was ever written, so
|
||||
// no Last-Event-ID cursor exists for a resume.
|
||||
await response.body!.getReader().cancel();
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(errors.map(e => e.message).join('\n')).toContain('No connection established for request ID');
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should hand off to replay for a legacy client that received an id-bearing notification', async () => {
|
||||
// A pre-2025-11-25 client gets no priming event, but any stored
|
||||
// notification delivered on the stream carries an id — that cursor is
|
||||
// enough to resume, so the completed response must be released to
|
||||
// replay, not surfaced as an error.
|
||||
const eventStore = createSimpleEventStore();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
let resolveTool: (() => void) | undefined;
|
||||
mcpServer.tool('notifying', async (extra): Promise<CallToolResult> => {
|
||||
await extra.sendNotification({ method: 'notifications/message', params: { level: 'info', data: 'progress' } });
|
||||
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: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0' } }
|
||||
}
|
||||
})
|
||||
);
|
||||
const sessionId = initResponse.headers.get('mcp-session-id') as string;
|
||||
|
||||
const errors: Error[] = [];
|
||||
mcpServer.server.onerror = error => {
|
||||
errors.push(error);
|
||||
};
|
||||
|
||||
const response = await transport.handleRequest(
|
||||
req('POST', {
|
||||
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'notifying', arguments: {} }, id: 'legacy-2' },
|
||||
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-03-26' }
|
||||
})
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const { value } = await reader.read();
|
||||
const cursor = /^id: (.+)$/m.exec(new TextDecoder().decode(value))?.[1];
|
||||
expect(cursor).toBeDefined();
|
||||
|
||||
// Client disconnects holding the notification's event id, then the tool completes
|
||||
await reader.cancel();
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
|
||||
// The resume from that cursor must replay the response
|
||||
const resumed = await transport.handleRequest(get(sessionId, cursor!));
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
let resumedData = '';
|
||||
for (let i = 0; i < 3 && !resumedData.includes('legacy-2'); i++) {
|
||||
const { value: chunk, done } = await resumedReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
resumedData += new TextDecoder().decode(chunk);
|
||||
}
|
||||
expect(resumedData).toContain('"id":"legacy-2"');
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should not surface an error when the transport closes during the response store write', async () => {
|
||||
let parkNext = false;
|
||||
let releaseStore: (() => void) | undefined;
|
||||
const inner = createSimpleEventStore();
|
||||
const eventStore: EventStore = {
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
if (parkNext) {
|
||||
parkNext = false;
|
||||
await new Promise<void>(resolve => {
|
||||
releaseStore = resolve;
|
||||
});
|
||||
}
|
||||
return inner.storeEvent(streamId, message);
|
||||
},
|
||||
replayEventsAfter: inner.replayEventsAfter.bind(inner)
|
||||
};
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
|
||||
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 errors: Error[] = [];
|
||||
mcpServer.server.onerror = error => {
|
||||
errors.push(error);
|
||||
};
|
||||
|
||||
void transport.handleRequest(
|
||||
req('POST', {
|
||||
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'race-1' },
|
||||
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The response send parks inside storeEvent; close() sweeps everything
|
||||
parkNext = true;
|
||||
resolveTool?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(releaseStore).toBeDefined();
|
||||
await transport.close();
|
||||
releaseStore!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The transport is gone; the late completion must be a no-op, not a
|
||||
// spurious 'No connection established' error.
|
||||
expect(errors.filter(e => e.message.includes('No connection established'))).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should make close() idempotent', async () => {
|
||||
const { transport } = await createTransport();
|
||||
let oncloseCalls = 0;
|
||||
transport.onclose = () => {
|
||||
oncloseCalls++;
|
||||
};
|
||||
|
||||
await transport.close();
|
||||
await transport.close();
|
||||
|
||||
expect(oncloseCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue