//! stdio MCP transport: newline-delimited JSON-RPC over stdin/stdout. //! //! This is the transport an MCP client like Claude Code uses when it launches //! the server as a subprocess (`claude mcp add -- --stdio`). //! One JSON-RPC message per line in on stdin, one per line out on stdout. //! Anything the process wants to log must go to **stderr** — stdout is the //! protocol channel and any stray byte there corrupts the stream. //! //! Reuses the same [`dispatch`](super::dispatch) as the HTTP server, so both //! transports expose an identical tool surface and capability gate. use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use super::ServeConfig; use super::protocol::{JsonRpcResponse, codes}; use crate::error::Result; use crate::tool::ToolRegistry; /// Serve the registry over stdio until stdin reaches EOF (the client closed the /// pipe). Blocks for the lifetime of the connection. pub async fn serve(registry: ToolRegistry, config: ServeConfig) -> Result<()> { let mut lines = BufReader::new(tokio::io::stdin()).lines(); let mut stdout = tokio::io::stdout(); while let Some(line) = lines.next_line().await? { let line = line.trim(); if line.is_empty() { continue; } let response = match serde_json::from_str(line) { Ok(req) => { // stdio has no session, so subscriptions and cancellation are // unavailable here; the resource read surface still works. super::dispatch( ®istry, config.resources.as_ref(), config.projection, config.grants.as_ref(), None, req, ) .await } Err(e) => Some(JsonRpcResponse::err( Value::Null, codes::PARSE_ERROR, e.to_string(), )), }; // Notifications (`dispatch` returned `None`) carry no reply. if let Some(response) = response { let mut buf = serde_json::to_vec(&response)?; buf.push(b'\n'); stdout.write_all(&buf).await?; stdout.flush().await?; } } Ok(()) }