You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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"constparser=RpcSerialization.ndjson.makeUnsafe()constattempt=(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*asNodeRuntimefrom"@effect/platform-node/NodeRuntime"import*asNodeStdiofrom"@effect/platform-node/NodeStdio"import{Effect,Layer,References,Schema}from"effect"import{McpProtocol,McpServer,Tool,Toolkit}from"effect/unstable/ai"constvariant=process.argv[2]??"echo"constEcho=Tool.make("echo",{parameters: Schema.Struct({text: Schema.String}),success: Schema.Struct({text: Schema.String})}).annotate(Tool.Strict,true)constAct=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})constPing=Tool.make("noargs",{parameters: Schema.Struct({}),success: Schema.String})constEchoKit=Toolkit.make(Echo)constUnionKit=Toolkit.make(Echo,Act)constEmptyKit=Toolkit.make(Echo,Ping)consttools=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 })})))constMain=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 echo2>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.
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
SyntaxErrorfor 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:
JSON.parse("")throws;TextDecoderremoves a BOM only at the start of the stream;MaxBufferSizeExceededclears 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(tageffect@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,makeNdjsonandmakeProtocolStdioare unchanged onmainat3788b63(packages/effect/src/rpc/RpcSerialization.ts:174,packages/effect/src/rpc/RpcServer.ts:1379-1395).Reproduction
The parser alone shows it without a server:
Over the wire, we used a minimal
McpServer.layerStdioserver overNodeStdio.layerwithReferences.LogToStderr. Each case sendsinitializeandnotifications/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:The server used for these runs (
server.ts)The driver used for these runs (
drive.sh)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
-32700 Parse erroras "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."stdinthat 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 shellecho, 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.parseon line 166 throws beforebuffer = buffer.slice(position)on line 171 runs. The bad line therefore stays at the head ofbuffer.RpcServer.ts:1358-1376:makeProtocolStdiocreates the parser once, outside the stdin stream, and thenEffect.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:failMaxBufferSizeresetsbufferto""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.layerStdiowraps the sameRpcSerialization.ndjsonparser (McpServer.ts:1460).RpcSerialization.ndJsonRpcbuilds onmakeNdjsontoo (RpcSerialization.ts:251), so we expect any RPC server that uses those serializations overlayerProtocolStdioto behave the same way. We only ran the MCP path.How we work around it
In
@effected/mcp,McpStdio.layergives the server aStdiowhosestdingoes through a frame guard. The guard frames lines the same waymakeNdjsondoes 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 atMcpStdio.ts:131-144. We guardstdinbecauselayerStdioprovides 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
-32700andid: null. We don't know which fits the RPC module's design better, so we're reporting what we see.Related
MaxBufferSizeExceededand the socket 1009 close.