Skip to content

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

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

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