//! SEP-2322 Multi Round-Trip Request (MRTR) end-to-end example. //! //! This runs a server and a client in the same process, connected over an //! in-memory duplex stream, to show the full MRTR flow: //! //! * The **server** answers `tools/call` with an [`InputRequiredResult`] instead //! of a final result, asking the client to elicit a value first. It stores its //! progress in an opaque, integrity-protected `requestState` produced by a //! [`RequestStateCodec`], and verifies that state when the client retries. //! * The **client** uses the high-level [`RunningService::call_tool`] helper, //! which automatically fulfils the elicitation through the local //! [`ClientHandler`] and retries the original request. The example then repeats //! the call with [`RunningService::call_tool_once`] to show the manual escape //! hatch that returns the intermediate result without retrying. //! //! ## Version gating //! //! `InputRequiredResult` is only valid once the peers have negotiated protocol //! version `2026-07-28` or newer. Both sides advertise that version below. If a //! server emits an `InputRequiredResult` to an older client, the SDK turns it //! into a protocol error instead of sending it on the wire. //! //! ## `requestState` is untrusted input //! //! The client echoes `requestState` back verbatim, so from the server's point of //! view it is attacker-controlled. A stateless server that puts meaningful data //! in `requestState` MUST verify it. This example uses [`RequestStateCodec`] to //! seal and open it with an HMAC tag; tampered values are rejected. //! //! Run with: //! //! ```sh //! cargo run -p mcp-server-examples --example servers_mrtr //! ``` use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::*, service::{RequestContext, RoleClient, RoleServer}, }; use serde::{Deserialize, Serialize}; use serde_json::json; /// A fixed key for this example. Real deployments should load at least 32 bytes /// of random secret key material from a secret manager and keep it stable for /// the lifetime of any in-flight MRTR exchange. const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!"; #[derive(Debug, Serialize, Deserialize)] struct WeatherRequestState { awaiting: WeatherInput, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum WeatherInput { City, } /// A server that needs a city name before it can answer a weather query. #[derive(Clone)] struct WeatherServer { codec: RequestStateCodec, } impl WeatherServer { fn new(key: impl Into>) -> Result { Ok(Self { codec: RequestStateCodec::try_new(key)?, }) } } impl WeatherServer { fn open_request_state(&self, sealed: &str) -> Result { self.codec .open_json(sealed) .map_err(|_| ErrorData::invalid_params("tampered or unknown request state", None)) } } fn finish_weather_request( input_responses: Option<&InputResponses>, ) -> Result { let response = input_responses .and_then(|responses| responses.get("city")) .ok_or_else(|| ErrorData::invalid_params("missing city elicitation response", None))?; let response = ElicitResult::deserialize(response) .map_err(|_| ErrorData::invalid_params("invalid city elicitation response", None))?; let message = match response.action { ElicitationAction::Accept => { let city = response .content .as_ref() .and_then(|content| content["city"].as_str()) .ok_or_else(|| { ErrorData::invalid_params("accepted elicitation did not include a city", None) })?; format!("It is sunny in {city}.") } ElicitationAction::Decline => "I cannot look up the weather without a city.".into(), ElicitationAction::Cancel => "Weather lookup cancelled.".into(), _ => { return Err(ErrorData::invalid_params( "unsupported elicitation action", None, )); } }; Ok(CallToolResult::success(vec![ContentBlock::text(message)])) } impl ServerHandler for WeatherServer { fn get_info(&self) -> ServerConfig { let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); // MRTR requires 2026-07-28 or newer. info.protocol_version = ProtocolVersion::V_2026_07_28; info } async fn call_tool( &self, request: CallToolRequestParams, _context: RequestContext, ) -> Result { match request.request_state { // First round: ask the client to provide a city, and remember where // we are by sealing our progress into `requestState`. None => { let sealed = self .codec .seal_json(&WeatherRequestState { awaiting: WeatherInput::City, }) .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; let mut input_requests = InputRequests::new(); input_requests.insert( "city".to_string(), InputRequest::Elicitation(ElicitRequest::new( ElicitRequestParams::FormElicitationParams { meta: None, message: "Which city do you want the weather for?".into(), requested_schema: serde_json::from_value(json!({ "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] })) .expect("valid schema"), }, )), ); Ok(InputRequiredResult::new(Some(input_requests), Some(sealed)).into()) } // Retry round: verify the echoed state before trusting it, read the // elicited city, and return the final result. Some(sealed) => { let state = self.open_request_state(&sealed)?; match state.awaiting { WeatherInput::City => finish_weather_request(request.input_responses.as_ref()) .map(CallToolResponse::from), } } } } } /// A client that fulfils elicitation requests. A real client would prompt a user. #[derive(Clone, Default)] struct InteractiveClient; impl ClientHandler for InteractiveClient { fn get_info(&self) -> ClientConfig { ClientConfig::new( ClientCapabilities::builder().enable_elicitation().build(), Implementation::new("mrtr-example-client", env!("CARGO_PKG_VERSION")), ) .with_protocol_version(ProtocolVersion::V_2026_07_28) } async fn create_elicitation( &self, request: ElicitRequestParams, _context: RequestContext, ) -> Result { if let ElicitRequestParams::FormElicitationParams { message, .. } = &request { println!(" [client] server asked: {message}"); } // Pretend the user typed "Paris". Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "city": "Paris" }))) } } #[tokio::main] async fn main() -> anyhow::Result<()> { let (server_transport, client_transport) = tokio::io::duplex(8192); let server = WeatherServer::new(REQUEST_STATE_KEY)?; // Spin up the server side. tokio::spawn(async move { let server = server .serve(server_transport) .await .expect("server should start"); let _ = server.waiting().await; }); // Connect the client (this performs the initialize handshake). let client = InteractiveClient.serve(client_transport).await?; // 1. High-level auto mode: the SDK fulfils the elicitation and retries for us. println!("== auto mode (call_tool) =="); let result = client .call_tool(CallToolRequestParams::new("weather")) .await?; println!( " [client] final result: {}\n", result.content[0].as_text().unwrap().text ); // 2. Manual mode: get the intermediate InputRequiredResult without retrying. println!("== manual mode (call_tool_once) =="); match client .call_tool_once(CallToolRequestParams::new("weather")) .await? { CallToolResponse::InputRequired(input_required) => { let requests = input_required.input_requests.unwrap_or_default(); println!( " [client] server requested {} input(s); handling them yourself is up to you.", requests.len() ); } CallToolResponse::Complete(result) => { println!(" [client] completed immediately: {result:?}"); } _ => println!(" [client] unhandled response variant"), } client.cancel().await?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn input_responses(response: ElicitResult) -> InputResponses { [("city".into(), serde_json::to_value(response).unwrap())].into() } #[test] fn finish_weather_request_uses_city_when_elicitation_is_accepted() { let responses = input_responses( ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "city": "Paris" })), ); let result = finish_weather_request(Some(&responses)).unwrap(); assert_eq!( result.content[0].as_text().unwrap().text, "It is sunny in Paris." ); } #[test] fn finish_weather_request_ignores_content_when_elicitation_is_declined() { let responses = input_responses( ElicitResult::new(ElicitationAction::Decline).with_content(json!({ "city": "Paris" })), ); let result = finish_weather_request(Some(&responses)).unwrap(); assert_eq!( result.content[0].as_text().unwrap().text, "I cannot look up the weather without a city." ); } #[test] fn finish_weather_request_stops_lookup_when_elicitation_is_cancelled() { let responses = input_responses(ElicitResult::new(ElicitationAction::Cancel)); let result = finish_weather_request(Some(&responses)).unwrap(); assert_eq!( result.content[0].as_text().unwrap().text, "Weather lookup cancelled." ); } #[test] fn finish_weather_request_rejects_accepted_elicitation_without_city() { let responses = input_responses(ElicitResult::new(ElicitationAction::Accept).with_content(json!({}))); let error = finish_weather_request(Some(&responses)).unwrap_err(); assert_eq!( error, ErrorData::invalid_params("accepted elicitation did not include a city", None) ); } #[test] fn finish_weather_request_rejects_missing_city_response() { let error = finish_weather_request(None).unwrap_err(); assert_eq!( error, ErrorData::invalid_params("missing city elicitation response", None) ); } #[test] fn open_request_state_rejects_unknown_phase() { let server = WeatherServer::new(REQUEST_STATE_KEY).unwrap(); let sealed = server .codec .seal_json(&json!({ "awaiting": "country" })) .unwrap(); let error = server.open_request_state(&sealed).unwrap_err(); assert_eq!( error, ErrorData::invalid_params("tampered or unknown request state", None) ); } }