From cfbb4063d1056ab9131baf20a2a2a066351a9c22 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 30 Jul 2026 20:39:50 -0400 Subject: [PATCH] feat: update LATEST and add LATEST_WITH_INITIALIZE --- crates/rmcp/src/model.rs | 71 ++++++++++++++++++- crates/rmcp/src/service.rs | 8 +-- crates/rmcp/src/service/client.rs | 14 +++- crates/rmcp/src/service/server.rs | 34 +++++---- crates/rmcp/tests/test_notification.rs | 9 ++- .../test_protocol_version_negotiation.rs | 18 ++--- .../rmcp/tests/test_server_initialization.rs | 24 +++++-- .../tests/test_stateless_protocol_version.rs | 14 ++-- .../test_streamable_http_session_store.rs | 65 ++++++++++++++++- .../test_streamable_http_stale_session.rs | 10 ++- 10 files changed, 219 insertions(+), 48 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ec7b93b3e..73006596f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -172,11 +172,35 @@ impl ProtocolVersion { pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); - pub const LATEST: Self = Self::V_2025_11_25; + + /// The newest protocol version known to this SDK. + pub const LATEST: Self = Self::V_2026_07_28; /// First protocol version that requires SEP-2243 standard HTTP headers. pub const STANDARD_HEADERS: Self = Self::V_2026_07_28; + /// First protocol version that replaced the `initialize` handshake with + /// per-request `_meta` (SEP-2567). + pub const NO_INITIALIZE: Self = Self::V_2026_07_28; + + /// The newest protocol version that still has an `initialize` handshake. + /// + /// From [`Self::NO_INITIALIZE`] onward the lifecycle moved into per-request + /// `_meta`, so there is no handshake left to answer. A server replying to + /// `initialize` can therefore never name [`Self::LATEST`] once `LATEST` + /// reaches that revision — it has to name this one instead. + /// + /// Use this, not `LATEST`, whenever the subject is the handshake itself: + /// the version a server can echo from `initialize`, or the version a test + /// needs in order to exercise session-based behavior. + /// + /// ``` + /// # use rmcp::model::ProtocolVersion; + /// assert!(ProtocolVersion::LATEST_WITH_INITIALIZE.has_initialize()); + /// assert!(!ProtocolVersion::LATEST.has_initialize()); + /// ``` + pub const LATEST_WITH_INITIALIZE: Self = Self::V_2025_11_25; + /// All protocol versions known to this SDK, oldest first. pub const KNOWN_VERSIONS: &[Self] = &[ Self::V_2024_11_05, @@ -191,6 +215,23 @@ impl ProtocolVersion { &self.0 } + /// Whether this revision negotiates its lifecycle over the `initialize` + /// handshake. + /// + /// `false` from [`Self::NO_INITIALIZE`] onward, where SEP-2567 moved the + /// lifecycle into per-request `_meta`. Unknown versions are compared + /// lexically, which orders correctly because every revision is dated + /// `YYYY-MM-DD`. + /// + /// ``` + /// # use rmcp::model::ProtocolVersion; + /// assert!(ProtocolVersion::V_2025_11_25.has_initialize()); + /// assert!(!ProtocolVersion::V_2026_07_28.has_initialize()); + /// ``` + pub fn has_initialize(&self) -> bool { + self.as_str() < Self::NO_INITIALIZE.as_str() + } + /// The known versions up to and including `max`, oldest first. /// /// Servers that implement every revision up to some ceiling can return @@ -4741,6 +4782,34 @@ mod tests { ); } + // Guards the pair of constants this SDK's negotiation rests on. `LATEST` + // and `LATEST_WITH_INITIALIZE` were the same value until `LATEST` reached + // `NO_INITIALIZE`; conflating them is what made bumping `LATEST` expensive. + #[test] + fn latest_with_initialize_is_the_newest_known_version_that_has_one() { + let derived = ProtocolVersion::KNOWN_VERSIONS + .iter() + .filter(|version| version.has_initialize()) + .max_by(|left, right| left.as_str().cmp(right.as_str())) + .expect("some known version should have an initialize handshake"); + assert_eq!( + derived, + &ProtocolVersion::LATEST_WITH_INITIALIZE, + "LATEST_WITH_INITIALIZE must track KNOWN_VERSIONS" + ); + } + + #[test] + fn has_initialize_splits_known_versions_at_no_initialize() { + for version in ProtocolVersion::KNOWN_VERSIONS { + assert_eq!( + version.has_initialize(), + version.as_str() < ProtocolVersion::NO_INITIALIZE.as_str(), + "{version} classified inconsistently" + ); + } + } + #[test] fn known_up_to_includes_the_ceiling_itself() { assert_eq!( diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 63b38e419..8fcf9b41b 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -201,17 +201,11 @@ pub enum PeerRequestAssociation { Unknown { has_pending_outbound_request: bool }, } -/// Whether `version` predates `2026-07-28`, the revision that replaced the -/// `initialize` handshake with per-request metadata. -pub(crate) fn is_legacy_version(version: &ProtocolVersion) -> bool { - version.as_str() < ProtocolVersion::V_2026_07_28.as_str() -} - pub(crate) fn uses_legacy_lifecycle( protocol_version: Option<&ProtocolVersion>, uses_discover_lifecycle: bool, ) -> bool { - !uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version) + !uses_discover_lifecycle && protocol_version.is_none_or(ProtocolVersion::has_initialize) } pub(crate) fn peer_request_association( diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 7d0d794e1..5dad6554a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -275,7 +275,7 @@ impl ServiceRole for RoleClient { let Some(server_info) = peer.peer_info() else { return; }; - if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { + if server_info.protocol_version.has_initialize() { return; } peer.set_client_request_metadata(ClientRequestMetadata { @@ -898,6 +898,7 @@ where T: Transport + 'static, { let id = id_provider.next_request_id(); + let client_info_for_meta = client_info.clone(); let init_request = InitializeRequest { method: Default::default(), params: client_info, @@ -920,6 +921,17 @@ where let ServerResult::InitializeResult(initialize_result) = response else { return Err(ClientInitializeError::ExpectedInitResult(Some(response))); }; + // A server may answer `initialize` with a version that has no handshake + // (e.g. a client pinned past `NO_INITIALIZE` talking to a server that + // honors it). From there the lifecycle lives in per-request `_meta`, so the + // metadata has to be seeded now or every later request omits it. + if !initialize_result.protocol_version.has_initialize() { + peer.set_client_request_metadata(ClientRequestMetadata { + protocol_version: initialize_result.protocol_version.clone(), + client_info: client_info_for_meta.client_info, + client_capabilities: client_info_for_meta.capabilities, + }); + } peer.set_peer_info(initialize_result.into()); // send notification diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 3ff229f8d..0cf3c7a6f 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -467,9 +467,15 @@ where /// /// `server_supported` comes from [`Service::supported_protocol_versions`], so a /// server that narrows that list is never made to answer `initialize` with a -/// version it cannot serve. `2026-07-28` replaced the handshake with -/// per-request metadata, so a client naming that revision or later is answered -/// with the server's newest legacy version instead. +/// version it cannot serve. [`ProtocolVersion::NO_INITIALIZE`] replaced the +/// handshake with per-request metadata, so a client naming that revision or +/// later is answered with the server's newest handshake version instead. +/// +/// `preferred_fallback` is a *preference*, not a guarantee: it is honored only +/// when it has an `initialize` handshake of its own. A server that pins itself +/// past [`ProtocolVersion::NO_INITIALIZE`] still has to answer the handshake +/// with something, so the newest handshake version in `server_supported` is +/// substituted. /// /// # Errors /// @@ -479,18 +485,18 @@ where /// [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`]: crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION pub(crate) fn negotiate_protocol_version( client_requested: &ProtocolVersion, - server_fallback: ProtocolVersion, + preferred_fallback: ProtocolVersion, server_supported: &[ProtocolVersion], ) -> Result { - if is_legacy_version(client_requested) && server_supported.contains(client_requested) { + if client_requested.has_initialize() && server_supported.contains(client_requested) { return Ok(client_requested.clone()); } - let legacy_fallback = if is_legacy_version(&server_fallback) { - Some(server_fallback) + let fallback = if preferred_fallback.has_initialize() { + Some(preferred_fallback) } else { - newest_legacy_version(server_supported) + newest_version_with_initialize(server_supported) }; - let Some(legacy_fallback) = legacy_fallback else { + let Some(negotiated) = fallback else { tracing::warn!( client_requested = %client_requested, "server supports no protocol version with an initialize handshake; rejecting" @@ -504,17 +510,17 @@ pub(crate) fn negotiate_protocol_version( // HTTP re-runs it on every request, so this is not a warning. tracing::debug!( client_requested = %client_requested, - server_fallback = %legacy_fallback, - "client requested a protocol version unavailable over initialize; falling back to server default" + negotiated = %negotiated, + "client requested a protocol version unavailable over initialize; falling back to a supported version" ); - Ok(legacy_fallback) + Ok(negotiated) } /// The newest of `versions` that still has an `initialize` handshake. -fn newest_legacy_version(versions: &[ProtocolVersion]) -> Option { +fn newest_version_with_initialize(versions: &[ProtocolVersion]) -> Option { versions .iter() - .filter(|version| is_legacy_version(version)) + .filter(|version| version.has_initialize()) .max_by(|left, right| left.as_str().cmp(right.as_str())) .cloned() } diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index dafb0df15..9e4db7fd3 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, + ClientNotification, CustomNotification, ProtocolVersion, ResourceUpdatedNotificationParam, ServerCapabilities, ServerConfig, ServerNotification, SubscribeRequestParams, }, }; @@ -55,6 +55,13 @@ pub struct Client { } impl ClientHandler for Client { + // Pinned to the handshake lifecycle: this test drives subscriptions over a + // session, which `LATEST` no longer establishes. + fn get_info(&self) -> rmcp::model::ClientInfo { + rmcp::model::ClientInfo::default() + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) + } + async fn on_resource_updated( &self, params: rmcp::model::ResourceUpdatedNotificationParam, diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index 1453e0f46..86894e73a 100644 --- a/crates/rmcp/tests/test_protocol_version_negotiation.rs +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -32,8 +32,9 @@ impl ServerHandler for EchoServer { } /// Every known version whose lifecycle still runs the `initialize` handshake. -/// `2026-07-28` replaced the handshake with per-request metadata, so this is -/// also the list a server that has not implemented that revision supports. +/// [`ProtocolVersion::NO_INITIALIZE`] replaced the handshake with per-request +/// metadata, so this is also the list a server that has not implemented that +/// revision supports. const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ ProtocolVersion::V_2024_11_05, ProtocolVersion::V_2025_03_26, @@ -168,8 +169,8 @@ async fn handshake_never_agrees_to_a_version_that_dropped_it() { let negotiated = negotiated_version(ProtocolVersion::V_2026_07_28).await; assert_eq!( negotiated, - ProtocolVersion::LATEST, - "a version that dropped the handshake should fall back to the server's own" + ProtocolVersion::LATEST_WITH_INITIALIZE, + "a version that dropped the handshake should fall back to the server's newest handshake version" ); } @@ -197,13 +198,14 @@ async fn modern_only_server_rejects_the_handshake() { } #[tokio::test] -async fn unknown_version_falls_back_to_latest() { - let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap(); +async fn unknown_version_falls_back_to_newest_handshake_version() { + let unknown: ProtocolVersion = + serde_json::from_str(r#""1999-01-01""#).expect("version literal should deserialize"); let negotiated = negotiated_version(unknown).await; assert_eq!( negotiated, - ProtocolVersion::LATEST, - "unknown version should fall back to LATEST" + ProtocolVersion::LATEST_WITH_INITIALIZE, + "unknown version should fall back to the newest handshake version" ); } diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 5be178e10..03661219a 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -266,16 +266,18 @@ async fn server_echoes_client_protocol_version_when_known_old() { assert_eq!(negotiated, ProtocolVersion::V_2024_11_05); } +// `initialize` can only answer with a version that still has a handshake, so a +// client naming `LATEST` gets `LATEST_WITH_INITIALIZE` instead. #[tokio::test] -async fn server_echoes_client_protocol_version_when_latest() { - let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); +async fn server_falls_back_to_newest_handshake_version_when_client_requests_latest() { + let negotiated = negotiate_version(TestServer::new(), ProtocolVersion::LATEST.as_str()).await; + assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE); } #[tokio::test] async fn server_falls_back_when_client_protocol_version_unknown() { let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); + assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE); } struct PinnedServer; @@ -289,8 +291,18 @@ impl ServerHandler for PinnedServer { #[tokio::test] async fn server_pinned_version_does_not_override_known_client_request() { - let negotiated = negotiate_version(PinnedServer, "2025-11-25").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); + let negotiated = negotiate_version( + PinnedServer, + ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(), + ) + .await; + assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE); +} + +#[tokio::test] +async fn server_pinned_version_used_as_fallback_when_client_requests_latest() { + let negotiated = negotiate_version(PinnedServer, ProtocolVersion::LATEST.as_str()).await; + assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); } #[tokio::test] diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index cc2a4b553..58e83ff68 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -39,8 +39,9 @@ impl ServerHandler for OverridingInitialize { } /// Every known version whose lifecycle still runs the `initialize` handshake. -/// `2026-07-28` replaced the handshake with per-request metadata, so this is -/// also the list a server that has not implemented that revision supports. +/// [`ProtocolVersion::NO_INITIALIZE`] replaced the handshake with per-request +/// metadata, so this is also the list a server that has not implemented that +/// revision supports. const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ ProtocolVersion::V_2024_11_05, ProtocolVersion::V_2025_03_26, @@ -183,14 +184,15 @@ async fn stateless_sse_init_echoes_handshake_versions_when_handler_overrides_ini } #[tokio::test] -async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() { +async fn stateless_json_init_substitutes_a_handshake_version_for_unknown_version() { let (client, url, ct) = spawn_server(stateless_json_config()).await; let resp = post_init(&client, &url, "1999-01-01").await; assert_eq!( resp["result"]["protocolVersion"], - ProtocolVersion::LATEST.as_str(), - "unknown version should preserve the handler's fallback" + ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(), + "the handler's `LATEST` fallback has no handshake, so the newest version \ + that does is substituted" ); ct.cancel(); @@ -221,7 +223,7 @@ async fn stateless_json_init_does_not_agree_to_version_outside_supported_list() let resp = post_init(&client, &url, ProtocolVersion::V_2026_07_28.as_str()).await; assert_eq!( resp["result"]["protocolVersion"], - ProtocolVersion::V_2025_11_25.as_str(), + ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(), "a version outside supported_protocol_versions should not be echoed back" ); diff --git a/crates/rmcp/tests/test_streamable_http_session_store.rs b/crates/rmcp/tests/test_streamable_http_session_store.rs index 0c8f6a835..8fdb94675 100644 --- a/crates/rmcp/tests/test_streamable_http_session_store.rs +++ b/crates/rmcp/tests/test_streamable_http_session_store.rs @@ -10,6 +10,7 @@ use std::{collections::HashMap, sync::Arc}; use rmcp::{ ServiceExt, + model::{ClientConfig, ProtocolVersion}, transport::{ StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig, @@ -108,7 +109,10 @@ async fn test_session_state_persisted_to_store() -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), ); - let client = ().serve(transport).await?; + let client = ClientConfig::default() + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) + .serve(transport) + .await?; // Make a real request so the session is fully active. let _resources = client.list_all_resources().await?; @@ -170,7 +174,10 @@ async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), ); - let client = ().serve(transport).await?; + let client = ClientConfig::default() + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) + .serve(transport) + .await?; let _resources = client.list_all_resources().await?; assert_eq!(store.len().await, 1, "store should have one entry"); @@ -396,3 +403,57 @@ async fn test_cross_instance_session_restore() -> anyhow::Result<()> { Ok(()) } + +// --------------------------------------------------------------------------- +// Test 4 — companion to Test 1: at LATEST the lifecycle lives in `_meta`, so +// the server answers statelessly and creates no session. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_no_session_created_at_latest_protocol_version() -> anyhow::Result<()> { + let store = Arc::new(InMemorySessionStore::new()); + let session_manager = Arc::new(LocalSessionManager::default()); + let ct = CancellationToken::new(); + let (addr, handle) = spawn_server(Some(store.clone()), session_manager.clone(), &ct); + + let response = reqwest::Client::new() + .post(format!("http://{addr}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-protocol-version", ProtocolVersion::LATEST.as_str()) + // Required by SEP-2243 from 2026-07-28 onward. + .header("mcp-method", "resources/list") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": ProtocolVersion::LATEST.as_str(), + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + .send() + .await?; + + assert!(response.status().is_success(), "got {}", response.status()); + assert!( + response.headers().get("mcp-session-id").is_none(), + "no session id should be assigned" + ); + // Drain the body so the request is fully handled before asserting on state. + let _ = response.bytes().await?; + + assert_eq!(store.len().await, 0, "no session should be persisted"); + assert_eq!( + session_manager.sessions.read().await.len(), + 0, + "no in-memory session should be created" + ); + + ct.cancel(); + handle.await?; + + Ok(()) +} diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index c9ea21497..880fab553 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -356,7 +356,10 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) .reinit_on_expired_session(true), ); - let client = ().serve(transport).await?; + let client = ClientConfig::default() + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) + .serve(transport) + .await?; // Verify the session is established: list_all_resources() succeeds let _resources = client.list_all_resources().await?; @@ -432,7 +435,10 @@ async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) .reinit_on_expired_session(false), ); - let client = ().serve(transport).await?; + let client = ClientConfig::default() + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) + .serve(transport) + .await?; // Verify the session is established let _resources = client.list_all_resources().await?;