What we observed
This is separate from the unparseable-line case, but close to it. Here the line is valid JSON but isn't a JSON-RPC message.
null, or an object whose method isn't a string and that has no id (for example {"jsonrpc":"2.0","method":1}): the decoder throws a TypeError. The ndjson layer had already consumed every line of that stdin chunk, so any other request in the same chunk is dropped without an answer. The server logs the error, re-subscribes, and answers later chunks normally.
- A number, string or boolean, or
{}: these decode as a successful Exit for request id "", so they're treated as a response to nothing. The client gets no reply.
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, decodeJsonRpcMessage is unchanged on main at 3788b63 (packages/effect/src/rpc/RpcSerialization.ts:313-315).
Reproduction
The decoder on its own:
import { RpcSerialization } from "effect/unstable/rpc"
const parser = RpcSerialization.jsonRpc().makeUnsafe()
for (const input of ["null", "{\"jsonrpc\":\"2.0\",\"method\":1}", "7", "{}"]) {
try {
console.log(input, "->", JSON.stringify(parser.decode(input)))
} catch (error) {
console.log(input, "-> throws", String(error))
}
}
null -> throws TypeError: Cannot convert undefined or null to object
{"jsonrpc":"2.0","method":1} -> throws TypeError: request.method.startsWith is not a function
7 -> [{"_tag":"Exit","requestId":"","exit":{"_tag":"Success"}}]
{} -> [{"_tag":"Exit","requestId":"","exit":{"_tag":"Success"}}]
Over the wire, we used a minimal McpServer.layerStdio server over NodeStdio.layer with References.LogToStderr. After the handshake, each case writes its line(s) in a single printf, then {"jsonrpc":"2.0","id":3,"method":"tools/list"} one second later. Captured live and trimmed:
##### case: null-batch printf 'null\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n'
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}} <- id 2 is never answered
ERROR (#2): TypeError: Cannot convert undefined or null to object
##### case: method-batch printf '{"jsonrpc":"2.0","method":1}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n'
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}} <- id 2 is never answered
ERROR (#2): TypeError: request.method.startsWith is not a function
##### case: scalar printf '7\n'
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}} <- nothing is written for `7`
##### case: empty-obj printf '{}\n'
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}} <- nothing is written for `{}`
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
Our reading of the spec
- JSON-RPC 2.0, §5.1 "Error object":
-32600 Invalid Request means "The JSON sent is not a valid Request object." §7 "Examples" uses the same shape as our second case: --> {"jsonrpc": "2.0", "method": 1, "params": "bar"} gets <-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}.
- MCP 2025-11-25, Transports, "stdio": "The client MUST NOT write anything to the server's
stdin that is not a valid MCP message." So this is about how a server copes with a misbehaving client, not about what a conforming client sends. We think dropping an unrelated, valid request in the same chunk is the part most worth a look.
{} is less clear to us. In MCP, both sides send requests, so a server also receives responses, and we can see why a shape without method would be read as a response. We include it only for completeness.
Where it happens
RpcSerialization.ts:301-304: Object.hasOwn(decoded, "method") throws on null. request.method.startsWith("@effect/rpc/") throws when id is nullish and method isn't a string.
RpcSerialization.ts:332-357: anything without method becomes an Exit, with requestId: response.id ?? "".
McpServer.ts:1464-1490: the MCP stdio decode loops over every frame from frames.decode(data) and calls parser.decode on each one. A throw on one frame loses the decoded array for the whole chunk.
How we work around it
The same @effected/mcp stdin guard answers these lines before they reach the server (answerFor). A JSON value that is neither an object nor an array gets -32600 with id: null. So does an object whose method isn't a string and that has no usable id, and an object with neither method nor id. Everything else is forwarded unchanged: arrays (core already answers those with -32600), response-shaped objects, and requests with an id (core answers those with -32601 even when method is malformed).
Is this intended?
We don't know whether jsonRpc() is meant to validate its input, or to trust a transport that already did. If it isn't intended, one possible direction is for decodeJsonRpcMessage to classify a value it can't read as an invalid request, instead of throwing. The server could then answer -32600 with id: null and keep the rest of the chunk.
Related
What we observed
This is separate from the unparseable-line case, but close to it. Here the line is valid JSON but isn't a JSON-RPC message.
null, or an object whosemethodisn't a string and that has noid(for example{"jsonrpc":"2.0","method":1}): the decoder throws aTypeError. The ndjson layer had already consumed every line of that stdin chunk, so any other request in the same chunk is dropped without an answer. The server logs the error, re-subscribes, and answers later chunks normally.{}: these decode as a successfulExitfor request id"", so they're treated as a response to nothing. The client gets no reply.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,decodeJsonRpcMessageis unchanged onmainat3788b63(packages/effect/src/rpc/RpcSerialization.ts:313-315).Reproduction
The decoder on its own:
Over the wire, we used a minimal
McpServer.layerStdioserver overNodeStdio.layerwithReferences.LogToStderr. After the handshake, each case writes its line(s) in a singleprintf, then{"jsonrpc":"2.0","id":3,"method":"tools/list"}one second later. Captured live and trimmed:The server used for these runs (
server.ts)The driver used for these runs (
drive.sh)Our reading of the spec
-32600 Invalid Requestmeans "The JSON sent is not a valid Request object." §7 "Examples" uses the same shape as our second case:--> {"jsonrpc": "2.0", "method": 1, "params": "bar"}gets<-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}.stdinthat is not a valid MCP message." So this is about how a server copes with a misbehaving client, not about what a conforming client sends. We think dropping an unrelated, valid request in the same chunk is the part most worth a look.{}is less clear to us. In MCP, both sides send requests, so a server also receives responses, and we can see why a shape withoutmethodwould be read as a response. We include it only for completeness.Where it happens
RpcSerialization.ts:301-304:Object.hasOwn(decoded, "method")throws onnull.request.method.startsWith("@effect/rpc/")throws whenidis nullish andmethodisn't a string.RpcSerialization.ts:332-357: anything withoutmethodbecomes anExit, withrequestId: response.id ?? "".McpServer.ts:1464-1490: the MCP stdiodecodeloops over every frame fromframes.decode(data)and callsparser.decodeon each one. A throw on one frame loses thedecodedarray for the whole chunk.How we work around it
The same
@effected/mcpstdin guard answers these lines before they reach the server (answerFor). A JSON value that is neither an object nor an array gets-32600withid: null. So does an object whosemethodisn't a string and that has no usableid, and an object with neithermethodnorid. Everything else is forwarded unchanged: arrays (core already answers those with-32600), response-shaped objects, and requests with anid(core answers those with-32601even whenmethodis malformed).Is this intended?
We don't know whether
jsonRpc()is meant to validate its input, or to trust a transport that already did. If it isn't intended, one possible direction is fordecodeJsonRpcMessageto classify a value it can't read as an invalid request, instead of throwing. The server could then answer-32600withid: nulland keep the rest of the chunk.Related