Skip to content

Commit 972af86

Browse files
authored
feat: update LATEST and add LATEST_WITH_INITIALIZE (#1105)
1 parent 6e47ca3 commit 972af86

10 files changed

Lines changed: 219 additions & 48 deletions

‎crates/rmcp/src/model.rs‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,35 @@ impl ProtocolVersion {
172172
pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
173173
pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
174174
pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05"));
175-
pub const LATEST: Self = Self::V_2025_11_25;
175+
176+
/// The newest protocol version known to this SDK.
177+
pub const LATEST: Self = Self::V_2026_07_28;
176178

177179
/// First protocol version that requires SEP-2243 standard HTTP headers.
178180
pub const STANDARD_HEADERS: Self = Self::V_2026_07_28;
179181

182+
/// First protocol version that replaced the `initialize` handshake with
183+
/// per-request `_meta` (SEP-2567).
184+
pub const NO_INITIALIZE: Self = Self::V_2026_07_28;
185+
186+
/// The newest protocol version that still has an `initialize` handshake.
187+
///
188+
/// From [`Self::NO_INITIALIZE`] onward the lifecycle moved into per-request
189+
/// `_meta`, so there is no handshake left to answer. A server replying to
190+
/// `initialize` can therefore never name [`Self::LATEST`] once `LATEST`
191+
/// reaches that revision — it has to name this one instead.
192+
///
193+
/// Use this, not `LATEST`, whenever the subject is the handshake itself:
194+
/// the version a server can echo from `initialize`, or the version a test
195+
/// needs in order to exercise session-based behavior.
196+
///
197+
/// ```
198+
/// # use rmcp::model::ProtocolVersion;
199+
/// assert!(ProtocolVersion::LATEST_WITH_INITIALIZE.has_initialize());
200+
/// assert!(!ProtocolVersion::LATEST.has_initialize());
201+
/// ```
202+
pub const LATEST_WITH_INITIALIZE: Self = Self::V_2025_11_25;
203+
180204
/// All protocol versions known to this SDK, oldest first.
181205
pub const KNOWN_VERSIONS: &[Self] = &[
182206
Self::V_2024_11_05,
@@ -191,6 +215,23 @@ impl ProtocolVersion {
191215
&self.0
192216
}
193217

218+
/// Whether this revision negotiates its lifecycle over the `initialize`
219+
/// handshake.
220+
///
221+
/// `false` from [`Self::NO_INITIALIZE`] onward, where SEP-2567 moved the
222+
/// lifecycle into per-request `_meta`. Unknown versions are compared
223+
/// lexically, which orders correctly because every revision is dated
224+
/// `YYYY-MM-DD`.
225+
///
226+
/// ```
227+
/// # use rmcp::model::ProtocolVersion;
228+
/// assert!(ProtocolVersion::V_2025_11_25.has_initialize());
229+
/// assert!(!ProtocolVersion::V_2026_07_28.has_initialize());
230+
/// ```
231+
pub fn has_initialize(&self) -> bool {
232+
self.as_str() < Self::NO_INITIALIZE.as_str()
233+
}
234+
194235
/// The known versions up to and including `max`, oldest first.
195236
///
196237
/// Servers that implement every revision up to some ceiling can return
@@ -4784,6 +4825,34 @@ mod tests {
47844825
);
47854826
}
47864827

4828+
// Guards the pair of constants this SDK's negotiation rests on. `LATEST`
4829+
// and `LATEST_WITH_INITIALIZE` were the same value until `LATEST` reached
4830+
// `NO_INITIALIZE`; conflating them is what made bumping `LATEST` expensive.
4831+
#[test]
4832+
fn latest_with_initialize_is_the_newest_known_version_that_has_one() {
4833+
let derived = ProtocolVersion::KNOWN_VERSIONS
4834+
.iter()
4835+
.filter(|version| version.has_initialize())
4836+
.max_by(|left, right| left.as_str().cmp(right.as_str()))
4837+
.expect("some known version should have an initialize handshake");
4838+
assert_eq!(
4839+
derived,
4840+
&ProtocolVersion::LATEST_WITH_INITIALIZE,
4841+
"LATEST_WITH_INITIALIZE must track KNOWN_VERSIONS"
4842+
);
4843+
}
4844+
4845+
#[test]
4846+
fn has_initialize_splits_known_versions_at_no_initialize() {
4847+
for version in ProtocolVersion::KNOWN_VERSIONS {
4848+
assert_eq!(
4849+
version.has_initialize(),
4850+
version.as_str() < ProtocolVersion::NO_INITIALIZE.as_str(),
4851+
"{version} classified inconsistently"
4852+
);
4853+
}
4854+
}
4855+
47874856
#[test]
47884857
fn known_up_to_includes_the_ceiling_itself() {
47894858
assert_eq!(

‎crates/rmcp/src/service.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,11 @@ pub enum PeerRequestAssociation {
201201
Unknown { has_pending_outbound_request: bool },
202202
}
203203

204-
/// Whether `version` predates `2026-07-28`, the revision that replaced the
205-
/// `initialize` handshake with per-request metadata.
206-
pub(crate) fn is_legacy_version(version: &ProtocolVersion) -> bool {
207-
version.as_str() < ProtocolVersion::V_2026_07_28.as_str()
208-
}
209-
210204
pub(crate) fn uses_legacy_lifecycle(
211205
protocol_version: Option<&ProtocolVersion>,
212206
uses_discover_lifecycle: bool,
213207
) -> bool {
214-
!uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version)
208+
!uses_discover_lifecycle && protocol_version.is_none_or(ProtocolVersion::has_initialize)
215209
}
216210

217211
pub(crate) fn peer_request_association<Req: crate::model::GetExtensions, V>(

‎crates/rmcp/src/service/client.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ impl ServiceRole for RoleClient {
275275
let Some(server_info) = peer.peer_info() else {
276276
return;
277277
};
278-
if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() {
278+
if server_info.protocol_version.has_initialize() {
279279
return;
280280
}
281281
peer.set_client_request_metadata(ClientRequestMetadata {
@@ -898,6 +898,7 @@ where
898898
T: Transport<RoleClient> + 'static,
899899
{
900900
let id = id_provider.next_request_id();
901+
let client_info_for_meta = client_info.clone();
901902
let init_request = InitializeRequest {
902903
method: Default::default(),
903904
params: client_info,
@@ -920,6 +921,17 @@ where
920921
let ServerResult::InitializeResult(initialize_result) = response else {
921922
return Err(ClientInitializeError::ExpectedInitResult(Some(response)));
922923
};
924+
// A server may answer `initialize` with a version that has no handshake
925+
// (e.g. a client pinned past `NO_INITIALIZE` talking to a server that
926+
// honors it). From there the lifecycle lives in per-request `_meta`, so the
927+
// metadata has to be seeded now or every later request omits it.
928+
if !initialize_result.protocol_version.has_initialize() {
929+
peer.set_client_request_metadata(ClientRequestMetadata {
930+
protocol_version: initialize_result.protocol_version.clone(),
931+
client_info: client_info_for_meta.client_info,
932+
client_capabilities: client_info_for_meta.capabilities,
933+
});
934+
}
923935
peer.set_peer_info(initialize_result.into());
924936

925937
// send notification

‎crates/rmcp/src/service/server.rs‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,15 @@ where
467467
///
468468
/// `server_supported` comes from [`Service::supported_protocol_versions`], so a
469469
/// server that narrows that list is never made to answer `initialize` with a
470-
/// version it cannot serve. `2026-07-28` replaced the handshake with
471-
/// per-request metadata, so a client naming that revision or later is answered
472-
/// with the server's newest legacy version instead.
470+
/// version it cannot serve. [`ProtocolVersion::NO_INITIALIZE`] replaced the
471+
/// handshake with per-request metadata, so a client naming that revision or
472+
/// later is answered with the server's newest handshake version instead.
473+
///
474+
/// `preferred_fallback` is a *preference*, not a guarantee: it is honored only
475+
/// when it has an `initialize` handshake of its own. A server that pins itself
476+
/// past [`ProtocolVersion::NO_INITIALIZE`] still has to answer the handshake
477+
/// with something, so the newest handshake version in `server_supported` is
478+
/// substituted.
473479
///
474480
/// # Errors
475481
///
@@ -479,18 +485,18 @@ where
479485
/// [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`]: crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION
480486
pub(crate) fn negotiate_protocol_version(
481487
client_requested: &ProtocolVersion,
482-
server_fallback: ProtocolVersion,
488+
preferred_fallback: ProtocolVersion,
483489
server_supported: &[ProtocolVersion],
484490
) -> Result<ProtocolVersion, ErrorData> {
485-
if is_legacy_version(client_requested) && server_supported.contains(client_requested) {
491+
if client_requested.has_initialize() && server_supported.contains(client_requested) {
486492
return Ok(client_requested.clone());
487493
}
488-
let legacy_fallback = if is_legacy_version(&server_fallback) {
489-
Some(server_fallback)
494+
let fallback = if preferred_fallback.has_initialize() {
495+
Some(preferred_fallback)
490496
} else {
491-
newest_legacy_version(server_supported)
497+
newest_version_with_initialize(server_supported)
492498
};
493-
let Some(legacy_fallback) = legacy_fallback else {
499+
let Some(negotiated) = fallback else {
494500
tracing::warn!(
495501
client_requested = %client_requested,
496502
"server supports no protocol version with an initialize handshake; rejecting"
@@ -504,17 +510,17 @@ pub(crate) fn negotiate_protocol_version(
504510
// HTTP re-runs it on every request, so this is not a warning.
505511
tracing::debug!(
506512
client_requested = %client_requested,
507-
server_fallback = %legacy_fallback,
508-
"client requested a protocol version unavailable over initialize; falling back to server default"
513+
negotiated = %negotiated,
514+
"client requested a protocol version unavailable over initialize; falling back to a supported version"
509515
);
510-
Ok(legacy_fallback)
516+
Ok(negotiated)
511517
}
512518

513519
/// The newest of `versions` that still has an `initialize` handshake.
514-
fn newest_legacy_version(versions: &[ProtocolVersion]) -> Option<ProtocolVersion> {
520+
fn newest_version_with_initialize(versions: &[ProtocolVersion]) -> Option<ProtocolVersion> {
515521
versions
516522
.iter()
517-
.filter(|version| is_legacy_version(version))
523+
.filter(|version| version.has_initialize())
518524
.max_by(|left, right| left.as_str().cmp(right.as_str()))
519525
.cloned()
520526
}

‎crates/rmcp/tests/test_notification.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::sync::Arc;
55
use rmcp::{
66
ClientHandler, ServerHandler, ServiceExt,
77
model::{
8-
ClientNotification, CustomNotification, ResourceUpdatedNotificationParam,
8+
ClientNotification, CustomNotification, ProtocolVersion, ResourceUpdatedNotificationParam,
99
ServerCapabilities, ServerConfig, ServerNotification, SubscribeRequestParams,
1010
},
1111
};
@@ -55,6 +55,13 @@ pub struct Client {
5555
}
5656

5757
impl ClientHandler for Client {
58+
// Pinned to the handshake lifecycle: this test drives subscriptions over a
59+
// session, which `LATEST` no longer establishes.
60+
fn get_info(&self) -> rmcp::model::ClientInfo {
61+
rmcp::model::ClientInfo::default()
62+
.with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE)
63+
}
64+
5865
async fn on_resource_updated(
5966
&self,
6067
params: rmcp::model::ResourceUpdatedNotificationParam,

‎crates/rmcp/tests/test_protocol_version_negotiation.rs‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ impl ServerHandler for EchoServer {
3232
}
3333

3434
/// Every known version whose lifecycle still runs the `initialize` handshake.
35-
/// `2026-07-28` replaced the handshake with per-request metadata, so this is
36-
/// also the list a server that has not implemented that revision supports.
35+
/// [`ProtocolVersion::NO_INITIALIZE`] replaced the handshake with per-request
36+
/// metadata, so this is also the list a server that has not implemented that
37+
/// revision supports.
3738
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
3839
ProtocolVersion::V_2024_11_05,
3940
ProtocolVersion::V_2025_03_26,
@@ -168,8 +169,8 @@ async fn handshake_never_agrees_to_a_version_that_dropped_it() {
168169
let negotiated = negotiated_version(ProtocolVersion::V_2026_07_28).await;
169170
assert_eq!(
170171
negotiated,
171-
ProtocolVersion::LATEST,
172-
"a version that dropped the handshake should fall back to the server's own"
172+
ProtocolVersion::LATEST_WITH_INITIALIZE,
173+
"a version that dropped the handshake should fall back to the server's newest handshake version"
173174
);
174175
}
175176

@@ -197,13 +198,14 @@ async fn modern_only_server_rejects_the_handshake() {
197198
}
198199

199200
#[tokio::test]
200-
async fn unknown_version_falls_back_to_latest() {
201-
let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap();
201+
async fn unknown_version_falls_back_to_newest_handshake_version() {
202+
let unknown: ProtocolVersion =
203+
serde_json::from_str(r#""1999-01-01""#).expect("version literal should deserialize");
202204
let negotiated = negotiated_version(unknown).await;
203205
assert_eq!(
204206
negotiated,
205-
ProtocolVersion::LATEST,
206-
"unknown version should fall back to LATEST"
207+
ProtocolVersion::LATEST_WITH_INITIALIZE,
208+
"unknown version should fall back to the newest handshake version"
207209
);
208210
}
209211

‎crates/rmcp/tests/test_server_initialization.rs‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,16 +266,18 @@ async fn server_echoes_client_protocol_version_when_known_old() {
266266
assert_eq!(negotiated, ProtocolVersion::V_2024_11_05);
267267
}
268268

269+
// `initialize` can only answer with a version that still has a handshake, so a
270+
// client naming `LATEST` gets `LATEST_WITH_INITIALIZE` instead.
269271
#[tokio::test]
270-
async fn server_echoes_client_protocol_version_when_latest() {
271-
let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await;
272-
assert_eq!(negotiated, ProtocolVersion::LATEST);
272+
async fn server_falls_back_to_newest_handshake_version_when_client_requests_latest() {
273+
let negotiated = negotiate_version(TestServer::new(), ProtocolVersion::LATEST.as_str()).await;
274+
assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE);
273275
}
274276

275277
#[tokio::test]
276278
async fn server_falls_back_when_client_protocol_version_unknown() {
277279
let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await;
278-
assert_eq!(negotiated, ProtocolVersion::LATEST);
280+
assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE);
279281
}
280282

281283
struct PinnedServer;
@@ -289,8 +291,18 @@ impl ServerHandler for PinnedServer {
289291

290292
#[tokio::test]
291293
async fn server_pinned_version_does_not_override_known_client_request() {
292-
let negotiated = negotiate_version(PinnedServer, "2025-11-25").await;
293-
assert_eq!(negotiated, ProtocolVersion::LATEST);
294+
let negotiated = negotiate_version(
295+
PinnedServer,
296+
ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(),
297+
)
298+
.await;
299+
assert_eq!(negotiated, ProtocolVersion::LATEST_WITH_INITIALIZE);
300+
}
301+
302+
#[tokio::test]
303+
async fn server_pinned_version_used_as_fallback_when_client_requests_latest() {
304+
let negotiated = negotiate_version(PinnedServer, ProtocolVersion::LATEST.as_str()).await;
305+
assert_eq!(negotiated, ProtocolVersion::V_2025_06_18);
294306
}
295307

296308
#[tokio::test]

‎crates/rmcp/tests/test_stateless_protocol_version.rs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ impl ServerHandler for OverridingInitialize {
3939
}
4040

4141
/// Every known version whose lifecycle still runs the `initialize` handshake.
42-
/// `2026-07-28` replaced the handshake with per-request metadata, so this is
43-
/// also the list a server that has not implemented that revision supports.
42+
/// [`ProtocolVersion::NO_INITIALIZE`] replaced the handshake with per-request
43+
/// metadata, so this is also the list a server that has not implemented that
44+
/// revision supports.
4445
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
4546
ProtocolVersion::V_2024_11_05,
4647
ProtocolVersion::V_2025_03_26,
@@ -183,14 +184,15 @@ async fn stateless_sse_init_echoes_handshake_versions_when_handler_overrides_ini
183184
}
184185

185186
#[tokio::test]
186-
async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() {
187+
async fn stateless_json_init_substitutes_a_handshake_version_for_unknown_version() {
187188
let (client, url, ct) = spawn_server(stateless_json_config()).await;
188189

189190
let resp = post_init(&client, &url, "1999-01-01").await;
190191
assert_eq!(
191192
resp["result"]["protocolVersion"],
192-
ProtocolVersion::LATEST.as_str(),
193-
"unknown version should preserve the handler's fallback"
193+
ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(),
194+
"the handler's `LATEST` fallback has no handshake, so the newest version \
195+
that does is substituted"
194196
);
195197

196198
ct.cancel();
@@ -221,7 +223,7 @@ async fn stateless_json_init_does_not_agree_to_version_outside_supported_list()
221223
let resp = post_init(&client, &url, ProtocolVersion::V_2026_07_28.as_str()).await;
222224
assert_eq!(
223225
resp["result"]["protocolVersion"],
224-
ProtocolVersion::V_2025_11_25.as_str(),
226+
ProtocolVersion::LATEST_WITH_INITIALIZE.as_str(),
225227
"a version outside supported_protocol_versions should not be echoed back"
226228
);
227229

0 commit comments

Comments
 (0)