fix(core): read the MCP SSE stream as an event stream, not as chunks by L4XB · Pull Request #287 · FellouAI/eko · GitHub
Skip to content

fix(core): read the MCP SSE stream as an event stream, not as chunks - #287

Open
L4XB wants to merge 1 commit into
FellouAI:mainfrom
L4XB:fix/sse-event-stream-parsing
Open

L4XB wants to merge 1 commit into
FellouAI:mainfrom
L4XB:fix/sse-event-stream-parsing

Conversation

@L4XB

@L4XB L4XB commented Sep 13, 2026

Copy link
Copy Markdown

Sibling of #286, same class on the other transport: the reader treats a network chunk as a unit of the protocol. Three things go wrong here with a server that is merely following the event-stream format.

A chunk can end in the middle of a character. decoder.decode(value) without stream: true decodes that half on its own and emits U+FFFD. Any non-ASCII payload the server split across writes arrives corrupted — and a JSON body corrupted that way either fails to parse or parses with mojibake inside it. Chinese tool names and descriptions are exactly the length where this shows up.

Events are separated by a blank line, and the line terminator may be CRLF, LF or CR. The reader searched for "\n\n", which a CRLF stream never contains — \r\n\r\n has no \n\n in it. Against a server sending CRLF, every event stayed in the buffer and the call waited out its 15s timeout while the data sat there.

One event may carry several data: lines, and they are one payload joined by newlines — which is how a server that pretty-prints its JSON sends it. Each line overwrote the last, so JSON.parse received the closing brace of the body.

The change

decode(value, { stream: true }), a separator that accepts all three line terminators, and data: lines collected instead of overwritten. The framing moves into an exported parseSseStream(chunks) generator, which is what makes it testable: connectSse feeds it the fetch reader, and the tests feed it byte chunks. parseChunk keeps its shape — id, event and any other field are unchanged, and a single-line event produces exactly what it produced before.

Reading ends the same way: a trailing partial event is not an event, and is dropped rather than guessed at.

Tests

packages/eko-core/test/mcp-sse-parsing.test.ts, seven cases over the real decoder and no socket:

npx jest test/mcp-sse-parsing.test.ts     # 7 passed

CRLF-separated events; CR-separated events; a pretty-printed body across several data: lines; a multi-byte character split at its lead byte (asserting the text round-trips and no U+FFFD survives); an event split at three arbitrary points; an ordinary LF stream with a trailing partial event dropped; and id/retry fields preserved.

Checked against mutants rather than only against the fix — each piece is graded by the test written for it: dropping stream: true fails only the split-character case, narrowing the separator to \n\n fails the CRLF and CR cases, keeping only the last data: line fails the multi-line case, and splitting lines on \n alone fails the CR case.

tsc -p tsconfig.test.json reports nothing new (src/agent/agent-llm.ts has a pre-existing AI-SDK typing error on main, untouched here). Tested on macOS, Node 24.

This branch is independent of #286 — different package, no shared files — so the two can land in either order.

The SSE reader treated each network chunk as if it were a unit of the protocol.
Three things go wrong with a server that is merely following the format.

A chunk can end in the middle of a multi-byte character. `decoder.decode(value)`
without `stream: true` decodes that half on its own and emits U+FFFD, so any
non-ASCII payload the server split across writes arrives corrupted - and a JSON
body corrupted that way either fails to parse or parses with mojibake inside it.

Events are separated by a BLANK LINE, and the line terminator may be CRLF, LF or
CR. The reader searched for `"\n\n"` only, which a CRLF stream never contains, so
every event stayed in the buffer and the caller waited out its timeout against a
server that was sending correctly.

One event may carry several `data:` lines, and they are one payload joined by
newlines - which is how a server that pretty-prints its JSON sends it. Each line
overwrote the last, so `JSON.parse` received a fragment.

The framing moves into an exported `parseSseStream(chunks)` generator so it can
be tested without a socket: `connectSse` feeds it the fetch reader, and the tests
feed it byte chunks split at the boundaries that used to break it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant