Skip to content

stdio server drops in-flight tool responses when stdin closes #753

Description

@coryvirok

Description

When a stdio MCP client sends multiple JSON-RPC messages then closes stdin, only the initialize response is flushed. Subsequent responses (e.g., tools/call) are processed by the handler but never reach stdout. This breaks non-interactive MCP clients that batch requests.

Root Cause

In service.rs, serve_inner's main event loop breaks immediately on stdin EOF:

m = transport.receive() => {ifletSome(m) = m {Event::PeerMessage(m)}else{
tracing::info!("input stream terminated");breakQuitReason::Closed// <-- exits immediately}}

Request handlers are spawned via tokio::spawn and send responses back through sink_proxy_tx. When stdin closes, the loop breaks before those spawned tasks complete. The sink_proxy_rx reader and transport are then dropped, so the responses are lost.

The sequence:

  1. Client sends initialize, notifications/initialized, tools/call, then closes stdin
  2. initialize is handled synchronously during the init phase — its response is flushed
  3. tools/call arrives in serve_inner, which spawns a handler task
  4. transport.receive() returns None (stdin EOF) — the loop breaks
  5. The handler task completes and sends to sink_proxy_tx, but nobody is reading sink_proxy_rx anymore

Suggested Fix

After the loop breaks with QuitReason::Closed, drain pending responses from sink_proxy_rx and write them to the transport before calling transport.close(). Something like:

// After the main loop breaks:// Drain any pending responses from in-flight handler taskswhileletOk(msg) = sink_proxy_rx.try_recv(){let _ = transport.send(msg).await;}

Or wait with a timeout for spawned handler tasks to complete before closing.

Minimal Reproduction

Cargo.toml:

[package]
name = "rmcp-stdin-eof-repro"version = "0.1.0"edition = "2021"
[dependencies]
rmcp = { version = "1.2", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "time"] }
serde_json = "1"

src/main.rs:

use rmcp::handler::server::ServerHandler;use rmcp::model::*;use rmcp::service::{RequestContext,RoleServer};use rmcp::ServiceExt;structEchoServer;implServerHandlerforEchoServer{asyncfninitialize(&self,_request:InitializeRequestParams,_context:RequestContext<RoleServer>,) -> Result<InitializeResult,ErrorData>{Ok(InitializeResult::new(ServerCapabilities::builder().enable_tools().build(),))}asyncfnlist_tools(&self,_request:Option<PaginatedRequestParams>,_context:RequestContext<RoleServer>,) -> Result<ListToolsResult,ErrorData>{Ok(ListToolsResult{ ..Default::default()})}asyncfncall_tool(&self,request:CallToolRequestParams,_context:RequestContext<RoleServer>,) -> Result<CallToolResult,ErrorData>{let msg = request
.arguments.as_ref().and_then(|a| a.get("msg")).and_then(|v| v.as_str()).unwrap_or("(empty)");Ok(CallToolResult::success(vec![Content::text(msg)]))}}#[tokio::main]asyncfnmain(){let transport = rmcp::transport::io::stdio();let service = EchoServer.serve(transport).await.expect("serve failed");
service.waiting().await.expect("waiting failed");}

Test (add tokio = { ..., features = ["process"] } to dev-dependencies):

#[cfg(test)]mod tests {use std::process::Stdio;use tokio::io::AsyncWriteExt;use tokio::process::Command;#[tokio::test]asyncfntool_response_survives_stdin_close(){Command::new("cargo").args(["build","--quiet"]).current_dir(env!("CARGO_MANIFEST_DIR")).status().await.unwrap();letmut child = Command::new("./target/debug/rmcp-stdin-eof-repro").current_dir(env!("CARGO_MANIFEST_DIR")).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();letmut stdin = child.stdin.take().unwrap();// initialize
stdin.write_all(br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}"#).await.unwrap();
stdin.write_all(b"\n").await.unwrap();// notifications/initialized
stdin.write_all(br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).await.unwrap();
stdin.write_all(b"\n").await.unwrap();// tools/call
stdin.write_all(br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hello"}}}"#).await.unwrap();
stdin.write_all(b"\n").await.unwrap();
stdin.flush().await.unwrap();// Let the server read all messages, then close stdin
tokio::time::sleep(std::time::Duration::from_millis(100)).await;drop(stdin);let output = child.wait_with_output().await.unwrap();let stdout_str = String::from_utf8_lossy(&output.stdout);let responses:Vec<&str> = stdout_str.lines().collect();// BUG: only 1 response (initialize). tools/call response is dropped.assert!(responses.len() >= 2,"Expected 2 responses, got {}. stdout:\n{}", responses.len(), stdout_str);}}

Observed Output

=== stdout (1 lines) ===
[0] {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"rmcp","version":"1.2.0"}}}

Only the initialize response appears. The tools/call response (id:2) is lost.

Expected Output

Both responses should appear on stdout before the server exits.

Version

  • rmcp 1.2.0
  • tokio 1.x
  • macOS / Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions