From a0fdcad02e164261e75a6720c12744ef85bd5bd7 Mon Sep 17 00:00:00 2001 From: theamodhshetty <153991321+theamodhshetty@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:38:03 +0530 Subject: [PATCH] docs(service): clarify startup and stdio lifecycle --- README.md | 22 +++++++++++++++++++++- crates/rmcp/src/service.rs | 20 ++++++++++++++++++++ crates/rmcp/src/transport/io.rs | 4 ++++ examples/servers/src/counter_stdio.rs | 12 +++++++++--- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5349cb75f..4f64de174 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,29 @@ let service = common::counter::Counter::new(); Start the server ```rust, ignore -// this call will finish the initialization process +// Await protocol startup, then receive a handle to the running service. let server = service.serve(transport).await?; ``` + +For a server, `serve()` waits for peer input that establishes the protocol +lifecycle, such as `initialize` from a legacy client. A stdio MCP server is +normally launched by an MCP client as a child process. Running the executable +alone in a terminal can leave this await pending because no client has sent +protocol messages yet. + +After `serve()` returns, the service handles messages in the background. You can +run other asynchronous work, then use `waiting()` to wait for shutdown. To cancel +during startup, use `serve_with_ct()` with a `CancellationToken`. Tokio's stdin +uses a blocking read that cannot be cancelled, so runtime shutdown may still wait +for input or EOF; see [Tokio's stdin documentation](https://docs.rs/tokio/latest/tokio/io/fn.stdin.html). + +For example, build the counter server from the repository root, then let the +MCP Inspector CLI launch it and list its tools: + +```sh +cargo build -p mcp-server-examples --example servers_counter_stdio +npx @modelcontextprotocol/inspector --cli ./target/debug/examples/servers_counter_stdio --method tools/list +```
diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index aaec82895..610e898f8 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -321,6 +321,17 @@ pub trait ServiceExt: Service + Sized { fn into_dyn(self) -> Box> { Box::new(self) } + /// Initialize the protocol over `transport` and return a running service. + /// + /// On a server, this waits for peer input that establishes a protocol + /// lifecycle, such as a legacy `initialize` request. A stdio server normally + /// runs as a child process of an MCP client; starting it in a terminal with + /// no protocol input leaves this future pending. + /// + /// Once this returns, the service handles messages in the background and + /// other asynchronous work can run. Use [`RunningService::waiting`] to wait + /// for shutdown or [`RunningService::cancel`] to stop the running service. + /// Use [`Self::serve_with_ct`] to supply a cancellation token during startup. fn serve( self, transport: T, @@ -332,6 +343,15 @@ pub trait ServiceExt: Service + Sized { { Self::serve_with_ct(self, transport, Default::default()) } + /// Initialize and run the service with the supplied cancellation token. + /// + /// Like [`Self::serve`], this waits for protocol initialization before + /// returning a [`RunningService`]. Cancelling `ct` interrupts initialization + /// or signals shutdown of the running service. + /// + /// Transport shutdown can have additional constraints: Tokio's standard + /// input uses a blocking read that cancellation cannot interrupt. See + /// . fn serve_with_ct( self, transport: T, diff --git a/crates/rmcp/src/transport/io.rs b/crates/rmcp/src/transport/io.rs index 666228407..cc25d95f4 100644 --- a/crates/rmcp/src/transport/io.rs +++ b/crates/rmcp/src/transport/io.rs @@ -13,6 +13,10 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; /// Every read from and write to these handles is executed on Tokio's blocking /// thread pool. When both standard streams are pipes, prefer [`stdio_pipes`], /// which avoids the per-operation thread hand-off on Linux. +/// +/// A pending read on [`tokio::io::Stdin`] cannot be cancelled. Cancelling an MCP +/// service can therefore still leave Tokio runtime shutdown waiting for stdin +/// input or EOF. See the [Tokio stdin documentation](https://docs.rs/tokio/latest/tokio/io/fn.stdin.html). pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { (tokio::io::stdin(), tokio::io::stdout()) } diff --git a/examples/servers/src/counter_stdio.rs b/examples/servers/src/counter_stdio.rs index 9339ab866..2398f9d0d 100644 --- a/examples/servers/src/counter_stdio.rs +++ b/examples/servers/src/counter_stdio.rs @@ -3,10 +3,14 @@ use common::counter::Counter; use rmcp::{ServiceExt, transport::stdio}; use tracing_subscriber::{self, EnvFilter}; mod common; -/// npx @modelcontextprotocol/inspector cargo run -p mcp-server-examples --example std_io +/// From the repository root: +/// ```sh +/// cargo build -p mcp-server-examples --example servers_counter_stdio +/// npx @modelcontextprotocol/inspector --cli ./target/debug/examples/servers_counter_stdio --method tools/list +/// ``` #[tokio::main] async fn main() -> Result<()> { - // Initialize the tracing subscriber with file and stdout logging + // Write logs to stderr; stdout carries MCP protocol messages. tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into())) .with_writer(std::io::stderr) @@ -15,11 +19,13 @@ async fn main() -> Result<()> { tracing::info!("Starting MCP server"); - // Create an instance of our counter router + // The MCP client launches this process and writes protocol messages to stdin. + // This await waits for startup input (e.g. initialize for a legacy client). let service = Counter::new().serve(stdio()).await.inspect_err(|e| { tracing::error!("serving error: {:?}", e); })?; + // Wait for the running service to stop after startup has completed. service.waiting().await?; Ok(()) }