Skip to main content

Module transport

Module transport 

Source
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:

  1. JsonRpcMessageCodec::decode() returns Err(JsonRpcMessageCodecError::Serde(_)) when a line is malformed (e.g. {"price": Infinity} — JSON spec forbids Infinity / NaN literals, but LLM clients emit them occasionally).
  2. FramedRead yields Some(Err(_)).
  3. AsyncRwTransport::receive() does next.await.and_then(|e| e.ok()) — converting Err to None.
  4. The rmcp service loop interprets None as “input stream closed” and breaks with QuitReason::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

  • ResilientStdioTransport implements rmcp::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 for receive(). Parse failures cause a synthetic JsonRpcError(-32700) to be enqueued onto the outbound channel directly (bypassing receive() 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§

PendingRequests 🔒
ResilientStdioTransport
Resilient stdio transport — see module docs.

Enums§

LimitedLineRead 🔒
TransportError

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 id field from a malformed JSON-RPC payload. Falls back to Number(0) when extraction fails (the JSON-RPC spec says “null” is the canonical placeholder, but rmcp’s RequestId doesn’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 of exit(0)-ing.
response_id 🔒
writer_task 🔒

Type Aliases§

OutboundRx 🔒
OutboundTx 🔒
PendingRequestIds 🔒