Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ impl StreamableHttpClient for reqwest::Client {
.text()
.await
.unwrap_or_else(|_| "<failed to read response body>".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()))
Expand All @@ -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}"),
)));
Expand Down
23 changes: 15 additions & 8 deletions crates/rmcp/src/transport/streamable_http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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::<ServerJsonRpcMessage>(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,
Expand Down
107 changes: 107 additions & 0 deletions crates/rmcp/tests/test_discover_http_client_startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Vec<String>>>,
}

/// 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<VersionNegotiatingHttpState>,
body: Bytes,
) -> Response<Body> {
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");
}
Loading