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(())
}