Skip to content

Commit f0c6062

Browse files
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.
1 parent 2aa697c commit f0c6062

File tree

2 files changed

+56
-0
lines changed

2 files changed

+56
-0
lines changed

src/client/streamableHttp.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,57 @@ describe('StreamableHTTPClientTransport', () => {
865865
const reconnectHeaders = fetchMock.mock.calls[1][1]?.headers as Headers;
866866
expect(reconnectHeaders.get('last-event-id')).toBe('event-123');
867867
});
868+
869+
it('should not throw JSON parse error on priming events with empty data', async () => {
870+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
871+
872+
const errorSpy = vi.fn();
873+
transport.onerror = errorSpy;
874+
875+
const resumptionTokenSpy = vi.fn();
876+
877+
// Create a stream that sends a priming event (ID only, empty data) then a real message
878+
const streamWithPrimingEvent = new ReadableStream({
879+
start(controller) {
880+
// Send a priming event with ID but empty data - this should NOT cause a JSON parse error
881+
controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n'));
882+
// Send a real message
883+
controller.enqueue(
884+
new TextEncoder().encode('id: msg-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"req-1"}\n\n')
885+
);
886+
controller.close();
887+
}
888+
});
889+
890+
const fetchMock = global.fetch as Mock;
891+
fetchMock.mockResolvedValueOnce({
892+
ok: true,
893+
status: 200,
894+
headers: new Headers({ 'content-type': 'text/event-stream' }),
895+
body: streamWithPrimingEvent
896+
});
897+
898+
await transport.start();
899+
transport.send(
900+
{
901+
jsonrpc: '2.0',
902+
method: 'tools/list',
903+
id: 'req-1',
904+
params: {}
905+
},
906+
{ resumptionToken: undefined, onresumptiontoken: resumptionTokenSpy }
907+
);
908+
909+
await vi.advanceTimersByTimeAsync(50);
910+
911+
// No JSON parse errors should have occurred
912+
expect(errorSpy).not.toHaveBeenCalledWith(
913+
expect.objectContaining({ message: expect.stringContaining('Unexpected end of JSON') })
914+
);
915+
// Resumption token callback should have been called for both events with IDs
916+
expect(resumptionTokenSpy).toHaveBeenCalledWith('priming-123');
917+
expect(resumptionTokenSpy).toHaveBeenCalledWith('msg-456');
918+
});
868919
});
869920

870921
it('invalidates all credentials on InvalidClientError during auth', async () => {

src/client/streamableHttp.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@ export class StreamableHTTPClientTransport implements Transport {
338338
onresumptiontoken?.(event.id);
339339
}
340340

341+
// Skip events with no data (priming events, keep-alives)
342+
if (!event.data) {
343+
continue;
344+
}
345+
341346
if (!event.event || event.event === 'message') {
342347
try {
343348
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));

0 commit comments

Comments
 (0)