Skip to content
Open
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
15 changes: 10 additions & 5 deletions crates/rmcp/src/handler/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,20 +322,25 @@ macro_rules! server_handler_methods {
) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ {
context.peer.set_peer_info(request.clone());
let mut info = self.get_info();
info.protocol_version = negotiate_protocol_version(
let Some(protocol_version) = negotiate_protocol_version(
&request.protocol_version,
info.protocol_version,
&self.supported_protocol_versions(),
);
) else {
return std::future::ready(Err(McpError::method_not_found::<
InitializeResultMethod,
>()));
};
info.protocol_version = protocol_version;
std::future::ready(Ok(info))
}
/// Return the protocol versions supported by this server.
///
/// Defaults to every version this SDK knows. Override it to narrow the
/// set to the revisions the server actually implements: the returned
/// list is advertised by [`Self::discover`], bounds what `initialize`
/// negotiation may agree to, and is what per-request versions are
/// validated against.
/// list is advertised by [`Self::discover`], bounds which legacy
/// revisions `initialize` negotiation may agree to, and is what
/// per-request versions are validated against.
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
Expand Down
5 changes: 3 additions & 2 deletions crates/rmcp/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,9 @@ pub trait Service<R: ServiceRole>: Send + Sync + 'static {
context: NotificationContext<R>,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_;
fn get_info(&self) -> R::Info;
/// The protocol versions this service can speak, bounding what `initialize`
/// negotiation may agree to.
/// The protocol versions this service can speak, bounding which legacy
/// revisions `initialize` negotiation may agree to and which revisions the
/// discover lifecycle may select.
///
/// Servers normally override
/// [`ServerHandler::supported_protocol_versions`] instead of this method;
Expand Down
71 changes: 53 additions & 18 deletions crates/rmcp/src/service/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ use crate::{
model::{
CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage,
ClientNotification, ClientRequest, ClientResult, CreateMessageRequest,
CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest,
ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam,
ProgressNotification, ProgressNotificationParam, PromptListChangedNotification,
ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification,
ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest,
ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification,
SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification,
CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData,
InitializeResultMethod, ListRootsRequest, ListRootsResult, LoggingMessageNotification,
LoggingMessageNotificationParam, ProgressNotification, ProgressNotificationParam,
PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification,
ResourceUpdatedNotification, ResourceUpdatedNotificationParam, ServerInfo,
ServerNotification, ServerRequest, ServerResult, SubscriptionFilter,
SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams,
ToolListChangedNotification,
},
transport::DynamicTransportError,
};
Expand Down Expand Up @@ -460,26 +461,46 @@ where
}
}

/// Echoes the client-requested version if the server supports it; otherwise
/// returns `server_fallback`.
/// Negotiates the legacy protocol version used by an `initialize` handshake.
///
/// `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 `initialize` with the discover lifecycle. A client that
/// nevertheless opens with `initialize` is offering to use the legacy lifecycle,
/// so a dual-era server negotiates its preferred supported legacy revision. A
/// modern-only server returns `None` and rejects the method.
pub(crate) fn negotiate_protocol_version(
client_requested: &ProtocolVersion,
server_fallback: ProtocolVersion,
server_supported: &[ProtocolVersion],
) -> ProtocolVersion {
if server_supported.contains(client_requested) {
client_requested.clone()
) -> Option<ProtocolVersion> {
let requested_legacy = client_requested < &ProtocolVersion::V_2026_07_28;
if requested_legacy && server_supported.contains(client_requested) {
return Some(client_requested.clone());
}

let fallback = if server_fallback < ProtocolVersion::V_2026_07_28
&& server_supported.contains(&server_fallback)
{
Some(server_fallback)
} else {
server_supported
.iter()
.filter(|version| *version < &ProtocolVersion::V_2026_07_28)
.max_by(|left, right| left.as_str().cmp(right.as_str()))
.cloned()
};
if let Some(server_fallback) = fallback {
tracing::warn!(
client_requested = %client_requested,
server_fallback = %server_fallback,
"client requested unsupported protocol version; falling back to server default"
"client requested a version unavailable to initialize; falling back to the server's preferred legacy version"
);
Comment on lines 492 to +496
Some(server_fallback)
} else {
tracing::warn!(
client_requested = %client_requested,
"server supports no legacy protocol version; rejecting initialize"
);
server_fallback
None
}
}

Expand Down Expand Up @@ -584,11 +605,25 @@ where
return Err(ServerInitializeError::InitializeFailed(e));
}
};
init_response.protocol_version = negotiate_protocol_version(
let negotiated_protocol_version = negotiate_protocol_version(
&requested_protocol_version,
init_response.protocol_version,
&service.supported_protocol_versions(),
);
let Some(negotiated_protocol_version) = negotiated_protocol_version else {
let error = ErrorData::method_not_found::<InitializeResultMethod>();
transport
.send(ServerJsonRpcMessage::error(error.clone(), Some(id)))
.await
.map_err(|transport_error| {
ServerInitializeError::transport::<T>(
transport_error,
"sending initialize error response",
)
})?;
return Err(ServerInitializeError::InitializeFailed(error));
};
init_response.protocol_version = negotiated_protocol_version;
// Update peer_info so context.protocol_version() reflects the negotiated
// version in all subsequent request handlers.
negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
Expand Down
24 changes: 14 additions & 10 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use crate::{
model::{
ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode,
ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest,
InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError,
ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult,
InitializeRequestParams, InitializeResultMethod, InitializedNotification, JsonObject,
JsonRpcError, ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult,
},
serve_server,
service::{
Expand Down Expand Up @@ -322,7 +322,8 @@ impl<S: Service<RoleServer>> Service<RoleServer> for NegotiatingStatelessHttpSer
&requested,
result.protocol_version.clone(),
&self.0.supported_protocol_versions(),
);
)
.ok_or_else(ErrorData::method_not_found::<InitializeResultMethod>)?;
if let Some(peer_info) = peer.peer_info() {
let mut peer_info = (*peer_info).clone();
peer_info.protocol_version = result.protocol_version.clone();
Expand Down Expand Up @@ -353,9 +354,10 @@ impl<S: Service<RoleServer>> Service<RoleServer> for NegotiatingStatelessHttpSer
clippy::result_large_err,
reason = "BoxResponse is intentionally large; matches other handlers in this file"
)]
// SEP-2567: sessions are removed from the discover lifecycle. Validate
// protocol-version consistency, then classify the request with the shared
// lifecycle helper.
// SEP-2567: sessions are removed from the discover lifecycle. An initialize
// opener always selects the legacy lifecycle, even when its version offer is
// modern and will be negotiated down. Otherwise validate protocol-version
// consistency, then classify the request with the shared lifecycle helper.
fn is_legacy_request(
message: Option<&ClientJsonRpcMessage>,
headers: &HeaderMap,
Expand Down Expand Up @@ -401,10 +403,12 @@ fn is_legacy_request(
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
})
.unwrap_or(ProtocolVersion::V_2025_03_26);
Ok(uses_legacy_lifecycle(
Some(&version),
uses_discover_lifecycle,
))
let is_initialize = matches!(
message,
Some(ClientJsonRpcMessage::Request(req))
if matches!(&req.request, ClientRequest::InitializeRequest(_))
);
Ok(is_initialize || uses_legacy_lifecycle(Some(&version), uses_discover_lifecycle))
}

fn method_not_allowed_response() -> BoxResponse {
Expand Down
52 changes: 46 additions & 6 deletions crates/rmcp/tests/test_protocol_version_negotiation.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
//! Tests for protocol version negotiation in the default ServerHandler::initialize impl.
//! Tests for legacy protocol version negotiation in ServerHandler::initialize.
//!
//! Known versions are echoed back; unknown versions fall back to LATEST.
//! Known legacy versions are echoed back; modern and unknown offers fall back
//! to the server's preferred supported legacy revision.
#![cfg(not(feature = "local"))]
#![cfg(feature = "client")]

use std::borrow::Cow;

use rmcp::{
ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt,
model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo},
model::{
ClientInfo, ClientJsonRpcMessage, ClientRequest, InitializeRequestParams, InitializeResult,
ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult,
},
service::RequestContext,
transport::{IntoTransport, Transport},
};

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -119,6 +124,9 @@ async fn negotiated_version_with<S: ServerHandler>(
#[tokio::test]
async fn known_version_echoed_back() {
for version in ProtocolVersion::KNOWN_VERSIONS {
if version >= &ProtocolVersion::V_2026_07_28 {
continue;
}
let negotiated = negotiated_version(version.clone()).await;
assert_eq!(
negotiated, *version,
Expand All @@ -127,6 +135,37 @@ async fn known_version_echoed_back() {
}
}

#[tokio::test]
async fn modern_initialize_negotiates_the_preferred_legacy_version_on_a_direct_transport() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server = tokio::spawn(async move { EchoServer.serve(server_transport).await });
let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport);
let request: ClientRequest = serde_json::from_value(serde_json::json!({
"method": "initialize",
"params": {
"protocolVersion": "2026-07-28",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
}))
.unwrap();
client
.send(ClientJsonRpcMessage::request(request, RequestId::Number(1)))
.await
.unwrap();

let Some(ServerJsonRpcMessage::Response(response)) = client.receive().await else {
panic!("expected initialize response");
};
let ServerResult::InitializeResult(result) = response.result else {
panic!("expected initialize result");
};
assert_eq!(result.protocol_version, ProtocolVersion::V_2025_11_25);

let running = server.await.unwrap().expect("server should initialize");
running.cancel().await.expect("server should cancel");
}

#[tokio::test]
async fn unknown_version_falls_back_to_latest() {
let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap();
Expand All @@ -151,7 +190,8 @@ async fn narrowed_server_still_echoes_versions_it_supports() {

#[tokio::test]
async fn narrowed_server_does_not_agree_to_version_it_excludes() {
let negotiated = negotiated_version_with(NarrowedServer, ProtocolVersion::V_2026_07_28).await;
let unsupported = serde_json::from_str(r#""1999-01-01""#).unwrap();
let negotiated = negotiated_version_with(NarrowedServer, unsupported).await;
assert_eq!(
negotiated,
ProtocolVersion::V_2025_11_25,
Expand All @@ -161,8 +201,8 @@ async fn narrowed_server_does_not_agree_to_version_it_excludes() {

#[tokio::test]
async fn narrowed_server_caps_even_when_it_overrides_initialize() {
let negotiated =
negotiated_version_with(NarrowedOverridingServer, ProtocolVersion::V_2026_07_28).await;
let unsupported = serde_json::from_str(r#""1999-01-01""#).unwrap();
let negotiated = negotiated_version_with(NarrowedOverridingServer, unsupported).await;
assert_eq!(
negotiated,
ProtocolVersion::V_2025_11_25,
Expand Down
Loading
Loading