Expand description
Resilient stdio transport for MCP server (v1.4.90 P0-A).
§Why this exists
rmcp::transport::stdio() (i.e. the default (Stdin, Stdout) adapter via
AsyncRwTransport + JsonRpcMessageCodec) treats any JSON parse error
as a fatal stream error. Concretely:
JsonRpcMessageCodec::decode()returnsErr(JsonRpcMessageCodecError::Serde(_))when a line is malformed (e.g.{"price": Infinity}— JSON spec forbidsInfinity/NaNliterals, but LLM clients emit them occasionally).FramedReadyieldsSome(Err(_)).AsyncRwTransport::receive()doesnext.await.and_then(|e| e.ok())— convertingErrtoNone.- The rmcp service loop interprets
Noneas “input stream closed” and breaks withQuitReason::Closed, terminating the entire MCP server.
Result: a single malformed JSON line silently kills the whole server, disconnecting every client (multi-version sweep proven across v1.4.47 → v1.4.86 — 11 versions all vulnerable).
Per JSON-RPC 2.0 §5.1, the correct behavior is to return a -32700 Parse error response and keep the connection alive. This module implements that
behavior as a drop-in replacement for rmcp::transport::stdio().
§Design
ResilientStdioTransportimplementsrmcp::transport::Transport<RoleServer>.- A background reader task owns stdin, reads newline-delimited frames,
and parses each into
RxJsonRpcMessage<RoleServer>. Successful parses go into an inbound mpsc channel forreceive(). Parse failures cause a syntheticJsonRpcError(-32700)to be enqueued onto the outbound channel directly (bypassingreceive()so the service never sees an error event), and the loop continues. - A background writer task owns stdout and drains the outbound channel, serialising messages as one-line JSON each.
send()enqueues onto the outbound channel;receive()polls the inbound channel;close()drops the senders so both tasks exit cleanly.
§What this is NOT
This is a stdio-only fix. The HTTP transport (StreamableHttpService)
has its own per-request HTTP body parsing — a malformed request there
returns 4xx without killing the server, so it’s not affected by this bug.
Future work: upstream PR to rmcp so all transports share resilient parsing.
Structs§
- Resilient
Stdio Transport - Resilient stdio transport — see module docs.
Enums§
Constants§
- INBOUND_
BUFFER 🔒 - Bound for the inbound channel. 64 frames buffered is plenty — the rmcp
service loop drains promptly. If the channel ever fills up,
receive()/ the reader task will simply backpressure on stdin, which is fine.
Functions§
- preview 🔒
- Truncate a line for log output (avoid dumping arbitrary client input into the audit log unbounded).
- reader_
task 🔒 - recover_
request_ 🔒id - Best-effort recovery of the
idfield from a malformed JSON-RPC payload. Falls back toNumber(0)when extraction fails (the JSON-RPC spec says “null” is the canonical placeholder, but rmcp’sRequestIddoesn’t admit a null variant —Number(0)is the closest match and round-trips cleanly). - resilient_
stdio - Drop-in replacement for
rmcp::transport::stdio(). Returns a transport that survives malformed JSON instead ofexit(0)-ing. - writer_
task 🔒
Type Aliases§
- Outbound
Rx 🔒 - Outbound
Tx 🔒 - Outbound is unbounded because we never want a slow consumer to deadlock the parse-error path (which writes synthetically). All writes are tiny JSON lines.