fix: disable priming events to fix backwards compatibility

Priming events (SEP-1699) have empty SSE data which clients in the 1.23.x
line cannot handle - they crash trying to JSON.parse(""). This patch
disables priming events entirely to prevent breaking existing clients.

Priming events will be re-enabled in 1.24.x with proper protocol version
gating to only send them to clients that can handle empty SSE data.
This commit is contained in:
Felix Weinberger 2025-12-04 14:04:37 +00:00
commit 05c26b327a
No known key found for this signature in database
2 changed files with 18 additions and 136 deletions

View file

@ -1592,12 +1592,13 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
});
// Test SSE priming events for POST streams
describe('StreamableHTTPServerTransport POST SSE priming events', () => {
// NOTE: Priming events are DISABLED in 1.23.1 to fix backwards compatibility issues.
// Clients in the 1.23.x line cannot handle empty SSE data (they crash on JSON.parse("")).
describe('StreamableHTTPServerTransport POST SSE priming events (DISABLED in 1.23.1)', () => {
let server: Server;
let transport: StreamableHTTPServerTransport;
let baseUrl: URL;
let sessionId: string;
let mcpServer: McpServer;
// Simple eventStore for priming event tests
const createEventStore = (): EventStore => {
@ -1641,7 +1642,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
}
});
it('should send priming event with retry field on POST SSE stream', async () => {
it('should NOT send priming events (disabled in 1.23.1 for backwards compatibility)', async () => {
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
eventStore: createEventStore(),
@ -1650,7 +1651,6 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
mcpServer = result.mcpServer;
// Initialize to get session ID
const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
@ -1671,7 +1671,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
'Content-Type': 'application/json',
Accept: 'text/event-stream, application/json',
'mcp-session-id': sessionId,
'mcp-protocol-version': '2025-03-26'
'mcp-protocol-version': '2025-06-18'
},
body: JSON.stringify(toolCallRequest)
});
@ -1679,128 +1679,15 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(postResponse.status).toBe(200);
expect(postResponse.headers.get('content-type')).toBe('text/event-stream');
// Read the priming event
// Read the first chunk - should be the actual response, not a priming event
const reader = postResponse.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
// Verify priming event has id and retry field
expect(text).toContain('id: ');
expect(text).toContain('retry: 5000');
expect(text).toContain('data: ');
});
it('should send priming event without retry field when retryInterval is not configured', async () => {
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
eventStore: createEventStore()
// No retryInterval
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
mcpServer = result.mcpServer;
// Initialize to get session ID
const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
sessionId = initResponse.headers.get('mcp-session-id') as string;
expect(sessionId).toBeDefined();
// Send a tool call request
const toolCallRequest: JSONRPCMessage = {
jsonrpc: '2.0',
id: 100,
method: 'tools/call',
params: { name: 'greet', arguments: { name: 'Test' } }
};
const postResponse = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream, application/json',
'mcp-session-id': sessionId,
'mcp-protocol-version': '2025-03-26'
},
body: JSON.stringify(toolCallRequest)
});
expect(postResponse.status).toBe(200);
// Read the priming event
const reader = postResponse.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
// Priming event should have id field but NOT retry field
expect(text).toContain('id: ');
expect(text).toContain('data: ');
expect(text).not.toContain('retry:');
});
it('should close POST SSE stream when closeSseStream is called', async () => {
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
eventStore: createEventStore(),
retryInterval: 1000
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
mcpServer = result.mcpServer;
// Track tool execution state
let toolResolve: () => void;
const toolPromise = new Promise<void>(resolve => {
toolResolve = resolve;
});
// Register a blocking tool
mcpServer.tool('blocking-tool', 'A blocking tool', {}, async () => {
await toolPromise;
return { content: [{ type: 'text', text: 'Done' }] };
});
// Initialize to get session ID
const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
sessionId = initResponse.headers.get('mcp-session-id') as string;
expect(sessionId).toBeDefined();
// Send a tool call request
const toolCallRequest: JSONRPCMessage = {
jsonrpc: '2.0',
id: 100,
method: 'tools/call',
params: { name: 'blocking-tool', arguments: {} }
};
const postResponse = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream, application/json',
'mcp-session-id': sessionId,
'mcp-protocol-version': '2025-03-26'
},
body: JSON.stringify(toolCallRequest)
});
expect(postResponse.status).toBe(200);
const reader = postResponse.body?.getReader();
// Read the priming event
await reader!.read();
// Close the SSE stream
transport.closeSSEStream(100);
// Stream should now be closed
const { done } = await reader!.read();
expect(done).toBe(true);
// Clean up - resolve the tool promise
toolResolve!();
// Should NOT contain a priming event (which would have empty data)
// The first message should be the actual tool result with event: message
expect(text).toContain('event: message');
expect(text).toContain('"result"');
});
});

View file

@ -270,20 +270,15 @@ export class StreamableHTTPServerTransport implements Transport {
/**
* Writes a priming event to establish resumption capability.
* Only sends if eventStore is configured (opt-in for resumability).
*
* DISABLED in 1.23.1: Priming events have empty SSE data which clients
* in the 1.23.x line cannot handle (they crash trying to JSON.parse("")).
* This feature is disabled to prevent breaking existing clients.
* Priming events will be re-enabled in 1.24.x with proper protocol version gating.
*/
private async _maybeWritePrimingEvent(res: ServerResponse, streamId: string): Promise<void> {
if (!this._eventStore) {
return;
}
const primingEventId = await this._eventStore.storeEvent(streamId, {} as JSONRPCMessage);
let primingEvent = `id: ${primingEventId}\ndata: \n\n`;
if (this._retryInterval !== undefined) {
primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`;
}
res.write(primingEvent);
private async _maybeWritePrimingEvent(_res: ServerResponse, _streamId: string): Promise<void> {
// Priming events disabled in 1.23.x - see docstring above
return;
}
/**