Skip to content

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

Closed

Description

@igorvieira

rmcp::transport::stdio() returns (tokio::io::Stdin, tokio::io::Stdout). In tokio, each read
from and write to these handles goes through the blocking thread pool, so every message pays a
cross-thread hand-off. Under concurrent tools/call, a profile of an rmcp stdio server
shows most of its time in __psynch_mutexwait / cvsignal, not in actual work.

When the server's stdin and stdout are pipes, which is how MCP clients launch servers,
readiness-driven pipes avoid the hand-off:

use std::os::fd::AsFd;
use tokio::net::unix::pipe;

let stdin = std::io::stdin().as_fd().try_clone_to_owned()?;
let stdout = std::io::stdout().as_fd().try_clone_to_owned()?;
// Both fail unless the descriptors are FIFOs; fall back to transport::stdio() then.
let transport = (pipe::Receiver::from_owned_fd(stdin)?, pipe::Sender::from_owned_fd(stdout)?);

The fallback keeps TTYs, files and Windows on the current path.

Numbers: the same trivial tool, 32 concurrent callers over one stdio connection, on an
Apple M3 Pro. Reproducible harness:
https://github.com/igorvieira/Carmy/tree/main/benches/compare

server throughput
rmcp 3.4 with transport::stdio() ~41k req/s
the same rmcp server over tokio::net::unix::pipe ~86k req/s
@modelcontextprotocol/sdk (TypeScript), for reference ~73k req/s

Would a pipe-based fast path in transport::stdio(), or a separate stdio_pipes()
constructor, be welcome? I'm happy to send a PR.

Activity

  1. added
    enhancementNew feature or request
    P2Medium: important but non-blocking improvement
    T-transportTransport layer changes
    on Sep 26, 2026
  2. itsmunzir commented on Sep 27, 2026

    @itsmunzir
    Contributor

    I explored this too, and the design question in the report has a working answer: an additive stdio_pipes() (the second option you floated) that duplicates the stdio descriptors, verifies both are FIFOs before handing either to Tokio (from_owned_fd sets O_NONBLOCK, which is shared per open file description, so a partial conversion would leave the blocking fallback in non-blocking mode), and falls back to today's handles for TTYs, files and Windows. stdio() stays untouched, since changing its return type trips VERSIONING.md and the public-api job.

    @igorvieira, you offered to send a PR for this - if you are already on it, just say so and I will close mine; otherwise it is ready for review, with your measurements as the reference in the PR body.

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

    P2Medium: important but non-blocking improvementT-transportTransport layer changesenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions