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 bounded 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§
- Pending
Requests 🔒 - Resilient
Stdio Transport - Resilient stdio transport — see module docs.
Enums§
Constants§
- EOF_
PENDING_ 🔒DRAIN_ GRACE - One-shot stdio clients commonly write initialize + tool call JSONL and then close stdin. rmcp treats stdin EOF as service shutdown and starts draining in-flight responses with its own short timeout; slow backend tools can lose their result before the response is written. Keep EOF hidden from rmcp until requests already accepted by this transport either respond or this guard expires. Normal persistent MCP clients never hit this path.
- 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. - MAX_
STDIO_ 🔒JSONL_ LINE_ BYTES - Max bytes accepted for one inbound stdio JSONL frame.
- OUTBOUND_
BUFFER 🔒 - Bound for server -> client JSON-RPC messages.
Functions§
- discard_
until_ 🔒newline - enqueue_
message_ 🔒too_ large_ response - enqueue_
parse_ 🔒error_ response - preview 🔒
- Truncate a line for log output (avoid dumping arbitrary client input into the audit log unbounded).
- read_
until_ 🔒newline_ limited - 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). - request_
id 🔒 - resilient_
stdio - Drop-in replacement for
rmcp::transport::stdio(). Returns a transport that survives malformed JSON instead ofexit(0)-ing. - response_
id 🔒 - writer_
task 🔒