fix(nodejs): frame stdio MCP messages by newline, not by chunk by L4XB · Pull Request #286 · FellouAI/eko · GitHub
Skip to content

fix(nodejs): frame stdio MCP messages by newline, not by chunk - #286

Open
L4XB wants to merge 2 commits into
FellouAI:mainfrom
L4XB:fix/stdio-jsonrpc-framing
Open

L4XB wants to merge 2 commits into
FellouAI:mainfrom
L4XB:fix/stdio-jsonrpc-framing

Conversation

@L4XB

@L4XB L4XB commented Sep 13, 2026

Copy link
Copy Markdown

Fixes #276.

The bug

A stdout data event is a chunk of bytes, not a JSON-RPC message. SimpleStdioMcpClient read each chunk as exactly one message:

const response = data.toString().trim();
if (!response.startsWith("{")) return;
const message = JSON.parse(response);

Three things go wrong with a real MCP server:

  • A response split across two chunks — JSON.parse throws on the first half. It throws inside a stream listener, where no request promise is on the stack, so it surfaces as an uncaught exception and takes the process down.
  • Two messages in one chunk (a notification followed by the answer, say) — the concatenation is not valid JSON, so the same throw happens; and a reader that only took the first message would drop the answer and leave sendMessage hanging forever.
  • A log line sharing a chunk with a message — the chunk no longer starts with {, so the whole chunk is skipped and the answer is lost.

Large responses and rapid-fire servers hit this routinely; on small, slow ones the chunks happen to line up and nothing looks wrong.

The fix

Buffer the tail of an incomplete line and hand only complete lines to one parser. That parser keeps the existing "ignore non-JSON output" rule per line, and logs an unparseable line instead of throwing, since a throw there has no caller to catch it.

One extra case: the old per-chunk read accepted a final message written without a trailing newline, because it never waited for a delimiter. A strict line reader would swallow it, so the end-of-stream handler flushes whatever is left. No behaviour a working server relied on is lost.

Tests

test/mcp-stdio-framing.test.ts spawns a small fixture server (test/fixtures/chunked-mcp-server.mjs) that answers over a real pipe with deliberately misaligned writes — one message torn in half, two messages in one write, a log line beside a message, a broken line beside a message, and an unterminated final message. No mocking; the failures are the real ones.

npx jest test/mcp-stdio-framing.test.ts     # 5 passed

Against main's parser 4 of the 5 fail; the fifth is the unterminated-message case, which the old code passed by accident and the end-of-stream flush keeps passing. Each piece of the fix was also checked on its own: dropping the try/catch fails exactly the broken-line test, dropping the end-of-stream flush fails exactly the unterminated-message test, and reading only the first line of each chunk fails the three tests about batched chunks.

Why the first commit is there

The package's tests could not run at all before this. jest-environment-node builds its own sandbox and does not forward Node's web-stream globals, so eventsource-parser — which @eko-ai/eko pulls in — threw ReferenceError: TransformStream is not defined at import time, before the first line of any test. That hits the existing test/mcp.test.ts too. The setup file installs ReadableStream, TransformStream and WritableStream from node:stream/web and leaves any real globals alone.

npx tsc -p tsconfig.test.json --noEmit is clean. Tested on macOS, Node 24.

jest-environment-node builds its own sandbox and does not forward Node's
web-stream globals into it, so eventsource-parser -- which @eko-ai/eko
pulls in -- threw "TransformStream is not defined" at import time. Every
test in this package failed before its first line ran, including the
existing test/mcp.test.ts.

Install ReadableStream, TransformStream and WritableStream from
node:stream/web in a setup file, leaving any real globals in place.
A stdout "data" event is a chunk of bytes, not a JSON-RPC message.
SimpleStdioMcpClient read each chunk as exactly one message, so a
response split across two chunks made JSON.parse throw out of the stream
listener -- an uncaught exception, since no request promise is on that
stack -- and a chunk carrying two messages, or a log line next to a
message, lost the response entirely and left the caller hanging.

Buffer the tail of an incomplete line and hand complete lines to one
parser that skips non-JSON output and logs an unparseable line instead of
throwing. An end-of-stream flush keeps a final message that a server
wrote without a trailing newline, which the old per-chunk read handled by
accident.

Fixes FellouAI#276
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.

[BUG] SimpleStdioMcpClient crashes on split or batched JSON-RPC messages from stdout

1 participant