diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 1b4756003..5ebe8fa68 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -280,6 +280,13 @@ impl StreamableHttpClient for reqwest::Client { .text() .await .unwrap_or_else(|_| "".to_owned()); + // Must precede the JSON-RPC branch below, which would forward a + // discover rejection with an id the lifecycle cannot correlate. + if let Some(response) = + legacy_discover_response(&message, session_was_attached, status, &body) + { + return Ok(response); + } if content_type .as_deref() .is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes())) @@ -293,11 +300,6 @@ impl StreamableHttpClient for reqwest::Client { ), } } - if let Some(response) = - legacy_discover_response(&message, session_was_attached, status, &body) - { - return Ok(response); - } return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( format!("HTTP {status}: {body}"), ))); diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 8af045fcf..a5811429a 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -362,11 +362,15 @@ impl StreamableHttpPostResponse { } /// Convert a sessionless discovery rejection into a response the lifecycle -/// layer can classify as a legacy-server signal. +/// layer can classify. The server's own JSON-RPC error is preserved so the +/// lifecycle can tell a modern rejection (retry at a supported version) from a +/// legacy one; `invalid_request` is synthesized only when the body has no error +/// to keep, as with an empty or plain-text 4xx from middleware. +/// +/// The id is re-correlated because such middleware rejections cannot echo it +/// (the Python SDK sends the literal `"server-error"`); otherwise the lifecycle +/// discards the response as uncorrelated without classifying it. /// -/// Some legacy streamable-HTTP servers reject `server/discover` in middleware -/// before it reaches JSON-RPC dispatch. Their response may be an empty or -/// plain-text 4xx, so there is no JSON-RPC error for the client to forward. /// Keep authentication failures and server errors on their original paths. pub(super) fn legacy_discover_response( message: &ClientJsonRpcMessage, @@ -388,10 +392,13 @@ pub(super) fn legacy_discover_response( return None; } - let error = ErrorData::invalid_request( - format!("server/discover rejected with HTTP {status}: {body}"), - None, - ); + let error = match serde_json::from_str::(body) { + Ok(ServerJsonRpcMessage::Error(error)) => error.error, + _ => ErrorData::invalid_request( + format!("server/discover rejected with HTTP {status}: {body}"), + None, + ), + }; Some(StreamableHttpPostResponse::Json( ServerJsonRpcMessage::error(error, Some(request.id.clone())), None, diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index 2d92df50d..70aa219dc 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -236,3 +236,110 @@ async fn auto_http_client_falls_back_after_plain_text_4xx_rejection() { ct.cancel(); server.await.expect("server task"); } + +#[derive(Clone, Default)] +struct VersionNegotiatingHttpState { + methods: Arc>>, +} + +/// Rejects the first probe with a 4xx `UnsupportedProtocolVersionError` carrying +/// an uncorrelated `id`, as a middleware rejection would. Accepts the retry. +async fn version_negotiating_http_handler( + State(state): State, + body: Bytes, +) -> Response { + let request: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON-RPC body"); + let method = request["method"] + .as_str() + .expect("request method") + .to_owned(); + state.methods.lock().await.push(method.clone()); + + if method == "server/discover" { + if state.methods.lock().await.len() == 1 { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "jsonrpc": "2.0", + "id": "server-error", + "error": { + "code": -32022, + "message": "Unsupported protocol version", + "data": {"supported": ["2025-11-25"]} + } + }) + .to_string(), + )) + .expect("build version rejection response"); + } + + let result = DiscoverResult::new(vec![ProtocolVersion::V_2025_11_25], Default::default()); + return Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": result, + }) + .to_string(), + )) + .expect("build discover response"); + } + + panic!("modern server must not be asked for {method}"); +} + +/// A recognized modern error in a 4xx discover rejection identifies a modern +/// server, so the client retries at a supported version instead of falling back +/// to legacy `initialize`. +#[tokio::test] +async fn auto_http_client_retries_version_after_modern_4xx_rejection() { + let ct = CancellationToken::new(); + let state = VersionNegotiatingHttpState::default(); + let methods = state.methods.clone(); + let router = Router::new() + .route("/mcp", post(version_negotiating_http_handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), + ); + let client = ClientConfig::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ + ProtocolVersion::V_2026_07_28, + ProtocolVersion::V_2025_11_25, + ], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("auto HTTP client should retry discover at a supported version"); + client.cancel().await.expect("cancel client"); + + assert_eq!( + methods.lock().await.as_slice(), + &["server/discover", "server/discover"], + "expected a version-negotiation retry, not a legacy fallback" + ); + ct.cancel(); + server.await.expect("server task"); +}