Skip to content

feat(transport): add non-blocking stdio_pipes transport - #1304

Merged
DaleSeo merged 4 commits into
modelcontextprotocol:mainfrom
itsmunzir:feat/stdio-pipes
Oct 14, 2026
Merged

DaleSeo merged 4 commits into
modelcontextprotocol:mainfrom
itsmunzir:feat/stdio-pipes

Conversation

@itsmunzir

Copy link
Copy Markdown
Contributor

Summary

transport::stdio() returns tokio::io::Stdin/Stdout, and every read and write on those handles is dispatched to Tokio's blocking thread pool. For a piped stdio server, that is a cross-thread hand-off per message: #1301 measured ~41k req/s vs ~86k over tokio::net::unix::pipe for the same server.

This adds transport::stdio_pipes() (behind the existing transport-io feature):

  • On Unix, when both standard streams are FIFOs — how MCP clients launch stdio servers — it duplicates the descriptors and wraps them as tokio::net::unix::pipe::{Receiver, Sender}, driven by the IO reactor.

  • Otherwise (TTY, file, socket, non-Unix platform) it falls back to the existing blocking stdio() handles.

  • It returns (StdioReader, StdioWriter), public enums implementing AsyncRead/AsyncWrite that convert into a transport through the existing IntoTransport tuple impl, so call sites stay portable:

    let service = server.serve(stdio_pipes()).await?;

stdio() is intentionally left unchanged: changing its return type is a breaking public API change (see VERSIONING.md and the public-api CI job). If maintainers prefer stdio() itself to use the fast path, that needs a major release — happy to follow up either way.

Design notes

  • Both descriptors are fstat-checked (S_IFIFO) before either is handed to Tokio. from_owned_fd switches the descriptor to O_NONBLOCK, which is shared across descriptors of the same open file description, so a partial conversion would leave the blocking fallback with non-blocking stdio.
  • The transport owns duplicated descriptors; dropping it does not close the process's real stdin/stdout.
  • On Unix, stdio_pipes() must be called inside a Tokio runtime with IO enabled (Tokio's from_owned_fd panics otherwise); this is documented on the function.
  • transport-io now enables tokio/net for the pipe types. Precedent: transport-streamable-http-client-unix-socket already enables tokio/net.

Tests

  • pipe_transport_round_trips_over_fifos (unix): spawns cat, wraps real Stdio::piped() FIFO descriptors through the same code path, round-trips a JSON-RPC line, and checks the peer exits cleanly.
  • pipe_transport_falls_back_for_non_fifos (unix): character devices are rejected.
  • stdio_pipes_builds_an_async_rw_transport: the returned pair satisfies the AsyncRwTransport/IntoTransport bounds used by serve.
  • stdio_pipes_falls_back_to_blocking_handles_on_windows.

Verification

  • cargo test -p rmcp --lib → 216 passed.
  • cargo test -p rmcp --lib --features transport-io → 218 passed (Windows).
  • Unix tests were compile-checked with cargo check --target x86_64-unknown-linux-gnu --lib --tests --features transport-io; they execute on CI (ubuntu).
  • cargo clippy -p rmcp --all-targets --all-features -- -D warnings → clean.
  • cargo clippy -p rmcp --all-targets --no-default-features --features <all except local> -- -D warnings → clean.
  • cargo +nightly fmt → clean for the changed files.

No throughput numbers are claimed here; #1301's harness (Apple M3 Pro) is the reference, and this change was developed on Windows.

Closes #1301


Contribution prepared with an AI coding agent (opencode); tests were written first and fail without the fix.

@itsmunzir
itsmunzir requested a review from a team as a code owner September 27, 2026 12:17
@github-actions github-actions Bot added T-dependencies Dependencies related changes T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Sep 27, 2026
Comment thread crates/rmcp/src/transport/io.rs Outdated
@itsmunzir

Copy link
Copy Markdown
Contributor Author

Good catch - you're right that O_NONBLOCK lives on the open file description, so it is shared with fd 0/1 and outlives a dropped transport. That is inherent to driving an anonymous pipe from the IO reactor: from_owned_fd sets the flag via fcntl(F_SETFL), and there is no way to have the reactor read a pipe without putting the whole description into non-blocking mode. The reporter's sketch in #1301 has the same property.

What the code does guard against is the partial case: both descriptors are verified as FIFOs before either is converted, so the fallback path can never receive a description that has already been flipped, and the two halves are always converted together.

Options if you want the process-wide effect contained:

  1. A Drop that restores the original file-status flags once both halves have been dropped. Care needed: with a split transport, one half can drop while the other is still being driven, and clearing the flag then would break the reactor - the restore has to be tied to the last owner, not each half.
  2. Keep the behavior and state it on stdio_pipes() as an explicit caveat for in-process embedders - a dedicated stdio MCP server owns its stdio for its lifetime, which is the intended call site, and stdio() remains untouched for everyone else.

I can implement either; my lean is 2 plus a doc note, since 1 can only restore the flag after the fact and has the split-transport edge above. But you know the SDK's expectations better - happy to go your way.

@DaleSeo

DaleSeo commented Sep 28, 2026

Copy link
Copy Markdown
Member

@itsmunzir Thanks for the careful analysis. The split-transport point about option 1 is a good one.

On Linux, we can avoid the leak entirely. Opening /proc/self/fd/0 and /1 with O_NONBLOCK set means a read-only FIFO open won't block. It also creates a new open file description, so fds 0 and 1 stay untouched. macOS has no equivalent, so I'd use the blocking fallback there.

I'd rather not rely on a caveat in the docs, since Command::spawn lets children inherit stdin by default. The partial case can still happen, too. Tokio sets O_NONBLOCK before registering the fd, so if Sender fails, stdin has already been flipped.

Could you add an Ubuntu test to check that fd 0's flags are unchanged after stdio_pipes()?

@itsmunzir

Copy link
Copy Markdown
Contributor Author

Done in 8f1193b. The Linux path now reopens /proc/self/fd/0 and /1 with O_NONBLOCK, so the new descriptors have their own open file descriptions and the process's stdin and stdout keep their flags. macOS and Windows keep the blocking fallback, and the pipe variant is Linux-only now.

Tests added:

  • reopening_a_fifo_keeps_the_original_flags — the reopened descriptor is non-blocking, the source descriptor stays blocking, and the reopened descriptor is a FIFO.
  • stdio_pipes_leaves_descriptor_zero_flags_alone — the check you asked for.
  • fifo_detection_rejects_character_devices — replaces the old non-FIFO wrapper test.
  • The round-trip test now uses the Linux wrapper directly.

Verified here: Windows tests 218/218, the Linux target compiles, and clippy passes with the CI feature sets. The Linux tests run on CI.

@itsmunzir

Copy link
Copy Markdown
Contributor Author

Gentle ping on this one. The /proc/self/fd rework is pushed in 8f1193b and CI is green — the Linux path reopens fd 0/1 so the original descriptors keep their flags, with the blocking fallback on macOS and Windows. Happy to adjust anything before review.

@itsmunzir

Copy link
Copy Markdown
Contributor Author

The commitlint failure is fixed: the rework commit body had 221/274/167-character lines, over the 100-character body-max-line-length. It is rewrapped and signed off (bc5160b).

On Check MSRV: this is dependency drift, not this PR. The job reads rust-version = "1.88" from rmcp and builds with 1.88, but Cargo.lock is gitignored, so a fresh resolution now picks uuid@1.27.0, which requires rustc 1.89 — any branch that resolves today hits it (error: rustc 1.88.0 is not supported by the following package: uuid@1.27.0 requires rustc 1.89.0). If you want, I can pin it in the CI job (cargo update -p uuid --precise 1.26.1 before the MSRV build) — say which version you prefer and I will add it here.

@DaleSeo

DaleSeo commented Oct 5, 2026

Copy link
Copy Markdown
Member

The MSRV failure isn't from your change. It was fixed on main in #1316. Could you rebase onto the latest main and push? A re-run alone won't pick up the fix.

`transport::stdio()` reads and writes through Tokio's blocking thread
pool, so every message on a piped stdio connection pays a cross-thread
hand-off. MCP clients launch stdio servers with FIFOs for stdin/stdout,
which Tokio's Unix pipe driver can drive from the IO reactor instead.

`stdio_pipes()` wraps the standard streams as non-blocking pipes when
both are FIFOs and otherwise falls back to the blocking `stdio()`
handles, so the returned `(StdioReader, StdioWriter)` pair still converts
into a transport on every platform. TTYs, files, sockets, and Windows
keep today's behavior.

`stdio()` is left unchanged because changing its return type would be a
breaking public API change; this constructor is additive.

The `transport-io` feature now enables `tokio/net` for the pipe types.

Refs modelcontextprotocol#1301
try_clone_to_owned duplicates the descriptor, so the duplicate shares its open file
description with fd 0 or 1. Tokio's O_NONBLOCK then leaked into the process's own
standard streams, and into children that inherit them.

stdio_pipes() now opens /proc/self/fd/0 and /1 with O_NONBLOCK on Linux. The kernel
gives the new descriptors their own file descriptions, so the flags stay private to the
transport. macOS has no /proc, so it keeps the blocking fallback; the pipe variant is
Linux-only now.

Tests: reopening a FIFO keeps the source descriptor blocking, descriptor 0 keeps its
flags across stdio_pipes(), and the round-trip and non-FIFO cases moved to Linux.

Signed-off-by: itsmunzir <itsmunzir@users.noreply.github.com>
@itsmunzir

Copy link
Copy Markdown
Contributor Author

Rebased onto 79437f2 and pushed (65f149a, 2e2d6b); cargo check -p rmcp --all-features is clean. Thanks for the pointer to #1316.

Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs
Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs
Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs Outdated
@itsmunzir

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All points are addressed in 8f05c2f:

  • try_pipe_transport is gone. reopen_fifo_pair reopens fd 0 and 1 first, then runs the FIFO check on the reopened descriptors, and hands those same descriptors to Tokio.
  • StdioReader and StdioWriter are now opaque structs over private enums, so the tokio::net::unix::pipe types stay out of the public API. Callers do not need to know which path was taken: a tracing::debug! line reports it.
  • Both the pipe path and the fallback log through tracing::debug! now.
  • The new reopened_fifo_pair_round_trips_and_keeps_originals_blocking test builds its own FIFOs with cat, so the reopen path runs on the Ubuntu runner even when the CI standard streams are not pipes. It also asserts that the source descriptors keep their flags.
  • reopening_a_fifo_keeps_the_original_flags and fifo_detection_rejects_character_devices are plain #[test] now.
  • The comment on stdio_pipes_leaves_descriptor_zero_flags_alone names the property being tested.
  • The fallback test is gated with #[cfg(not(target_os = "linux"))] and renamed.

cargo check -p rmcp --all-features --tests is clean locally on Windows. The Linux-only tests run in CI.

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@itsmunzir Could you check failing checks on the PR?

Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs Outdated
Comment thread crates/rmcp/src/transport/io.rs Outdated
- Reopen fd 0 and 1 first, then check the FIFOs on the descriptors that go
  to Tokio.
- Keep the platform-specific handle in private enums with Debug. Callers
  only need AsyncRead/AsyncWrite.
- Log the taken path with tracing::debug!.
- Drop the vacuous stdin-flags test; the owned-FIFO round trip covers the
  reopen path on the runner.

Signed-off-by: itsmunzir <munzirbh@gmail.com>
@itsmunzir

Copy link
Copy Markdown
Contributor Author

Your inline comment pointed at the root cause of the failing checks: pipe_transport still built the removed variants. That code is Linux-only, so the local cargo check on Windows (which strips cfg(target_os = "linux") items) did not catch it. Fixed in 8957ae1; the Windows check is green and CI verifies Linux.

Summary of this round:

  • pipe_transport now builds the opaque structs as you suggested.
  • stdio_pipes_leaves_descriptor_zero_flags_alone is dropped. Under cargo test the standard streams are never FIFOs, so the test always exercised the fallback. reopened_fifo_pair_round_trips_and_keeps_originals_blocking owns its FIFOs and covers the reopen path on the runner.
  • The doc comments on both structs now use your wording.
  • The commit body is rewrapped under the 100-character limit, so commitlint is happy.

Signed-off-by: itsmunzir <munzirbh@gmail.com>
@DaleSeo
DaleSeo merged commit 718bc60 into modelcontextprotocol:main Oct 14, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

transport::stdio() runs every read and write on tokio's blocking pool, which caps stdio throughput

2 participants