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
11 changes: 11 additions & 0 deletions crates/rmcp/src/handler/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ impl<H: ServerHandler> Service<RoleServer> for H {
fn get_info(&self) -> <RoleServer as ServiceRole>::Info {
self.get_info()
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
ServerHandler::supported_protocol_versions(self)
}
}

macro_rules! server_handler_methods {
Expand All @@ -321,10 +325,17 @@ macro_rules! server_handler_methods {
info.protocol_version = negotiate_protocol_version(
&request.protocol_version,
info.protocol_version,
&self.supported_protocol_versions(),
);
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.
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
Expand Down
11 changes: 9 additions & 2 deletions crates/rmcp/src/handler/server/router.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use std::sync::Arc;
use std::{borrow::Cow, sync::Arc};

use prompt::{IntoPromptRoute, PromptRoute};
use tool::{IntoToolRoute, ToolRoute};

use super::ServerHandler;
use crate::{
RoleServer, Service,
model::{ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ServerResult},
model::{
ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ProtocolVersion,
ServerResult,
},
service::NotificationContext,
};

Expand Down Expand Up @@ -155,6 +158,10 @@ where
.list_changed = Some(true);
info
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
ServerHandler::supported_protocol_versions(&self.service)
}
}

#[cfg(test)]
Expand Down
36 changes: 35 additions & 1 deletion crates/rmcp/src/service.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::sync::OnceLock;
use std::{borrow::Cow, sync::OnceLock};

use futures::FutureExt;
#[cfg(not(feature = "local"))]
Expand Down Expand Up @@ -284,6 +284,19 @@ 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.
///
/// Servers normally override
/// [`ServerHandler::supported_protocol_versions`] instead of this method;
/// the blanket `Service` impl forwards to it. This method exists so the
/// transport and handshake layers, which see only a `Service`, can read the
/// list and avoid agreeing to a version the server cannot serve.
///
/// [`ServerHandler::supported_protocol_versions`]: crate::handler::server::ServerHandler::supported_protocol_versions
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
}

#[cfg(feature = "local")]
Expand All @@ -299,6 +312,12 @@ pub trait Service<R: ServiceRole>: 'static {
context: NotificationContext<R>,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_;
fn get_info(&self) -> R::Info;
/// The protocol versions this service can speak.
///
/// See the non-`local` variant of this trait for details.
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
}

pub trait ServiceExt<R: ServiceRole>: Service<R> + Sized {
Expand Down Expand Up @@ -350,6 +369,10 @@ impl<R: ServiceRole> Service<R> for Box<dyn DynService<R>> {
fn get_info(&self) -> R::Info {
DynService::get_info(self.as_ref())
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
DynService::supported_protocol_versions(self.as_ref())
}
}

#[cfg(not(feature = "local"))]
Expand All @@ -365,6 +388,10 @@ pub trait DynService<R: ServiceRole>: Send + Sync {
context: NotificationContext<R>,
) -> MaybeBoxFuture<'_, Result<(), McpError>>;
fn get_info(&self) -> R::Info;
/// See [`Service::supported_protocol_versions`].
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
}

#[cfg(feature = "local")]
Expand All @@ -380,6 +407,10 @@ pub trait DynService<R: ServiceRole> {
context: NotificationContext<R>,
) -> MaybeBoxFuture<'_, Result<(), McpError>>;
fn get_info(&self) -> R::Info;
/// See [`Service::supported_protocol_versions`].
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
}
}

impl<R: ServiceRole, S: Service<R>> DynService<R> for S {
Expand All @@ -400,6 +431,9 @@ impl<R: ServiceRole, S: Service<R>> DynService<R> for S {
fn get_info(&self) -> R::Info {
self.get_info()
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Service::supported_protocol_versions(self)
}
}

use std::{
Expand Down
17 changes: 13 additions & 4 deletions crates/rmcp/src/service/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,18 @@ where
}
}

/// Echoes the client-requested version if known; otherwise returns `server_fallback`.
/// Echoes the client-requested version if the server supports it; otherwise
/// returns `server_fallback`.
///
/// `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.
pub(crate) fn negotiate_protocol_version(
client_requested: &ProtocolVersion,
server_fallback: ProtocolVersion,
server_supported: &[ProtocolVersion],
) -> ProtocolVersion {
if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested) {
if server_supported.contains(client_requested) {
client_requested.clone()
} else {
tracing::warn!(
Expand Down Expand Up @@ -578,8 +584,11 @@ where
return Err(ServerInitializeError::InitializeFailed(e));
}
};
init_response.protocol_version =
negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version);
init_response.protocol_version = negotiate_protocol_version(
&requested_protocol_version,
init_response.protocol_version,
&service.supported_protocol_versions(),
);
// 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
11 changes: 9 additions & 2 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,11 @@ impl<S: Service<RoleServer>> Service<RoleServer> for NegotiatingStatelessHttpSer
if let (Some(requested), ServerResult::InitializeResult(result)) =
(requested_protocol_version, &mut response)
{
result.protocol_version =
negotiate_protocol_version(&requested, result.protocol_version.clone());
result.protocol_version = negotiate_protocol_version(
&requested,
result.protocol_version.clone(),
&self.0.supported_protocol_versions(),
);
if let Some(peer_info) = peer.peer_info() {
let mut peer_info = (*peer_info).clone();
peer_info.protocol_version = result.protocol_version.clone();
Expand All @@ -301,6 +304,10 @@ impl<S: Service<RoleServer>> Service<RoleServer> for NegotiatingStatelessHttpSer
fn get_info(&self) -> ServerInfo {
self.0.get_info()
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
self.0.supported_protocol_versions()
}
}

#[expect(
Expand Down
94 changes: 91 additions & 3 deletions crates/rmcp/tests/test_protocol_version_negotiation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
#![cfg(not(feature = "local"))]
#![cfg(feature = "client")]

use std::borrow::Cow;

use rmcp::{
ClientHandler, ServerHandler, ServiceExt,
model::{ClientInfo, ProtocolVersion, ServerInfo},
ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt,
model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo},
service::RequestContext,
};

#[derive(Debug, Clone, Default)]
Expand All @@ -18,6 +21,52 @@ impl ServerHandler for EchoServer {
}
}

/// Every known version except `2026-07-28`, standing in for a server that has
/// not implemented that revision.
const NARROWED_VERSIONS: &[ProtocolVersion] = &[
ProtocolVersion::V_2024_11_05,
ProtocolVersion::V_2025_03_26,
ProtocolVersion::V_2025_06_18,
ProtocolVersion::V_2025_11_25,
];

#[derive(Debug, Clone, Default)]
struct NarrowedServer;

impl ServerHandler for NarrowedServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::default()
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(NARROWED_VERSIONS)
}
}

/// Narrows the supported versions *and* overrides `initialize`, so the
/// handler's own answer never runs the default negotiation. The handshake layer
/// must still honor the narrowed list.
#[derive(Debug, Clone, Default)]
struct NarrowedOverridingServer;

impl ServerHandler for NarrowedOverridingServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::default()
}

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(NARROWED_VERSIONS)
}

async fn initialize(
&self,
_request: InitializeRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<InitializeResult, ErrorData> {
Ok(self.get_info())
}
}

#[derive(Debug, Clone)]
struct VersionedClient {
protocol_version: ProtocolVersion,
Expand All @@ -32,10 +81,17 @@ impl ClientHandler for VersionedClient {
}

async fn negotiated_version(client_version: ProtocolVersion) -> ProtocolVersion {
negotiated_version_with(EchoServer, client_version).await
}

async fn negotiated_version_with<S: ServerHandler>(
server: S,
client_version: ProtocolVersion,
) -> ProtocolVersion {
let (server_transport, client_transport) = tokio::io::duplex(4096);

tokio::spawn(async move {
let _ = EchoServer
let _ = server
.serve(server_transport)
.await
.expect("server should start")
Expand Down Expand Up @@ -81,3 +137,35 @@ async fn unknown_version_falls_back_to_latest() {
"unknown version should fall back to LATEST"
);
}

#[tokio::test]
async fn narrowed_server_still_echoes_versions_it_supports() {
for version in NARROWED_VERSIONS {
let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await;
assert_eq!(
negotiated, *version,
"supported version {version} should be echoed back"
);
}
}

#[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;
assert_eq!(
negotiated,
ProtocolVersion::V_2025_11_25,
"a version outside supported_protocol_versions should not be echoed back"
);
}

#[tokio::test]
async fn narrowed_server_caps_even_when_it_overrides_initialize() {
let negotiated =
negotiated_version_with(NarrowedOverridingServer, ProtocolVersion::V_2026_07_28).await;
assert_eq!(
negotiated,
ProtocolVersion::V_2025_11_25,
"the handshake layer should not raise the version above what the server supports"
);
}
Loading