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
71 changes: 70 additions & 1 deletion crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
alexhancock marked this conversation as resolved.

/// 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,
Expand All @@ -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
Expand Down Expand Up @@ -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!(
Expand Down
8 changes: 1 addition & 7 deletions crates/rmcp/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Req: crate::model::GetExtensions, V>(
Expand Down
14 changes: 13 additions & 1 deletion crates/rmcp/src/service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -898,6 +898,7 @@ where
T: Transport<RoleClient> + '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,
Expand All @@ -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
Expand Down
34 changes: 20 additions & 14 deletions crates/rmcp/src/service/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -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<ProtocolVersion, ErrorData> {
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"
Expand All @@ -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<ProtocolVersion> {
fn newest_version_with_initialize(versions: &[ProtocolVersion]) -> Option<ProtocolVersion> {
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()
}
Expand Down
9 changes: 8 additions & 1 deletion crates/rmcp/tests/test_notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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)
}
Comment on lines +58 to +63

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this version pin still needed?


async fn on_resource_updated(
&self,
params: rmcp::model::ResourceUpdatedNotificationParam,
Expand Down
18 changes: 10 additions & 8 deletions crates/rmcp/tests/test_protocol_version_negotiation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
);
}

Expand Down Expand Up @@ -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"
);
}

Expand Down
24 changes: 18 additions & 6 deletions crates/rmcp/tests/test_server_initialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand Down
14 changes: 8 additions & 6 deletions crates/rmcp/tests/test_stateless_protocol_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
);

Expand Down
Loading
Loading