From fa5098647fdc2ac34c62b7c3a995b80789e7285e Mon Sep 17 00:00:00 2001 From: Jack Liu Date: Wed, 5 Aug 2026 14:58:46 -0700 Subject: [PATCH] fix(client): fall back on legacy JSON-RPC errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e006650d-d10e-432f-aaa2-15a763fdf4b6 --- README.md | 8 +- crates/rmcp/src/service/client.rs | 13 +- .../rmcp/tests/test_client_lifecycle_modes.rs | 118 +++++++++++++----- 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index b6c580e17..5ebba7706 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ let client = ClientInfo::default() ) .await?; -// Or probe the discover lifecycle and fall back when a legacy server reports -// that server/discover is not implemented. +// Or probe the discover lifecycle and fall back when the response does not +// positively identify a modern server. let client = ClientInfo::default() .serve_with_lifecycle( transport, @@ -127,7 +127,9 @@ let client = ClientInfo::default() `ClientLifecycleMode::Initialize` is equivalent to the existing `serve()` behavior. Discover startup does not send `notifications/initialized`; discovery completes startup, and each subsequent request carries its protocol version, client -information, and capabilities in `_meta`. +information, and capabilities in `_meta`. Auto mode preserves authentication, +transport, and recognized modern-protocol errors instead of treating them as +legacy-server signals. ### Build a Server diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 2e25b13a3..4bf5a29ae 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -740,7 +740,7 @@ where match discover_result { Ok(()) => {} Err(ClientInitializeError::JsonRpcError(error)) - if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND => + if !is_modern_server_json_rpc_error(&error) => { let mut legacy_info = client_info; if let Some(version) = legacy_version { @@ -756,6 +756,17 @@ where Ok(serve_inner(service, transport, peer, peer_rx, ct)) } +// Only specification-defined modern errors prove that the peer understands +// per-request metadata; other JSON-RPC errors are legacy fallback signals. +fn is_modern_server_json_rpc_error(error: &ErrorData) -> bool { + matches!( + error.code, + crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + | crate::model::ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + | crate::model::ErrorCode::HEADER_MISMATCH + ) +} + async fn legacy_startup( service: &S, transport: &mut T, diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 4b75c3550..cf7613f33 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -277,8 +277,7 @@ async fn discover_startup_omits_initialize() { server_task.await.expect("server task"); } -#[tokio::test] -async fn auto_startup_falls_back_after_discover_method_not_found() { +async fn run_auto_discover_error_scenario(error: ErrorData, expect_fallback: bool) { let (server_transport, client_transport) = tokio::io::duplex(4096); let mut server = IntoTransport::::into_transport(server_transport); let server_task = tokio::spawn(async move { @@ -292,38 +291,39 @@ async fn auto_startup_falls_back_after_discover_method_not_found() { ClientRequest::DiscoverRequest(_) )); server - .send(ServerJsonRpcMessage::error( - ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None), - Some(discover.id), - )) + .send(ServerJsonRpcMessage::error(error, Some(discover.id))) .await - .expect("send method-not-found"); + .expect("send discover error"); - let ClientJsonRpcMessage::Request(initialize) = - server.receive().await.expect("expected initialize request") - else { - panic!("expected request"); - }; - assert!(matches!( - initialize.request, - ClientRequest::InitializeRequest(_) - )); - server - .send(ServerJsonRpcMessage::response( - ServerResult::InitializeResult( - InitializeResult::new(ServerCapabilities::default()), - ), - initialize.id, - )) - .await - .expect("send initialize response"); - assert!(matches!( - server.receive().await, - Some(ClientJsonRpcMessage::Notification(_)) - )); + if expect_fallback { + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + assert!(matches!( + initialize.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(InitializeResult::new( + ServerCapabilities::default(), + )), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + } else if let Some(message) = server.receive().await { + panic!("modern discover error should close the client, got {message:?}"); + } }); - let client = DiscoverClient + let client_result = DiscoverClient .serve_with_lifecycle( client_transport, ClientLifecycleMode::Auto { @@ -331,12 +331,64 @@ async fn auto_startup_falls_back_after_discover_method_not_found() { legacy_version: Some(ProtocolVersion::V_2025_11_25), }, ) - .await - .expect("auto client should fall back"); - client.cancel().await.expect("cancel client"); + .await; + + if expect_fallback { + let client = client_result.expect("auto client should fall back"); + client.cancel().await.expect("cancel client"); + } else { + assert!( + client_result.is_err(), + "modern discover error should be surfaced" + ); + } server_task.await.expect("server task"); } +#[tokio::test] +async fn auto_startup_falls_back_after_discover_method_not_found() { + run_auto_discover_error_scenario( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None), + true, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_falls_back_after_discover_implementation_defined_error() { + run_auto_discover_error_scenario( + ErrorData::new( + ErrorCode(-32000), + "Bad Request: The MCP-Protocol-Version header value '2026-07-28' is not supported.", + None, + ), + true, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_does_not_fall_back_after_missing_required_capability() { + run_auto_discover_error_scenario( + ErrorData::new( + ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, + "Missing required client capability", + None, + ), + false, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_does_not_fall_back_after_header_mismatch() { + run_auto_discover_error_scenario( + ErrorData::new(ErrorCode::HEADER_MISMATCH, "Header mismatch", None), + false, + ) + .await; +} + #[tokio::test] async fn discover_startup_retries_a_mutually_supported_version() { let unsupported: ProtocolVersion =