From f0c6062c55347a6ff6ed7d4e5072072f028c3e00 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 27 Nov 2025 19:38:18 +0000 Subject: [PATCH] Fix JSON parse error on SSE events with empty data Priming events for resumability (SEP-1699) have an event ID but empty data. The client was attempting JSON.parse("") which throws "Unexpected end of JSON input". Skip processing for all events with empty data, not just message events. This handles priming events, keep-alives, and any other events that may have empty data fields. --- src/client/streamableHttp.test.ts | 51 +++++++++++++++++++++++++++++++ src/client/streamableHttp.ts | 5 +++ 2 files changed, 56 insertions(+) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 7c6895416..7a98cb78a 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -865,6 +865,57 @@ describe('StreamableHTTPClientTransport', () => { const reconnectHeaders = fetchMock.mock.calls[1][1]?.headers as Headers; expect(reconnectHeaders.get('last-event-id')).toBe('event-123'); }); + + it('should not throw JSON parse error on priming events with empty data', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + const resumptionTokenSpy = vi.fn(); + + // Create a stream that sends a priming event (ID only, empty data) then a real message + const streamWithPrimingEvent = new ReadableStream({ + start(controller) { + // Send a priming event with ID but empty data - this should NOT cause a JSON parse error + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // Send a real message + controller.enqueue( + new TextEncoder().encode('id: msg-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"req-1"}\n\n') + ); + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithPrimingEvent + }); + + await transport.start(); + transport.send( + { + jsonrpc: '2.0', + method: 'tools/list', + id: 'req-1', + params: {} + }, + { resumptionToken: undefined, onresumptiontoken: resumptionTokenSpy } + ); + + await vi.advanceTimersByTimeAsync(50); + + // No JSON parse errors should have occurred + expect(errorSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Unexpected end of JSON') }) + ); + // Resumption token callback should have been called for both events with IDs + expect(resumptionTokenSpy).toHaveBeenCalledWith('priming-123'); + expect(resumptionTokenSpy).toHaveBeenCalledWith('msg-456'); + }); }); it('invalidates all credentials on InvalidClientError during auth', async () => { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index aa52ec732..c79ea0395 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -338,6 +338,11 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?.(event.id); } + // Skip events with no data (priming events, keep-alives) + if (!event.data) { + continue; + } + if (!event.event || event.event === 'message') { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));