From a8c71e6287f4a4974c8309d3aca65f5b92396a7e Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:15:27 -0400 Subject: [PATCH 1/3] test: enable supported draft SEP coverage --- .github/workflows/conformance.yml | 28 ++++++++++++++++++++++++++ conformance/src/bin/server.rs | 33 ++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 787dc4b0..a8db23a8 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,6 +14,7 @@ concurrency: env: # Pinned for reproducible runs; bump deliberately when the suite updates. CONFORMANCE_VERSION: "0.1.16" + DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server: @@ -64,6 +65,33 @@ jobs: -o conformance-results done + - name: Start draft conformance server + run: | + STATELESS=1 PORT=8002 ./target/debug/conformance-server & + echo $! > draft-server.pid + for _ in $(seq 1 30); do + if curl -s -o /dev/null http://127.0.0.1:8002/mcp; then + exit 0 + fi + sleep 1 + done + echo "draft conformance server did not become ready" >&2 + exit 1 + + - name: Run draft SEP scenarios + run: | + for scenario in sep-2164-resource-not-found caching http-header-validation; do + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --scenario "$scenario" \ + --spec-version draft \ + -o conformance-results + done + + - name: Stop draft conformance server + if: always() + run: kill "$(cat draft-server.pid)" 2>/dev/null || true + - name: Stop conformance server if: always() run: kill "$(cat server.pid)" 2>/dev/null || true diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 2c2f63d4..b7548633 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -18,6 +18,7 @@ use tracing_subscriber::EnvFilter; const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; // Small base64-encoded WAV (silence) const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA="; +const CACHE_TTL_MS: u64 = 60_000; /// Helper to convert a serde_json::Value (must be an object) into a JsonObject fn json_object(v: Value) -> JsonObject { @@ -45,7 +46,7 @@ impl ConformanceServer { impl ServerHandler for ConformanceServer { async fn initialize( &self, - _request: InitializeRequestParams, + request: InitializeRequestParams, _cx: RequestContext, ) -> Result { Ok(InitializeResult::new( @@ -56,6 +57,7 @@ impl ServerHandler for ConformanceServer { .enable_logging() .build(), ) + .with_protocol_version(request.protocol_version) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) .with_instructions("Rust MCP conformance test server")) } @@ -206,7 +208,9 @@ impl ServerHandler for ConformanceServer { Ok(ListToolsResult { tools, ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn call_tool( @@ -549,7 +553,9 @@ impl ServerHandler for ConformanceServer { .with_mime_type("image/png"), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn read_resource( @@ -600,7 +606,13 @@ impl ServerHandler for ConformanceServer { } } }; - result.map(Into::into) + result + .map(|result| { + result + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public) + }) + .map(Into::into) } async fn list_resource_templates( @@ -615,7 +627,9 @@ impl ServerHandler for ConformanceServer { .with_mime_type("application/json"), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn subscribe( @@ -674,7 +688,9 @@ impl ServerHandler for ConformanceServer { ), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn get_prompt( @@ -782,7 +798,10 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let config = StreamableHttpServerConfig::default(); + let stateless = std::env::var_os("STATELESS").is_some(); + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(!stateless) + .with_json_response(stateless); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), From 4113884147b8a075e775d5d3d71249da1f3847af Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:00:25 -0400 Subject: [PATCH 2/3] test: add SEP-2322 MRTR conformance scenarios --- .github/workflows/conformance.yml | 36 +++ conformance/Cargo.toml | 1 + conformance/src/bin/client.rs | 92 ++++++- conformance/src/bin/server.rs | 402 ++++++++++++++++++++++++++++++ 4 files changed, 530 insertions(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a8db23a8..e57fa972 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -88,10 +88,38 @@ jobs: -o conformance-results done + # SEP-2322 MRTR scenarios (spec 2026-07-28). They speak the stateless + # lifecycle (bare JSON-RPC POSTs, no initialize handshake), so they run + # against the stateless draft server. + - name: Run SEP-2322 MRTR scenarios + run: | + for scenario in \ + input-required-result-basic-elicitation \ + input-required-result-basic-sampling \ + input-required-result-basic-list-roots \ + input-required-result-request-state \ + input-required-result-multiple-input-requests \ + input-required-result-multi-round \ + input-required-result-missing-input-response \ + input-required-result-non-tool-request \ + input-required-result-result-type \ + input-required-result-unsupported-methods \ + input-required-result-tampered-state \ + input-required-result-capability-check \ + input-required-result-ignore-extra-params \ + input-required-result-validate-input \ + ; do + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --scenario "$scenario" \ + -o conformance-results + done + - name: Stop draft conformance server if: always() run: kill "$(cat draft-server.pid)" 2>/dev/null || true + - name: Stop conformance server if: always() run: kill "$(cat server.pid)" 2>/dev/null || true @@ -126,6 +154,14 @@ jobs: --spec-version 2025-11-25 \ -o conformance-client-results/full + # SEP-2322 MRTR client scenario (spec 2026-07-28). + - name: Run SEP-2322 MRTR client scenario + run: | + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + --command "$(pwd)/target/debug/conformance-client" \ + --scenario sep-2322-client-request-state \ + -o conformance-client-results/mrtr + - name: Upload results if: always() uses: actions/upload-artifact@v7 diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index de9a44dd..83b7007c 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [ "client", "elicitation", "auth", + "request-state", "transport-streamable-http-server", "transport-streamable-http-client-reqwest", ] } diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 9885b998..fbe24f50 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,7 +1,7 @@ use rmcp::{ ClientHandler, ErrorData, RoleClient, ServiceExt, model::*, - service::RequestContext, + service::{RequestContext, serve_directly}, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::{AuthorizationCallback, OAuthState}, @@ -824,6 +824,95 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> Ok(()) } +/// A minimal stateless client transport: every outgoing message is one HTTP +/// POST and the JSON response body (if any) is queued for `receive()`. +/// +/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle +/// (no `initialize` handshake, plain JSON responses), which the session-based +/// `StreamableHttpClientTransport` cannot do. The transport is harness +/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs +/// unchanged on top of it. +struct StatelessHttpTransport { + http: reqwest::Client, + uri: std::sync::Arc, + tx: tokio::sync::mpsc::Sender, + rx: tokio::sync::mpsc::Receiver, +} + +impl StatelessHttpTransport { + fn new(uri: &str) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(16); + Self { + http: reqwest::Client::new(), + uri: uri.into(), + tx, + rx, + } + } +} + +impl rmcp::transport::Transport for StatelessHttpTransport { + type Error = std::io::Error; + + fn send( + &mut self, + item: rmcp::model::ClientJsonRpcMessage, + ) -> impl std::future::Future> + Send + 'static { + let http = self.http.clone(); + let uri = self.uri.clone(); + let tx = self.tx.clone(); + async move { + let response = http + .post(uri.as_ref()) + .header("MCP-Protocol-Version", "2026-07-28") + .json(&item) + .send() + .await + .map_err(std::io::Error::other)?; + match response.json::().await { + Ok(message) => { + let _ = tx.send(message).await; + } + Err(_) => { + // No JSON-RPC body (e.g. 202/204 for notifications). + } + } + Ok(()) + } + } + + async fn receive(&mut self) -> Option { + self.rx.recv().await + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// SEP-2322 MRTR client scenario: the mock server has no `initialize` handler +/// (stateless lifecycle), so skip the handshake with `serve_directly` and let +/// the high-level `call_tool` helper drive the `input_required` retry rounds. +/// The mock server verifies requestState echo, fresh JSON-RPC ids on retry, +/// state omission, isolation between tools, and the `resultType` default. +async fn run_mrtr_client(server_url: &str) -> anyhow::Result<()> { + let transport = StatelessHttpTransport::new(server_url); + let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(ProtocolVersion::V_2026_07_28); + let client = serve_directly(FullClientHandler, transport, Some(peer_info)); + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let result = client + .call_tool(CallToolRequestParams::new(tool.name.clone())) + .await; + tracing::debug!("Called {}: {:?}", tool.name, result.is_ok()); + } + client.cancel().await?; + Ok(()) +} + async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_uri(server_url); let client = BasicClientHandler.serve(transport).await?; @@ -869,6 +958,7 @@ async fn main() -> anyhow::Result<()> { run_elicitation_defaults_client(&server_url).await? } "sse-retry" => run_sse_retry_client(&server_url).await?, + "sep-2322-client-request-state" => run_mrtr_client(&server_url).await?, // Auth scenarios - standard OAuth flow "auth/metadata-default" diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index b7548633..0e779f66 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -28,10 +28,15 @@ fn json_object(v: Value) -> JsonObject { } } +/// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a +/// conformance harness; real servers must load a secret out of clients' reach. +const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!"; + #[derive(Clone)] struct ConformanceServer { subscriptions: Arc>>, log_level: Arc>, + request_state_codec: RequestStateCodec, } impl ConformanceServer { @@ -39,6 +44,318 @@ impl ConformanceServer { Self { subscriptions: Arc::new(Mutex::new(HashSet::new())), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), + request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), + } + } +} + +// ─── SEP-2322 MRTR (InputRequiredResult) helpers ──────────────────────────── + +fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest { + InputRequest::Elicitation(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: serde_json::from_value(json!({ + "type": "object", + "properties": properties, + "required": required, + })) + .expect("valid elicitation schema"), + }, + )) +} + +fn mrtr_sampling_request(prompt: &str) -> InputRequest { + InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text(prompt)], + 100, + ))) +} + +fn mrtr_list_roots_request() -> InputRequest { + InputRequest::ListRoots(ListRootsRequest::default()) +} + +/// An input response is usable when it is a JSON object (an `ElicitResult`, +/// `CreateMessageResult`, or `ListRootsResult` shape). Anything else (e.g. a +/// bare number) is treated as missing so the server re-requests it. +fn mrtr_response<'a>( + responses: Option<&'a InputResponses>, + key: &str, +) -> Option<&'a serde_json::Map> { + responses + .and_then(|r| r.get(key)) + .and_then(Value::as_object) +} + +impl ConformanceServer { + fn mrtr_tampered_state_error() -> ErrorData { + ErrorData::invalid_params("requestState failed integrity verification", None) + } + + /// SEP-2322 test tools. Each returns an `InputRequiredResult` until the + /// client retries with the expected `inputResponses` (and, where used, the + /// echoed `requestState`). + /// + /// `meta` is the request's `_meta`, which the service loop moves out of the + /// params and into the `RequestContext`. + async fn call_mrtr_tool( + &self, + request: CallToolRequestParams, + meta: &Meta, + ) -> Result { + let responses = request.input_responses.as_ref(); + match request.name.as_ref() { + "test_input_required_result_elicitation" => { + match mrtr_response(responses, "user_name") { + Some(response) => { + let name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("friend"); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Hello, {name}!" + ))]) + .into()) + } + // Initial call, or a retry with missing/invalid responses: + // (re-)request the input per the SEP's recommendation. + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_sampling" => { + match mrtr_response(responses, "capital_question") { + Some(response) => { + let text = response + .get("content") + .and_then(|c| c.get("text")) + .and_then(Value::as_str) + .unwrap_or("(no sampling text)"); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Sampling response: {text}" + ))]) + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert( + "capital_question".into(), + mrtr_sampling_request("What is the capital of France?"), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_list_roots" => { + match mrtr_response(responses, "client_roots") { + Some(response) => { + let roots = response + .get("roots") + .and_then(Value::as_array) + .map(|roots| { + roots + .iter() + .filter_map(|r| r.get("uri").and_then(Value::as_str)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Client roots: [{roots}]" + ))]) + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert("client_roots".into(), mrtr_list_roots_request()); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_request_state" + | "test_input_required_result_tampered_state" => { + match request.request_state.as_deref() { + // Initial call: request confirmation and seal our progress. + None => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "stage": "confirm" })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "confirm".into(), + mrtr_elicitation_request( + "Please confirm", + json!({ "ok": { "type": "boolean" } }), + json!(["ok"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + // Retry: the echoed state is untrusted input and MUST pass + // integrity verification before we act on it. + Some(sealed) => { + self.request_state_codec + .open(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + Ok( + CallToolResult::success(vec![ContentBlock::text( + "Confirmed: state-ok", + )]) + .into(), + ) + } + } + } + + "test_input_required_result_multiple_inputs" => { + if let Some(sealed) = request.request_state.as_deref() { + self.request_state_codec + .open(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + } + let all_present = mrtr_response(responses, "user_name").is_some() + && mrtr_response(responses, "greeting").is_some() + && mrtr_response(responses, "client_roots").is_some(); + if all_present && request.request_state.is_some() { + Ok( + CallToolResult::success(vec![ContentBlock::text("All inputs received")]) + .into(), + ) + } else { + let sealed = self + .request_state_codec + .seal_json(&json!({ "stage": "gather" })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + requests.insert( + "greeting".into(), + mrtr_sampling_request("Generate a greeting"), + ); + requests.insert("client_roots".into(), mrtr_list_roots_request()); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + } + + "test_input_required_result_multi_round" => { + let round = match request.request_state.as_deref() { + None => 0, + Some(sealed) => { + let state: Value = self + .request_state_codec + .open_json(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + state.get("round").and_then(Value::as_i64).unwrap_or(0) + } + }; + match round { + 0 => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "round": 1 })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "step1".into(), + mrtr_elicitation_request( + "Step 1: What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + 1 => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "round": 2 })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "step2".into(), + mrtr_elicitation_request( + "Step 2: What is your favorite color?", + json!({ "color": { "type": "string" } }), + json!(["color"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + _ => Ok(CallToolResult::success(vec![ContentBlock::text( + "Multi-round flow complete", + )]) + .into()), + } + } + + "test_input_required_result_capabilities" => { + if responses.is_some() { + return Ok(CallToolResult::success(vec![ContentBlock::text( + "Capability-aware flow complete", + )]) + .into()); + } + // Per SEP-2322, only request inputs the client declared support + // for in `_meta['io.modelcontextprotocol/clientCapabilities']`. + let capabilities = meta.client_capabilities().unwrap_or_default(); + let mut requests = InputRequests::new(); + if capabilities.elicitation.is_some() { + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + } + if capabilities.sampling.is_some() { + requests.insert( + "greeting".into(), + mrtr_sampling_request("Generate a greeting"), + ); + } + if capabilities.roots.is_some() { + requests.insert("client_roots".into(), mrtr_list_roots_request()); + } + if requests.is_empty() { + Ok(CallToolResult::success(vec![ContentBlock::text( + "Client declared no MRTR-capable capabilities", + )]) + .into()) + } else { + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + + _ => Err(ErrorData::invalid_params( + format!("Unknown tool: {}", request.name), + None, + )), } } } @@ -205,6 +522,51 @@ impl ServerHandler for ConformanceServer { })), ), ]; + // SEP-2322 MRTR test tools; all take no arguments. + let mrtr_tools = [ + ( + "test_input_required_result_elicitation", + "Requires an elicitation input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_sampling", + "Requires a sampling input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_list_roots", + "Requires a roots/list input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_request_state", + "Round-trips integrity-protected requestState (SEP-2322)", + ), + ( + "test_input_required_result_multiple_inputs", + "Requires elicitation + sampling + roots inputs in one round (SEP-2322)", + ), + ( + "test_input_required_result_multi_round", + "Drives multiple input_required rounds with evolving requestState (SEP-2322)", + ), + ( + "test_input_required_result_tampered_state", + "Rejects tampered requestState with a JSON-RPC error (SEP-2322)", + ), + ( + "test_input_required_result_capabilities", + "Only requests inputs for declared client capabilities (SEP-2322)", + ), + ]; + let tools = tools + .into_iter() + .chain(mrtr_tools.into_iter().map(|(name, description)| { + Tool::new( + name, + description, + json_object(json!({ "type": "object", "properties": {} })), + ) + })) + .collect(); Ok(ListToolsResult { tools, ..Default::default() @@ -218,6 +580,9 @@ impl ServerHandler for ConformanceServer { request: CallToolRequestParams, cx: RequestContext, ) -> Result { + if request.name.starts_with("test_input_required_result_") { + return self.call_mrtr_tool(request, &cx.meta).await; + } let args = request.arguments.unwrap_or_default(); let result = match request.name.as_ref() { "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( @@ -686,6 +1051,13 @@ impl ServerHandler for ConformanceServer { Some("A test prompt that includes an image"), None, ), + Prompt::new( + "test_input_required_result_prompt", + Some( + "A prompt that requires elicitation input via InputRequiredResult (SEP-2322)", + ), + None, + ), ], ..Default::default() } @@ -698,6 +1070,36 @@ impl ServerHandler for ConformanceServer { request: GetPromptRequestParams, _cx: RequestContext, ) -> Result { + // SEP-2322: InputRequiredResult on a non-tool request (prompts/get). + if request.name == "test_input_required_result_prompt" { + return match mrtr_response(request.input_responses.as_ref(), "user_context") { + Some(response) => { + let context = response + .get("content") + .and_then(|c| c.get("context")) + .and_then(Value::as_str) + .unwrap_or("(no context)"); + Ok(GetPromptResult::new(vec![PromptMessage::new_text( + Role::User, + format!("Prompt with elicited context: {context}"), + )]) + .with_description("A prompt built from elicited context") + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_context".into(), + mrtr_elicitation_request( + "What context should the prompt use?", + json!({ "context": { "type": "string" } }), + json!(["context"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + }; + } let result = match request.name.as_str() { "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( Role::User, From 3c832c0ce07b192f302bf6dfe078bc5c83db047c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:42 -0400 Subject: [PATCH 3/3] refactor: rename run_mrtr_client to run_stateless_client --- conformance/src/bin/client.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index fbe24f50..850d74f3 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -890,12 +890,14 @@ impl rmcp::transport::Transport for StatelessHttpTransport { } } -/// SEP-2322 MRTR client scenario: the mock server has no `initialize` handler -/// (stateless lifecycle), so skip the handshake with `serve_directly` and let -/// the high-level `call_tool` helper drive the `input_required` retry rounds. -/// The mock server verifies requestState echo, fresh JSON-RPC ids on retry, -/// state omission, isolation between tools, and the `resultType` default. -async fn run_mrtr_client(server_url: &str) -> anyhow::Result<()> { +/// A stateless-lifecycle client: the scenario's server has no `initialize` +/// handler, so skip the handshake with `serve_directly`, list the tools, and +/// call each one via the high-level `call_tool` helper (which drives SEP-2322 +/// `input_required` retry rounds when the server requests them). Used by the +/// `sep-2322-client-request-state` scenario, whose mock server verifies +/// requestState echo, fresh JSON-RPC ids on retry, state omission, isolation +/// between tools, and the `resultType` default. +async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> { let transport = StatelessHttpTransport::new(server_url); let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2026_07_28); @@ -958,7 +960,7 @@ async fn main() -> anyhow::Result<()> { run_elicitation_defaults_client(&server_url).await? } "sse-retry" => run_sse_retry_client(&server_url).await?, - "sep-2322-client-request-state" => run_mrtr_client(&server_url).await?, + "sep-2322-client-request-state" => run_stateless_client(&server_url).await?, // Auth scenarios - standard OAuth flow "auth/metadata-default"