Skip to content

RpcSerialization.jsonRpc: a JSON value that isn't a JSON-RPC message either throws, dropping the requests sent with it, or gets no reply #8498

Description

@spencerbeggs

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

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