Skip to content

RpcSerialization ndjson over stdio: after one line fails JSON.parse, the server stops reading requests #8497

Description

@spencerbeggs

What we observed

We send an MCP stdio server one stdin line that isn't JSON. After that, it never answers another request. Each later chunk logs the same SyntaxError for that first line. No reply of any kind goes to the client. The process stays alive and exits at stdin EOF, with the same status as a healthy session.

The same thing happens with:

  • a blank line, since JSON.parse("") throws;
  • a U+FEFF at the start of any line except the first. The streaming TextDecoder removes a BOM only at the start of the stream;
  • a line longer than the 16 MiB cap. MaxBufferSizeExceeded clears the buffer, so the rest of that line then arrives as a fresh line that isn't JSON.

Environment: effect@4.0.0-rc.117 (tag effect@4.0.0-rc.117, 14a3f14) and @effect/platform-node@4.0.0-rc.117, on Node 26.10.0 and macOS. From reading the source, makeNdjson and makeProtocolStdio are unchanged on main at 3788b63 (packages/effect/src/rpc/RpcSerialization.ts:174, packages/effect/src/rpc/RpcServer.ts:1379-1395).

Reproduction

The parser alone shows it without a server:

import { RpcSerialization } from "effect/unstable/rpc"

const parser = RpcSerialization.ndjson.makeUnsafe()
const attempt = (label: string, input: string) => {
  try {
    console.log(label, "->", JSON.stringify(parser.decode(input)))
  } catch (error) {
    console.log(label, "-> throws", String(error))
  }
}
attempt("1. valid line   ", "{\"id\":1}\n")
attempt("2. non-JSON line", "not json\n")
attempt("3. valid line   ", "{\"id\":3}\n")
attempt("4. valid line   ", "{\"id\":4}\n")
1. valid line    -> [{"id":1}]
2. non-JSON line -> throws SyntaxError: Unexpected token 'o', "not json" is not valid JSON
3. valid line    -> throws SyntaxError: Unexpected token 'o', "not json" is not valid JSON
4. valid line    -> throws SyntaxError: Unexpected token 'o', "not json" is not valid JSON

Over the wire, we used a minimal McpServer.layerStdio server over NodeStdio.layer with References.LogToStderr. Each case sends initialize and notifications/initialized, then the case's line, then {"jsonrpc":"2.0","id":3,"method":"tools/list"} one second later. Captured live, trimmed to the id-3 outcome and the stderr ERROR lines:

##### case: control (no extra line)
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}}
##### case: not-json   (printf 'not json\n')
(no reply to id 3)
ERROR (#2): SyntaxError: Unexpected token 'o', "not json" is not valid JSON
ERROR (#2): SyntaxError: Unexpected token 'o', "not json" is not valid JSON
##### case: blank      (printf '\n')
(no reply to id 3)
ERROR (#2): SyntaxError: Unexpected end of JSON input
ERROR (#2): SyntaxError: Unexpected end of JSON input
##### case: bom        (printf '\xef\xbb\xbf{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n')
(no reply to id 2 or id 3)
ERROR (#2): SyntaxError: Unexpected token '', "{"jsonrpc"... is not valid JSON
ERROR (#2): SyntaxError: Unexpected token '', "{"jsonrpc"... is not valid JSON
##### case: overcap    (one tools/call line whose "text" argument is 17 MiB of "a")
(no reply to id 2 or id 3)
ERROR (#2): MaxBufferSizeExceeded: RPC serialization buffer exceeded the maximum size of 16777216
ERROR (#2): SyntaxError: Unexpected token 'a', "aaaaaaaaaa"... is not valid JSON
ERROR (#2): SyntaxError: Unexpected token 'a', "aaaaaaaaaa"... is not valid JSON
The server used for these runs (server.ts)
// Minimal MCP stdio server over the installed effect + @effect/platform-node.
// Variant selected by argv[2]: "echo" (default), "union", "empty".
// PROTOCOLS=both serves [v2026_07_28, v2025_11_25]; default is [v2025_11_25].
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeStdio from "@effect/platform-node/NodeStdio"
import { Effect, Layer, References, Schema } from "effect"
import { McpProtocol, McpServer, Tool, Toolkit } from "effect/unstable/ai"

const variant = process.argv[2] ?? "echo"

const Echo = Tool.make("echo", {
  parameters: Schema.Struct({ text: Schema.String }),
  success: Schema.Struct({ text: Schema.String })
}).annotate(Tool.Strict, true)

const Act = Tool.make("act", {
  parameters: Schema.Union([
    Schema.Struct({ action: Schema.Literal("start"), id: Schema.String }),
    Schema.Struct({ action: Schema.Literal("stop"), reason: Schema.String })
  ]),
  success: Schema.String
})

const Ping = Tool.make("noargs", {
  parameters: Schema.Struct({}),
  success: Schema.String
})

const EchoKit = Toolkit.make(Echo)
const UnionKit = Toolkit.make(Echo, Act)
const EmptyKit = Toolkit.make(Echo, Ping)

const tools =
  variant === "union"
    ? McpServer.toolkit(UnionKit).pipe(
      Layer.provide(UnionKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }), act: () => Effect.succeed("ok") }))
    )
    : variant === "empty"
    ? McpServer.toolkit(EmptyKit).pipe(
      Layer.provide(EmptyKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }), noargs: () => Effect.succeed("ok") }))
    )
    : McpServer.toolkit(EchoKit).pipe(
      Layer.provide(EchoKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }) }))
    )

const Main = tools.pipe(
  Layer.provideMerge(
    McpServer.layerStdio({ name: "repro", version: "0.0.0", protocols: process.env.PROTOCOLS === "both"
      ? [McpProtocol.v2026_07_28, McpProtocol.v2025_11_25]
      : [McpProtocol.v2025_11_25]
  })
  ),
  Layer.provide(NodeStdio.layer)
)

// Logs go to stderr so stdout carries only the JSON-RPC wire.
NodeRuntime.runMain(Layer.launch(Main).pipe(Effect.provideService(References.LogToStderr, true)))
The driver used for these runs (drive.sh)
#!/bin/bash
# drive.sh <case>: handshake, then the case's frames, then a probe request
# (id 3) one second later. Each `printf` is one write to the server's stdin.
cd "$(dirname "$0")"
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"repro","version":"0"}}}'
INITED='{"jsonrpc":"2.0","method":"notifications/initialized"}'
LIST2='{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
LIST3='{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
{
  printf '%s\n%s\n' "$INIT" "$INITED"; sleep 0.5
  case "$1" in
    not-json)   printf 'not json\n' ;;
    blank)      printf '\n' ;;
    bom)        printf '\xef\xbb\xbf%s\n' "$LIST2" ;;
    null-batch) printf 'null\n%s\n' "$LIST2" ;;
    method-batch) printf '{"jsonrpc":"2.0","method":1}\n%s\n' "$LIST2" ;;
    scalar)     printf '7\n' ;;
    empty-obj)  printf '{}\n' ;;
    eof-ctl)    printf '{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}\n' ;;
    control) : ;;
    array)      printf '[1]\n' ;;
    method-id)  printf '{"jsonrpc":"2.0","id":4,"method":5}\n' ;;
    eof-ctl-id) printf '{"jsonrpc":"2.0","id":5,"method":"@effect/rpc/Eof"}\n' ;;
    ping-ctl)   printf '{"jsonrpc":"2.0","method":"@effect/rpc/Ping"}\n' ;;
    overcap)    python3 -c 'import sys; sys.stdout.write("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"" + "a" * (17 * 1024 * 1024) + "\"}}}\n")' ;;
  esac
  sleep 1
  printf '%s\n' "$LIST3"; sleep 1
} | node server.ts echo 2>stderr.txt | cut -c1-110
echo "exit=${PIPESTATUS[1]}"
echo "stderr (ERROR lines):"; grep ERROR stderr.txt | cut -c1-160

In every case, the process exits at stdin EOF with the same status as the control: 130 under NodeRuntime.runMain's default teardown.

Our reading of the spec

  • JSON-RPC 2.0, §5.1 "Error object" defines -32700 Parse error as "Invalid JSON was received by the server." §5 "Response object" says: "If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null."
  • MCP 2025-11-25, Transports, "stdio" also says "The client MUST NOT write anything to the server's stdin that is not a valid MCP message." So a conforming client never sends these lines. We still think the case is worth raising, because each of these lines is easy to produce: an editor-saved fixture with a BOM, a trailing blank line from a shell echo, a hand-typed test session. After one of them the server can't be used for the rest of the session, and it doesn't signal that.

We read these as two separate questions. First, whether the decoder is meant to skip past a line it can't parse. Second, whether the MCP stdio transport is meant to answer such a line with -32700.

Where it happens

  • RpcSerialization.ts:157-175: JSON.parse on line 166 throws before buffer = buffer.slice(position) on line 171 runs. The bad line therefore stays at the head of buffer.
  • RpcServer.ts:1358-1376: makeProtocolStdio creates the parser once, outside the stdin stream, and then Effect.retry(Schedule.spaced(500)) re-subscribes. Each new chunk is appended to the same buffer and throws again on the same line.
  • RpcSerialization.ts:152-155: failMaxBufferSize resets buffer to "" and throws, so the rest of the over-cap line is parsed as a new line. On sockets, Cap RPC streaming decoder buffers #6803 closes the connection with code 1009 at this point. Stdio has no equivalent.
  • McpServer.layerStdio wraps the same RpcSerialization.ndjson parser (McpServer.ts:1460). RpcSerialization.ndJsonRpc builds on makeNdjson too (RpcSerialization.ts:251), so we expect any RPC server that uses those serializations over layerProtocolStdio to behave the same way. We only ran the MCP path.

How we work around it

In @effected/mcp, McpStdio.layer gives the server a Stdio whose stdin goes through a frame guard. The guard frames lines the same way makeNdjson does and forwards only complete lines that parse and fit under the cap. It answers every other non-blank line with {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}} and drops lines that are only JSON whitespace. An over-cap line is answered once and discarded up to its newline. It is wired in at McpStdio.ts:131-144. We guard stdin because layerStdio provides its serialization internally and we couldn't replace it.

Is this intended?

Maybe a stdio peer that sends a malformed line is meant to be treated as a broken connection. If so, we wonder whether ending the protocol would be preferable to continuing to retry, since today the process stays up but no longer answers. If the behaviour isn't intended, one possible direction is to advance past each line before parsing it, or catch per line. The MCP serialization could then answer an unparseable or oversized line with -32700 and id: null. We don't know which fits the RPC module's design better, so we're reporting what we see.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions